From 51eea7ecfb15aa1d10cd3c53e631283ef3bd9c8c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:37:30 -0800 Subject: [PATCH] Tabs: the ruler shows when a non-text object is first in the selection With the Selection tool, the Tabs panel showed "No text selected" when the first selected object was a shape, or a group holding text, even with text selected. It now shows the ruler and stops of the first paragraph of the first selected text object, looking into selected groups as the Character and Paragraph panels do. A stop set on that ruler goes to every selected text object, as text.tabs.set already did; other objects are left as they were. A selection without text still shows the empty state. --- crates/ui-egui/src/panels/character.rs | 15 +++-- crates/ui-egui/src/panels/tabs.rs | 8 ++- crates/ui-egui/src/panels/tabs/tests.rs | 77 +++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..e21d6dd2de 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -42,14 +42,21 @@ pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft /// The first selected text descendant, shared by Character and Paragraph controls. pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> { + match &first_selected_text_node(app)?.kind { + NodeKind::Text(t) => Some(t.as_ref()), + _ => None, + } +} + +/// The node of [`first_selected_text`]: the first text object among the selected objects and +/// their descendants, in selection order, then paint order within each selected object. +pub(crate) fn first_selected_text_node(app: &VectorcraftApp) -> Option<&vectorcraft_doc::Node> { let st = app.session.active()?; for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { let mut text = None; node.walk(&mut |n| { - if text.is_none() - && let NodeKind::Text(t) = &n.kind - { - text = Some(t.as_ref()); + if text.is_none() && matches!(n.kind, NodeKind::Text(_)) { + text = Some(n); } }); if text.is_some() { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 77dd07e15f..bc1f9352c3 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -10,7 +10,7 @@ use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject}; -use super::{first_selected, pstate, set_pstate}; +use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; @@ -330,12 +330,14 @@ fn current(app: &VectorcraftApp) -> Option<(Node, Vec, f64)> { /// As [`current`], with all the attributes of the paragraph whose stops the panel shows, its left /// indent as laid out ([`ParaStyle::laid_out_left_indent`]: its list level's indent added), where -/// the ruler shows it. +/// the ruler shows it. Without the Type tool editing text, the panel shows the first paragraph of +/// the first selected text object, also when that text is in a selected group or follows a +/// selected object that is not text. fn current_para(app: &VectorcraftApp) -> Option<(Node, ParaStyle, f64)> { let editing = super::character::text_editing(app); let n = match editing { Some((id, _, _)) => app.session.active()?.doc.node(id)?.clone(), - None => first_selected(app)?, + None => super::character::first_selected_text_node(app)?.clone(), }; let NodeKind::Text(t) = &n.kind else { return None }; let para = editing.map_or(0, |(_, a, _)| t.paragraphs_in(a, a).start); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index d6bcef15f1..6472b1017b 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -27,6 +27,83 @@ fn panel_draws_empty_with_text_and_with_stops() { assert_eq!(back[1]["align"], json!("decimal")); } +/// A rectangle and two area texts, the first with a stop at 40 pt and the second with one at +/// 90 pt, nothing selected. → the app and the ids of the rectangle and the two texts. +fn mixed_selection_app() -> (VectorcraftApp, [u64; 3]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 10, "y": 200, "width": 40, "height": 40})).unwrap()); + let a = id(app.run("text.create", json!({"x": 10, "y": 20, "text": "a\tb", "area": {"width": 300, "height": 60}})).unwrap()); + let b = id(app.run("text.create", json!({"x": 10, "y": 100, "text": "c\td", "area": {"width": 250, "height": 60}})).unwrap()); + for (text, position) in [(a, 40), (b, 90)] { + app.run("select.set", json!({"ids": [text]})).unwrap(); + app.run("text.tabs.set", json!({"stops": [{"position": position}]})).unwrap(); + } + app.run("select.set", json!({"ids": []})).unwrap(); + (app, [rect, a, b]) +} + +/// With a rectangle selected first and a text after it, the panel shows the text's stops on its +/// ruler, not the "No text selected" state. +#[test] +fn the_ruler_shows_text_selected_after_an_object_that_is_not_text() { + let (mut app, [rect, a, _]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, a); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [40.0]); + assert_eq!(span, 300.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// With a group selected first whose text follows a rectangle in it, the panel shows that text's +/// stops, ahead of a text selected after the group. +#[test] +fn the_ruler_shows_text_inside_a_selected_group() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + app.run("select.set", json!({"ids": [rect, b]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, a]})).unwrap(); + let (node, stops, span) = current(&app).unwrap(); + assert_eq!(node.id.0, b); + assert_eq!(stops.iter().map(|s| s.position).collect::>(), [90.0]); + assert_eq!(span, 250.0); + let ctx = egui::Context::default(); + assert_eq!(ruler_labels(&ctx, &mut app).first().map(String::as_str), Some("0")); +} + +/// A stop added on the ruler of a mixed selection sets the first text's stops plus the new one on +/// every selected text, and leaves the rectangle as it was. +#[test] +fn a_stop_added_on_a_mixed_selection_sets_every_selected_text() { + let (mut app, [rect, a, b]) = mixed_selection_app(); + let shape = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().clone(); + let before = shape(&app); + app.run("select.set", json!({"ids": [rect, a, b]})).unwrap(); + let ctx = egui::Context::default(); + drag_on_ruler(&ctx, &mut app, 60.0, 60.0, false); + for text in [a, b] { + assert_eq!(text_of(&app, text).para_at(0).tabs.iter().map(|s| s.position).collect::>(), [40.0, 60.0], "text {text}"); + } + assert_eq!(shape(&app), before); +} + +/// A selection without text keeps the "No text selected" state and draws no ruler. +#[test] +fn a_selection_without_text_shows_no_ruler() { + let (mut app, [rect, ..]) = mixed_selection_app(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 200, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [rect, id]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + assert!(current(&app).is_none()); + let ctx = egui::Context::default(); + assert!(ruler_labels(&ctx, &mut app).is_empty()); + assert!(crate::tests_labels::painted_text(&mut app, show).contains("No text selected")); +} + /// In vertical type the Left and Right alignment buttons are named Top-Justified Tab and /// Bottom-Justified Tab, in their tool tips and for screen readers, and the position field is /// labeled Y. Horizontal type keeps Left-Justified Tab, Right-Justified Tab and X. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..9f590f4055 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1288,6 +1288,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Snap to Unit off and Shift not held, a tab stop placed by a click on the Tabs panel's ruler or dragged on it lands on the nearest whole point. A position typed in the X or Y field keeps its hundredths of a point. The Tabs ruler's upper first-line marker and lower left-indent marker show absolute positions and set paragraph indents (`text.setFormat`). Dragging the lower marker keeps the upper position, while Ctrl-dragging it (Command on macOS) moves both; marker drags snap like tab stops, and each release is one undo step. With differing left indents, only the upper marker is shown and its absolute target applies to every touched paragraph. When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. + With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged.