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 <noreply@anthropic.com>

* Label drag: measure from the press point, cap values, keep scrub kit-private

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PaddleStroke
2026-11-04 22:10:04 +00:00
committed by GitHub
parent 09bb8aaaf5
commit 08fa042039
4 changed files with 218 additions and 6 deletions
+113
View File
@@ -128,6 +128,119 @@ fn step_count(text: &str, delta: i64, limits: &Count<'_>) -> Option<String> {
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<Literal> {
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::<Scrub>(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::<Scrub>(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::<Scrub>(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) {
+68
View File
@@ -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<egui::Event>, 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");
}
+33 -6
View File
@@ -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<R>(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<R>(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<R>(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<R>(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<R>(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<R>(ui: &mut Ui, text: &str, height: f32, control: impl FnOnce(&mut Ui) -> R) -> R {
fn sized_row_body<R>(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<R>(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<R>(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;
+4
View File
@@ -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.