Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) (#188)
This commit is contained in:
parent
574d0d3d53
commit
4b53f620ef
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue