mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 04:06:53 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ddc77f64c |
No files matched your search
@@ -1107,7 +1107,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
|
||||
// Pen pressure/tilt for this frame's tool events (mouse = 1.0).
|
||||
app.stylus.update(&ui.input(|i| i.events.clone()));
|
||||
let space_pan = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let space_down = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
// Space while drawing a crop frame moves it instead of panning.
|
||||
crate::crop_ui::set_space(app, space_down);
|
||||
let space_pan = space_down && !crate::crop_ui::active(app);
|
||||
let middle = ui.input(|i| i.pointer.middle_down());
|
||||
let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool };
|
||||
|
||||
@@ -1204,6 +1207,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
} else if let Some(c) = response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p))) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if let Some(c) = response.hover_pos().filter(|_| tool == Tool::Crop).and_then(|p| crate::crop_ui::cursor(app, xf.to_doc(p))) {
|
||||
ui.ctx().set_cursor_icon(c);
|
||||
} else if let Some(p) = response.hover_pos() {
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
let icon = match tool {
|
||||
@@ -1345,7 +1350,7 @@ fn marching_ants(painter: &egui::Painter, r: Rect, time: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop crop overlay: dimmed outside, bright frame, rule-of-thirds grid, corner handles.
|
||||
/// Photoshop crop overlay: dimmed outside, bright frame, rule-of-thirds grid, corner and edge handles.
|
||||
fn crop_overlay(painter: &egui::Painter, r: Rect) {
|
||||
let clip = painter.clip_rect();
|
||||
let dim = Color32::from_black_alpha(130);
|
||||
@@ -1371,6 +1376,12 @@ fn crop_overlay(painter: &egui::Painter, r: Rect) {
|
||||
painter.line_segment([c, c + vec2(l * dx, 0.0)], h);
|
||||
painter.line_segment([c, c + vec2(0.0, l * dy)], h);
|
||||
}
|
||||
// Edge handles: short bars at the middle of each side.
|
||||
let (lx, ly) = (l.min(r.width() / 4.0) / 2.0, l.min(r.height() / 4.0) / 2.0);
|
||||
for (c, horizontal) in [(r.center_top(), true), (r.center_bottom(), true), (r.left_center(), false), (r.right_center(), false)] {
|
||||
let e = if horizontal { vec2(lx, 0.0) } else { vec2(0.0, ly) };
|
||||
painter.line_segment([c - e, c + e], h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlays that persist between gestures: polygonal lasso in progress, pending crop box.
|
||||
@@ -1386,9 +1397,7 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
if let Some(c) = app.ui.crop_rect
|
||||
&& app.drag.is_none()
|
||||
{
|
||||
if let Some(c) = app.ui.crop_rect {
|
||||
let r = Rect::from_two_pos(xf.to_screen(c[0] as f32, c[1] as f32), xf.to_screen(c[2] as f32, c[3] as f32));
|
||||
crop_overlay(painter, r);
|
||||
}
|
||||
@@ -1453,9 +1462,6 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
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);
|
||||
}
|
||||
if d.tool == Tool::Crop {
|
||||
last = crop_end(app, d.start, last);
|
||||
}
|
||||
match d.tool {
|
||||
// The canvas shows the live stroke itself (`LiveStroke`).
|
||||
Tool::Brush | Tool::Eraser => {}
|
||||
@@ -1494,10 +1500,6 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
painter.circle_filled(a, 3.0, Color32::WHITE);
|
||||
painter.circle_filled(b, 3.0, Color32::WHITE);
|
||||
}
|
||||
Tool::Crop => {
|
||||
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));
|
||||
crop_overlay(painter, r);
|
||||
}
|
||||
Tool::Type => {
|
||||
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 pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
|
||||
@@ -1532,6 +1534,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if crate::slice_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
}
|
||||
// Crop tool: draw, move and resize the frame.
|
||||
if crate::crop_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
// Move tool over a guide drags the guide (off the canvas deletes it).
|
||||
match ev {
|
||||
@@ -1735,13 +1741,6 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
);
|
||||
}
|
||||
}
|
||||
Tool::Crop => {
|
||||
let end = crop_end(app, d.start, [end[0], end[1]]);
|
||||
let r = [d.start[0].min(end[0]), d.start[1].min(end[1]), d.start[0].max(end[0]), d.start[1].max(end[1])];
|
||||
if r[2] - r[0] >= 2.0 && r[3] - r[1] >= 2.0 {
|
||||
app.ui.crop_rect = Some(r);
|
||||
}
|
||||
}
|
||||
Tool::Move => {
|
||||
let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round());
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
@@ -1804,15 +1803,6 @@ pub fn commit_polygon(app: &mut PhotocraftApp, mods: egui::Modifiers) {
|
||||
}
|
||||
|
||||
/// Apply the crop tool's rectangle.
|
||||
/// Crop drag end under the options-bar aspect ratio.
|
||||
fn crop_end(app: &PhotocraftApp, start: [f64; 2], end: [f64; 2]) -> [f64; 2] {
|
||||
let size = app.session.active().map_or((1.0, 1.0), |s| (s.doc.size.width as f64, s.doc.size.height as f64));
|
||||
match crate::chrome_ui::crop_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1) {
|
||||
Some((w, h)) => crate::chrome_ui::marquee_end("fixedRatio", w, h, false, start, end),
|
||||
None => end,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_crop(app: &mut PhotocraftApp) {
|
||||
let Some(r) = app.ui.crop_rect.take() else { return };
|
||||
let (x, y) = (r[0].round(), r[1].round());
|
||||
|
||||
@@ -259,6 +259,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if let Some(t) = s("tool").and_then(Tool::from_name) {
|
||||
app.ui.tool = t;
|
||||
}
|
||||
// Space held: the Crop tool moves the frame being drawn.
|
||||
crate::crop_ui::set_space(app, flag("space"));
|
||||
for e in events {
|
||||
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Crop tool gestures, Photoshop "Classic Mode" style (the frame moves over a fixed image):
|
||||
//!
|
||||
//! - outside the frame (or with no frame), a drag draws a new frame; ⇧ makes it square, ⌥ draws
|
||||
//! it from the centre, and holding Space while drawing repositions the frame being drawn;
|
||||
//! - inside the frame, a drag moves it;
|
||||
//! - on an edge or corner, a drag resizes it; ⇧ keeps the frame's aspect ratio, ⌥ resizes about
|
||||
//! the centre, and an options-bar ratio preset always holds.
|
||||
//!
|
||||
//! ↵ commits (`image.crop`, see `canvas::commit_crop`) and Esc cancels. The pending frame lives in
|
||||
//! `UiState::crop_rect`, so the control channel reads it. `image.crop` has no angle, so dragging
|
||||
//! outside the frame draws a new one rather than rotating it.
|
||||
|
||||
use egui::{CursorIcon, Modifiers};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::canvas::ToolEvent;
|
||||
use crate::state::Tool;
|
||||
|
||||
/// How far (screen points) from an edge the pointer still grabs it.
|
||||
const HANDLE_PX: f64 = 8.0;
|
||||
/// Smallest frame (document px) a gesture may leave behind.
|
||||
const MIN_FRAME: f64 = 1.0;
|
||||
|
||||
/// Crop tool pointer state (not serialized: it only exists during a gesture).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CropState {
|
||||
pub drag: Option<CropDrag>,
|
||||
/// Space is held: drawing a new frame moves it instead of sizing it.
|
||||
pub space: bool,
|
||||
}
|
||||
|
||||
/// A crop gesture in progress. Rects are `[x0, y0, x1, y1]` in document coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum CropDrag {
|
||||
/// Drawing a new frame from `anchor`; `prev` is the frame it replaces, restored when the new
|
||||
/// one is too small (a click).
|
||||
Draw { anchor: [f64; 2], cur: [f64; 2], last: [f64; 2], prev: Option<[f64; 4]> },
|
||||
/// Moving the frame `rect` grabbed at `start`.
|
||||
Move { start: [f64; 2], rect: [f64; 4] },
|
||||
/// Dragging an edge or corner: `hx`/`hy` are -1 (left/top), 1 (right/bottom) or 0 (untouched).
|
||||
Resize { hx: i8, hy: i8, start: [f64; 2], rect: [f64; 4] },
|
||||
}
|
||||
|
||||
/// What the pointer is over, relative to a crop frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hit {
|
||||
Handle(i8, i8),
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Hit-test a frame with `tol` document px of reach (shrunk for tiny frames so they stay movable).
|
||||
pub fn hit(r: [f64; 4], p: [f64; 2], tol: f64) -> Hit {
|
||||
let axis = |lo: f64, hi: f64, v: f64, other_in: bool| -> i8 {
|
||||
let t = tol.min((hi - lo) / 4.0).max(0.0);
|
||||
// Outside the frame the full reach applies; inside it, the shrunk one.
|
||||
let near = |e: f64, outward: f64| (v - e) * outward <= tol && (e - v) * outward <= t;
|
||||
match (other_in, near(lo, -1.0), near(hi, 1.0)) {
|
||||
(false, ..) => 0,
|
||||
(_, true, true) => {
|
||||
if (v - lo).abs() <= (v - hi).abs() {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
(_, true, false) => -1,
|
||||
(_, false, true) => 1,
|
||||
_ => 0,
|
||||
}
|
||||
};
|
||||
let in_x = p[0] >= r[0] - tol && p[0] <= r[2] + tol;
|
||||
let in_y = p[1] >= r[1] - tol && p[1] <= r[3] + tol;
|
||||
let (hx, hy) = (axis(r[0], r[2], p[0], in_y), axis(r[1], r[3], p[1], in_x));
|
||||
if hx != 0 || hy != 0 {
|
||||
Hit::Handle(hx, hy)
|
||||
} else if p[0] > r[0] && p[0] < r[2] && p[1] > r[1] && p[1] < r[3] {
|
||||
Hit::Inside
|
||||
} else {
|
||||
Hit::Outside
|
||||
}
|
||||
}
|
||||
|
||||
/// `[x0, y0, x1, y1]` with x0 ≤ x1, y0 ≤ y1.
|
||||
fn normalized(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
}
|
||||
|
||||
fn size_ok(r: [f64; 4]) -> bool {
|
||||
r[2] - r[0] >= MIN_FRAME && r[3] - r[1] >= MIN_FRAME
|
||||
}
|
||||
|
||||
/// The options-bar ratio preset as width / height.
|
||||
fn preset_ratio(app: &PhotocraftApp) -> Option<f64> {
|
||||
let size = app.session.active().map_or((1.0, 1.0), |s| (s.doc.size.width as f64, s.doc.size.height as f64));
|
||||
crate::chrome_ui::crop_ratio(&app.ui.tool_options.crop_ratio, size.0, size.1).map(|(w, h)| w / h).filter(|k| k.is_finite() && *k > 0.0)
|
||||
}
|
||||
|
||||
/// A new frame drawn from `anchor` to `cur`: ⇧ square, ⌥ from the centre, `ratio` held.
|
||||
pub fn drawn(anchor: [f64; 2], cur: [f64; 2], ratio: Option<f64>, mods: Modifiers) -> [f64; 4] {
|
||||
let e = match ratio {
|
||||
Some(k) => crate::chrome_ui::marquee_end("fixedRatio", k, 1.0, false, anchor, cur),
|
||||
None => crate::chrome_ui::marquee_end("", 0.0, 0.0, mods.shift, anchor, cur),
|
||||
};
|
||||
if mods.alt { normalized([2.0 * anchor[0] - e[0], 2.0 * anchor[1] - e[1]], e) } else { normalized(anchor, e) }
|
||||
}
|
||||
|
||||
/// Frame `r` with handle (`hx`, `hy`) dragged by `d`. `alt` resizes about the centre; `ratio`
|
||||
/// (width / height) keeps the proportions.
|
||||
pub fn resized(r: [f64; 4], hx: i8, hy: i8, d: [f64; 2], ratio: Option<f64>, alt: bool) -> [f64; 4] {
|
||||
// Per axis: the fixed point, the signed extent from it, and whether it is centred (the
|
||||
// extent is then a half-extent).
|
||||
let axis = |lo: f64, hi: f64, h: i8, d: f64| -> (f64, f64, bool) {
|
||||
let c = (lo + hi) / 2.0;
|
||||
match h {
|
||||
1 if alt => (c, hi + d - c, true),
|
||||
1 => (lo, hi + d - lo, false),
|
||||
-1 if alt => (c, lo + d - c, true),
|
||||
-1 => (hi, lo + d - hi, false),
|
||||
_ => (c, (hi - lo) / 2.0, true),
|
||||
}
|
||||
};
|
||||
let (fx, mut ex, cx) = axis(r[0], r[2], hx, d[0]);
|
||||
let (fy, mut ey, cy) = axis(r[1], r[3], hy, d[1]);
|
||||
if let Some(k) = ratio.filter(|k| k.is_finite() && *k > 0.0) {
|
||||
let full = |e: f64, c: bool| e.abs() * if c { 2.0 } else { 1.0 };
|
||||
let (w, h) = (full(ex, cx), full(ey, cy));
|
||||
let (w, h) = match (hx != 0, hy != 0) {
|
||||
// A corner: the larger relative extent wins.
|
||||
(true, true) => {
|
||||
let s = (w / k).max(h);
|
||||
(s * k, s)
|
||||
}
|
||||
(true, false) => (w, w / k),
|
||||
(false, true) => (h * k, h),
|
||||
(false, false) => (w, h),
|
||||
};
|
||||
let back = |e: f64, len: f64, c: bool| if e < 0.0 { -1.0 } else { 1.0 } * if c { len / 2.0 } else { len };
|
||||
ex = back(ex, w, cx);
|
||||
ey = back(ey, h, cy);
|
||||
}
|
||||
let span = |f: f64, e: f64, c: bool| if c { (f - e, f + e) } else { (f, f + e) };
|
||||
let ((ax, bx), (ay, by)) = (span(fx, ex, cx), span(fy, ey, cy));
|
||||
normalized([ax, ay], [bx, by])
|
||||
}
|
||||
|
||||
/// Space is held (set by the canvas each frame, or by `ui.pointer`'s `space` flag).
|
||||
pub fn set_space(app: &mut PhotocraftApp, down: bool) {
|
||||
app.crop.space = down;
|
||||
}
|
||||
|
||||
/// A crop gesture is in progress: Space repositions the frame rather than panning.
|
||||
pub fn active(app: &PhotocraftApp) -> bool {
|
||||
app.ui.tool == Tool::Crop && app.crop.drag.is_some()
|
||||
}
|
||||
|
||||
fn tolerance(app: &PhotocraftApp) -> f64 {
|
||||
HANDLE_PX / (app.current_zoom() as f64).max(0.01)
|
||||
}
|
||||
|
||||
/// Pointer input for the Crop tool. Returns true when the event was its.
|
||||
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
|
||||
if app.ui.tool != Tool::Crop {
|
||||
app.crop.drag = None;
|
||||
return false;
|
||||
}
|
||||
let p = match ev {
|
||||
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => [x, y],
|
||||
};
|
||||
if app.session.active().is_none() || !p[0].is_finite() || !p[1].is_finite() {
|
||||
return true;
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { .. } => {
|
||||
let frame = app.ui.crop_rect.filter(|r| r.iter().all(|v| v.is_finite()));
|
||||
app.crop.drag = Some(match frame.map(|r| (r, hit(r, p, tolerance(app)))) {
|
||||
Some((rect, Hit::Handle(hx, hy))) => CropDrag::Resize { hx, hy, start: p, rect },
|
||||
Some((rect, Hit::Inside)) => CropDrag::Move { start: p, rect },
|
||||
_ => CropDrag::Draw { anchor: p, cur: p, last: p, prev: app.ui.crop_rect },
|
||||
});
|
||||
}
|
||||
ToolEvent::Move { .. } => update(app, p, mods),
|
||||
ToolEvent::Up { .. } => {
|
||||
update(app, p, mods);
|
||||
let Some(drag) = app.crop.drag.take() else { return true };
|
||||
let ok = app.ui.crop_rect.is_some_and(size_ok);
|
||||
match drag {
|
||||
CropDrag::Draw { prev, .. } if !ok => app.ui.crop_rect = prev,
|
||||
CropDrag::Move { rect, .. } | CropDrag::Resize { rect, .. } if !ok => app.ui.crop_rect = Some(rect),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The frame follows the pointer at `p`.
|
||||
fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
|
||||
let ratio = preset_ratio(app);
|
||||
let space = app.crop.space;
|
||||
let Some(drag) = app.crop.drag.as_mut() else { return };
|
||||
let rect = match drag {
|
||||
CropDrag::Draw { anchor, cur, last, .. } => {
|
||||
if space {
|
||||
// Space: the frame being drawn follows the pointer, keeping its size.
|
||||
let d = [p[0] - last[0], p[1] - last[1]];
|
||||
*anchor = [anchor[0] + d[0], anchor[1] + d[1]];
|
||||
*cur = [cur[0] + d[0], cur[1] + d[1]];
|
||||
} else {
|
||||
*cur = p;
|
||||
}
|
||||
*last = p;
|
||||
if *anchor == *cur {
|
||||
return;
|
||||
}
|
||||
drawn(*anchor, *cur, ratio, mods)
|
||||
}
|
||||
CropDrag::Move { start, rect } => {
|
||||
let (dx, dy) = (p[0] - start[0], p[1] - start[1]);
|
||||
[rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy]
|
||||
}
|
||||
CropDrag::Resize { hx, hy, start, rect } => {
|
||||
let keep = || {
|
||||
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
(w > 0.0 && h > 0.0).then(|| w / h)
|
||||
};
|
||||
let ratio = ratio.or_else(|| if mods.shift { keep() } else { None });
|
||||
resized(*rect, *hx, *hy, [p[0] - start[0], p[1] - start[1]], ratio, mods.alt)
|
||||
}
|
||||
};
|
||||
app.ui.crop_rect = Some(rect);
|
||||
}
|
||||
|
||||
/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside.
|
||||
pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option<CursorIcon> {
|
||||
if app.ui.tool != Tool::Crop {
|
||||
return None;
|
||||
}
|
||||
let h = match app.crop.drag {
|
||||
Some(CropDrag::Move { .. }) => Hit::Inside,
|
||||
Some(CropDrag::Resize { hx, hy, .. }) => Hit::Handle(hx, hy),
|
||||
Some(CropDrag::Draw { .. }) => Hit::Outside,
|
||||
None => hit(app.ui.crop_rect?, p, tolerance(app)),
|
||||
};
|
||||
Some(match h {
|
||||
Hit::Handle(-1, -1) | Hit::Handle(1, 1) => CursorIcon::ResizeNwSe,
|
||||
Hit::Handle(-1, 1) | Hit::Handle(1, -1) => CursorIcon::ResizeNeSw,
|
||||
Hit::Handle(_, 0) => CursorIcon::ResizeHorizontal,
|
||||
Hit::Handle(..) => CursorIcon::ResizeVertical,
|
||||
Hit::Inside => CursorIcon::Move,
|
||||
Hit::Outside => CursorIcon::Crosshair,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::canvas::tool_event;
|
||||
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
|
||||
use serde_json::json;
|
||||
|
||||
const NONE: Modifiers = Modifiers::NONE;
|
||||
const SHIFT: Modifiers = Modifiers::SHIFT;
|
||||
|
||||
fn app(depth: SampleType) -> PhotocraftApp {
|
||||
let doc = Document::with_background("crop", Size::new(200, 100), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.add_document(doc, None);
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
app.ui.tool = Tool::Crop;
|
||||
app.ui.extras.snap = false;
|
||||
app.ui.view.show.smart_guides = false;
|
||||
app
|
||||
}
|
||||
|
||||
fn drag(app: &mut PhotocraftApp, pts: &[[f64; 2]], mods: Modifiers) {
|
||||
let (first, last) = (pts[0], pts[pts.len() - 1]);
|
||||
tool_event(app, ToolEvent::Down { x: first[0], y: first[1], pressure: 1.0 }, mods);
|
||||
for p in &pts[1..] {
|
||||
tool_event(app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, mods);
|
||||
}
|
||||
tool_event(app, ToolEvent::Up { x: last[0], y: last[1] }, mods);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_finds_handles_inside_and_outside() {
|
||||
let r = [10.0, 10.0, 110.0, 60.0];
|
||||
assert_eq!(hit(r, [10.0, 10.0], 4.0), Hit::Handle(-1, -1));
|
||||
assert_eq!(hit(r, [112.0, 61.0], 4.0), Hit::Handle(1, 1));
|
||||
assert_eq!(hit(r, [60.0, 8.0], 4.0), Hit::Handle(0, -1));
|
||||
assert_eq!(hit(r, [108.0, 30.0], 4.0), Hit::Handle(1, 0));
|
||||
assert_eq!(hit(r, [60.0, 30.0], 4.0), Hit::Inside);
|
||||
assert_eq!(hit(r, [150.0, 30.0], 4.0), Hit::Outside);
|
||||
// A tiny frame keeps a movable middle.
|
||||
assert_eq!(hit([0.0, 0.0, 4.0, 4.0], [2.0, 2.0], 8.0), Hit::Inside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_moves_the_frame() {
|
||||
let mut app = app(SampleType::U8);
|
||||
drag(&mut app, &[[20.0, 20.0], [80.0, 60.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 80.0, 60.0]));
|
||||
drag(&mut app, &[[50.0, 40.0], [60.0, 45.0], [70.0, 50.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([40.0, 30.0, 100.0, 70.0]), "moved, not redrawn");
|
||||
// Outside, a drag still draws a new frame.
|
||||
drag(&mut app, &[[150.0, 10.0], [190.0, 90.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([150.0, 10.0, 190.0, 90.0]));
|
||||
// A click outside keeps the frame.
|
||||
drag(&mut app, &[[5.0, 5.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([150.0, 10.0, 190.0, 90.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_the_frame_snaps_its_edges() {
|
||||
let mut app = app(SampleType::U8);
|
||||
app.ui.extras.snap = true;
|
||||
app.ui.crop_rect = Some([10.0, 10.0, 50.0, 40.0]);
|
||||
drag(&mut app, &[[30.0, 25.0], [23.0, 25.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([0.0, 10.0, 40.0, 40.0]), "left edge snaps to the document edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_resize_with_shift_and_alt() {
|
||||
let mut app = app(SampleType::U8);
|
||||
app.ui.crop_rect = Some([20.0, 20.0, 100.0, 60.0]);
|
||||
// Right edge.
|
||||
drag(&mut app, &[[100.0, 40.0], [120.0, 50.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 120.0, 60.0]));
|
||||
// Bottom-right corner, free.
|
||||
drag(&mut app, &[[120.0, 60.0], [140.0, 70.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 140.0, 70.0]));
|
||||
// ⇧ keeps 120:50 from the top-left corner: the larger relative extent wins.
|
||||
app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]);
|
||||
drag(&mut app, &[[20.0, 20.0], [10.0, 0.0]], SHIFT);
|
||||
let r = app.ui.crop_rect.unwrap();
|
||||
assert_eq!((r[2], r[3]), (140.0, 70.0), "opposite corner fixed");
|
||||
assert!(((r[2] - r[0]) / (r[3] - r[1]) - 120.0 / 50.0).abs() < 1e-9, "{r:?}");
|
||||
assert_eq!(r[3] - r[1], 70.0);
|
||||
// ⌥ resizes about the centre.
|
||||
app.ui.crop_rect = Some([40.0, 20.0, 80.0, 60.0]);
|
||||
drag(&mut app, &[[80.0, 40.0], [90.0, 40.0]], Modifiers::ALT);
|
||||
assert_eq!(app.ui.crop_rect, Some([30.0, 20.0, 90.0, 60.0]));
|
||||
// An options-bar ratio holds on an edge drag, centred on the other axis.
|
||||
app.ui.tool_options.crop_ratio = "1:1".into();
|
||||
app.ui.crop_rect = Some([40.0, 20.0, 80.0, 60.0]);
|
||||
drag(&mut app, &[[80.0, 40.0], [100.0, 40.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([40.0, 10.0, 100.0, 70.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_handle_past_the_other_side_flips_and_collapse_reverts() {
|
||||
let mut app = app(SampleType::U8);
|
||||
app.ui.crop_rect = Some([20.0, 20.0, 60.0, 60.0]);
|
||||
drag(&mut app, &[[60.0, 40.0], [10.0, 40.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 20.0, 60.0]));
|
||||
// Collapsing to nothing restores the frame the gesture started from.
|
||||
drag(&mut app, &[[20.0, 40.0], [10.0, 40.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([10.0, 20.0, 20.0, 60.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_repositions_the_frame_being_drawn() {
|
||||
let mut app = app(SampleType::U8);
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, NONE);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 50.0, y: 40.0, pressure: 1.0 }, NONE);
|
||||
assert!(active(&app));
|
||||
set_space(&mut app, true);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 70.0, y: 50.0, pressure: 1.0 }, NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([30.0, 20.0, 70.0, 50.0]), "same size, moved");
|
||||
set_space(&mut app, false);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 90.0, y: 60.0, pressure: 1.0 }, NONE);
|
||||
tool_event(&mut app, ToolEvent::Up { x: 90.0, y: 60.0 }, NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([30.0, 20.0, 90.0, 60.0]), "sizing resumes from the moved anchor");
|
||||
assert!(!active(&app));
|
||||
}
|
||||
|
||||
/// Real egui input on the canvas: holding Space mid-draw moves the frame instead of panning.
|
||||
#[test]
|
||||
fn space_on_the_canvas_moves_the_frame_not_the_view() {
|
||||
use egui::{Event, Key, PointerButton, pos2};
|
||||
let mut a = app(SampleType::U8);
|
||||
let view = crate::state::View { zoom: 2.0, center: [100.0, 50.0], fit_pending: false, doc_size: [200, 100] };
|
||||
a.ui.views = vec![view.clone()];
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(600.0, 400.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
let v = app.ui.views[0].clone();
|
||||
crate::canvas::canvas_view(app, ui, 0, ui.max_rect(), v, true);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(2);
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
let space = |pressed| Event::Key { key: Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||||
let p0 = pos2(200.0, 150.0);
|
||||
h.event(Event::PointerMoved(p0));
|
||||
h.event(button(p0, true));
|
||||
h.step();
|
||||
for p in [pos2(220.0, 170.0), pos2(260.0, 190.0)] {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
let drawn = h.state().ui.crop_rect.unwrap();
|
||||
// egui starts the drag at the first move past its threshold (220, 170).
|
||||
assert_eq!((drawn[2] - drawn[0], drawn[3] - drawn[1]), (20.0, 10.0), "40x20 screen px at 200%");
|
||||
h.event(space(true));
|
||||
h.step();
|
||||
for p in [pos2(280.0, 200.0), pos2(300.0, 210.0)] {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
let moved = h.state().ui.crop_rect.unwrap();
|
||||
assert_eq!(moved, [drawn[0] + 20.0, drawn[1] + 10.0, drawn[2] + 20.0, drawn[3] + 10.0], "moved by 40x20 screen px");
|
||||
h.event(space(false));
|
||||
h.event(button(pos2(300.0, 210.0), false));
|
||||
h.step();
|
||||
let st = h.state();
|
||||
assert_eq!(st.ui.crop_rect, Some(moved));
|
||||
assert_eq!(st.ui.views[0].center, view.center, "the view did not pan");
|
||||
assert!(st.crop.drag.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_crops_to_the_moved_frame_at_8_and_16_bit() {
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let mut app = app(depth);
|
||||
app.run("select.rect", json!({"x": 60, "y": 40, "width": 1, "height": 1})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
drag(&mut app, &[[10.0, 10.0], [50.0, 40.0]], NONE);
|
||||
drag(&mut app, &[[30.0, 25.0], [70.0, 45.0]], NONE);
|
||||
assert_eq!(app.ui.crop_rect, Some([50.0, 30.0, 90.0, 60.0]));
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
assert!(app.ui.crop_rect.is_none());
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
assert_eq!((doc.size.width, doc.size.height), (40, 30), "{depth:?}");
|
||||
assert_eq!(doc.depth, depth);
|
||||
let px = doc.layers[0].surface().unwrap().rgba(10, 10);
|
||||
assert!(px[0] > 0.99 && px[1] < 0.01, "{depth:?} {px:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_and_bad_input_never_panics() {
|
||||
let mut app = app(SampleType::U8);
|
||||
for r in [[0.0; 4], [5.0, 5.0, 5.0, 5.0], [10.0, 10.0, 0.0, 0.0], [f64::NAN, 0.0, 10.0, 10.0], [f64::INFINITY, 0.0, f64::MAX, 1e300]] {
|
||||
app.ui.crop_rect = Some(r);
|
||||
for m in [NONE, SHIFT, Modifiers::ALT, SHIFT | Modifiers::ALT] {
|
||||
drag(&mut app, &[[5.0, 5.0], [f64::NAN, 3.0], [1e300, -1e300], [6.0, 7.0]], m);
|
||||
let _ = cursor(&app, [5.0, 5.0]);
|
||||
app.ui.crop_rect = Some(r);
|
||||
}
|
||||
}
|
||||
app.ui.tool_options.crop_ratio = "0:0".into();
|
||||
app.ui.crop_rect = Some([0.0, 0.0, 10.0, 10.0]);
|
||||
drag(&mut app, &[[10.0, 10.0], [30.0, 30.0]], SHIFT);
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
// No document: events are swallowed quietly.
|
||||
let mut empty = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
empty.ui.tool = Tool::Crop;
|
||||
drag(&mut empty, &[[1.0, 1.0], [9.0, 9.0]], NONE);
|
||||
assert!(empty.ui.crop_rect.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_follows_the_frame() {
|
||||
let mut app = app(SampleType::U8);
|
||||
assert_eq!(cursor(&app, [5.0, 5.0]), None, "no frame yet: the default crosshair");
|
||||
app.ui.crop_rect = Some([20.0, 20.0, 100.0, 60.0]);
|
||||
assert_eq!(cursor(&app, [20.0, 20.0]), Some(CursorIcon::ResizeNwSe));
|
||||
assert_eq!(cursor(&app, [100.0, 20.0]), Some(CursorIcon::ResizeNeSw));
|
||||
assert_eq!(cursor(&app, [60.0, 60.0]), Some(CursorIcon::ResizeVertical));
|
||||
assert_eq!(cursor(&app, [60.0, 40.0]), Some(CursorIcon::Move));
|
||||
assert_eq!(cursor(&app, [150.0, 40.0]), Some(CursorIcon::Crosshair));
|
||||
app.ui.tool = Tool::Brush;
|
||||
assert_eq!(cursor(&app, [60.0, 40.0]), None);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod color_picker_ui;
|
||||
pub mod color_range_ui;
|
||||
pub mod comps_ui;
|
||||
pub mod control;
|
||||
pub mod crop_ui;
|
||||
pub mod dialogs;
|
||||
pub mod discard_ui;
|
||||
pub mod distort_ui;
|
||||
@@ -262,6 +263,8 @@ pub struct PhotocraftApp {
|
||||
info_sample: Option<((i32, i32, u64), [f32; 4])>,
|
||||
/// Guide being dragged (from a ruler or with the Move tool).
|
||||
pub(crate) guide_drag: Option<rulers::GuideDrag>,
|
||||
/// Crop tool gesture in progress (see `crop_ui`).
|
||||
pub(crate) crop: crop_ui::CropState,
|
||||
/// Type tool layout cache: ((doc, revision, layer), layout).
|
||||
pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc<photocraft_text::TextLayout>)>,
|
||||
/// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels.
|
||||
@@ -324,6 +327,7 @@ impl PhotocraftApp {
|
||||
channel_views: HashMap::new(),
|
||||
type_layout: None,
|
||||
guide_drag: None,
|
||||
crop: Default::default(),
|
||||
hover_doc: None,
|
||||
info_sample: None,
|
||||
os_clip_sig: None,
|
||||
|
||||
@@ -627,7 +627,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
Tool::Hand => hint(ui, "Drag to pan · hold Space with any tool"),
|
||||
Tool::Lasso | Tool::PolygonLasso => hint(ui, "Drag (lasso) or click points (polygonal) · ⇧ add · ⌥ subtract"),
|
||||
Tool::Crop => hint(ui, "Drag a crop box · ↵ commits · Esc cancels"),
|
||||
Tool::Crop => hint(
|
||||
ui,
|
||||
"Drag a crop box · drag inside to move · edges resize (⇧ ratio, ⌥ centre) · Space moves while drawing · ↵ commits · Esc cancels",
|
||||
),
|
||||
Tool::Gradient => hint(ui, "Drag to draw a gradient"),
|
||||
Tool::PaintBucket => hint(ui, "Click to fill similar colours"),
|
||||
Tool::Type => hint(ui, "Click to add text"),
|
||||
|
||||
@@ -254,6 +254,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if pressed(Key::Escape) {
|
||||
app.ui.polygon.clear();
|
||||
app.ui.crop_rect = None;
|
||||
app.crop.drag = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2]) {
|
||||
} else if tool == Tool::Move {
|
||||
let exclude = app.session.active().map(|s| s.selected_layers()).unwrap_or_default();
|
||||
moving_rect(app).map(|rect| (Gesture::Move { rect }, exclude))
|
||||
} else if tool == Tool::Crop
|
||||
&& let Some(rect) = app.ui.crop_rect.filter(|r| crate::crop_ui::hit(*r, p, tol) == crate::crop_ui::Hit::Inside)
|
||||
{
|
||||
// Moving the crop frame snaps its edges, like the Move tool's layer bounds.
|
||||
Some((Gesture::Move { rect }, Vec::new()))
|
||||
} else if is_point_tool(tool) {
|
||||
Some((Gesture::Point, Vec::new()))
|
||||
} else {
|
||||
|
||||
@@ -39,7 +39,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `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
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen)
|
||||
- `ui.pointer {events: [{kind: down|move|up, x, y, pressure?, tiltX?, tiltY?, rotation?}], modifiers?}`: drive the active tool in document coordinates (pressure 0..1, tilt in degrees -90..90, barrel rotation 0..360: a simulated pen); `space: true` holds Space (the Crop tool then moves the frame being drawn)
|
||||
- `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}`
|
||||
- `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing)
|
||||
- `ui.resize {width, height}`: resize the main window
|
||||
|
||||
Reference in new issue
Block a user