Files
vectorcraft/crates/ui-egui/src/scrub.rs
T
bflatasticandClaude Opus 5.5 b71d877a0a Fix #400: Scrub numeric fields by dragging their label
Dragging a numeric field's label left or right steps the field's value,
as Photoshop's scrubby labels do (beyond Illustrator): one unit every
2 points, ten with Shift, a tenth with Ctrl/Cmd (counts stay whole), in
the field's unit and precision. The panels apply each value as they
apply a typed one, so the art follows live and the caller's limits
hold (dragging back from a limit changes the value at once).

- scrub.rs: the label zone over the label noted just before a field
  (dim_label, label_row, widgets::field_label, the Character and
  Paragraph panels' icons, the Transform and Properties rotate/shear
  icons), the ↔ cursor on hover, a click on the label focuses the
  field. The text box itself never scrubs; a field without a label
  doesn't. num_field, plain_field, mixed_field (and the spin fields)
  and opt_field take part.
- Engine: undo groups (Session::begin_undo_group / end_undo_group). The
  edits made in a group, commands and committed live drags alike, are
  one undo step, journaled one by one; a cancelled group is undone and
  dropped from the journal. The app opens one while a label is dragged,
  so a scrub is one undo step whichever path the panel applies values
  by (app.run, session.execute, live_run), and Escape cancels it
  (shortcuts leave Escape to the drag, so it doesn't close a dialog or
  deselect).
- Preferences > General > Scrub Numeric Fields by Dragging
  (`scrubNumericFields`, on by default), translated in ja, es, zh-hans,
  zh-hant and pt-br.
- Tests: label drags with Shift/Ctrl, limits, counts and units, Escape,
  the preference, the cursor, click-to-focus, no scrub on the box or
  without a label, and in the app one undo step for a W drag.
- ROADMAP and docs/mcp.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +07:00

206 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Scrubbing numeric fields (#400, beyond Illustrator; Photoshop's scrubby labels): a horizontal
//! drag on a numeric field's label (`W:`, `Opacity:`, the Character panel's size icon) steps the
//! field's value by one unit every [`PX_PER_STEP`] points, ten with Shift and a tenth with
//! Ctrl/Cmd, as the arrow keys step a focused field; a click on the label focuses the field. The
//! text box itself never scrubs, and a field without a label doesn't.
//!
//! The panels apply each value the drag passes as they apply a typed one, so the art follows the
//! drag live; the app holds an undo group open meanwhile ([`begin_frame`], [`end_frame`]), so the
//! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by
//! Dragging turns it off.
use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui};
use crate::VectorcraftApp;
/// Points of horizontal drag per step.
pub const PX_PER_STEP: f32 = 2.0;
/// The widest gap between a label and the field it names.
const LABEL_GAP: f32 = 64.0;
/// Where the scrub in progress is kept (egui memory).
fn state_id() -> Id {
Id::new("numeric-field-scrub")
}
/// Where the last label drawn waits for its field.
fn label_id() -> Id {
Id::new("numeric-field-label")
}
fn enabled_id() -> Id {
Id::new("numeric-field-scrub-enabled")
}
/// Turn scrubbing on or off (General › Scrub Numeric Fields by Dragging; on until set).
pub(crate) fn set_enabled(ctx: &Context, on: bool) {
ctx.data_mut(|d| d.insert_temp(enabled_id(), on));
}
fn enabled(ctx: &Context) -> bool {
ctx.data(|d| d.get_temp(enabled_id())).unwrap_or(true)
}
/// The scrub in progress.
#[derive(Clone, Copy, Debug)]
struct Scrub {
/// The zone dragged: a field or its label.
zone: Id,
/// The field's value when the drag started (in its units), which Escape puts back.
start: f64,
/// Drag not applied yet, in the field's units.
pending: f64,
/// The step `pending` counts in (it changes with Shift and Ctrl/Cmd).
step: f64,
/// A value was applied.
moved: bool,
/// Escape ended the drag.
cancelled: bool,
}
/// Where the scrub of a numeric field stands this frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Phase {
Idle,
Dragging,
/// Released this frame.
Released,
/// Escape ended it this frame.
Cancelled,
}
pub(crate) fn phase(ctx: &Context) -> Phase {
let Some(s) = ctx.data(|d| d.get_temp::<Scrub>(state_id())) else { return Phase::Idle };
if ctx.drag_stopped_id() == Some(s.zone) {
if s.cancelled { Phase::Cancelled } else { Phase::Released }
} else if ctx.is_being_dragged(s.zone) {
Phase::Dragging
} else {
Phase::Idle
}
}
/// Before the panels draw: while a field is scrubbed, the edits its values make are one undo step.
/// (The drag's first frame applies nothing, so the group is open before its first value.)
pub(crate) fn begin_frame(app: &mut VectorcraftApp, ctx: &Context) {
if !app.scrub_group && matches!(phase(ctx), Phase::Dragging | Phase::Released) {
app.session.begin_undo_group();
app.scrub_group = true;
}
}
/// After the panels: a finished drag closes its undo group; Escape undoes it.
pub(crate) fn end_frame(app: &mut VectorcraftApp, ctx: &Context) {
let phase = phase(ctx);
if !app.scrub_group || phase == Phase::Dragging {
return;
}
app.scrub_group = false;
app.session.end_undo_group(phase == Phase::Cancelled);
app.sync_views();
}
/// A field's label drawn this pass, waiting for the field after it.
#[derive(Clone, Copy, Debug)]
struct Label {
rect: Rect,
pass: u64,
}
/// Note the label (text or icon) drawn at `rect`: a numeric field drawn right after it on the
/// same line takes it as its label, which scrubs the field.
pub(crate) fn note_label(ui: &Ui, rect: Rect) {
let pass = ui.ctx().cumulative_pass_nr();
ui.data_mut(|d| d.insert_temp(label_id(), Label { rect, pass }));
}
/// The label noted just before the field at `field`, on its line.
fn take_label(ui: &Ui, field: Rect) -> Option<Rect> {
let l = ui.data_mut(|d| {
let l = d.get_temp::<Label>(label_id());
d.remove::<Label>(label_id());
l
})?;
let gap = field.left() - l.rect.right();
(l.pass == ui.ctx().cumulative_pass_nr() && (-1.0..=LABEL_GAP).contains(&gap) && field.y_range().contains(l.rect.center().y)).then_some(l.rect)
}
/// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded
/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it
/// started at when Escape cancels the drag. A click on the label (or a press held without
/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label,
/// disabled or showing no value (objects that differ).
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32) -> Option<f64> {
let label = take_label(ui, rect)?;
let value = value.filter(|v| v.is_finite())?;
if !enabled(ui.ctx()) || !ui.is_enabled() {
return None;
}
// On top of the label, which may sense clicks itself (selectable text).
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
drag(ui, &resp, field, text, value, decimals)
}
/// What a drag on `resp`, the label of field `field`, does this frame.
fn drag(ui: &Ui, resp: &Response, field: Id, text: &str, value: f64, decimals: i32) -> Option<f64> {
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(CursorIcon::ResizeHorizontal);
}
if resp.clicked() {
crate::widgets::focus_field(ui, field, text);
return None;
}
if resp.drag_started() {
// Typing elsewhere ends, as a click elsewhere ends it.
ui.memory_mut(|m| m.stop_text_input());
let s = Scrub { zone: resp.id, start: value, pending: 0.0, step: 0.0, moved: false, cancelled: false };
ui.data_mut(|d| d.insert_temp(state_id(), s));
}
if !(resp.dragged() || resp.drag_stopped()) {
return None;
}
let mut s = ui.data(|d| d.get_temp::<Scrub>(state_id())).filter(|s| s.zone == resp.id && !s.cancelled)?;
// egui ends the drag on Escape: the field goes back to where it started.
let out = if ui.input_mut(|i| i.consume_key(i.modifiers, Key::Escape)) {
s.cancelled = true;
s.moved.then_some(s.start)
} else if resp.drag_stopped() {
// Held without stepping: a slow click.
if !s.moved {
crate::widgets::focus_field(ui, field, text);
}
None
} else {
step(ui, resp, &mut s, value, decimals)
};
ui.data_mut(|d| d.insert_temp(state_id(), s));
out
}
/// The value this frame's drag steps `value` to, if it makes a whole step.
fn step(ui: &Ui, resp: &Response, s: &mut Scrub, value: f64, decimals: i32) -> Option<f64> {
let mods = ui.input(|i| i.modifiers);
let size = if mods.command {
0.1
} else if mods.shift {
10.0
} else {
1.0
};
// A count steps by whole numbers: Ctrl/Cmd makes it slower instead.
let step = f64::max(size, 10f64.powi(-decimals));
if step != s.step {
(s.step, s.pending) = (step, 0.0);
}
s.pending += f64::from(resp.drag_delta().x / PX_PER_STEP) * size;
// (Nudged off the float error of summed tenths.)
let n = (s.pending / step + s.pending.signum() * 1e-9).trunc();
// The drag's first frame only starts it: the app opens its undo group on the next.
if n == 0.0 || resp.drag_started() {
return None;
}
s.pending -= n * step;
s.moved = true;
Some(crate::widgets::round_to(value + n * step, decimals))
}