Merge pull request #357 from theironchef/fix/num-field-arrow-keys

Panel number fields: Up/Down arrows step the value (Shift ×10, Ctrl ÷10)
This commit is contained in:
BFlat 2026-10-08 06:01:17 +07:00 committed by GitHub
commit 8e80d6bcc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 119 additions and 25 deletions

View File

@ -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).

View File

@ -14,19 +14,21 @@ struct Field {
time: f64,
rect: Cell<Rect>,
id: Cell<egui::Id>,
/// 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<Event>, draw: &dyn Fn(&mut egui::Ui) -> Option<f64>) -> Option<f64> {
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));
}

View File

@ -133,9 +133,42 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
})
.inner;
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ 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 { None }
if commit { unit.parse(&buf) } else { stepped.map(|v| unit.to_pt(v)) }
}
/// ↑/↓ 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<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
// 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)) {
return None;
}
use egui::{Key, Modifiers};
let mut steps = 0.0;
ui.input_mut(|i| {
// Most specific first: a plain pattern would also take Shift+↑.
for (mods, size) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1), (Modifiers::NONE, 1.0)] {
let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64;
let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64;
steps += size * (up - down);
}
});
if steps == 0.0 {
return None;
}
// 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)
}
/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the
@ -151,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::<bool>(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
@ -164,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();
}
}
@ -240,23 +276,26 @@ pub fn mixed_field(
width: f32,
) -> Option<f64> {
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`.