diff --git a/ROADMAP.md b/ROADMAP.md index b97cf0a2..eaca3a38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2) Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 9f630263..b10b80da 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -139,3 +139,37 @@ fn weight_presets_display_cleanly_in_their_native_unit() { // Units no stroke is measured in keep the pt ladder, in points. assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points)); } + +/// #991: the spinner left of the Stroke Weight field steps ten with Shift on both arrows and a +/// tenth with Ctrl/Cmd, as ↑/↓ do in the focused field, while a plain click steps one point. +#[test] +fn stroke_weight_stepper_uses_ten_point_shift_steps() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + run(&mut app, "stroke.set", json!({"weight": 14})); + let t = texts(&ctx, &mut app, stroke::show); + // The 16 px spinner sits just left of the field's text. + let r = text_rect(&t, "14 pt"); + let x = r.left() - 14.0; + let click_step = |app: &mut VectorcraftApp, y: f32, modifiers: egui::Modifiers| { + let pos = egui::pos2(x, y); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + let events = vec![egui::Event::ModifiersChanged(modifiers), event]; + frame_raw(&ctx, app, egui::RawInput { events, ..Default::default() }, stroke::show); + } + }; + + click_step(&mut app, r.center().y - 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 24.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::NONE); + assert_eq!(app.session.shown_stroke().unwrap().width, 23.0); + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::SHIFT); + assert_eq!(app.session.shown_stroke().unwrap().width, 13.0); + // Ctrl/Cmd steps a tenth, as ↑/↓ and the wheel do in the field. + click_step(&mut app, r.center().y + 5.0, egui::Modifiers::COMMAND); + assert!((app.session.shown_stroke().unwrap().width - 12.9).abs() < 1e-6); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6cb9da82..d8883a17 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1170,8 +1170,8 @@ fn spin_generic( && let Some(p) = sresp.interact_pointer_pos() && let Some(v) = value { - let nv = if up.contains(p) { v + step } else { v - step }; - out = Some(SpinPick::Value(nv.max(min))); + let modifiers = ui.input(|i| i.modifiers); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; if let Some(v) = field(ui, fw) { @@ -1198,6 +1198,14 @@ fn spin_generic( out } +/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: +/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. +fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { + let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); + let delta = step * multiplier; + (value + if up { delta } else { -delta }).max(min) +} + /// Drag phase of a live slider/handle: previews while dragging, commits on release. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Live { @@ -1826,3 +1834,21 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val }); out } + +#[cfg(test)] +mod spinner_modifier_tests { + use super::*; + + #[test] + fn shift_steps_stroke_weight_by_ten_points() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT), 24.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 4.0); + } + + #[test] + fn unmodified_and_command_steps_keep_existing_behavior() { + assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE), 15.0); + assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND), 13.9); + assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 0.0); + } +}