Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 4167681bb1 Marquee: Shift squares, Alt draws from the centre, live W × H readout (#208) (#247)
Merged with #240 (stroke trail reset on drag end and start).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 12:04:55 -04:00
3 changed files with 303 additions and 11 deletions

No files matched your search

+94 -11
View File
@@ -42,6 +42,89 @@ pub struct Drag {
pub erase: bool,
/// ⇧ constraint of a painting stroke (stroke_constraint.rs).
pub constrain: Option<crate::stroke_constraint::Axis>,
/// Modifiers held now (updated on every pointer event of the gesture).
pub live: egui::Modifiers,
/// Which of the modifiers held at the press have been released since.
pub released: egui::Modifiers,
}
impl Drag {
pub fn new(tool: Tool, start: [f64; 2], points: Vec<[f64; 3]>, modifiers: egui::Modifiers, erase: bool) -> Self {
Self { tool, start, points, modifiers, erase, constrain: None, live: modifiers, released: egui::Modifiers::NONE }
}
/// Record the modifiers of a pointer event.
fn track(&mut self, mods: egui::Modifiers) {
self.live = mods;
self.released.shift |= !mods.shift;
self.released.alt |= !mods.alt;
}
/// ⇧ (square / circle) and ⌥ (from the centre) for a marquee drag. A modifier held at the press
/// picks the selection mode instead (add / subtract, #188) until it is released and pressed
/// again, as in Photoshop.
pub fn marquee_mods(&self) -> (bool, bool) {
let fresh = |now: bool, at_press: bool, released: bool| now && (!at_press || released);
(fresh(self.live.shift, self.modifiers.shift, self.released.shift), fresh(self.live.alt, self.modifiers.alt, self.released.alt))
}
}
/// The marquee dragged from `d.start` to `end` as two document-space corners: the options-bar
/// style (fixed ratio / fixed size), ⇧ for a square or circle, ⌥ to draw from the centre.
pub fn marquee_corners(o: &crate::state::ToolOptions, d: &Drag, end: [f64; 2]) -> ([f64; 2], [f64; 2]) {
let (shift, alt) = d.marquee_mods();
let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, shift, d.start, end);
if !alt {
return (d.start, e);
}
let (dx, dy) = (e[0] - d.start[0], e[1] - d.start[1]);
// A fixed size is centred on the press point; otherwise the drag is the half extent.
let (hx, hy) = if o.marquee_style == "fixedSize" { (dx / 2.0, dy / 2.0) } else { (dx, dy) };
([d.start[0] - hx, d.start[1] - hy], [d.start[0] + hx, d.start[1] + hy])
}
/// The pixel rectangle `[x0, y0, x1, y1]` a marquee between two corners selects.
pub fn marquee_px(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
[a[0].min(b[0]).floor(), a[1].min(b[1]).floor(), a[0].max(b[0]).ceil(), a[1].max(b[1]).ceil()]
}
/// The size readout shown beside the cursor while dragging a marquee: the width and height values.
pub fn marquee_readout(r: [f64; 4]) -> [String; 2] {
[format!("{} px", r[2] - r[0]), format!("{} px", r[3] - r[1])]
}
/// Draw the marquee size readout below-right of the cursor (kept on screen), like Photoshop's:
/// two rows, `W:` / `H:` labels on the left and the values right-aligned.
fn draw_marquee_readout(ctx: &egui::Context, cursor: Pos2, values: [String; 2]) {
let t = crate::theme::Tokens::get(ctx);
let font = egui::FontId::proportional(11.5);
let labels = ["W:", "H:"];
let width = |text: &str| ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), t.text).size().x);
let (lw, vw) = (labels.map(width), [width(&values[0]), width(&values[1])]);
let (label_col, value_col) = (lw[0].max(lw[1]), vw[0].max(vw[1]));
egui::Area::new(egui::Id::new("marquee-readout"))
.order(egui::Order::Tooltip)
.fixed_pos(cursor + vec2(16.0, 18.0))
.interactable(false)
.constrain(true)
.show(ctx, |ui| {
egui::Frame::new()
.fill(t.card)
.stroke(Stroke::new(1.0, t.card_border))
.corner_radius(t.radius_sm)
.inner_margin(egui::Margin::symmetric(7, 4))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 1.0);
for (i, value) in values.into_iter().enumerate() {
// Labels left, values right-aligned on one edge, sharing a baseline.
ui.horizontal(|ui| {
ui.add(egui::Label::new(egui::RichText::new(labels[i]).font(font.clone()).color(t.text_dim)).extend());
ui.add_space(label_col - lw[i] + 12.0 + value_col - vw[i]);
ui.add(egui::Label::new(egui::RichText::new(value).font(font.clone()).color(t.text)).extend());
});
}
});
});
}
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
@@ -1594,10 +1677,10 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
app.trail = None;
return;
};
let mut last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
if matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) {
let o = &app.ui.tool_options;
last = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, false, d.start, last);
let last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
let marquee = matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee).then(|| marquee_corners(&app.ui.tool_options, d, last));
if let Some((a, b)) = marquee {
draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(marquee_px(a, b)));
}
match d.tool {
// The canvas shows the live stroke itself (`LiveStroke`).
@@ -1616,7 +1699,8 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
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));
let (a, b) = marquee.unwrap_or((d.start, last));
let r = Rect::from_two_pos(xf.to_screen(a[0] as f32, a[1] as f32), xf.to_screen(b[0] as f32, b[1] as f32));
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)
@@ -1784,7 +1868,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
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.drag = Some(Drag::new(tool, from.unwrap_or([x, y]), points, mods, erase));
app.trail = None;
app.stylus.begin_stroke();
if from.is_some() {
@@ -1801,6 +1885,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
let zoom = app.current_zoom();
if let Some(d) = &mut app.drag {
d.track(mods);
// ⇧: 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);
@@ -1822,6 +1907,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
let zoom = app.current_zoom();
let Some(mut d) = app.drag.take() else { return };
d.track(mods);
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);
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
@@ -1881,11 +1967,8 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
}
Tool::RectMarquee | Tool::EllipseMarquee => {
let o = &app.ui.tool_options;
let e = crate::chrome_ui::marquee_end(&o.marquee_style, o.marquee_width as f64, o.marquee_height as f64, false, d.start, [end[0], end[1]]);
let end = [e[0], e[1], 1.0];
let (x0, y0) = (d.start[0].min(end[0]).floor(), d.start[1].min(end[1]).floor());
let (x1, y1) = (d.start[0].max(end[0]).ceil(), d.start[1].max(end[1]).ceil());
let (a, b) = marquee_corners(&app.ui.tool_options, &d, [end[0], end[1]]);
let [x0, y0, x1, y1] = marquee_px(a, b);
if x1 - x0 < 2.0 || y1 - y0 < 2.0 {
if app.session.is_enabled("select.deselect") {
let _ = app.run("select.deselect", json!({}));
+3
View File
@@ -1133,6 +1133,9 @@ impl PhotocraftApp {
#[cfg(test)]
mod input_tests;
#[cfg(test)]
mod marquee_tests;
#[cfg(test)]
mod clipboard_tests {
use super::*;
+206
View File
@@ -0,0 +1,206 @@
//! Marquee modifiers through the real canvas (#208): ⇧ during the drag draws a square or circle,
//! ⌥ draws from the centre (both together work too), a "W × H px" readout follows the cursor, and
//! modifiers held before the drag still pick add/subtract (#188) instead of constraining.
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_geom::Rect;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
use crate::state::Tool;
fn harness(tool: Tool) -> 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 = tool;
let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
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);
// 100 %: one document pixel per point, so drags land on whole pixels.
let v = &mut h.state_mut().ui.views[0];
v.zoom = 1.0;
v.center = [200.0, 150.0];
v.fit_pending = false;
h.run_steps(2);
h
}
fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 {
let app = h.state();
let v = &app.ui.views[0];
let xf = ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal };
xf.to_screen(x, y)
}
fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) {
h.event(egui::Event::ModifiersChanged(m));
h.run_steps(1);
}
fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, down: bool, m: Modifiers) {
h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: down, modifiers: m });
h.run_steps(1);
}
/// Move the pointer to document point `(x, y)` in a few steps.
fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) {
let p = screen(h, x, y);
h.event(egui::Event::PointerMoved(p));
h.run_steps(2);
}
fn press_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) {
let p = screen(h, x, y);
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
button(h, p, true, m);
// Past egui's click distance, so the drag starts at the press point.
move_to(h, x + 8.0, y + 6.0);
}
fn release_at(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) {
move_to(h, x, y);
button(h, screen(h, x, y), false, m);
h.run_steps(2);
}
fn selection(h: &Harness<'static, PhotocraftApp>) -> Rect {
h.state().session.active().unwrap().doc.selection.as_ref().map_or(Rect::EMPTY, |s| s.content_bounds())
}
/// The two-row readout shows `W: {w} px` and `H: {ht} px` (Photoshop's format).
fn readout(h: &Harness<'static, PhotocraftApp>, w: i32, ht: i32) -> bool {
let labels = h.query_by_label("W:").is_some() && h.query_by_label("H:").is_some();
let count = |v: i32| h.query_all_by_label(&format!("{v} px")).count();
let values = if w == ht { count(w) == 2 } else { count(w) == 1 && count(ht) == 1 };
labels && values && h.query_all_by_label_contains(" px").count() == 2
}
#[test]
fn shift_during_the_drag_draws_a_square_and_the_readout_follows() {
let mut h = harness(Tool::RectMarquee);
press_at(&mut h, 50.0, 50.0, Modifiers::NONE);
move_to(&mut h, 150.0, 90.0);
assert!(readout(&h, 100, 40), "free drag: 100 × 40");
mods(&mut h, Modifiers::SHIFT);
move_to(&mut h, 150.0, 91.0);
assert!(readout(&h, 100, 100), "⇧: the larger extent wins");
release_at(&mut h, 150.0, 91.0, Modifiers::SHIFT);
mods(&mut h, Modifiers::NONE);
assert_eq!(selection(&h), Rect::new(50, 50, 150, 150));
assert!(h.query_by_label("W:").is_none() && h.query_by_label_contains(" px").is_none(), "the readout goes away with the drag");
}
#[test]
fn alt_draws_from_the_centre_and_shift_alt_a_centred_circle() {
let mut h = harness(Tool::RectMarquee);
press_at(&mut h, 200.0, 150.0, Modifiers::NONE);
mods(&mut h, Modifiers::ALT);
move_to(&mut h, 240.0, 170.0);
assert!(readout(&h, 80, 40));
release_at(&mut h, 240.0, 170.0, Modifiers::ALT);
mods(&mut h, Modifiers::NONE);
assert_eq!(selection(&h), Rect::new(160, 130, 240, 170), "centred on the press point");
// Elliptical, ⇧⌥: a circle centred on the press point (replacing the selection).
let mut h = harness(Tool::EllipseMarquee);
press_at(&mut h, 200.0, 150.0, Modifiers::NONE);
mods(&mut h, Modifiers::SHIFT | Modifiers::ALT);
move_to(&mut h, 230.0, 160.0);
assert!(readout(&h, 60, 60));
release_at(&mut h, 230.0, 160.0, Modifiers::SHIFT | Modifiers::ALT);
mods(&mut h, Modifiers::NONE);
let r = selection(&h);
assert!(r.x0.abs_diff(170) <= 1 && r.x1.abs_diff(230) <= 1 && r.y0.abs_diff(120) <= 1 && r.y1.abs_diff(180) <= 1, "{r:?}");
}
#[test]
fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() {
// #188: ⇧ held at the press adds (no square); released and pressed again it constrains.
let mut h = harness(Tool::RectMarquee);
h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
mods(&mut h, Modifiers::SHIFT);
press_at(&mut h, 100.0, 100.0, Modifiers::SHIFT);
move_to(&mut h, 180.0, 130.0);
assert!(readout(&h, 80, 30), "not squared by the add-mode ⇧");
release_at(&mut h, 180.0, 130.0, Modifiers::SHIFT);
mods(&mut h, Modifiers::NONE);
assert_eq!(selection(&h), Rect::new(10, 10, 180, 130), "added to the first selection");
let st = h.state().session.active().unwrap();
let sel = st.doc.selection.as_ref().unwrap();
assert!(sel.sample_channel(20, 20, 0) > 0.99 && sel.sample_channel(150, 120, 0) > 0.99 && sel.sample_channel(60, 60, 0) < 0.01);
// ⇧ at the press, released, pressed again: add mode and a square.
let mut h = harness(Tool::RectMarquee);
h.state_mut().run("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
mods(&mut h, Modifiers::SHIFT);
press_at(&mut h, 100.0, 100.0, Modifiers::SHIFT);
mods(&mut h, Modifiers::NONE);
move_to(&mut h, 180.0, 130.0);
mods(&mut h, Modifiers::SHIFT);
move_to(&mut h, 180.0, 131.0);
assert!(readout(&h, 80, 80));
release_at(&mut h, 180.0, 131.0, Modifiers::SHIFT);
mods(&mut h, Modifiers::NONE);
let st = h.state().session.active().unwrap();
let sel = st.doc.selection.as_ref().unwrap();
assert!(sel.sample_channel(20, 20, 0) > 0.99, "still added");
assert!(sel.sample_channel(170, 170, 0) > 0.99 && sel.sample_channel(170, 185, 0) < 0.01, "an 80 px square");
// ⌥ held at the press subtracts.
let mut h = harness(Tool::RectMarquee);
h.state_mut().run("select.rect", json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
mods(&mut h, Modifiers::ALT);
press_at(&mut h, 100.0, 100.0, Modifiers::ALT);
release_at(&mut h, 150.0, 120.0, Modifiers::ALT);
mods(&mut h, Modifiers::NONE);
let st = h.state().session.active().unwrap();
let sel = st.doc.selection.as_ref().unwrap();
assert!(sel.sample_channel(120, 110, 0) < 0.01 && sel.sample_channel(90, 110, 0) > 0.99, "subtracted, not centred");
}
/// `cargo test --release -p photocraft-ui-egui marquee_drag_bench -- --ignored --nocapture`
#[test]
#[ignore]
fn marquee_drag_bench() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 6000, "height": 4000})).unwrap();
app.run("filter.render.clouds", json!({})).unwrap();
app.sync_views();
app.ui.tool = Tool::RectMarquee;
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
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(6);
press_at(&mut h, 1000.0, 1000.0, Modifiers::NONE);
mods(&mut h, Modifiers::SHIFT | Modifiers::ALT);
let n = 60;
let t0 = std::time::Instant::now();
for i in 0..n {
let p = screen(&h, 1500.0 + i as f32 * 20.0, 1300.0 + i as f32 * 7.0);
h.event(egui::Event::PointerMoved(p));
h.step();
}
let ms = t0.elapsed().as_secs_f64() * 1e3 / f64::from(n);
eprintln!("marquee drag on 6000x4000 (shift+alt, readout): {ms:.2} ms per frame");
assert!(h.query_by_label("W:").is_some());
}