From b30d80e05661a785ef6fbc20b06d425d73adf7db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 07:06:59 -0700 Subject: [PATCH] Fields: arrow keys and the wheel step to the next whole unit, and Ctrl/Cmd steps to the next tenth In number fields, Up/Down and a wheel notch added a whole step and kept the fraction, so 5.1 mm stepped up to 6.1 mm. A plain step now goes to the next whole unit in its direction (5.1 goes up to 6 and down to 5; 5 goes up to 6 and down to 4), and a Ctrl/Cmd step goes to the next tenth, in the field's own unit. Repeated presses or notches of one size add or take one step each, and presses and notches are applied in the order they came. A Shift step still adds or takes ten and keeps the fraction. The number fields that widgets.rs draws for the panels, the Control bar and the dialogs (unit, percent, degree and count fields) all use this step (widgets::step_value). --- crates/ui-egui/src/tests_numfields.rs | 89 +++++++++++++++++-- crates/ui-egui/src/widgets.rs | 119 +++++++++++++++++++------- docs/target-app-parity.md | 1 + 3 files changed, 175 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index cb37db720..4cd728879 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -447,16 +447,95 @@ fn arrow_keys_step_a_focused_field() { (Key::ArrowUp, Modifiers::NONE, 5.0), (Key::ArrowUp, Modifiers::SHIFT, 15.0), (Key::ArrowDown, Modifiers::COMMAND, 14.9), - (Key::ArrowDown, Modifiers::NONE, 13.9), + (Key::ArrowDown, Modifiers::NONE, 14.0), ] { 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"); + assert_eq!(f.selection(), Some((0, "14 mm".len())), "the new value is selected"); // Enter after the steps commits nothing more. assert_eq!(f.frame(vec![enter()], &draw), None); } +/// #2881, #2793: ↑/↓ and the wheel step a fractional value to the next whole unit in their +/// direction (5.1 mm: up to 6, down to 5), and Ctrl/Cmd steps to the next tenth; Shift adds or takes +/// ten and keeps the fraction. In each field's own unit: millimeters, points, percent, degrees. +#[test] +fn steps_go_to_the_next_whole_unit_or_tenth() { + use egui::MouseWheelUnit::Line; + let u = Unit::Millimeters; + let mm = Cell::new(0.0); + let mm_field = |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))); + let mut f = Field::new(); + f.frame(vec![], &mm_field); + f.frame(f.click(1), &mm_field); + f.frame(vec![], &mm_field); + let at = f.rect.get().center(); + // From `from` (set from outside, which the focused field follows), the value `events` give. + let mut step = |from: f64, events: Vec| { + mm.set(from); + f.frame(vec![], &mm_field); + assert!(f.frame(events, &mm_field).is_some(), "from {from}"); + // The field shows the new value before the next one is set. + f.frame(vec![], &mm_field); + mm.get() + }; + let arrow = |k, mods| vec![key(k, mods)]; + let turn = |dy, mods| wheel(at, Line, dy, mods); + for (from, events, want) in [ + (5.1, arrow(Key::ArrowUp, Modifiers::NONE), 6.0), + (5.1, arrow(Key::ArrowDown, Modifiers::NONE), 5.0), + (5.0, arrow(Key::ArrowUp, Modifiers::NONE), 6.0), + (5.0, arrow(Key::ArrowDown, Modifiers::NONE), 4.0), + (-2.5, arrow(Key::ArrowUp, Modifiers::NONE), -2.0), + (-2.5, arrow(Key::ArrowDown, Modifiers::NONE), -3.0), + (5.13, arrow(Key::ArrowUp, Modifiers::COMMAND), 5.2), + (5.13, arrow(Key::ArrowDown, Modifiers::COMMAND), 5.1), + (5.1, arrow(Key::ArrowUp, Modifiers::SHIFT), 15.1), + (5.1, arrow(Key::ArrowDown, Modifiers::SHIFT), -4.9), + (5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::NONE)], 7.0), + // Several presses in one frame step in the order they came. + (5.1, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowDown, Modifiers::NONE)], 5.0), + (5.13, vec![key(Key::ArrowUp, Modifiers::NONE), key(Key::ArrowUp, Modifiers::COMMAND)], 6.1), + (24.8, turn(1.0, Modifiers::NONE), 25.0), + (24.8, turn(-1.0, Modifiers::NONE), 24.0), + (24.8, turn(2.0, Modifiers::NONE), 26.0), + (24.8, turn(-2.0, Modifiers::NONE), 23.0), + (24.0, turn(1.0, Modifiers::NONE), 25.0), + (24.83, turn(1.0, Modifiers::COMMAND), 24.9), + (24.8, turn(1.0, Modifiers::SHIFT), 34.8), + ] { + let got = step(from, events); + assert!((got - want).abs() < 1e-6, "{from} mm to {got}, not {want}"); + } + // A points field steps whole points. + let pt = Cell::new(12.25); + let pt_field = |ui: &mut egui::Ui| widgets::num_field(ui, "f", Some(pt.get()), Unit::Points, 80.0).inspect(|&v| pt.set(v)); + let mut f = Field::new(); + f.frame(vec![], &pt_field); + f.frame(f.click(1), &pt_field); + f.frame(vec![], &pt_field); + assert_eq!(f.frame(vec![key(Key::ArrowUp, Modifiers::NONE)], &pt_field), Some(13.0)); + // Percent and degree fields step whole percents and degrees. + for suffix in ["%", "°"] { + let mut f = Field::new(); + let value = Cell::new(37.5); + let plain = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), suffix, 2, 80.0).inspect(|&v| value.set(v)); + f.frame(vec![], &plain); + f.frame(f.click(1), &plain); + f.frame(vec![], &plain); + for (k, mods, want) in [ + (Key::ArrowDown, Modifiers::NONE, 37.0), + (Key::ArrowUp, Modifiers::NONE, 38.0), + (Key::ArrowUp, Modifiers::COMMAND, 38.1), + (Key::ArrowUp, Modifiers::NONE, 39.0), + ] { + assert_eq!(f.frame(vec![key(k, mods)], &plain), Some(want), "{suffix} {k:?} {mods:?}"); + f.frame(vec![], &plain); + } + } +} + /// Plain fields (degrees, percent, counts) step too, at their precision. #[test] fn arrow_keys_step_plain_fields() { @@ -498,17 +577,17 @@ fn the_wheel_steps_a_focused_field_under_the_pointer() { f.frame(f.click(1), &draw); f.frame(vec![], &draw); for (dy, mods, want) in - [(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 23.9)] + [(1.0, Modifiers::NONE, 5.0), (2.0, Modifiers::SHIFT, 25.0), (-1.0, Modifiers::COMMAND, 24.9), (-1.0, Modifiers::NONE, 24.0)] { assert!(f.frame(wheel(at, Line, dy, mods), &draw).is_some(), "{dy} {mods:?}"); assert!((mm.get() - want).abs() < 1e-6, "{dy} {mods:?}: {}", mm.get()); } - assert_eq!(f.selection(), Some((0, "23.9 mm".len())), "the new value is selected"); + assert_eq!(f.selection(), Some((0, "24 mm".len())), "the new value is selected"); // Points: a step for each line's worth, what is left carried to the next turn. let line = f.ctx.options(|o| o.input_options.line_scroll_speed); assert_eq!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw), None); assert!(f.frame(wheel(at, Point, line * 0.6, Modifiers::NONE), &draw).is_some()); - assert!((mm.get() - 24.9).abs() < 1e-6, "{}", mm.get()); + assert!((mm.get() - 25.0).abs() < 1e-6, "{}", mm.get()); assert_eq!(f.frame(wheel(Pos2::new(390.0, 190.0), Line, 1.0, Modifiers::NONE), &draw), None, "the pointer elsewhere"); // Plain fields (degrees, percent, counts) too. let mut f = Field::new(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 13d64a816..b8a5f6e4d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -260,15 +260,31 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { } /// One step of a numeric field with each modifier, most specific first (a plain pattern would also -/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. +/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to +/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; +/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next +/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 +/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a +/// step of a multiple counts as that multiple. +fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { + if n == 0.0 { + return v; + } + let q = v / size; + let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() }; + (from + n) * size +} + /// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` -/// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: 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. While the pointer is over the focused field the wheel is the field's (the panel under it -/// doesn't scroll); an unfocused field leaves the wheel to the panel and the canvas. +/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step +/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to +/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in +/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing +/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused +/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the +/// wheel to the panel and the canvas. fn step_value(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. @@ -281,41 +297,51 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> // left of a notch carries over to the next turn. let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; - let mut steps = 0.0; + // Each press and each turn of whole notches in the order they came: its modifiers and size from + // [`STEPS`], and how many steps up (down when negative). + let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); ui.input_mut(|i| { - for (mods, size) in STEPS { - 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 !wheel { - return; - } i.events.retain(|e| { - let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true }; - let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; - // Shift may turn the wheel sideways (the Mac does). - let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; - notches += match unit { - MouseWheelUnit::Point => d / line.max(1.0), - MouseWheelUnit::Line | MouseWheelUnit::Page => d, + let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; + let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; + let n = match e { + Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::MouseWheel { unit, delta, .. } if wheel => { + // Shift may turn the wheel sideways (the Mac does). + let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; + notches += match unit { + MouseWheelUnit::Point => d / line.max(1.0), + MouseWheelUnit::Line | MouseWheelUnit::Page => d, + }; + let whole = notches.trunc(); + notches -= whole; + f64::from(whole) + } + _ => return true, }; - let whole = notches.trunc(); - notches -= whole; - steps += size * f64::from(whole); + if n != 0.0 { + steps.push((mods, size, n)); + } false }); - // The rest of a turn egui spreads over the next frames scrolls nothing either. - i.smooth_scroll_delta = egui::Vec2::ZERO; + if wheel { + // The rest of a turn egui spreads over the next frames scrolls nothing either. + i.smooth_scroll_delta = egui::Vec2::ZERO; + } }); if wheel { ui.data_mut(|d| d.insert_temp(notch_id, notches)); } - if steps == 0.0 { + if steps.is_empty() { return None; } + let mut v = read(buf)?; + for (mods, size, n) in steps { + v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) }; + } // Rounded as fields show it. - let v = round_to(read(buf)? + steps, STEP_DECIMALS); + let v = round_to(v, STEP_DECIMALS); *buf = show(v); ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); select_all(ui, resp.id, buf); @@ -2554,6 +2580,41 @@ mod spinner_modifier_tests { } } +#[cfg(test)] +mod step_to_multiple_tests { + use super::*; + + /// #2881: the first step goes to the next multiple of its size in its direction, and each further + /// step adds or takes one size. + #[test] + fn a_step_goes_to_the_next_multiple_of_its_size() { + for (v, size, n, want) in [ + (5.1, 1.0, 1.0, 6.0), + (5.1, 1.0, -1.0, 5.0), + (5.0, 1.0, 1.0, 6.0), + (5.0, 1.0, -1.0, 4.0), + (-2.5, 1.0, 1.0, -2.0), + (-2.5, 1.0, -1.0, -3.0), + (24.8, 1.0, 2.0, 26.0), + (24.8, 1.0, -2.0, 23.0), + (5.13, 0.1, 1.0, 5.2), + (5.13, 0.1, -1.0, 5.1), + (5.1, 1.0, 0.0, 5.1), + ] { + assert!((step_to_multiple(v, size, n) - want).abs() < 1e-9, "{v} by {size} × {n}: {}", step_to_multiple(v, size, n)); + } + } + + /// A value a float error away from a multiple moves a whole step from it: 0.7 is 6.999… tenths. + #[test] + fn a_value_next_to_a_multiple_moves_a_whole_step() { + assert!((step_to_multiple(0.7, 0.1, 1.0) - 0.8).abs() < 1e-9); + assert!((step_to_multiple(0.3, 0.1, -1.0) - 0.2).abs() < 1e-9); + assert!((step_to_multiple(5.000_000_1, 1.0, 1.0) - 6.0).abs() < 1e-9); + assert!((step_to_multiple(4.999_999_9, 1.0, -1.0) - 4.0).abs() < 1e-9); + } +} + #[cfg(test)] mod menu_room_tests { use super::*; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bdacbf7d3..f5ec2836d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -485,6 +485,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. + - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - 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. - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. - 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.