diff --git a/ROADMAP.md b/ROADMAP.md index 8dbdbd6b..12c91b81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,7 +102,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, Remove Anchor Points when anchors are selected, 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…). + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - 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. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it). diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index 716f3517..eb2b59c3 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -14,19 +14,21 @@ struct Field { time: f64, rect: Cell, id: Cell, + /// Whether the window reports keyboard focus (the live app's doesn't always). + focused: bool, } impl Field { fn new() -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL) } + Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL), focused: true } } fn frame(&mut self, events: Vec, draw: &dyn Fn(&mut egui::Ui) -> Option) -> Option { self.time += 0.05; let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))); - let input = egui::RawInput { events, time: Some(self.time), screen_rect, ..Default::default() }; + let input = egui::RawInput { events, time: Some(self.time), screen_rect, focused: self.focused, ..Default::default() }; let mut got = None; let mut out = self.ctx.run_ui(input, |ui| { self.id.set(ui.id().with("f")); @@ -56,7 +58,11 @@ impl Field { } fn enter() -> Event { - Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } + key(Key::Enter, Modifiers::NONE) +} + +fn key(key: Key, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } } #[test] @@ -125,3 +131,52 @@ fn plain_fields_do_math_with_their_suffix() { f.frame(vec![Event::Text("45*2°".into())], &draw); assert_eq!(f.frame(vec![enter()], &draw), Some(90.0)); } + +/// ↑/↓ step a focused numeric field by one of its unit (Shift: ten, Ctrl/Cmd: a tenth) and apply it +/// at once, the new value selected so typing replaces it; an unfocused field leaves the arrows to +/// the canvas (nudge). +#[test] +fn arrow_keys_step_a_focused_field() { + let mut f = Field::new(); + let u = Unit::Millimeters; + let mm = Cell::new(4.0); + let draw = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(u.to_pt(mm.get())), u, 80.0).inspect(|&v| mm.set(u.from_pt(v))); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &draw), None, "unfocused"); + f.frame(f.click(1), &draw); + f.frame(vec![], &draw); + // The window needn't report keyboard focus: the field has egui's. + f.focused = false; + for (k, mods, want) in [ + (Key::ArrowUp, Modifiers::NONE, 5.0), + (Key::ArrowUp, Modifiers::SHIFT, 15.0), + (Key::ArrowDown, Modifiers::COMMAND, 14.9), + (Key::ArrowDown, Modifiers::NONE, 13.9), + ] { + assert!(f.frame(vec![key(k, mods)], &draw).is_some(), "{k:?} {mods:?}"); + assert!((mm.get() - want).abs() < 1e-6, "{k:?} {mods:?}: {}", mm.get()); + } + assert_eq!(f.selection(), Some((0, "13.9 mm".len())), "the new value is selected"); + // Enter after the steps commits nothing more. + assert_eq!(f.frame(vec![enter()], &draw), None); +} + +/// Plain fields (degrees, percent, counts) step too, at their precision. +#[test] +fn arrow_keys_step_plain_fields() { + let mut f = Field::new(); + let angle = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 10.0, "°", 2, 80.0); + f.frame(vec![], &angle); + f.frame(f.click(1), &angle); + f.frame(vec![], &angle); + assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &angle), Some(11.0)); + assert_eq!(f.frame(vec![key(Key::ArrowDown, Modifiers::SHIFT)], &angle), Some(1.0)); + // A count ignores the tenth. + let mut f = Field::new(); + let count = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 3.0, "", 0, 80.0); + f.frame(vec![], &count); + f.frame(f.click(1), &count); + f.frame(vec![], &count); + assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::COMMAND)], &count), None); + assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &count), Some(4.0)); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 587d8951..fcd3b9e7 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -133,15 +133,18 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: }) .inner; select_all_on_focus(ui, &resp, &buf); - let stepped = step_with_arrows(ui, &resp, &mut buf, unit); + // ↑/↓ step in the field's unit. + let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v))); let commit = resp.lost_focus() && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if commit { unit.parse(&buf) } else { stepped } + if commit { unit.parse(&buf) } else { stepped.map(|v| unit.to_pt(v)) } } -/// ↑/↓ in a focused numeric field step its value by one `unit` (Shift: ten, Ctrl/Cmd: a tenth), -/// applied at once as in Illustrator's panels. Returns the new value (points) when it changed. -fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, unit: Unit) -> Option { +/// ↑/↓ in focused numeric field `resp` step the number `buf` shows (`read` parses it, `show` +/// formats it) by one (Shift: ten, Ctrl/Cmd: a tenth), applied at once as in Illustrator's panels +/// and dialogs. The new text replaces `buf`, all selected so typing replaces it. Returns the new +/// number when a step was taken. +fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option, show: impl Fn(f64) -> String) -> Option { // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the // window to report keyboard focus. if !ui.memory(|m| m.has_focus(resp.id)) { @@ -160,10 +163,11 @@ fn step_with_arrows(ui: &Ui, resp: &Response, buf: &mut String, unit: Unit) -> O if steps == 0.0 { return None; } - // In the field's unit, rounded as fields show it (no 12.300000001). - let v = unit.from_pt(unit.parse(buf)?) + steps; - let v = unit.to_pt((v * 1000.0).round() / 1000.0); - *buf = unit.format(v); + // Rounded as fields show it (no 12.300000001). + let v = ((read(buf)? + steps) * 1000.0).round() / 1000.0; + *buf = show(v); + ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); + select_all(ui, resp.id, buf); Some(v) } @@ -180,11 +184,16 @@ pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { if ui.is_sizing_pass() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { return; } + select_all(ui, id, text); + ui.memory_mut(|m| m.request_focus(id)); +} + +/// Select all of `text` in text field `id`. +fn select_all(ui: &Ui, id: egui::Id, text: &str) { 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 @@ -193,10 +202,8 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { if !(resp.gained_focus() || resp.double_clicked()) { return; } - if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { - 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(), resp.id); + if egui::TextEdit::load_state(ui.ctx(), resp.id).is_some() { + select_all(ui, resp.id, text); ui.ctx().request_repaint(); } } @@ -269,23 +276,26 @@ pub fn mixed_field( width: f32, ) -> Option { let id = ui.id().with(id); - let shown = value - .map(|value| { - let s = format!("{:.*}", decimals, value); - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; - format!("{s}{suffix}") - }) - .unwrap_or_default(); - let (buf, resp) = recessed_text(ui, id, &shown, width, 1); - select_all_on_focus(ui, &resp, &buf); - if resp.lost_focus() && buf != shown { - // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. + let show = |value: f64| { + let s = format!("{:.*}", decimals, value); + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; + format!("{s}{suffix}") + }; + // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. + let read = |buf: &str| { let suffix = suffix.trim(); - let bare = if suffix.is_empty() { buf.clone() } else { buf.replace(suffix, "") }; + let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) - } else { - None - } + }; + let shown = value.map(show).unwrap_or_default(); + let (mut buf, resp) = recessed_text(ui, id, &shown, width, 1); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ step at the field's precision (a count ignores Ctrl/Cmd's tenth). + let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| { + let scale = 10f64.powi(decimals.min(6) as i32); + (v * scale).round() / scale + }); + if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.filter(|&v| Some(v) != value) } } /// Draw a paint preview (swatch chip) into `rect`. @@ -1483,38 +1493,3 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val }); out } - -#[cfg(test)] -mod tests { - use super::*; - - /// ↑/↓ step a focused numeric field by one unit (Shift: ten, Ctrl/Cmd: a tenth) at once. - #[test] - fn arrow_keys_step_a_focused_numeric_field() { - let ctx = egui::Context::default(); - let value = std::cell::Cell::new(Unit::Millimeters.to_pt(4.0)); - let frame = |events: Vec, focus: bool| { - // The window doesn't report keyboard focus (as in the live app), yet the field has egui's. - let mut out = ctx.run_ui(egui::RawInput { events, focused: false, ..Default::default() }, |ui| { - if focus { - ui.memory_mut(|m| m.request_focus(ui.id().with("w"))); - } - if let Some(v) = num_field(ui, "w", Some(value.get()), Unit::Millimeters, 80.0) { - value.set(v); - } - }); - out.textures_delta.clear(); - }; - let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; - let mm = |v: f64| Unit::Millimeters.from_pt(v); - frame(vec![], true); - frame(vec![], false); - let start = mm(value.get()); - frame(vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)], false); - assert!((mm(value.get()) - (start + 1.0)).abs() < 1e-6, "{}", mm(value.get())); - frame(vec![key(egui::Key::ArrowUp, egui::Modifiers::SHIFT)], false); - assert!((mm(value.get()) - (start + 11.0)).abs() < 1e-6, "{}", mm(value.get())); - frame(vec![key(egui::Key::ArrowDown, egui::Modifiers::COMMAND)], false); - assert!((mm(value.get()) - (start + 10.9)).abs() < 1e-6, "{}", mm(value.get())); - } -}