diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dd0060a0d7..0712cb504e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -40,6 +40,8 @@ pub struct Drag { /// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with /// Preferences › Tools › Right-click with painting tools set to Erase. pub erase: bool, + /// ⇧ constraint of a painting stroke (stroke_constraint.rs). + pub constrain: Option, } /// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of @@ -1150,7 +1152,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Tools follow the left button; the right one opens the Brush Preset picker or erases // (Preferences › Tools, `paint_mouse`). crate::paint_mouse::sync_tool_smoothing(app); - let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); + let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool); + // Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below. + if tool == Tool::Zoom && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) { + (buttons.started, buttons.dragged, buttons.stopped) = (false, false, false); + } // A drag is only recognised once the pointer has moved past egui's click distance: the // gesture starts where the button went down, not where it is now (#123). if buttons.started @@ -1205,6 +1211,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::rulers::draw_guides(app, &painter, &xf, &doc); } draw_drag_preview(app, &painter, &xf); + crate::zoom_tool::draw(&ctx, &painter); draw_transform_controls(app, &painter, &xf); crate::paint_mouse::show_picker(app, &ctx); crate::snap_ui::draw(app, &painter, &xf); @@ -1261,7 +1268,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220))); // The Background Eraser always shows its sampling hotspot (Photoshop). - if cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser { + // Quick Selection shows its +/− badge there instead. + if (cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser) && tool != Tool::QuickSelection { crosshair(3.0); } egui::CursorIcon::None @@ -1291,6 +1299,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: _ => egui::CursorIcon::Crosshair, }; ui.ctx().set_cursor_icon(icon); + // Selection tools: + / − / × badge for the effective mode (#170). A gesture keeps the + // mode it started with (⇧ then constrains the marquee instead of adding). + let held = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers)); + let mods = app.drag.as_ref().map_or(held, |d| d.modifiers); + if let Some(b) = crate::tool_feedback::badge(app, tool, mods) { + crate::tool_feedback::draw_badge(&painter, p, b, tool == Tool::QuickSelection); + } } } if primary { @@ -1411,10 +1426,10 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, if let Some(h) = hover { pts.push(h); } - painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); - painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0)); - for p in &pts[..app.ui.polygon.len()] { + crate::tool_feedback::draw_ants(painter, &pts, false); + for p in pts.iter().take(app.ui.polygon.len()) { painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE); + painter.rect_stroke(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Outside); } } if let Some(c) = app.ui.crop_rect { @@ -1498,19 +1513,19 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor } t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.modifiers), Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => { + // Marching ants, visible on any pixels (#172). let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32)); - if d.tool == Tool::EllipseMarquee { - painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE))); + let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5)); + let pts = if d.tool == Tool::EllipseMarquee { + crate::tool_feedback::ellipse_points(r) } else { - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle); - } + vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()] + }; + crate::tool_feedback::draw_ants(painter, &pts, true); } Tool::Lasso => { let pts: Vec = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect(); - if pts.len() > 1 { - painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))); - painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0)); - } + crate::tool_feedback::draw_ants(painter, &pts, false); } Tool::Gradient => { let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32); @@ -1654,8 +1669,18 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } crate::paint_mouse::sync_tool_smoothing(app); let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase); - app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods, erase }); + // ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs). + let active = app.session.active().map(|st| st.doc.id); + let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p); + let mut points = vec![[x, y, pressure as f64]]; + if let Some(p) = from { + points.insert(0, [p[0], p[1], pressure as f64]); + } + app.drag = Some(Drag { tool, start: from.unwrap_or([x, y]), points, modifiers: mods, erase, constrain: None }); app.stylus.begin_stroke(); + if from.is_some() { + app.stylus.record_point(); + } app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None }; } ToolEvent::Move { x, y, pressure } => { @@ -1665,11 +1690,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if tool == Tool::Pen { crate::vector_ui::pen_move(app, x, y); } - if let Some(d) = &mut app.drag - && d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) - { - d.points.push([x, y, pressure as f64]); - app.stylus.record_point(); + let zoom = app.current_zoom(); + if let Some(d) = &mut app.drag { + // ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs). + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); + if d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) { + d.points.push([x, y, pressure as f64]); + app.stylus.record_point(); + } } feed_live_stroke(app); } @@ -1682,7 +1711,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if tool == Tool::Pen { crate::vector_ui::pen_up(app); } + let zoom = app.current_zoom(); let Some(mut d) = app.drag.take() else { return }; + let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]); + let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom); if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) { d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]); app.stylus.record_point(); @@ -1695,6 +1727,12 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + // Where the next ⇧-click line starts. + if crate::stroke_constraint::connects(d.tool) + && let Some(st) = app.session.active() + { + app.last_stroke_end = Some((st.doc.id, [end[0], end[1]])); + } if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { return; } @@ -1738,18 +1776,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } return; } - let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]; - let mode = if !d.modifiers.shift && !d.modifiers.alt { - bar - } else if d.modifiers.shift && d.modifiers.alt { - "intersect" - } else if d.modifiers.shift { - "add" - } else if d.modifiers.alt { - "subtract" - } else { - "replace" - }; + let mode = selection_mode(app, d.modifiers); let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather); let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather})); } @@ -1813,16 +1840,9 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } /// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect). +/// The cursor badge announces the same mode (`tool_feedback`). fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str { - if m.shift && m.alt { - "intersect" - } else if m.shift { - "add" - } else if m.alt { - "subtract" - } else { - ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize] - } + crate::tool_feedback::selection_mode(Tool::Lasso, app.ui.selection_mode, m) } /// Close the polygonal lasso and make the selection. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 27b207ca37..f595122f81 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -123,6 +123,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest if let Some(m) = p.get("maskTarget").and_then(Value::as_bool) { app.ui.mask_target = m; } + // Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect. + if let Some(m) = p.get("selectionMode").and_then(Value::as_u64) { + app.ui.selection_mode = m.min(3) as u8; + } if let Some(tabs) = p.get("dockTabs") { let mut cur = serde_json::to_value(app.ui.dock_tabs).unwrap_or_default(); if let (Some(c), Some(n)) = (cur.as_object_mut(), tabs.as_object()) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b9843f8738..7169c0d132 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,11 +81,13 @@ pub mod slice_ui; pub mod smart_ui; pub mod snap_ui; pub mod state; +pub mod stroke_constraint; pub mod stylus; mod tab_strip; pub mod theme; mod timeline_ui; pub mod tone; +pub mod tool_feedback; pub mod transform_tex; pub mod transform_tool; pub mod type_panels_ui; @@ -96,6 +98,7 @@ pub mod view_cmds; pub mod wide_angle_ui; pub mod widgets; pub mod workspace_ui; +pub mod zoom_tool; use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; @@ -209,6 +212,8 @@ pub struct PhotocraftApp { live_stroke: Option, /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). secondary_erase: bool, + /// End of the last painting stroke: ⇧-click draws a straight line from it (#178). + last_stroke_end: Option<(DocId, [f64; 2])>, control_rx: Option>, pending_screenshots: Vec<(u64, Option, Sender)>, /// Screenshots not yet requested from the viewport: (token, earliest time in ms, frames seen). @@ -317,6 +322,7 @@ impl PhotocraftApp { drag: None, live_stroke: None, secondary_erase: false, + last_stroke_end: None, control_rx: None, pending_screenshots: Vec::new(), queued_screenshots: Vec::new(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 470b19231a..330c7e15ef 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -619,7 +619,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Tool::Move => hint(ui, "Drag to move the active layer"), Tool::Eyedropper => hint(ui, "Click to sample the foreground colour · ⌥-click for background"), Tool::Zoom => { - hint(ui, "Click to zoom in · ⌥-click to zoom out"); + widgets::checkbox(ui, &mut app.ui.tool_options.zoom_scrubby, "Scrubby Zoom"); + hint(ui, "Click to zoom in · ⌥-click to zoom out · drag right/left to zoom in/out"); if widgets::secondary_button(ui, "Fit Screen", 0.0).clicked() && let Some(i) = app.session.active_index() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e36130cd90..1f79c610a2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -378,6 +378,9 @@ pub struct ToolOptions { pub bg_limits: String, pub bg_tolerance: f32, pub bg_protect_fg: bool, + /// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle). + #[serde(default = "yes")] + pub zoom_scrubby: bool, } fn yes() -> bool { @@ -445,6 +448,7 @@ impl Default for ToolOptions { bg_limits: "contiguous".into(), bg_tolerance: 50.0, bg_protect_fg: false, + zoom_scrubby: true, } } } diff --git a/crates/ui-egui/src/stroke_constraint.rs b/crates/ui-egui/src/stroke_constraint.rs new file mode 100644 index 0000000000..c9735becbd --- /dev/null +++ b/crates/ui-egui/src/stroke_constraint.rs @@ -0,0 +1,236 @@ +//! ⇧ with painting tools and the Gradient tool (#178), as in Photoshop: +//! +//! - ⇧ while dragging a painting stroke keeps it on a straight line at 0°, 45° or 90° from where +//! the constraint began (the stroke's start, or the point where ⇧ went down mid-stroke). The +//! direction locks once the pointer has moved a few screen points. +//! - ⇧-click after a stroke paints a straight line from the end of the previous stroke to the +//! click. The line is part of the new stroke, so it is one `paint.stroke` and one undo step. +//! - The Gradient tool's ⇧ snaps the gradient angle to 45° increments. +//! +//! Points go through the normal drag/`LiveStroke`/commit path, so the live preview, pen dynamics +//! and Smoothing treat them like any other stroke points. + +use crate::state::Tool; + +/// Screen points the pointer must travel before a ⇧-constrained stroke locks its direction. +const LOCK_DISTANCE: f64 = 3.0; + +/// A ⇧ constraint in progress: its origin and, once locked, the unit direction of the line. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Axis { + pub origin: [f64; 2], + pub dir: Option<[f64; 2]>, +} + +/// Tools whose drag ⇧ constrains: every painting tool and the Gradient tool. +pub fn applies(tool: Tool) -> bool { + tool.is_brushlike() || tool == Tool::Gradient +} + +/// Tools that ⇧-click connects to the previous stroke with a straight line. +pub fn connects(tool: Tool) -> bool { + tool.is_brushlike() +} + +/// The unit direction from `origin` towards `p`, snapped to the nearest multiple of `step` +/// radians; None when the points coincide (or aren't finite). +pub fn snapped_dir(origin: [f64; 2], p: [f64; 2], step: f64) -> Option<[f64; 2]> { + let (dx, dy) = (p[0] - origin[0], p[1] - origin[1]); + if !(dx.is_finite() && dy.is_finite()) || dx.hypot(dy) < 1e-9 || step <= 0.0 { + return None; + } + let a = (dy.atan2(dx) / step).round() * step; + Some([a.cos(), a.sin()]) +} + +/// `p` projected onto the line through `origin` along unit `dir`. +pub fn project(origin: [f64; 2], dir: [f64; 2], p: [f64; 2]) -> [f64; 2] { + let t = (p[0] - origin[0]) * dir[0] + (p[1] - origin[1]) * dir[1]; + [origin[0] + dir[0] * t, origin[1] + dir[1] * t] +} + +/// `p` moved onto the nearest 45° ray from `origin` (the Gradient tool's ⇧). +pub fn snap45(origin: [f64; 2], p: [f64; 2]) -> [f64; 2] { + match snapped_dir(origin, p, std::f64::consts::FRAC_PI_4) { + Some(d) => project(origin, d, p), + None => p, + } +} + +/// Constrain the pointer at `p` for a drag of `tool` that started at `start` and has reached +/// `last`. `axis` carries the stroke's lock between events; `zoom` converts the lock distance +/// to document pixels. Without ⇧ the point passes through and the lock is dropped. +pub fn constrain(tool: Tool, axis: &mut Option, start: [f64; 2], last: [f64; 2], p: [f64; 2], shift: bool, zoom: f32) -> [f64; 2] { + if !shift || !applies(tool) { + *axis = None; + return p; + } + if tool == Tool::Gradient { + return snap45(start, p); + } + let a = axis.get_or_insert(Axis { origin: last, dir: None }); + if a.dir.is_none() { + let reach = LOCK_DISTANCE / f64::from(zoom.max(1e-3)); + if (p[0] - a.origin[0]).hypot(p[1] - a.origin[1]) < reach { + return a.origin; + } + a.dir = snapped_dir(a.origin, p, std::f64::consts::FRAC_PI_4); + } + match a.dir { + Some(d) => project(a.origin, d, p), + None => a.origin, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PhotocraftApp; + use crate::canvas::{ToolEvent, tool_event}; + use serde_json::json; + + fn close(a: [f64; 2], b: [f64; 2]) -> bool { + (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6 + } + + #[test] + fn snapping_angles() { + let o = [10.0, 10.0]; + // Gradient: 45° increments. + assert!(close(snap45(o, [50.0, 13.0]), [50.0, 10.0])); + assert!(close(snap45(o, [12.0, -40.0]), [10.0, -40.0])); + let d = snap45(o, [40.0, 37.0]); + assert!((d[0] - d[1]).abs() < 1e-9 && d[0] > 30.0, "45°: {d:?}"); + assert!(close(snap45(o, o), o)); + assert!(snap45(o, [f64::NAN, 1.0])[0].is_nan(), "non-finite input passes through"); + // Painting: the direction locks once the pointer has moved a few points. + let mut axis = None; + let p = |q, ax: &mut Option| constrain(Tool::Brush, ax, o, o, q, true, 1.0); + assert!(close(p([11.0, 11.0], &mut axis), o), "inside the lock distance"); + assert!(close(p([30.0, 12.0], &mut axis), [30.0, 10.0]), "horizontal"); + // Locked: wandering towards 45° stays on the horizontal line. + assert!(close(p([60.0, 45.0], &mut axis), [60.0, 10.0])); + // Releasing ⇧ frees the stroke and drops the lock. + assert!(close(constrain(Tool::Brush, &mut axis, o, o, [70.0, 50.0], false, 1.0), [70.0, 50.0]) && axis.is_none()); + // Vertical and diagonal locks. + let mut axis = None; + assert!(close(p([9.0, 60.0], &mut axis), [10.0, 60.0])); + let mut axis = None; + let q = p([40.0, 43.0], &mut axis); + assert!((q[0] - 10.0 - (q[1] - 10.0)).abs() < 1e-9); + // Other tools pass through. + assert!(close(constrain(Tool::Move, &mut None, o, o, [30.0, 12.0], true, 1.0), [30.0, 12.0])); + } + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 120, "height": 120, "background": "transparent"})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 4, "hardness": 1.0}})).unwrap(); + app.session.tools.brush.smoothing.amount = 0.0; + app + } + + fn last_stroke_points(app: &PhotocraftApp) -> Vec<[f64; 2]> { + let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.stroke").map(|(_, p)| p.clone()).unwrap(); + p["points"].as_array().unwrap().iter().map(|q| [q[0].as_f64().unwrap(), q[1].as_f64().unwrap()]).collect() + } + + fn alpha(app: &PhotocraftApp, x: i32, y: i32) -> f32 { + app.session.documents()[0].doc.layers[0].surface().unwrap().rgba(x, y)[3] + } + + #[test] + fn shift_drag_paints_a_straight_constrained_stroke() { + for tool in [Tool::Brush, Tool::Eraser] { + let mut app = app(); + if tool == Tool::Eraser { + app.run("edit.fill", json!({"contents": "foreground"})).unwrap(); + } + app.ui.tool = tool; + let shift = egui::Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 60.0, pressure: 1.0 }, shift); + for (x, y) in [(30.0, 63.0), (50.0, 55.0), (80.0, 68.0), (100.0, 52.0)] { + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, shift); + } + tool_event(&mut app, ToolEvent::Up { x: 105.0, y: 70.0 }, shift); + let pts = last_stroke_points(&app); + assert!(pts.len() >= 2 && pts.iter().all(|p| p[1] == 60.0), "{tool:?}: {pts:?}"); + assert_eq!(pts.last().unwrap()[0], 105.0); + // The pixels follow the line: painted (or erased) along y = 60, untouched at y = 68. + let on = alpha(&app, 70, 60); + let off = alpha(&app, 80, 68); + if tool == Tool::Brush { + assert!(on > 0.9 && off == 0.0, "{on} {off}"); + } else { + assert!(on < 0.1 && off > 0.9, "{on} {off}"); + } + assert_eq!(app.session.documents()[0].history.past_len(), 1 + usize::from(tool == Tool::Eraser)); + } + } + + #[test] + fn shift_click_draws_a_line_from_the_last_stroke_in_one_step() { + let mut app = app(); + app.ui.tool = Tool::Brush; + let none = egui::Modifiers::NONE; + // A first (free) stroke ending at (20, 20). + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Move { x: 20.0, y: 20.0, pressure: 1.0 }, none); + tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 20.0 }, none); + let before = app.session.documents()[0].history.past_len(); + // ⇧-click at (100, 100): a straight line from (20, 20). + let shift = egui::Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 100.0 }, shift); + assert_eq!(last_stroke_points(&app), vec![[20.0, 20.0], [100.0, 100.0]]); + assert_eq!(app.session.documents()[0].history.past_len(), before + 1, "one undo step per line"); + assert!(alpha(&app, 60, 60) > 0.9 && alpha(&app, 60, 70) == 0.0); + // And the next ⇧-click continues from there. + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 20.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 100.0, y: 20.0 }, shift); + assert_eq!(last_stroke_points(&app), vec![[100.0, 100.0], [100.0, 20.0]]); + assert!(alpha(&app, 100, 60) > 0.9); + // Undo removes just the last line. + app.session.undo(); + assert!(alpha(&app, 100, 60) == 0.0 && alpha(&app, 60, 60) > 0.9); + // The line start survives the undo (as in Photoshop). With smoothing on, the line still + // runs from (100, 20) exactly to the click. + app.session.tools.brush.smoothing.amount = 0.6; + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 100.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 100.0 }, shift); + assert!(alpha(&app, 20, 100) > 0.9 && alpha(&app, 40, 80) > 0.9 && alpha(&app, 90, 30) > 0.9); + } + + #[test] + fn shift_click_without_a_previous_stroke_is_a_dab() { + let mut app = app(); + app.ui.tool = Tool::Brush; + let shift = egui::Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 50.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 50.0 }, shift); + assert_eq!(last_stroke_points(&app), vec![[50.0, 50.0]]); + // A new document doesn't connect to another document's stroke. + app.run("file.new", json!({"width": 60, "height": 60, "background": "transparent"})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 5.0, y: 5.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 5.0, y: 5.0 }, shift); + assert_eq!(last_stroke_points(&app), vec![[5.0, 5.0]]); + } + + #[test] + fn shift_gradient_snaps_to_45_degrees() { + let mut app = app(); + app.ui.tool = Tool::Gradient; + let shift = egui::Modifiers::SHIFT; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 52.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 85.0 }, shift); + let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.gradient").map(|(_, p)| p.clone()).unwrap(); + let to = [p["to"][0].as_f64().unwrap(), p["to"][1].as_f64().unwrap()]; + assert!((to[0] - to[1]).abs() < 1e-9 && to[0] > 80.0, "45°: {to:?}"); + // Horizontal. + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, shift); + tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 22.0 }, shift); + let p = app.session.journal.iter().rev().find(|(id, _)| id == "paint.gradient").map(|(_, p)| p.clone()).unwrap(); + assert_eq!(p["to"], json!([90.0, 10.0])); + } +} diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs new file mode 100644 index 0000000000..d8fc6a47f7 --- /dev/null +++ b/crates/ui-egui/src/tool_feedback.rs @@ -0,0 +1,255 @@ +//! On-canvas tool feedback: the selection tools' intent badge (#170) and outlines that stay +//! visible on any image (#172). +//! +//! Photoshop marks the selection-tool cursor with a small `+` (add), `−` (subtract) or `×` +//! (intersect) reflecting the effective mode: the options bar's mode, overridden by the held +//! modifiers. Selection outlines and drag previews are drawn as black-and-white dashes (marching +//! ants) so they read on light and dark pixels alike. + +use egui::{Color32, Pos2, Shape, Stroke, pos2, vec2}; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// The intent a selection tool's next gesture has, shown as a cursor badge (none for New). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Badge { + Add, + Subtract, + Intersect, +} + +impl Badge { + /// The badge for an engine selection mode name (`replace` has none). + pub fn from_mode(mode: &str) -> Option { + match mode { + "add" => Some(Badge::Add), + "subtract" => Some(Badge::Subtract), + "intersect" => Some(Badge::Intersect), + _ => None, + } + } +} + +/// Tools whose cursor carries the selection-mode badge. +pub fn is_selection_tool(tool: Tool) -> bool { + matches!(tool, Tool::RectMarquee | Tool::EllipseMarquee | Tool::Lasso | Tool::PolygonLasso | Tool::MagicWand | Tool::QuickSelection | Tool::ObjectSelection) +} + +/// The effective selection mode of `tool` for the options-bar mode `bar` (0 New, 1 Add, +/// 2 Subtract, 3 Intersect) and the held modifiers: exactly what the tool's gesture will use. +pub fn selection_mode(tool: Tool, bar: u8, m: egui::Modifiers) -> &'static str { + match tool { + // Quick Selection always adds (⌥ subtracts), like Photoshop's after the first stroke. + Tool::QuickSelection => { + if m.alt { + "subtract" + } else { + "add" + } + } + // Object Selection: ⌥ subtracts, ⇧ adds (see `retouch_ui::finish_object_selection`). + Tool::ObjectSelection => { + if m.alt { + "subtract" + } else if m.shift { + "add" + } else { + "replace" + } + } + _ => { + if m.shift && m.alt { + "intersect" + } else if m.shift { + "add" + } else if m.alt { + "subtract" + } else { + ["replace", "add", "subtract", "intersect"][bar.min(3) as usize] + } + } + } +} + +/// The badge the cursor of `tool` shows with modifiers `m` (None for non-selection tools or New). +pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option { + if !is_selection_tool(tool) { + return None; + } + Badge::from_mode(selection_mode(tool, app.ui.selection_mode, m)) +} + +/// The badge's glyph as line segments around `c` (half-size `h`). +pub fn badge_glyph(b: Badge, c: Pos2, h: f32) -> Vec<[Pos2; 2]> { + match b { + Badge::Add => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)], [c - vec2(0.0, h), c + vec2(0.0, h)]], + Badge::Subtract => vec![[c - vec2(h, 0.0), c + vec2(h, 0.0)]], + Badge::Intersect => { + let d = h * 0.8; + vec![[c + vec2(-d, -d), c + vec2(d, d)], [c + vec2(-d, d), c + vec2(d, -d)]] + } + } +} + +/// Draw the badge next to the cursor hotspot `p` (Photoshop puts it below-right of the +/// crosshair; Quick Selection shows it inside the brush circle, at the centre). +pub fn draw_badge(painter: &egui::Painter, p: Pos2, b: Badge, centred: bool) { + // Right of the system arrow cursor: at 1× the arrow covers (11, 11), so the badge sat under it. + let c = if centred { p } else { p + vec2(30.0, 11.0) }; + // White glyph with a dark halo: readable on any pixels. + for (w, col) in [(3.0, Color32::from_black_alpha(200)), (1.2, Color32::WHITE)] { + for seg in badge_glyph(b, c, 3.5) { + painter.line_segment(seg, Stroke::new(w, col)); + } + } +} + +/// Dash length of the marching ants, in screen points. +pub const DASH: f32 = 4.0; + +/// Marching ants along a polyline: a solid white line under black dashes, the dashes shifted by +/// `phase` (animate it with time). Visible on any image, like Photoshop's selection edges. +pub fn ants(points: &[Pos2], closed: bool, phase: f32) -> Vec { + let Some(&first) = points.first().filter(|_| points.len() >= 2) else { return Vec::new() }; + let mut pts = points.to_vec(); + if closed { + pts.push(first); + } + let mut out = vec![Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE))]; + let black = Stroke::new(1.0, Color32::BLACK); + // Arc length so dashes run continuously across vertices. + let mut along = -phase.rem_euclid(DASH * 2.0); + for w in pts.windows(2) { + let (a, b) = (w[0], w[1]); + let len = a.distance(b); + if len < 1e-3 { + continue; + } + let dir = (b - a) / len; + // Position of this segment's start within the dash period. + let mut t = -(along.rem_euclid(DASH * 2.0)); + while t < len { + let (s0, s1) = (t.max(0.0), (t + DASH).min(len)); + if s1 > s0 { + out.push(Shape::line_segment([a + dir * s0, a + dir * s1], black)); + } + t += DASH * 2.0; + } + along += len; + } + out +} + +/// Marching-ants phase for the frame time `time` (s), and keep the animation running. +pub fn ants_phase(ctx: &egui::Context) -> f32 { + let time = ctx.input(|i| i.time); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + ((time * 10.0) % (DASH as f64 * 2.0)) as f32 +} + +/// Draw animated marching ants along `points`. +pub fn draw_ants(painter: &egui::Painter, points: &[Pos2], closed: bool) { + let phase = ants_phase(painter.ctx()); + painter.extend(ants(points, closed, phase)); +} + +/// Polygon approximating the ellipse inscribed in `r` (enough vertices to look smooth). +pub fn ellipse_points(r: egui::Rect) -> Vec { + let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0); + let n = ((a.abs() + b.abs()) * 0.5).clamp(16.0, 256.0) as usize; + (0..n) + .map(|i| { + let t = i as f32 / n as f32 * std::f32::consts::TAU; + pos2(c.x + a * t.cos(), c.y + b * t.sin()) + }) + .collect() +} + +/// A path preview in `color` with a contrasting dark halo, so a blue shape outline still shows +/// on blue (or any) pixels: the shape tools' drag preview. +pub fn contrast_path(points: Vec, closed: bool, color: Color32) -> Vec { + let make = |pts: Vec, s: Stroke| if closed { Shape::closed_line(pts, s) } else { Shape::line(pts, s) }; + vec![make(points.clone(), Stroke::new(3.0, Color32::from_black_alpha(150))), make(points, Stroke::new(1.0, color))] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strokes(shapes: &[Shape]) -> Vec { + shapes + .iter() + .filter_map(|s| match s { + Shape::LineSegment { stroke, .. } => Some(stroke.color), + Shape::Path(p) => match p.stroke.color { + egui::epaint::ColorMode::Solid(c) => Some(c), + _ => None, + }, + _ => None, + }) + .collect() + } + + #[test] + fn badge_follows_modifiers_and_the_options_bar() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let none = egui::Modifiers::NONE; + let (shift, alt) = (egui::Modifiers::SHIFT, egui::Modifiers::ALT); + let both = egui::Modifiers { shift: true, alt: true, ..Default::default() }; + for t in [Tool::RectMarquee, Tool::EllipseMarquee, Tool::Lasso, Tool::PolygonLasso, Tool::MagicWand] { + assert_eq!(badge(&app, t, none), None, "{t:?}: New selection has no badge"); + assert_eq!(badge(&app, t, shift), Some(Badge::Add)); + assert_eq!(badge(&app, t, alt), Some(Badge::Subtract)); + assert_eq!(badge(&app, t, both), Some(Badge::Intersect)); + } + // The options bar's mode shows without any key held; keys still override it. + app.ui.selection_mode = 2; + assert_eq!(badge(&app, Tool::Lasso, none), Some(Badge::Subtract)); + assert_eq!(badge(&app, Tool::Lasso, shift), Some(Badge::Add)); + app.ui.selection_mode = 3; + assert_eq!(badge(&app, Tool::RectMarquee, none), Some(Badge::Intersect)); + // Smart selection tools. + assert_eq!(badge(&app, Tool::QuickSelection, none), Some(Badge::Add)); + assert_eq!(badge(&app, Tool::QuickSelection, alt), Some(Badge::Subtract)); + assert_eq!(badge(&app, Tool::ObjectSelection, none), None); + assert_eq!(badge(&app, Tool::ObjectSelection, shift), Some(Badge::Add)); + // Other tools never show one. + for t in [Tool::Brush, Tool::Move, Tool::Zoom, Tool::Crop] { + assert_eq!(badge(&app, t, shift), None); + } + // Distinct glyphs: + has two strokes, − one, × two diagonals. + let c = pos2(10.0, 10.0); + assert_eq!(badge_glyph(Badge::Add, c, 3.0).len(), 2); + assert_eq!(badge_glyph(Badge::Subtract, c, 3.0).len(), 1); + assert!(badge_glyph(Badge::Intersect, c, 3.0).iter().all(|[a, b]| a.x != b.x && a.y != b.y)); + } + + #[test] + fn ants_use_two_contrasting_colours_and_march() { + let pts = [pos2(0.0, 0.0), pos2(40.0, 0.0), pos2(40.0, 30.0)]; + let a = ants(&pts, true, 0.0); + let cols = strokes(&a); + assert!(cols.contains(&Color32::WHITE) && cols.contains(&Color32::BLACK), "{cols:?}"); + // Roughly half the length is black dashes: (40 + 30 + 50) / 8 ≈ 15 dashes. + let dashes = cols.iter().filter(|c| **c == Color32::BLACK).count(); + assert!((13..=18).contains(&dashes), "{dashes}"); + // The phase moves the dashes (the ants march). + let first = |s: &[Shape]| { + s.iter().find_map(|s| match s { + Shape::LineSegment { points, stroke } if stroke.color == Color32::BLACK => Some(points[0]), + _ => None, + }) + }; + assert_ne!(first(&a), first(&ants(&pts, true, 2.0))); + assert!(ants(&pts[..1], false, 0.0).is_empty()); + // Shape previews: the accent path over a dark halo. + let s = contrast_path(pts.to_vec(), true, Color32::from_rgb(0, 120, 255)); + let cols = strokes(&s); + assert_eq!(cols.len(), 2); + assert!(cols[0].r() < 10 && cols[0].a() > 100 && cols[1] == Color32::from_rgb(0, 120, 255)); + // Ellipses close smoothly. + let e = ellipse_points(egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0))); + assert!(e.len() >= 16 && e.iter().all(|p| p.x >= -0.01 && p.x <= 100.01 && p.y >= -0.01 && p.y <= 50.01)); + } +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 647cca7d9a..cbe8d254fe 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -110,7 +110,8 @@ pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &Vie for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) { // Every tool's own shape is convex (custom shapes aren't, and draw no fill). painter.add(egui::Shape::convex_polygon(pts.clone(), fill, stroke)); - painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, accent))); + // The accent path over a dark halo: visible on any pixels (#172). + painter.extend(crate::tool_feedback::contrast_path(pts, true, accent)); } } diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs new file mode 100644 index 0000000000..7d9ddf9066 --- /dev/null +++ b/crates/ui-egui/src/zoom_tool.rs @@ -0,0 +1,223 @@ +//! Zoom tool drags (#171). +//! +//! With Scrubby Zoom on (the default, as in Photoshop) press-and-drag zooms continuously: right +//! zooms in, left zooms out, anchored at the point where the button went down. With it off, the +//! drag draws a rectangle and the release zooms to fit it. A click (no drag) still steps the zoom +//! in, or out with ⌥ (handled by the canvas). + +use egui::{Pos2, Rect, vec2}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::paint_mouse::Buttons; +use crate::state::View; + +/// Zoom limits of the canvas (1 % … 6400 %). +pub const MIN_ZOOM: f32 = 0.01; +pub const MAX_ZOOM: f32 = 64.0; +/// Horizontal drag (screen points) that doubles, or halves, the zoom. +pub const DOUBLING: f32 = 100.0; + +/// A Zoom-tool drag in progress. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ZoomDrag { + /// Screen point where the button went down. + anchor: Pos2, + /// The document point under it when the drag began (it stays under the anchor). + doc: [f64; 2], + zoom0: f32, + /// Scrubby Zoom off: a zoom rectangle, its current corner. + rect_to: Option, +} + +fn id() -> egui::Id { + egui::Id::new("pc-zoom-tool-drag") +} + +/// The scrubby zoom for a horizontal drag of `dx` points from a press at zoom `zoom0`. +pub fn scrub_zoom(zoom0: f32, dx: f32) -> f32 { + // ±40 doublings already span far more than the zoom range; it keeps the power finite. + let e = if dx.is_finite() { (dx / DOUBLING).clamp(-40.0, 40.0) } else { 0.0 }; + let z = zoom0 * e.exp2(); + if z.is_finite() { z.clamp(MIN_ZOOM, MAX_ZOOM) } else { 1.0 } +} + +/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`. +pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32) { + view.zoom = zoom; + let d = (screen - xf.rect.center()) / zoom; + let dx = if xf.flip { -d.x } else { d.x }; + view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y]; +} + +/// Route the canvas buttons for the Zoom tool. Returns true when it consumed the drag (the +/// canvas then handles only the click). +pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, pointer: Option) -> bool { + let scrubby = app.ui.tool_options.zoom_scrubby; + if b.started { + let anchor = ctx.input(|i| i.pointer.press_origin()).filter(|p| xf.rect.contains(*p)).or(pointer); + if let Some(anchor) = anchor { + let z = ZoomDrag { anchor, doc: xf.to_doc(anchor), zoom0: view.zoom, rect_to: (!scrubby).then_some(anchor) }; + ctx.data_mut(|d| d.insert_temp(id(), z)); + } + } + let Some(mut z) = ctx.data(|d| d.get_temp::(id())) else { return b.started || b.dragged || b.stopped }; + if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { + match z.rect_to.as_mut() { + Some(to) => *to = p, + None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x)), + } + ctx.data_mut(|d| d.insert_temp(id(), z)); + } + if b.stopped { + ctx.data_mut(|d| d.remove::(id())); + if let Some(to) = z.rect_to { + zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt)); + } + } + true +} + +/// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom +/// at the press point; ⌥ steps out). +fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) { + if out || r.width() < 4.0 || r.height() < 4.0 { + let doc = xf.to_doc(anchor); + anchor_view(view, xf, anchor, doc, crate::canvas::zoom_step(view.zoom, if out { -1 } else { 1 }).clamp(MIN_ZOOM, MAX_ZOOM)); + return; + } + let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height()); + let zoom = (view.zoom * k).clamp(MIN_ZOOM, MAX_ZOOM); + let c = xf.to_doc(r.center()); + anchor_view(view, xf, xf.rect.center(), c, zoom); +} + +/// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants. +pub fn draw(ctx: &egui::Context, painter: &egui::Painter) { + let Some(z) = ctx.data(|d| d.get_temp::(id())) else { return }; + let Some(to) = z.rect_to else { return }; + let r = Rect::from_two_pos(z.anchor, to); + let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5)); + crate::tool_feedback::draw_ants(painter, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()], true); +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Event, PointerButton}; + use egui_kittest::Harness; + use serde_json::json; + + #[test] + fn scrub_zoom_is_continuous_and_clamped() { + assert_eq!(scrub_zoom(1.0, 0.0), 1.0); + assert!((scrub_zoom(1.0, DOUBLING) - 2.0).abs() < 1e-5); + assert!((scrub_zoom(1.0, -DOUBLING) - 0.5).abs() < 1e-5); + // Monotone: every point of drag to the right zooms in a little more. + let zs: Vec = (0..50).map(|i| scrub_zoom(0.7, i as f32 * 3.0)).collect(); + assert!(zs.windows(2).all(|w| w[1] > w[0])); + assert_eq!(scrub_zoom(1.0, 1e6), MAX_ZOOM); + assert_eq!(scrub_zoom(1.0, -1e6), MIN_ZOOM); + assert_eq!(scrub_zoom(2.0, f32::NAN), 2.0); + } + + fn harness(scrubby: bool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = crate::state::Tool::Zoom; + app.ui.tool_options.zoom_scrubby = scrubby; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + // Fonts set up after the first frame only apply from the next one. + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h + } + + fn view_xf(h: &Harness<'static, PhotocraftApp>) -> ViewXform { + let app = h.state(); + let v = &app.ui.views[0]; + ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: false } + } + + fn press(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }); + h.step(); + } + + #[test] + fn scrubby_zoom_drags_continuously_about_the_press_point() { + let mut h = harness(true); + let r = h.state().last_canvas_rect; + let p0 = r.center() + vec2(-150.0, 60.0); + let doc0 = view_xf(&h).to_doc(p0); + let z0 = h.state().current_zoom(); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true); + // Right: zooms in a little more each frame, the pressed document point stays put. + let mut last = z0; + for i in 1..=8 { + h.event(Event::PointerMoved(p0 + vec2(i as f32 * 15.0, (i % 3) as f32))); + h.step(); + let z = h.state().current_zoom(); + assert!(z > last, "frame {i}: {last} -> {z}"); + last = z; + let s = view_xf(&h).to_screen(doc0[0] as f32, doc0[1] as f32); + assert!(s.distance(p0) < 0.5, "anchor drifted to {s:?} (pressed at {p0:?})"); + } + // Back left past the press point: zooms out below the start. + h.event(Event::PointerMoved(p0 - vec2(60.0, 0.0))); + h.step(); + let z = h.state().current_zoom(); + assert!(z < z0 && (z - scrub_zoom(z0, -60.0)).abs() < 1e-4, "{z0} -> {z}"); + press(&mut h, p0 - vec2(60.0, 0.0), false); + h.run_steps(2); + // Release keeps the zoom; nothing was painted or recorded. + assert!((h.state().current_zoom() - z).abs() < 1e-5); + assert_eq!(h.state().session.documents()[0].history.past_len(), 0); + // A plain click still steps in. + let zc = h.state().current_zoom(); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true); + press(&mut h, p0, false); + h.run_steps(2); + assert_eq!(h.state().current_zoom(), crate::canvas::zoom_step(zc, 1)); + } + + #[test] + fn zoom_rectangle_without_scrubby_zoom() { + let mut h = harness(false); + let r = h.state().last_canvas_rect; + let z0 = h.state().current_zoom(); + let a = r.center() - vec2(60.0, 40.0); + let b = r.center() + vec2(60.0, 40.0); + let mid = view_xf(&h).to_doc(r.center()); + h.event(Event::PointerMoved(a)); + h.step(); + press(&mut h, a, true); + for t in [0.3, 0.7, 1.0] { + h.event(Event::PointerMoved(a + (b - a) * t)); + h.step(); + } + assert_eq!(h.state().current_zoom(), z0, "no zoom while the rectangle is drawn"); + press(&mut h, b, false); + h.run_steps(2); + let z = h.state().current_zoom(); + let k = (r.width() / 120.0).min(r.height() / 80.0); + assert!((z - z0 * k).abs() < z0 * k * 0.02, "{z0} * {k} vs {z}"); + // The rectangle's centre is now the view centre. + let c = h.state().ui.views[0].center; + assert!((c[0] as f64 - mid[0]).abs() < 1.0 && (c[1] as f64 - mid[1]).abs() < 1.0, "{c:?} vs {mid:?}"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 180020de53..ac7d8b5b02 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, menu tree, window size). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?}`: change UI state (`selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520) - `ui.menu.invoke {id}` / `ui.menu.list`: activate a menu item by id; list the menu tree - `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog}` / `ui.dialog.cancel {dialog}` - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows