From b5759662c1f353b98346bd91a8b749e5da7af54e Mon Sep 17 00:00:00 2001 From: David Panusch Date: Thu, 8 Oct 2026 17:38:18 +0200 Subject: [PATCH] Control bar: Transform link opens the Transform panel; reference point locator redrawn The Control bar's underlined Transform link opens the Transform panel as a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners, Scale Strokes & Effects), as the Stroke link does the Stroke panel. X/Y/W/H stay inline while the bar has room. The reference point locator is now nine squares with the outer eight joined around the box; the old outline was inset off the squares' centres. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 11 ++++++++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/transform.rs | 17 +++++++++++++++- crates/ui-egui/src/tests_paintchips.rs | 28 ++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 21 +++++++++++-------- 10 files changed, 72 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3a7ec9da6..3222b463e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,6 +111,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels. Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. + - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected). - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 45e28b347..20da29203 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -146,6 +146,9 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The room the Control bar's inline X/Y/W/H fields need; with less, only the Transform link shows. +const INLINE_TRANSFORM_WIDTH: f32 = 440.0; + /// The Control bar (Window → Control), context-sensitive like Illustrator's. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -252,9 +255,13 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } ui.separator(); - // Transform fields. + // The Transform link opens the whole Transform panel (reference point, rotate, + // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. + crate::panels::transform::link(app, ui); // The bounding box, rotated with rotated objects: its centre and its own sides. - if let Some(b) = app.selection_box() { + if let Some(b) = app.selection_box() + && ui.available_width() >= INLINE_TRANSFORM_WIDTH + { let c = b.center(); let link = app.session.prefs.constrain_proportions; for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1237ffffb..3af7719ae 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2546,6 +2546,7 @@ Character: Carácter: Character options Opciones de carácter Stroke options Opciones de trazo + Transform options Opciones de transformación Stroke Weight Grosor de trazo Stroke: Trazo: Stroke: click for swatches, Shift-click for the color mixer Trazo: clic para muestras, Shift+clic para el mezclador de colores diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c3ecb5fda..1e8fc1bc8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2352,6 +2352,7 @@ Character: 文字: Character options 文字オプション Stroke options 線オプション + Transform options 変形オプション Stroke: 線: Style スタイル Style Name: スタイル名: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e47c6991..596c41c36 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2248,6 +2248,7 @@ Character: Caractere: Character options Opções de caractere Stroke options Opções de traçado + Transform options Opções de transformação Stroke: Traçado: Style Estilo Style Name: Nome do Estilo: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8c01b809d..d1db2ad31 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2484,6 +2484,7 @@ Character: 字符: Character options 字符选项 Stroke options 描边选项 + Transform options 变换选项 Stroke Weight 描边粗细 Stroke: 描边: Stroke: click for swatches, Shift-click for the color mixer 描边:单击开启色板,按住 Shift 单击开启混色器 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 054676899..ea04b060b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2566,6 +2566,7 @@ Character: 字元: Character options 字元選項 Stroke options 筆畫選項 + Transform options 變形選項 Stroke Weight 筆畫寬度 Stroke: 筆畫: Stroke: click for swatches, Shift-click for the color mixer 筆畫:按一下開啟色票,按住 Shift 按一下開啟混色器 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 003f5440a..038b85603 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -13,6 +13,8 @@ use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; pub const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0]; +/// The shear angle's presets: steep enough to be useful, short of the degenerate ±90°. +pub const SHEAR_PRESETS: [f64; 9] = [-60.0, -45.0, -30.0, -15.0, 0.0, 15.0, 30.0, 45.0, 60.0]; /// Proportional size: the other dimension when one changes with the link on. pub fn constrained(w: f64, h: f64, new_w: Option, new_h: Option) -> (f64, f64) { @@ -151,8 +153,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear")); crate::scrub::note_label(ui, icon.rect); - if let Some(a) = widgets::plain_field(ui, "xfp-shear", 0.0, "°", 1, 56.0) + // Shear is relative: the field rests at 0° and a new value shears by it. + if let Some(a) = widgets::spin_plain(ui, "xfp-shear", 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS) && a != 0.0 + && a.abs() < 90.0 { app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin})).ok(); } @@ -209,6 +213,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a +/// popover under it, as the Stroke link does with the Stroke panel. +pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let resp = ui.link(egui::RichText::new(tl!("Transform")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Transform options")); + widgets::popover(&resp, resp.clicked(), |ui| { + ui.set_width(300.0); + show(app, ui); + }); +} + /// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the /// `constrainProportions` preference, which the size fields pass on as `proportional`. pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs index bcd155291..4ce5cf5b9 100644 --- a/crates/ui-egui/src/tests_paintchips.rs +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -242,3 +242,31 @@ fn the_window_menu_panel_keys() { app.ui.shortcut_overrides.clear(); shortcut_editor::sync(&app.ui); } + +/// Where `shapes` paint the text `s`. +fn text_rect(shapes: &[Shape], s: &str) -> Option { + shapes.iter().find_map(|sh| match sh { + Shape::Text(t) if t.galley.text() == s => Some(t.visual_bounding_rect()), + Shape::Vec(v) => text_rect(v, s), + _ => None, + }) +} + +#[test] +fn the_transform_link_opens_the_transform_panel() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + let ctx = context(); + frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + let link = text_rect(&shapes, "Transform").expect("the Control bar shows the Transform link"); + assert!(text_rect(&shapes, "Scale Corners").is_none()); + click(&mut app, &ctx, link.center(), Modifiers::NONE, control_bar); + let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar); + for s in ["Scale Corners", "Scale Strokes & Effects", "0°"] { + assert!(text_rect(&shapes, s).is_some(), "the popover shows {s}"); + } + // A click outside closes it. + click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar); + assert!(text_rect(&frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar), "Scale Corners").is_none()); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8c0076569..a666281d4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -806,18 +806,23 @@ pub fn opacity_blend( edit } -/// The 3×3 reference point locator. Returns a new index when clicked. +/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around +/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. pub fn reference_point(ui: &mut Ui, current: usize) -> Option { let t = Tokens::get(ui.ctx()); - let size = 22.0; + // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through + // the squares' centres stay crisp. + let (sq, step) = (5.0, 9.0); + let size = 2.0 * step + sq; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let o = rect.min.round(); + let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); + let line = Stroke::new(1.0, t.text_dim); + ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); let mut out = None; - let step = size / 2.0 - 2.5; - ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle); for i in 0..9 { - let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5); - let r = Rect::from_center_size(c, Vec2::splat(5.0)); - let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click()); + let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); + let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click()); if resp.clicked() { out = Some(i); } @@ -825,7 +830,7 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { ui.painter().rect_filled(r, 0.0, t.text); } else { ui.painter().rect_filled(r, 0.0, t.panel); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); } } out