From aa9e2e89c8fdbd795338950313a8719d52692f76 Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Wed, 7 Oct 2026 03:17:53 -0700 Subject: [PATCH 1/2] Fix #307: a form dialog opens with its first field focused Rotate, Scale, Move and the other form dialogs opened with no field focused, so a typed value went nowhere and Enter applied the default (a Rotate by 0 degrees). The frame a dialog opens on (after the window's measuring frame), its first text or number field takes the keyboard focus with its text selected, so typing replaces it and Enter applies it, as in the reference app. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/form.rs | 4 +++- crates/ui-egui/src/dialogs/mod.rs | 23 +++++++++++++++++++++ crates/ui-egui/src/dialogs/tests.rs | 32 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 21 +++++++++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 3e125c81..c973b467 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -66,12 +66,14 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { let t = Tokens::get(ui.ctx()); let mut s = d.str(key); + let id = ui.id().with(("dlg-text", key)); + crate::widgets::take_dialog_focus(ui, id, &s); let r = egui::Frame::NONE .fill(t.input) .stroke(egui::Stroke::new(1.0, t.input_border)) .corner_radius(egui::CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(6, 3)) - .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(width))); + .show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width))); if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index b7e292c9..325f2068 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -314,10 +314,24 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); + // The kind of dialog shown last frame: a different one (or none) means this one just opened, and + // its first field is to take the keyboard focus (`focus_id`, until a field takes it). + let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { app.ui.dialog_file = None; + ctx.data_mut(|m| { + m.remove::(shown_id); + m.remove::(focus_id); + }); return; }; + let focus_first = ctx.data_mut(|m| { + if m.get_temp::(shown_id).as_deref() != Some(d.kind.as_str()) { + m.insert_temp(shown_id, d.kind.clone()); + m.insert_temp(focus_id, true); + } + m.get_temp::(focus_id).unwrap_or(false) + }); let spec = spec(&d.kind); if let Some(window) = spec.window { return window(app, ctx); @@ -347,7 +361,16 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room))); ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); + // Just opened: its first field takes the keyboard focus, as in the reference app (type a + // value, press Enter). Only the body's fields can take it. + if focus_first { + ui.data_mut(|m| m.insert_temp(widgets::dialog_focus_flag(), true)); + } cancel = (spec.body)(app, ui, &mut d); + // Taken (the flag is gone): done. Still there (the window's measuring frame): next frame. + if focus_first && ui.data_mut(|m| m.remove_temp::(widgets::dialog_focus_flag())).is_none() { + ui.data_mut(|m| m.remove::(focus_id)); + } ui.add_space(16.0); // The button row is as wide as the fields above it and as tall as the buttons: a // right-to-left layout would otherwise take all the room left in the window, so the diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index bb861c42..853f29a4 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -313,3 +313,35 @@ fn given_headings_are_shown_as_they_are() { let d = Dialog::new(plugin::KIND, json!({"__label": "Black", "__plugin": "x"})); assert_eq!((spec(&d.kind).heading)(&d), "Black"); } + +#[test] +fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { + let mut app = app(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + // Object › Transform › Rotate…: type 90 over the angle, press Enter. + crate::menus::invoke(&mut app, "object.rotate", json!({})); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("90".into())]); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none()); + let b = bounds(&app); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + // Move…: its first field is a distance (with a unit): typing replaces it as well. + let before = bounds(&app); + crate::menus::invoke(&mut app, "object.move", json!({})); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("15".into())]); + frame(&mut app, vec![enter()]); + assert!(app.ui.dialog.is_none()); + let after = bounds(&app); + assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 06a2425c..bf5f5ffb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -109,6 +109,7 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if !editing { buf = shown.clone(); } + take_dialog_focus(ui, id, &buf); let resp = ui .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(width); @@ -137,6 +138,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if commit { unit.parse(&buf) } else { None } } +/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the +/// first field drawn takes it ([`take_dialog_focus`]). +pub(crate) fn dialog_focus_flag() -> egui::Id { + egui::Id::new("dialog-focus-first-field") +} + +/// If field `id` (showing `text`) is the first field of a dialog that has just opened, give it the +/// keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. +pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { + // A new window's first frame only measures its contents: nothing can take focus yet. + if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { + return; + } + let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + st.cursor.set_char_range(Some(all)); + st.store(ui.ctx(), id); + ui.memory_mut(|m| m.request_focus(id)); +} + /// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked /// (egui would select one word): typing a number or an expression replaces it. fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { From 820f405c61cb4ce74e13d883d82d64b72b7f40be Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 01:50:59 +0700 Subject: [PATCH 2/2] Dialogs: note first-field focus in the ROADMAP (review of #308) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0ec2a9d2..292be64a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the Pen and shape-tool modifier fixes; see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework, the Pen and shape-tool modifier fixes and the Layers, Artboards, Swatches/Brushes and dialog fixes; see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -101,6 +101,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). - 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. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.