Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 517b166722 Fix #485: the mouse wheel steps the focused numeric field
The wheel did nothing over numeric fields. As in Illustrator, once a numeric
field has the focus, turning the wheel over it steps its value like Up/Down:
a notch a step, Shift ten, Ctrl/Cmd a tenth, applied at once with the new value
selected.

- widgets::step_value (was step_with_arrows) reads the arrows and the wheel
  through one path for num_field, plain/mixed fields and the spin fields built
  on them (panels, the Control bar, dialogs, the Font Size and Leading combo
  boxes), and now opt_field (Stroke dash/gap) too. Trackpad and fine-wheel
  points add up to notches; the rest carries to the next turn.
- While the pointer is over the focused field the panel under it doesn't
  scroll; an unfocused field, or the pointer anywhere else, leaves the wheel to
  the panel and the canvas as before.
- The dialogs' and preferences' egui drag values become the same recessed
  number fields (widgets::range_field: clamped to their range, rounded to their
  precision), so they type, step and scroll like every other numeric field.
- Headless tests for the wheel (units, modifiers, points, focus and pointer),
  and for the panel scrolling or not; docs/control-protocol.md and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:21:39 +07:00
12 changed files with 192 additions and 59 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483); see [Honest assessment](#honest-assessment-2026-10-05))._
_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -107,7 +107,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Four brightness themes, persistent preferences.
- 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) 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 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. They reach agents too (`prefs.set`, then `pointer_gesture` or `press_key`; `ui.wheel` on the control channel). About 35 stored options still do nothing yet (the rest of #394: Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, Construction and Spacing Guides, performance, devices…).
- 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…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). 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.
- 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.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too. Not yet: Select All across threaded frames (one frame's text is selected).
+1 -2
View File
@@ -225,8 +225,7 @@ fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
if d.fields.contains_key("gamma") {
widgets::label_row(ui, tl!("Gamma"), LABEL_W, |ui| {
let mut g = d.f64("gamma", 1.0);
if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() {
if let Some(g) = widgets::range_field(ui, "gamma", d.f64("gamma", 1.0), 0.1..=10.0, "", 2, 60.0) {
d.fields.insert("gamma".into(), json!(g));
}
});
+5 -8
View File
@@ -238,9 +238,7 @@ pub(super) fn param_fields(
}
match v {
Value::Number(n) => {
let mut x = n.as_f64().unwrap_or(0.0);
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
d.fields.insert(k, json!(x));
changed = true;
}
@@ -288,13 +286,12 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value());
let new = match spec {
ParamSpec::Number { min, max, .. } => {
let mut x = cur.as_f64().unwrap_or(*min).clamp(*min, *max);
let speed = ((max - min) / 200.0).clamp(0.01, 10.0);
ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(speed).max_decimals(2)).changed().then(|| json!(x))
let x = cur.as_f64().unwrap_or(*min);
crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x))
}
ParamSpec::Int { min, max, .. } => {
let mut x = cur.as_i64().unwrap_or(*min).clamp(*min, *max);
ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(0.2)).changed().then(|| json!(x))
let x = cur.as_i64().unwrap_or(*min) as f64;
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
}
ParamSpec::Bool { .. } => {
let mut b = cur.as_bool().unwrap_or(false);
+4 -3
View File
@@ -178,9 +178,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
toggle(ui, d, "preview", tl!("Preview"), true);
ui.horizontal(|ui| {
toggle(ui, d, "dimImages", tl!("Dim Images to:"), true);
let mut v = d.f64("dimPercent", 50.0);
if ui.add_enabled(d.bool("dimImages"), egui::DragValue::new(&mut v).range(0.0..=100.0).suffix("%")).changed() {
d.fields.insert("dimPercent".into(), json!(v.round()));
let v = d.f64("dimPercent", 50.0);
let dim = d.bool("dimImages");
if let Some(v) = ui.add_enabled_ui(dim, |ui| crate::widgets::range_field(ui, "dimPercent", v, 0.0..=100.0, "%", 0, 52.0)).inner {
d.fields.insert("dimPercent".into(), json!(v));
}
});
});
@@ -51,9 +51,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if let Some(i) = widgets::dropdown(ui, "layers-row-size", shown, &labels, 110.0) {
d.fields.insert("rowSize".into(), json!(SIZES.get(i).map_or("other", |s| s.0)));
}
let mut v = d.f64("otherSize", f64::from(ROW_MEDIUM));
if ui.add_enabled(d.str("rowSize") == "other", egui::DragValue::new(&mut v).range(12.0..=100.0).suffix(" pt")).changed() {
d.fields.insert("otherSize".into(), json!(v.round()));
let v = d.f64("otherSize", f64::from(ROW_MEDIUM));
let other = d.str("rowSize") == "other";
if let Some(v) = ui.add_enabled_ui(other, |ui| widgets::range_field(ui, "otherSize", v, 12.0..=100.0, " pt", 0, 60.0)).inner {
d.fields.insert("otherSize".into(), json!(v));
}
});
ui.end_row();
+9 -10
View File
@@ -254,9 +254,9 @@ fn method_row(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &
choice(ui, d, "method", &Method::ALL.map(Method::id), &Method::ALL.map(Method::label));
if Method::from_id(&d.str("method")) == Some(Method::Progressive) {
label(ui, tl!("Scans:"));
let mut scans = d.f64("scans", 3.0).round().clamp(*jpeg::SCANS.start() as f64, *jpeg::SCANS.end() as f64) as u8;
if ui.add(egui::DragValue::new(&mut scans).range(jpeg::SCANS)).changed() {
d.fields.insert("scans".into(), json!(scans));
let range = f64::from(*jpeg::SCANS.start())..=f64::from(*jpeg::SCANS.end());
if let Some(scans) = widgets::range_field(ui, "scans", d.f64("scans", 3.0), range, "", 0, 44.0) {
d.fields.insert("scans".into(), json!(scans as u8));
}
}
});
@@ -277,8 +277,8 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
n = COLOR_COUNTS.get(i).and_then(|c| c.parse().ok()).unwrap_or(n);
d.fields.insert("colors".into(), json!(n));
}
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
d.fields.insert("colors".into(), json!(n));
if let Some(n) = widgets::range_field(ui, "colors", f64::from(n), 2.0..=256.0, "", 0, 52.0) {
d.fields.insert("colors".into(), json!(n as u16));
}
});
ui.end_row();
@@ -286,11 +286,10 @@ fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui,
label(ui, tl!("Dither:"));
ui.horizontal(|ui| {
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
let mut amount = d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0) as u8;
if ui.add(egui::DragValue::new(&mut amount).range(0..=100).suffix("%")).changed() {
d.fields.insert("ditherAmount".into(), json!(amount));
}
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None)
&& let Some(amount) = widgets::range_field(ui, "ditherAmount", d.f64("ditherAmount", 100.0), 0.0..=100.0, "%", 0, 52.0)
{
d.fields.insert("ditherAmount".into(), json!(amount as u8));
}
});
ui.end_row();
+2 -2
View File
@@ -646,8 +646,8 @@ fn colors_row(ui: &mut egui::Ui, d: &mut Dialog) {
n = c.parse().unwrap_or(n);
d.fields.insert("colors".into(), json!(n));
}
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
d.fields.insert("colors".into(), json!(n));
if let Some(n) = widgets::range_field(ui, "sfw-colors-n", f64::from(n), 2.0..=256.0, "", 0, 52.0) {
d.fields.insert("colors".into(), json!(n as u16));
}
});
}
+5 -5
View File
@@ -169,11 +169,11 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
}
} else {
let integer = o.ty == "integer";
let mut x = value.as_f64().unwrap_or(0.0);
let drag =
egui::DragValue::new(&mut x).range(0.0..=f64::MAX).speed(if integer { 1.0 } else { 0.1 }).max_decimals(if integer { 0 } else { 2 });
if ui.add(drag).on_hover_text(o.description).changed() {
d.fields.insert(o.name.into(), if integer { json!(x.round() as i64) } else { json!(x) });
let x = value.as_f64().unwrap_or(0.0);
let field = ui.scope(|ui| widgets::range_field(ui, ("save-option", o.name), x, 0.0..=f64::MAX, "", if integer { 0 } else { 2 }, 80.0));
field.response.on_hover_text(o.description);
if let Some(x) = field.inner {
d.fields.insert(o.name.into(), if integer { json!(x as i64) } else { json!(x) });
}
}
ui.end_row();
+8 -9
View File
@@ -235,13 +235,12 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
PrefKind::Num { min, max, unit } => {
labeled(ui, sp.label, |ui| {
let mut x = v.as_f64().unwrap_or(min);
let r = if sp.key == "uiScaling" {
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger")))
let new = if sp.key == "uiScaling" {
ui.add(egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x)
} else {
let speed = if max - min > 100.0 { 0.5 } else { 0.05 };
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(speed).max_decimals(3).suffix(format!(" {unit}")))
widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0)
};
if r.changed() {
if let Some(x) = new {
d.fields.insert(sp.key.into(), json!(x));
}
});
@@ -259,12 +258,12 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
PrefKind::Int { min, max } => {
labeled(ui, sp.label, |ui| {
let mut x = v.as_i64().unwrap_or(min);
let r = if sp.key == "anchorSize" {
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size")))
let new = if sp.key == "anchorSize" {
ui.add(egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x)
} else {
ui.add(egui::DragValue::new(&mut x).range(min..=max).speed(0.2))
widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64)
};
if r.changed() {
if let Some(x) = new {
d.fields.insert(sp.key.into(), json!(x));
}
});
+87
View File
@@ -180,3 +180,90 @@ fn arrow_keys_step_plain_fields() {
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));
}
/// A wheel turn of `dy` (`unit`s, up positive) with `modifiers`, the pointer at `at`.
fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Vec<Event> {
vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }]
}
/// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift
/// ten, Ctrl/Cmd a tenth; a trackpad's points add up to notches. Unfocused, or with the pointer
/// elsewhere, the wheel isn't the field's.
#[test]
fn the_wheel_steps_a_focused_field_under_the_pointer() {
use egui::MouseWheelUnit::{Line, Point};
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);
let at = f.rect.get().center();
assert_eq!(f.frame(wheel(at, Line, 1.0, Modifiers::NONE), &draw), None, "unfocused");
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)]
{
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");
// 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_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();
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(wheel(f.rect.get().center(), Line, -1.0, Modifiers::NONE), &count), Some(2.0));
}
/// The wheel over a focused field steps it and leaves the panel where it is; over an unfocused one
/// it scrolls the panel as before.
#[test]
fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() {
for focus in [false, true] {
let mut f = Field::new();
let offset = Cell::new(0.0);
let draw = |ui: &mut egui::Ui| {
let out = egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| {
let v = widgets::plain_field(ui, "f", 10.0, "", 0, 80.0);
ui.add_space(600.0);
v
});
offset.set(out.state.offset.y);
out.inner
};
f.frame(vec![], &draw);
let at = Pos2::new(20.0, 12.0);
if focus {
f.frame(
f.click(1)
.into_iter()
.map(|e| {
if let Event::PointerButton { button, pressed, modifiers, .. } = e {
Event::PointerButton { pos: at, button, pressed, modifiers }
} else {
Event::PointerMoved(at)
}
})
.collect(),
&draw,
);
}
let stepped = f.frame(wheel(at, egui::MouseWheelUnit::Line, -2.0, Modifiers::NONE), &draw);
for _ in 0..20 {
f.frame(vec![], &draw);
}
if focus {
assert_eq!((stepped, offset.get()), (Some(8.0), 0.0));
} else {
assert!(stepped.is_none() && offset.get() > 0.0, "{stepped:?} {}", offset.get());
}
}
}
+64 -14
View File
@@ -181,8 +181,8 @@ 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; a drag on its label scrubs it.
let stepped = step_with_arrows(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
// ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it.
let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.format(unit.to_pt(v)));
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
let commit = resp.lost_focus() && buf != shown;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
@@ -198,26 +198,58 @@ pub(crate) fn round_to(v: f64, decimals: i32) -> f64 {
(v * scale).round() / scale
}
/// ↑/↓ 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> {
/// 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.
const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)];
/// ↑/↓ 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.
fn step_value(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};
use egui::{Event, Key, MouseWheelUnit};
let wheel = resp.contains_pointer();
// Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is
// 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;
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)] {
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 whole = notches.trunc();
notches -= whole;
steps += size * f64::from(whole);
false
});
// 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 {
return None;
}
@@ -331,6 +363,22 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu
mixed_field(ui, id, Some(value), suffix, decimals, width)
}
/// [`plain_field`] for a number kept within `range` (the values of dialogs and preferences): what is
/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count).
pub fn range_field(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
value: f64,
range: std::ops::RangeInclusive<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
let (lo, hi) = (*range.start(), *range.end());
let places = decimals.min(6) as i32;
plain_field(ui, id, value.clamp(lo, hi), suffix, decimals, width).map(|v| round_to(v, places).clamp(lo, hi))
}
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
pub fn mixed_field(
ui: &mut Ui,
@@ -355,10 +403,10 @@ pub fn mixed_field(
let shown = value.map(show).unwrap_or_default();
let (mut buf, resp, rect) = 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); a drag on its label
// scrubs it.
// ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on
// its label scrubs it.
let decimals = decimals.min(6) as i32;
let stepped = step_with_arrows(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
let stepped = step_value(ui, &resp, &mut buf, read, show).map(|v| round_to(v, decimals));
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals);
if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
}
@@ -1265,13 +1313,15 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
});
let resp = framed.inner;
select_all_on_focus(ui, &resp, &buf);
// ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it.
let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v)));
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if resp.lost_focus() && buf != shown {
let s = buf.trim();
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
} else {
scrubbed.map(|v| Some(unit.to_pt(v)))
stepped.or(scrubbed).map(|v| Some(unit.to_pt(v)))
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways |
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |