Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) (#188)

This commit is contained in:
Brandon Thomas 2026-10-05 08:01:52 -04:00 committed by GitHub
parent 574d0d3d53
commit 4b53f620ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 793 additions and 43 deletions

View File

@ -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<crate::stroke_constraint::Axis>,
}
/// 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<Pos2> = 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.

View File

@ -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()) {

View File

@ -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<canvas::LiveStroke>,
/// 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<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
/// 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(),

View File

@ -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()
{

View File

@ -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,
}
}
}

View File

@ -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<Axis>, 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<Axis>| 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]));
}
}

View File

@ -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<Badge> {
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<Badge> {
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<Shape> {
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<Pos2> {
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<Pos2>, closed: bool, color: Color32) -> Vec<Shape> {
let make = |pts: Vec<Pos2>, 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<Color32> {
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));
}
}

View File

@ -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));
}
}

View File

@ -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<Pos2>,
}
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<Pos2>) -> 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::<ZoomDrag>(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::<ZoomDrag>(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::<ZoomDrag>(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<f32> = (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:?}");
}
}

View File

@ -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