From 08fa0420398da964bf60fd37d802b42866dcbbdb Mon Sep 17 00:00:00 2001 From: PaddleStroke Date: Wed, 4 Nov 2026 22:10:04 +0000 Subject: [PATCH] Dialogs: drag a value's label sideways to change it (#2217) (#9685) * claim: #2217 * Dialogs: drag a value's label sideways to change it (#2217) A value row's label (expr_row, hint_row) now scrubs a literal value: about a hundredth of its size per point of drag, on that step's grid, Shift ten times finer, Ctrl/Cmd ten times coarser. The unit and decimal mark stay as typed, a size never turns negative, and expressions and parameters are left alone. The text changes as if typed, so previews follow and OK still makes one command. Co-Authored-By: Claude Opus 5.5 * Label drag: measure from the press point, cap values, keep scrub kit-private Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/ui_kit/fields.rs | 113 ++++++++++++++++++++++ crates/ui-egui/src/ui_kit/fields_tests.rs | 68 +++++++++++++ crates/ui-egui/src/ui_kit/layout.rs | 39 ++++++-- docs/ui-kit.md | 4 + 4 files changed, 218 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 90be756237..c6cd6b837c 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -128,6 +128,119 @@ fn step_count(text: &str, delta: i64, limits: &Count<'_>) -> Option { candidates.into_iter().flatten().find(|c| (limits.eval)(c).is_some_and(|v| (v - target as f64).abs() < 1e-9)) } +/// A value a label drag can change: a number with an optional unit ("20 mm", "1,5 in", "45°"). +#[derive(Clone, Debug, PartialEq)] +struct Literal { + number: f64, + /// The text after the number, as written (" mm"). + unit: String, + comma: bool, +} + +/// The largest value a label drag reads or writes. +const LITERAL_CAP: f64 = 1e12; + +/// `text` as a [`Literal`], or None for anything else: a parameter, a formula, an empty field. +/// Dragging never rewrites an expression, so a driving parameter stays driving. +fn literal(text: &str) -> Option { + let text = text.trim(); + if !solidcraft_engine::doc::expr::is_literal(text) { + return None; + } + let sign = usize::from(text.starts_with(['-', '+'])); + let end = text.char_indices().skip(sign).find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == ',')).map_or(text.len(), |(at, _)| at); + let (number, unit) = (text.get(..end)?, text.get(end..)?); + // An exponent ("1e3") or anything else that is not a unit is left alone. + solidcraft_engine::doc::expr::unit_info(unit)?; + let comma = number.contains(','); + let number: f64 = number.replace(',', ".").parse().ok().filter(|n: &f64| n.is_finite() && n.abs() < LITERAL_CAP)?; + Some(Literal { number, unit: unit.to_owned(), comma }) +} + +/// How much one point of drag changes a value of this size, before Shift or Ctrl: about a +/// hundredth of it, as a power of ten, so 20 mm moves by 0.1 mm and 250 mm by 1 mm. +fn scrub_step(number: f64) -> f64 { + let decade = number.abs().max(1.0).log10().floor().clamp(0.0, 8.0); + 10f64.powf(decade - 2.0) +} + +/// `anchor` moved by `points` of drag at `step` per point, on the step's grid. A value that +/// starts at zero or above stays there: a drag never turns a size negative. +fn scrubbed(anchor: f64, points: f32, step: f64) -> f64 { + let moved = ((anchor + f64::from(points) * step) / step).round() * step; + let moved = if anchor >= 0.0 { moved.max(0.0) } else { moved }; + // Literal values are capped where `literal` reads them; a drag never leaves that range. + if moved.is_finite() && moved.abs() < LITERAL_CAP { moved } else { anchor } +} + +/// The scrubbed text: as many decimals as the step needs, the unit and decimal mark as typed. +fn scrub_text(number: f64, step: f64, literal: &Literal) -> String { + let decimals = (-step.log10()).ceil().clamp(0.0, 6.0) as usize; + let mut text = format!("{number:.decimals$}"); + if text.starts_with('-') && text.trim_start_matches(['-', '0', '.']).is_empty() { + text.remove(0); + } + if literal.comma { + text = text.replace('.', ","); + } + text + &literal.unit +} + +/// An ongoing label drag: the value and pointer x it is measured from, and the speed in use. +#[derive(Clone)] +struct Scrub { + anchor: f64, + origin: f32, + step: f64, +} + +/// Dragging a row's label sideways changes the literal value in its field: Shift for a tenth of +/// the speed, Ctrl (Cmd on a Mac) for ten times. The text changes as if typed, so previews +/// follow, and the dialog's OK still makes the one command (and the one undo step). A field +/// holding an expression or a parameter is left alone. Returns whether the text changed. +pub(super) fn scrub(ui: &Ui, label: &Response, value: &mut String) -> bool { + let id = label.id.with("sc_scrub"); + let Some(now) = literal(value).filter(|_| ui.is_enabled()) else { + ui.ctx().data_mut(|data| data.remove::(id)); + return false; + }; + if label.hovered() || label.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if !label.dragged() { + ui.ctx().data_mut(|data| data.remove::(id)); + return false; + } + let factor = ui.input(|i| { + if i.modifiers.shift { + 0.1 + } else if i.modifiers.command { + 10.0 + } else { + 1.0 + } + }); + // Measured from where the pointer is, not summed per frame, so no frame's jump is lost or doubled. + let (Some(x), Some(pressed)) = ui.input(|i| (i.pointer.latest_pos().map(|p| p.x), i.pointer.press_origin().map(|p| p.x))) else { + return false; + }; + let base = ui.ctx().data(|data| data.get_temp::(id)); + // A new drag starts from where it was pressed; a changed modifier starts over from here. + let state = match base { + Some(state) if (state.step - scrub_step(state.anchor) * factor).abs() <= state.step * 1e-9 => state, + Some(_) => Scrub { anchor: now.number, origin: x, step: scrub_step(now.number) * factor }, + None => Scrub { anchor: now.number, origin: pressed, step: scrub_step(now.number) * factor }, + }; + let points = x - state.origin; + let text = scrub_text(scrubbed(state.anchor, if points.is_finite() { points } else { 0.0 }, state.step), state.step, &now); + ui.ctx().data_mut(|data| data.insert_temp(id, state)); + if text == *value { + return false; + } + *value = text; + true +} + /// Names, thread designations and plain numeric counts do not accept expression completion. /// Plain text editor without expression completion; returns (Enter, changed). Tokens: `same as field`. pub(crate) fn text_field(ui: &mut Ui, value: &mut String, hint: &str) -> (bool, bool) { diff --git a/crates/ui-egui/src/ui_kit/fields_tests.rs b/crates/ui-egui/src/ui_kit/fields_tests.rs index fba9e19bb3..6b87ca4fc6 100644 --- a/crates/ui-egui/src/ui_kit/fields_tests.rs +++ b/crates/ui-egui/src/ui_kit/fields_tests.rs @@ -1303,3 +1303,71 @@ fn a_track_number_adds_up_and_a_sign_adds_to_the_value_now() { assert_eq!(typed("1e-3+1", 0.0), Some(1.001)); assert_eq!(typed("abc", 1.0), None); } + +#[test] +fn a_label_drag_reads_only_literal_values_and_moves_them_on_the_step_grid() { + let literal = |text: &str| super::literal(text).map(|l| (l.number, l.unit, l.comma)); + assert_eq!(literal("20 mm"), Some((20.0, " mm".into(), false))); + assert_eq!(literal(" 1,5in "), Some((1.5, "in".into(), true))); + assert_eq!(literal("-3"), Some((-3.0, String::new(), false))); + for not_a_value in ["width", "width + 2 mm", "2 * 3 mm", "", "1e3", "1e400 mm", "20 bananas"] { + assert_eq!(literal(not_a_value), None, "{not_a_value:?} is left alone"); + } + assert_eq!(super::scrub_step(20.0), 0.1); + assert_eq!(super::scrub_step(250.0), 1.0); + assert_eq!(super::scrub_step(0.0), 0.01); + assert_eq!(super::scrub_step(-45.0), 0.1); + assert!((super::scrubbed(20.0, 30.0, 0.1) - 23.0).abs() < 1e-9); + assert!((super::scrubbed(20.37, 1.0, 0.1) - 20.5).abs() < 1e-9, "the value lands on the step's grid"); + assert_eq!(super::scrubbed(2.0, -500.0, 0.1), 0.0, "a size stops at zero"); + assert!((super::scrubbed(-2.0, -10.0, 0.1) + 3.0).abs() < 1e-9, "a negative offset keeps moving"); + assert_eq!(super::scrubbed(1.0, f32::MAX, 1e8), 1.0, "an overflowing drag keeps the value"); + let text = |n, step, typed: &str| super::scrub_text(n, step, &super::literal(typed).unwrap()); + assert_eq!(text(23.0, 0.1, "20 mm"), "23.0 mm"); + assert_eq!(text(1.75, 0.01, "1,5 in"), "1,75 in"); + assert_eq!(text(-0.0001, 0.01, "-1"), "0.00"); + assert_eq!(text(300.0, 1.0, "250 mm"), "300 mm"); +} + +#[test] +fn dragging_a_value_rows_label_changes_its_literal_and_leaves_expressions_alone() { + fn frame(ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers, values: &mut [String; 2]) { + scenario::clear_handles(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 400.0))), + events: std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(), + focused: true, + ..Default::default() + }; + crate::test_support::frame(ctx, input, |ui| { + ui.set_width(280.0); + scope(ui, |ui| { + let [distance, offset] = &mut *values; + expr_row(ui, "Distance", distance); + expr_row(ui, "Offset", offset); + }); + }); + } + fn drag(ctx: &egui::Context, row: &str, dx: f32, modifiers: egui::Modifiers, values: &mut [String; 2]) { + let start = scenario::rect_of(&format!("dialog:label:{row}")).unwrap().center(); + let end = start + egui::vec2(dx, 0.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + frame(ctx, vec![egui::Event::PointerMoved(start), button(start, true)], modifiers, values); + for step in 1..=4 { + frame(ctx, vec![egui::Event::PointerMoved(start + egui::vec2(dx * step as f32 / 4.0, 0.0))], modifiers, values); + } + frame(ctx, vec![button(end, false)], modifiers, values); + frame(ctx, vec![], modifiers, values); + } + let ctx = crate::fonts::test_context(); + let mut values = ["20 mm".to_owned(), "depth + 2 mm".to_owned()]; + frame(&ctx, vec![], Default::default(), &mut values); + drag(&ctx, "Distance", 40.0, Default::default(), &mut values); + assert_eq!(values[0], "24.0 mm", "40 points at 0.1 mm a point"); + drag(&ctx, "Distance", 40.0, egui::Modifiers::SHIFT, &mut values); + assert_eq!(values[0], "24.40 mm", "Shift is ten times finer"); + drag(&ctx, "Distance", -20.0, egui::Modifiers::COMMAND, &mut values); + assert_eq!(values[0], "4 mm", "Ctrl is ten times coarser"); + drag(&ctx, "Offset", 60.0, Default::default(), &mut values); + assert_eq!(values[1], "depth + 2 mm", "an expression keeps its parameter"); +} diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index 83bd1f9fa5..8cee5627be 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -213,8 +213,14 @@ pub(crate) fn status_line(ui: &mut Ui, text: &str, tone: StatusTone) -> Response } /// Declarative expression row; retains the field's completion and accepted Enter. Tokens: `row, field`. +/// Dragging the label sideways changes a literal value (`fields::scrub`). pub(crate) fn expr_row(ui: &mut Ui, label: &str, value: &mut String) -> bool { - row(ui, label, |ui| super::field(ui, value)) + scrub_row(ui, label, |ui, scrub| { + if let Some(scrub) = scrub { + super::fields::scrub(ui, scrub, value); + } + super::field(ui, value) + }) } /// Whole-number count row with step arrows; retains completion and accepted Enter. Tokens: `row, count_field`. @@ -223,8 +229,14 @@ pub(crate) fn count_row(ui: &mut Ui, label: &str, value: &mut String, limits: &s } /// Expression row with an empty-value hint. Tokens: `row, hint_field`. +/// Its label scrubs a literal value as `expr_row`'s does. pub(crate) fn hint_row(ui: &mut Ui, label: &str, value: &mut String, hint: &str) -> bool { - row(ui, label, |ui| super::hint_field(ui, value, hint)) + scrub_row(ui, label, |ui, scrub| { + if let Some(scrub) = scrub { + super::fields::scrub(ui, scrub, value); + } + super::hint_field(ui, value, hint) + }) } /// Expression row with an empty-value hint whose value the form checks: with `warning`, the @@ -276,6 +288,16 @@ pub(crate) fn continuity_tip(allowed: &[Continuity]) -> String { /// Internal form geometry for controls which need several lines (e.g. text areas). pub(super) fn sized_row(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R { + row_with(ui, text, height, false, |ui, _| control(ui)) +} + +/// A row whose label also senses a drag; the control gets that drag first (None while a +/// Preferences search hides the row). Tokens: `row`. +fn scrub_row(ui: &mut Ui, text: &str, control: impl FnOnce(&mut Ui, Option<&Response>) -> R) -> R { + row_with(ui, text, row_height(), true, control) +} + +fn row_with(ui: &mut Ui, text: &str, height: f32, scrub: bool, control: impl FnOnce(&mut Ui, Option<&Response>) -> R) -> R { let mut passthrough = false; if crate::prefs_search::querying(ui) { // A row with no label has no words to match; its controls supply them (#9265). @@ -289,7 +311,7 @@ pub(super) fn sized_row(ui: &mut Ui, text: &str, height: f32, control: impl F if unlabelled { crate::prefs_search::push_unlabelled(ui); } - let result = crate::prefs_search::run_hidden(ui, text, control); + let result = crate::prefs_search::run_hidden(ui, text, |ui| control(ui, None)); if unlabelled { crate::prefs_search::pop_unlabelled(ui); } @@ -299,14 +321,14 @@ pub(super) fn sized_row(ui: &mut Ui, text: &str, height: f32, control: impl F crate::prefs_search::push_passthrough(ui); passthrough = true; } - let result = sized_row_body(ui, text, height, control); + let result = sized_row_body(ui, text, height, scrub, control); if passthrough { crate::prefs_search::pop_passthrough(ui); } result } -fn sized_row_body(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R { +fn sized_row_body(ui: &mut Ui, text: &str, height: f32, scrub: bool, control: impl FnOnce(&mut Ui, Option<&Response>) -> R) -> R { let t = Tokens::get(); let width = dimension(ui.available_width()); let (rect, _) = ui.allocate_exact_size(vec2(width, height), egui::Sense::hover()); @@ -345,6 +367,11 @@ fn sized_row_body(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce( } }); } + // Sensed after the label, so a drag on it reaches this rather than the label; a help icon + // beside the label keeps its own hover and click. + let scrub_width = if help.is_some() { (label_width - t.space_16 - t.space_4).max(0.0) } else { label_width }; + let scrub_rect = egui::Rect::from_min_size(label_rect.min, vec2(scrub_width, rect.height())); + let drag = (scrub && scrub_width > 0.0).then(|| ui.interact(scrub_rect, ui.id().with(("sc_row_scrub", text)), egui::Sense::drag())); let gutter = if label_width > 0.0 { label_gap(ui, width) } else { 0.0 }; let control_rect = egui::Rect::from_min_max(egui::pos2((label_rect.right() + gutter).min(rect.right()), rect.top()), rect.max); let result = ui @@ -357,7 +384,7 @@ fn sized_row_body(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce( |ui| { ui.set_clip_rect(ui.clip_rect().intersect(control_rect)); ui.spacing_mut().text_edit_width = control_rect.width(); - control(ui) + control(ui, drag.as_ref()) }, ) .inner; diff --git a/docs/ui-kit.md b/docs/ui-kit.md index e37b1533a0..3ca4ef19de 100644 --- a/docs/ui-kit.md +++ b/docs/ui-kit.md @@ -98,6 +98,10 @@ rules. Its compass is 110 pt; the 48 pt Shots strip clears the docks by 8 pt. `hint_row` for a value the form checks before it runs: with a warning, the field is outlined in the warning colour and the message sits under it (`warning_caption`), so colour is never the only cue. + The label of an `expr_row` or `hint_row` scrubs: dragging it sideways changes a + literal value (`20 mm`, `45°`) by about a hundredth of its size per point, on that step's + grid, Shift ten times finer and Ctrl/Cmd ten times coarser. It keeps the unit and decimal + mark as typed, never makes a size negative, and leaves expressions and parameters alone. - `Panel::workspace` owns grip-based left/right placement and bounded width resizing for floating workspace cards, including in Compact ribbon layouts. It reuses the 40 pt Panel/Window header and padded body, with no second top inset below the ribbon.