diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 32ff56b2b..b427bbbfc 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -632,6 +632,68 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0))); } +/// In a dialog, Shift+Enter in a field applies its value and confirms the dialog, as Enter does: +/// the Move dialog moves the art, and the New Document dialog creates a document as wide as typed. +/// Panel fields keep the focus on Shift+Enter (`tests_numfields`). +#[test] +fn shift_enter_in_a_dialog_field_applies_it_and_confirms_the_dialog() { + 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 screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0))); + let mut time = 0.0; + // Whole app frames: the panels, then the dialog. + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 0.1; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + }; + let shift_enter = + || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + let bounds = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap(); + // Move…: type 15 over its first field and press Shift+Enter. + let before = bounds(&app); + crate::menus::invoke(&mut app, "object.move", json!({})); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("15".into())]); + frame(&mut app, vec![shift_enter()]); + assert!(app.ui.dialog.is_none(), "Shift+Enter confirms the Move dialog"); + let after = bounds(&app); + assert!((after.x0 - before.x0 - 15.0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} → {after:?}"); + // New Document (the recent 200 × 200 pt size): click into Width, type 300 and press Shift+Enter. + app.run("file.newDialog", json!({})).unwrap(); + frame(&mut app, vec![]); + let out = frame(&mut app, vec![]); + fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match s { + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect().center())), + _ => {} + } + } + let mut texts = vec![]; + out.shapes.iter().for_each(|c| walk(&c.shape, &mut texts)); + // The Width field comes right after its caption. + let width = texts.iter().position(|(t, _)| t == "Width").and_then(|i| texts.get(i + 1)); + assert_eq!(width.map(|(t, _)| t.as_str()), Some("200 pt"), "{texts:?}"); + let at = width.map(|(_, p)| *p).unwrap(); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at), press(true)]); + frame(&mut app, vec![press(false)]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("300".into())]); + frame(&mut app, vec![shift_enter()]); + assert!(app.ui.dialog.is_none(), "Shift+Enter creates the document"); + assert_eq!(app.session.active().unwrap().doc.artboards[0].rect.width(), 300.0); +} + /// #1367: a dialog opened by a click (its menu item) is drawn in the frame of that click, where egui /// takes the focus from a field the click missed: its first field still has the focus, each time it /// opens. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64998ed24..55bcfb5e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1353,6 +1353,7 @@ impl VectorcraftApp { return; } let t0 = now_ms(); + widgets::set_dialog_open(&ctx, self.ui.dialog.is_some()); scrub::begin_frame(self, &ctx); font_menu::end_stale_preview(self, &ctx); floating::track(self, &ctx); diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index 9597f34e9..cb37db720 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -2,7 +2,7 @@ //! on keyboard focus, a released focus click or double-click so typing replaces it; a drag //! keeps the pressed character as its selection anchor. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2}; use vectorcraft_doc::Unit; @@ -316,6 +316,93 @@ fn plain_fields_do_math_with_their_suffix() { assert_eq!(f.frame(vec![enter()], &draw), Some(90.0)); } +/// Shift+Enter applies what is typed into a field and keeps the field active, its new value all +/// selected so typing replaces it; Enter then ends its editing. In a dialog, Shift+Enter ends the +/// editing as Enter does. +#[test] +fn shift_enter_applies_the_value_and_keeps_the_field_active() { + let shift_enter = || key(Key::Enter, Modifiers::SHIFT); + let has_focus = |f: &Field| f.ctx.memory(|m| m.has_focus(f.id.get())); + // A unit field, a plain field with a suffix and a field that may be empty, each applying what + // it returns. → (text typed, value applied, text shown afterwards). + let px = Cell::new(100.0); + let num = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(px.get()), Unit::Pixels, 80.0).inspect(|&v| px.set(v)); + let deg = Cell::new(10.0); + let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", deg.get(), "°", 2, 80.0).inspect(|&v| deg.set(v)); + let dash = Cell::new(Some(20.0)); + let opt = |ui: &mut egui::Ui| widgets::opt_field(ui, "f", dash.get(), Unit::Points, 60.0).map(|v| dash.set(v)).and(dash.get()); + type Draw<'a> = &'a dyn Fn(&mut egui::Ui) -> Option; + let fields: [(&str, Draw, &str, f64, &str); 3] = + [("num_field", &num, "150", 150.0, "150 px"), ("plain_field", &plain, "45", 45.0, "45°"), ("opt_field", &opt, "2+3", 5.0, "5")]; + for (name, draw, typed, want, shows) in fields { + let mut f = Field::new(); + f.frame(vec![], draw); + f.frame(f.click(1), draw); + f.frame(vec![], draw); + f.frame(vec![Event::Text(typed.into())], draw); + assert_eq!(f.frame(vec![shift_enter()], draw), Some(want), "{name}: Shift+Enter applies the value"); + assert!(has_focus(&f), "{name}: the field keeps the focus"); + assert_eq!(f.frame(vec![], draw), None, "{name}: applied once"); + assert_eq!(f.selection(), Some((0, shows.chars().count())), "{name}: the value shown is selected"); + assert_eq!(f.ctx.data(|d| d.get_temp::(f.id.get())).as_deref(), Some(shows), "{name}"); + // Typing replaces it, and Enter applies that and ends the editing. + f.frame(vec![Event::Text("2".into())], draw); + assert_eq!(f.frame(vec![enter()], draw), Some(2.0), "{name}: Enter after it"); + assert!(!has_focus(&f), "{name}: Enter ends the editing"); + } + // A text field and a hex field (its color shown in capitals) the same way; a field of several + // lines takes Shift+Enter as a line break. + let (text, hex, hex_id) = (Cell::new(None), Cell::new(None), Cell::new(egui::Id::NULL)); + let color = RefCell::new(String::from("FF8000")); + let draw = |ui: &mut egui::Ui| { + text.set(widgets::text_field(ui, "f", Some("Name"), 120.0, 1).or(text.take())); + hex_id.set(ui.id().with("hex")); + let shown = color.borrow().clone(); + let applied = widgets::hex_field(ui, "hex", &shown); + if let Some(h) = &applied { + color.replace(h.to_uppercase()); + } + hex.set(applied.or(hex.take())); + None + }; + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw); + f.frame(vec![key(Key::A, Modifiers::COMMAND)], &draw); + f.frame(vec![Event::Text("Logo".into()), shift_enter()], &draw); + assert_eq!(text.take().as_deref(), Some("Logo")); + assert!(has_focus(&f), "the text field keeps the focus"); + f.frame(vec![key(Key::Tab, Modifiers::NONE)], &draw); + f.frame(vec![key(Key::A, Modifiers::COMMAND)], &draw); + f.frame(vec![Event::Text("00ff00".into()), shift_enter()], &draw); + assert_eq!((text.take(), hex.take().as_deref()), (None, Some("00ff00"))); + assert!(f.ctx.memory(|m| m.has_focus(hex_id.get())), "the hex field keeps the focus"); + f.frame(vec![], &draw); + assert_eq!(f.ctx.data(|d| d.get_temp::(hex_id.get())).as_deref(), Some("00FF00"), "the hex field shows the color applied"); + let selected = egui::TextEdit::load_state(&f.ctx, hex_id.get()).and_then(|st| st.cursor.char_range()).map(|r| r.as_sorted_char_range()); + assert_eq!(selected.map(|r| (r.start.0, r.end.0)), Some((0, 6)), "the hex field's color is selected"); + let lines = Cell::new(None); + let draw = |ui: &mut egui::Ui| { + lines.set(widgets::text_field(ui, "f", Some(""), 120.0, 3).or(lines.take())); + None + }; + let mut f = Field::new(); + f.frame(vec![], &draw); + f.frame(f.click(1), &draw); + f.frame(vec![Event::Text("a".into()), shift_enter(), Event::Text("b".into())], &draw); + assert!(has_focus(&f) && lines.take().is_none(), "a line break, nothing applied"); + f.frame(vec![key(Key::Escape, Modifiers::NONE)], &draw); + // In a dialog, Shift+Enter applies the value and ends the editing. + let mut f = Field::new(); + widgets::set_dialog_open(&f.ctx, true); + px.set(100.0); + f.frame(vec![], &num); + f.frame(f.click(1), &num); + f.frame(vec![Event::Text("150".into())], &num); + assert_eq!(f.frame(vec![shift_enter()], &num), Some(150.0)); + assert!(!has_focus(&f), "a dialog's field ends its editing"); +} + /// Preferences › Units › Numbers Without Units Are Points (#394): on (the default), a number typed /// with no unit into a field in picas is read in points; a typed unit still wins (`2p6`, `1in`); /// off, it is in picas. A field in any other unit reads it in its unit either way. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0712275ea..f358ff2c8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -208,9 +208,9 @@ pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { } /// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) -/// when the user commits (Enter / focus loss). Supports unit suffixes and arithmetic; a number -/// without a unit is in `unit` ([`typed_unit`]: in points in a picas field with Numbers Without -/// Units Are Points on). +/// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). +/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in +/// points in a picas field with Numbers Without Units Are Points on). pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { unit_field(ui, id, value, unit, width, false) } @@ -228,13 +228,13 @@ fn unit_field( let id = ui.id().with(id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); take_dialog_focus(ui, id, &shown); - let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1); select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let read = typed_unit(ui.ctx(), unit); let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v))); let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS); - let commit = resp.lost_focus() && buf != shown; + let commit = commit && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if commit { read.parse(&buf).or_else(|| if share_of_value { share_of(&buf, value?) } else { None }) @@ -384,6 +384,45 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) { st.store(ui.ctx(), id); } +/// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). +fn dialog_open_flag() -> egui::Id { + egui::Id::new("dialog-open") +} + +/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field +/// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). +pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { + ctx.data_mut(|d| d.insert_temp(dialog_open_flag(), open)); +} + +/// Shift+Enter (Shift+Return) in single-line field `id` while it has the keyboard focus and no +/// dialog is open: the press is taken from this frame's events before the field's text box reads +/// them, so the field keeps the focus. Returns true when it was pressed; the field then applies its +/// text as Enter does, and in the next frame shows its value with all of it selected +/// ([`after_shift_enter`]). +fn shift_enter_keeps_focus(ui: &Ui, id: egui::Id) -> bool { + if !ui.memory(|m| m.has_focus(id)) || ui.data(|d| d.get_temp::(dialog_open_flag())).unwrap_or(false) { + return false; + } + let pressed = ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| { + !matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::SHIFT)) + }); + i.events.len() < before + }); + if pressed { + ui.data_mut(|d| d.insert_temp(id.with("shift-enter"), true)); + } + pressed +} + +/// Whether field `id`, still focused, took Shift+Enter in the last frame +/// ([`shift_enter_keeps_focus`]). Clears that record. +fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { + ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) +} + /// Numeric fields select the whole text on keyboard focus, a completed focus click or a /// double-click. A pointer press keeps its caret so dragging selects from the pressed position, /// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap @@ -435,9 +474,10 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { } /// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), -/// its text kept under `id` while it has focus. Returns the text (as edited), the text edit's -/// response and the box. -fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response, Rect) { +/// its text kept under `id` while it has focus. Returns the text (as edited), whether the field +/// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept +/// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. +fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, bool, Response, Rect) { let t = Tokens::get(ui.ctx()); let editing = ui.memory(|m| m.has_focus(id)); // A press outside the field ends its editing in this frame, so the text typed is committed @@ -460,8 +500,14 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize // `lost_focus`: the text typed is still taken then, to be committed (#1637). Escape drops it. let focus_id = id.with("had-focus"); let had_focus = ui.data_mut(|d| d.get_temp::(focus_id)).unwrap_or(false) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); - let typed = if (editing || had_focus) && followed { ui.data_mut(|d| d.get_temp::(id)) } else { None }; + // After Shift+Enter applied the text, the field shows the value as it is now, all selected. + let reshown = after_shift_enter(ui, id); + let typed = if (editing || had_focus) && followed && !reshown { ui.data_mut(|d| d.get_temp::(id)) } else { None }; let mut buf: String = typed.unwrap_or_else(|| shown.to_string()); + if reshown { + select_all(ui, id, &buf); + } + let kept = rows == 1 && shift_enter_keeps_focus(ui, id); // The box is exactly as tall as the other controls of a row (26 points a line), its text // centred in it: a frame around the text came out a point taller and stood out next to // spinners and dropdowns (#900). @@ -496,25 +542,26 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize d.insert_temp(id, buf.clone()); d.insert_temp(focus_id, focused); }); - (buf, resp, rect) + let commit = resp.lost_focus() || kept; + (buf, commit, resp, rect) } /// A recessed text field showing `value` (blank when `None`: the selection's values differ), -/// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a -/// change is committed (Enter or focus loss). +/// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, +/// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option { let id = ui.id().with(id); let shown = value.unwrap_or_default(); - let (buf, resp, _) = recessed_text(ui, id, shown, width, rows); - (resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string()) + let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows); + (commit && buf.trim() != shown).then(|| buf.trim().to_string()) } /// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab -/// leader such as ". "). Returns it when a change is committed (Enter or focus loss). +/// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, width: f32) -> Option { let id = ui.id().with(id); - let (buf, resp, _) = recessed_text(ui, id, value, width, 1); - (resp.lost_focus() && buf != value).then_some(buf) + let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1); + (commit && buf != value).then_some(buf) } /// A form row: `label` in a column `label_width` wide, then what `add` draws. @@ -572,14 +619,14 @@ pub fn mixed_field( }; let shown = value.map(show).unwrap_or_default(); take_dialog_focus(ui, id, &shown); - let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1); select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on // its label scrubs it. let decimals = decimals.min(6) as i32; let stepped = step_value(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals)); let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals); - if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } + if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } } /// Draw a paint preview (swatch chip) into `rect`. @@ -1672,7 +1719,8 @@ pub fn channel_slider_named( } /// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it -/// was typed into the field and differs from `hex`. +/// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus +/// outside dialogs) and focus loss commit it. pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new("#").size(15.0).color(t.text)); @@ -1685,6 +1733,10 @@ pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: & let edited_id = id.with("edited"); let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + if after_shift_enter(ui, id) { + select_all(ui, id, &buf); + } + let kept = shift_enter_keeps_focus(ui, id); let resp = egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) @@ -1704,11 +1756,11 @@ pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: & ) }) .inner; - let commit = resp.lost_focus() && buf != hex; + let commit = (resp.lost_focus() || kept) && buf != hex; let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); }); commit.then_some(buf) } @@ -1779,8 +1831,9 @@ pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> } /// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without -/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit, where `new` is `None` -/// when the field was cleared; typed units and arithmetic work as in [`num_field`]. +/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or +/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units +/// and arithmetic work as in [`num_field`]. pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); @@ -1793,9 +1846,11 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); let before = ui.data_mut(|d| d.get_temp::(id)); let mut buf: String = if edited { before.clone().unwrap_or_else(|| shown.clone()) } else { shown.clone() }; - if editing && !edited && before.is_some_and(|b| b != buf) { + let reshown = after_shift_enter(ui, id); + if reshown || editing && !edited && before.is_some_and(|b| b != buf) { select_all(ui, id, &buf); } + let kept = shift_enter_keeps_focus(ui, id); let enabled = ui.is_enabled(); let framed = egui::Frame::NONE .fill(t.input) @@ -1820,9 +1875,9 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); }); - if resp.lost_focus() && buf != shown { + if (resp.lost_focus() || kept) && buf != shown { let s = buf.trim(); if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(s).map(Some) } } else { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c546ddfb5..fd3a0d9ee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -454,6 +454,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences that act (#394, in part): Show Tool Tips, Double Click To Isolate, Zoom with Mouse Wheel (the wheel zooms about the pointer, Shift-wheel scrolls up and down, Cmd/Ctrl-wheel sideways; Alt/Option-wheel and a trackpad pinch zoom about the pointer either way), Use Precise Cursors; Selection & Anchor Display › Tolerance (objects, anchors and handles), Object Selection by Path Only, Command Click to Select Objects Behind (cycling down the stack), Zoom to Selection, anchor Size, Highlight anchors on mouse over; Type › Number of Recent Fonts (up to 15); Guides & Grid › guide and grid Color and Style (lines or dots) and Grids In Back. Then Snap to Point (its distance, for moves, drawn points and reference points), Move Locked and Hidden Artwork with Artboard, Enable Rubber Band for the Pen and Curvature tools, Handles style, Show handles when multiple anchors are selected, Hide Corner Widget for angles greater than; General › Transform Pattern Tiles (the transforms' and dialogs' Transform Patterns, Align and Distribute, art moved or duplicated with an artboard, and objects moved whole with direct-selected anchors) and Select Same Tint %; Type › Size/Leading, Tracking and Baseline Shift increments (`type.step`, Cmd+Shift+. and Cmd+Shift+, for the size, the Type tool's Alt+arrows), Enable Missing Glyph Protection and Fill New Type Objects With Placeholder Text. Then Smart Guides › Color, Alignment Guides, Anchor/Path Labels, Measurement Labels, Transform Tools, Object Highlighting (with Smart Guides on) and Snapping Tolerance (4 px by default, for every smart guide snap), then Construction Guides and their Angles (#506: a drawn point near a line at one of those angles through the point it leaves from lands on it). Then Type › Type Object Selection by Path Only (type is picked on the baseline of any of its lines, area type's frame or the path of type on a path, no longer anywhere in its bounds; the Type tools and the Eyedropper still take a click among the characters). Then General › Anti-aliased Artwork (off, the canvas draws the art with hard edges). Then Guides & Grid › Show Pixel Grid (Above 600% Zoom), a line at every document pixel over the art in Pixel Preview, turning with a rotated view as the document grid now does too. Then General › Show The Home Screen When No Documents Are Open (off, closing the last document leaves an empty window; the Home button still opens the screen). Then Units › Numbers Without Units Are Points (on by default: a number typed with no unit into a field in picas is read in points, a typed unit still wins; dimmed unless a unit is Picas). Then User Interface › Auto-Collapse Iconic Panels (off by default: on, a click away from a panel popped out of the icon column puts it away). Then Performance › Animated Zoom (on, with GPU Performance, which it needs and which dims it when off: the Zoom tool dragged sideways zooms about the press, right in and left out, and held still zooms on, Alt out; off, a drag zooms to the area dragged across, now drawn as a marquee), User Interface › Large Tabs (taller document tabs) and Scale Cursor Proportional to UI (off, the tool cursors keep their size whatever the UI Scaling), and File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (off by default: Pixel Preview draws images with each pixel taking its nearest image pixel). Type › Auto Size New Area Type works too (new area type gets Auto Size). They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). Then Smart Guides › Spacing Guides (moves space the selection evenly with its row or column, see Drawing aids). Then Hyphenation › Exceptions (comma-separated words that never break, or break only at their hyphens; `prefs.set` and restore push them into the hyphenator, #394). Then Type › Show Font Names in English (off, font menus label a family by its native-language name when the font has one; documents still use the English family, #394). Then General › Display Print Size at 100% Zoom (off by default: View › Actual Size is one document point per screen point; on, one document inch fills 96 screen points, an inch at the reference density display scaling is set against; `view.actualSize` / `prefs.set`, #394). Then Guides & Grid › Gridline every and Subdivisions (changing either sets the grid of every open document; new and opened documents take that grid too, except a native file and the native document an SVG, PDF, `.ai` or EPS file carries, which open with the grid they were saved with; `file.new`, `document.open`, `file.newFromTemplate`, `file.revert`, #394). Then Appearance of Black › On Screen (Display All Blacks Accurately by default: pure CMYK black shows through the CMYK profile, a dark grey; Display All Blacks as Rich Black shows it on the canvas as RGB black, also in CMYK documents drawn by ink planes, while rich blacks, black tints and images show as before; `RenderOptions::rich_black`, #394). Then Appearance of Black › Printing / Exporting (Output All Blacks Accurately by default; Output All Blacks as Rich Black draws pure CMYK black as RGB black in RGB raster exports and Print as Bitmap; CMYK ink exports and the document stay unchanged; `prefs.blackOutput` / `richBlack`, #394). Then Units › Identify Objects By (Object Name by default: Layers, Variables and Layer Options show and edit free names; XML ID shows and edits AI-style XML ids, `_` for a space and `_xHH_` escapes, and `layer.setProps` / rename refuse invalid ids; the document still stores the object name, #394). About 15 stored options still do nothing yet (the rest of #394: Highlight Alternate Glyphs, East Asian Type units, Open Documents As Tabs, Real-time Drawing and Editing, GPU Performance beyond Animated Zoom, hyphenation language, scratch disks, the legacy clipboard, Touch Workspace). - 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…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). 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. + - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. - 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. - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - 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.