Merge pull request #308 from theironchef/fix/dialog-first-field-focus
Dialogs: a form dialog opens with its first field focused
This commit is contained in:
commit
7ead49429e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>(shown_id);
|
||||
m.remove::<bool>(focus_id);
|
||||
});
|
||||
return;
|
||||
};
|
||||
let focus_first = ctx.data_mut(|m| {
|
||||
if m.get_temp::<String>(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::<bool>(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::<bool>(widgets::dialog_focus_flag())).is_none() {
|
||||
ui.data_mut(|m| m.remove::<bool>(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
|
||||
|
|
|
|||
|
|
@ -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<egui::Event>| {
|
||||
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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<bool>(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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue