fix(crop): stabilize resizing and apply ratio changes immediately (#3206)

Compute snapped handles from press-time geometry to avoid feedback from
Auto Center Preview. Paint the image and crop frame with the same camera.

Apply ratio and size options immediately as centred rectangles contained
in the current frame. Respect saved ratios, keep Swap and X consistent,
and let Enter apply output sizes without an initial resize.

Use container padding for the options bar's text allowance so right-aligned
crop actions do not introduce horizontal overflow.

Add regression coverage for handle snapping, rendered frames, preset/Enter
and Swap/X interactions, and options-bar scrolling at multiple UI scales.
Enable public GitHub profile name syncing for TheTeslak's contributor credit.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Teslak
2026-10-11 05:54:09 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent d96bb50161
commit 2189a1610e
9 changed files with 620 additions and 223 deletions
+3
View File
@@ -93,3 +93,6 @@ sync_github_name = true
[people.FinnNk]
sync_github_name = true
[people.TheTeslak]
sync_github_name = true
+217 -191
View File
@@ -2479,214 +2479,225 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
None => paint_dots(ui, rect, app.session.prefs().interface.show_canvas_dots),
}
let border = app.session.prefs().interface.canvas_border;
let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow;
// Drop shadow, checkerboard, document image.
let img_rect = xf.doc_rect(doc.bounds());
// Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas
// (whose edge blends into them) and without its shadow.
let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output);
let drop_shadow = drop_shadow && !beyond;
// Live adjustment previews on big documents use a downsampled proxy (see proxy.rs), unless
// Preferences › Performance › Low Resolution Previews is off.
let mut on_gpu = false;
// A flipped or pixel-aspect-corrected view draws through the CPU path (the GPU canvas shader
// has no mirroring or non-square pixels). Rotation stays on the GPU: it is a view uniform.
let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect);
if app.gpu.is_some()
&& gpu_ok
&& let Some((k, key, preview_size)) =
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: preview_size,
zoom: view.zoom * k as f32 / ppp,
// A size-changing preview grows around the old image center; keep the view's pan.
center: [
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
view.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0,
],
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
let t = crate::theme::Tokens::get(&ctx);
!t.bevel && !t.pro && drop_shadow
},
pixel_grid: false,
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &doc, key, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
on_gpu = true;
app.perf.gpu = true;
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
let params = crate::gpu_canvas::ViewParams {
doc: doc.id.0,
doc_size: [doc.size.width, doc.size.height],
zoom: point_zoom,
center: view.center,
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
let t = crate::theme::Tokens::get(&ctx);
!t.bevel && !t.pro && drop_shadow
},
pixel_grid,
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &doc, doc.id.0, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else {
if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow {
painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0));
}
let rotated = view.rotation.abs() > 0.05;
let size = [doc.size.width as f32, doc.size.height as f32];
match app.session.prefs().transparency_and_gamut.square() {
Some(square) => {
let checker_id = checker(app, &ctx);
if rotated {
let tiles = vec2(size[0], size[1]) / (2.0 * square);
paint_mapped_image(&painter, &xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE);
} else {
let tiles = img_rect.size() / (2.0 * square);
painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE);
// Default-mode Crop changes its camera during input. Defer its image until the new camera
// is known; other tools keep their existing paint order and texture/outline snapshots.
let crop_camera = primary && crate::crop_mode::moves_image(app);
let paint_canvas = |app: &mut PhotocraftApp, ui: &egui::Ui, view: &View, xf: ViewXform| {
let point_zoom = view.zoom / ppp;
let drop_shadow = border == photocraft_engine::prefs::CanvasBorder::DropShadow;
// Drop shadow, checkerboard, document image.
let img_rect = xf.doc_rect(doc.bounds());
// Crop tool: the layers' pixels past the canvas while a frame is edited, under the canvas
// (whose edge blends into them) and without its shadow.
let beyond = draw_beyond_canvas(app, &painter, &xf, idx, output);
let drop_shadow = drop_shadow && !beyond;
// Live adjustment previews on big documents use a downsampled proxy (see proxy.rs), unless
// Preferences › Performance › Low Resolution Previews is off.
let mut on_gpu = false;
// A flipped or pixel-aspect-corrected view draws through the CPU path (the GPU canvas shader
// has no mirroring or non-square pixels). Rotation stays on the GPU: it is a view uniform.
let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation, aspect);
if app.gpu.is_some()
&& gpu_ok
&& let Some((k, key, preview_size)) =
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: preview_size,
zoom: view.zoom * k as f32 / ppp,
// A size-changing preview grows around the old image center; keep the view's pan.
center: [
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
view.center[1] / k as f32 + (preview_size[1] as f32 - original_size[1] as f32) / 2.0,
],
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
let t = crate::theme::Tokens::get(&ctx);
!t.bevel && !t.pro && drop_shadow
},
pixel_grid: false,
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &doc, key, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else if gpu_ok && ensure_gpu(app, idx, visible_doc_rect(&xf)) {
on_gpu = true;
app.perf.gpu = true;
// Shadow, checkerboard, document and pixel grid in one custom shader (gpu_canvas.rs).
let params = crate::gpu_canvas::ViewParams {
doc: doc.id.0,
doc_size: [doc.size.width, doc.size.height],
zoom: point_zoom,
center: view.center,
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
let t = crate::theme::Tokens::get(&ctx);
!t.bevel && !t.pro && drop_shadow
},
pixel_grid,
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &doc, doc.id.0, output),
output: output.unwrap_or(0),
hdr: hdr_preview(app, &doc),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else {
if !crate::theme::Tokens::get(&ctx).bevel && drop_shadow {
painter.add(egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(150) }.as_shape(img_rect, 0));
}
let rotated = view.rotation.abs() > 0.05;
let size = [doc.size.width as f32, doc.size.height as f32];
match app.session.prefs().transparency_and_gamut.square() {
Some(square) => {
let checker_id = checker(app, &ctx);
if rotated {
let tiles = vec2(size[0], size[1]) / (2.0 * square);
paint_mapped_image(&painter, &xf, checker_id, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), size, Color32::WHITE);
} else {
let tiles = img_rect.size() / (2.0 * square);
painter.image(checker_id, img_rect, Rect::from_min_max(Pos2::ZERO, pos2(tiles.x, tiles.y)), Color32::WHITE);
}
}
None => {
if rotated {
paint_mapped_quad(&painter, &xf, size, Color32::WHITE);
} else {
painter.rect_filled(img_rect, 0.0, Color32::WHITE);
}
}
}
None => {
if let Some((tex, _scale)) = ensure_texture(app, &ctx, idx, output) {
if rotated {
paint_mapped_quad(&painter, &xf, size, Color32::WHITE);
paint_mapped_image(&painter, &xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE);
} else {
painter.rect_filled(img_rect, 0.0, Color32::WHITE);
let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) };
painter.image(tex, img_rect, uv, Color32::WHITE);
}
}
}
if let Some((tex, _scale)) = ensure_texture(app, &ctx, idx, output) {
if rotated {
paint_mapped_image(&painter, &xf, tex, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), size, Color32::WHITE);
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) {
if view.rotation.abs() > 0.05 {
paint_mapped_image(
&painter,
&xf,
tex,
Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)),
[doc.size.width as f32, doc.size.height as f32],
Color32::WHITE,
);
} else {
let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) };
painter.image(tex, img_rect, uv, Color32::WHITE);
}
}
}
// Channels panel: alpha / Quick Mask overlays and single-channel views (channel_view.rs).
if let Some(tex) = crate::channel_view::ensure(app, &ctx, idx) {
if view.rotation.abs() > 0.05 {
paint_mapped_image(
&painter,
&xf,
tex,
Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)),
[doc.size.width as f32, doc.size.height as f32],
Color32::WHITE,
);
} else {
let uv = if flip { Rect::from_min_max(pos2(1.0, 0.0), pos2(0.0, 1.0)) } else { Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)) };
painter.image(tex, img_rect, uv, Color32::WHITE);
}
}
crate::color_range_ui::paint_selection_preview(app, &ctx, &painter, doc.id, img_rect, flip);
// Artboards: pasteboard between the boards, outlines and names (artboard_ui.rs).
if doc.has_artboards() {
let t = crate::theme::Tokens::get(&ctx);
let pasteboard = crate::prefs_ui::pasteboard_color(app);
let dot_tex = dots(&ctx, &t);
for r in crate::artboard_ui::pasteboard_rects(&xf, &doc) {
let r = r.intersect(rect);
if !r.is_positive() {
continue;
}
match pasteboard {
Some(c) => {
painter.rect_filled(r, 0.0, c);
crate::color_range_ui::paint_selection_preview(app, &ctx, &painter, doc.id, img_rect, flip);
// Artboards: pasteboard between the boards, outlines and names (artboard_ui.rs).
if doc.has_artboards() {
let t = crate::theme::Tokens::get(&ctx);
let pasteboard = crate::prefs_ui::pasteboard_color(app);
let dot_tex = dots(&ctx, &t);
for r in crate::artboard_ui::pasteboard_rects(&xf, &doc) {
let r = r.intersect(rect);
if !r.is_positive() {
continue;
}
None => {
painter.rect_filled(r, 0.0, t.canvas);
if let Some(id) = dot_tex {
// Same phase as the dots around the document.
let uv = Rect::from_min_max(((r.min - rect.min) / 22.0).to_pos2(), ((r.max - rect.min) / 22.0).to_pos2());
painter.image(id, r, uv, Color32::WHITE);
match pasteboard {
Some(c) => {
painter.rect_filled(r, 0.0, c);
}
None => {
painter.rect_filled(r, 0.0, t.canvas);
if let Some(id) = dot_tex {
// Same phase as the dots around the document.
let uv = Rect::from_min_max(((r.min - rect.min) / 22.0).to_pos2(), ((r.max - rect.min) / 22.0).to_pos2());
painter.image(id, r, uv, Color32::WHITE);
}
}
}
}
}
if primary {
crate::artboard_ui::draw_frames(app, &painter, &xf);
}
}
// Pixel grid at high zoom, over pixels with content only (the GPU path draws its own).
if !on_gpu && pixel_grid && crate::pixel_grid::shows_at(view.zoom) {
crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf));
}
// View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
let layer_edges = painter.add(egui::Shape::Noop);
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it. The step is in
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
// selection still traces at 100% on a scaled display.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
let mut x0 = docs[0][0];
let mut y0 = docs[0][1];
let mut x1 = x0;
let mut y1 = y0;
for d in docs {
x0 = x0.min(d[0]);
y0 = y0.min(d[1]);
x1 = x1.max(d[0]);
y1 = y1.max(d[1]);
}
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
let px = |v: f64| v.clamp(-f64::from(1 << 30), f64::from(1 << 30)) as i32;
let vis = photocraft_geom::Rect::new(
px(x0.floor()).div_euclid(q) * q - q,
px(y0.floor()).div_euclid(q) * q - q,
(px(x1.ceil()).div_euclid(q) + 2) * q,
(px(y1.ceil()).div_euclid(q) + 2) * q,
);
let key = crate::surface_fingerprint(sel)
^ (step as u64) << 56
^ doc.id.0.rotate_left(17)
^ (vis.x0 as u64) << 8
^ (vis.y0 as u64) << 24
^ (vis.x1 as u64) << 36
^ (vis.y1 as u64) << 48;
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
// Filters can occupy every worker in Rayon's global pool. A parallel scan here would
// block the UI thread waiting for those workers, preventing job cancellation (#1554).
let segs = if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) };
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
}
if let Some((_, _, segs)) = &app.outline_cache {
let time = ui.input(|i| i.time);
// A floating piece (and a selection being dragged) draws its ants where it is shown.
let mut shown = xf;
if let Some((dx, dy)) = selection_shown_offset(app) {
shown.center = [xf.center[0] - dx as f32, xf.center[1] - dy as f32];
if primary {
crate::artboard_ui::draw_frames(app, &painter, &xf);
}
marching_ants_segments(&painter, &shown, segs, time);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
// Pixel grid at high zoom, over pixels with content only (the GPU path draws its own).
if !on_gpu && pixel_grid && crate::pixel_grid::shows_at(view.zoom) {
crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf));
}
// View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are
// in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer.
let layer_edges = painter.add(egui::Shape::Noop);
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it. The step is in
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
// selection still traces at 100% on a scaled display.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
let mut x0 = docs[0][0];
let mut y0 = docs[0][1];
let mut x1 = x0;
let mut y1 = y0;
for d in docs {
x0 = x0.min(d[0]);
y0 = y0.min(d[1]);
x1 = x1.max(d[0]);
y1 = y1.max(d[1]);
}
let q = 256 * step as i32; // quantise the region so small pans reuse the cache
// The view centre can be any finite number (`ui.set`, #980). Past ±2³⁰ there is no document
// to outline, and the padding below (at most 2 · 256 · 64) can't overflow i32 from there.
let px = |v: f64| v.clamp(-f64::from(1 << 30), f64::from(1 << 30)) as i32;
let vis = photocraft_geom::Rect::new(
px(x0.floor()).div_euclid(q) * q - q,
px(y0.floor()).div_euclid(q) * q - q,
(px(x1.ceil()).div_euclid(q) + 2) * q,
(px(y1.ceil()).div_euclid(q) + 2) * q,
);
let key = crate::surface_fingerprint(sel)
^ (step as u64) << 56
^ doc.id.0.rotate_left(17)
^ (vis.x0 as u64) << 8
^ (vis.y0 as u64) << 24
^ (vis.x1 as u64) << 36
^ (vis.y1 as u64) << 48;
let fresh = matches!(&app.outline_cache, Some((d, k, _)) if *d == doc.id && *k == key);
if !fresh {
let t0 = crate::gpu_canvas::now_ms();
let b = app.cached_bounds(u64::MAX - doc.id.0, sel).intersect(&vis);
// Filters can occupy every worker in Rayon's global pool. A parallel scan here would
// block the UI thread waiting for those workers, preventing job cancellation (#1554).
let segs =
if app.session.has_jobs() { crate::outline::outline_scaled_serial(sel, b, step) } else { crate::outline::outline_scaled(sel, b, step) };
app.perf.span("outline", crate::gpu_canvas::now_ms() - t0);
app.outline_cache = Some((doc.id, key, std::sync::Arc::new(segs)));
}
if let Some((_, _, segs)) = &app.outline_cache {
let time = ui.input(|i| i.time);
// A floating piece (and a selection being dragged) draws its ants where it is shown.
let mut shown = xf;
if let Some((dx, dy)) = selection_shown_offset(app) {
shown.center = [xf.center[0] - dx as f32, xf.center[1] - dy as f32];
}
marching_ants_segments(&painter, &shown, segs, time);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
(img_rect, beyond, layer_edges)
};
let painted = if crop_camera { None } else { Some(paint_canvas(app, ui, &view, xf)) };
let img_rect = xf.doc_rect(doc.bounds());
// Under an open dialog the canvas widget is inert, but the image still pans and zooms.
let under_dialog = !app.ui.dialogs.is_empty();
@@ -3092,6 +3103,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
if tool.is_type() && response.double_clicked() && !crate::type_transform::visible(app, crate::workspace_ui::sticky_mods(app, mods)) {
crate::type_tool::select_word(app);
}
}
if crop_camera && let (Some((center, rotation)), Some(now)) = (camera, app.ui.views.get(idx)) {
if now.center != center {
view.center = now.center;
ctx.request_repaint();
}
if now.rotation != rotation {
view.rotation = now.rotation;
ctx.request_repaint();
}
}
let xf = if crop_camera { ViewXform { center: view.center, rotation: view.rotation, ..xf } } else { xf };
let (img_rect, beyond, layer_edges) = painted.unwrap_or_else(|| paint_canvas(app, ui, &view, xf));
if primary && !(hand_pan && response.dragged()) {
if app.ui.extras.grid && app.ui.view.extras {
crate::rulers::draw_grid(app, &painter, &xf, &doc);
}
@@ -3330,7 +3355,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
crate::rotate_view::overlay(app, &ctx, &painter, &xf, &mut view, &response);
if primary {
// The camera the tools set this frame wins over the copy's.
// Tool input can change the stored camera while navigation edits the local view.
// Merge changed camera values so write-back preserves tool-driven pan and rotation.
if let (Some((center, rotation)), Some(now)) = (camera, app.ui.views.get(idx)) {
if now.center != center {
view.center = now.center;
+55 -4
View File
@@ -132,7 +132,8 @@ pub struct LiveResize {
/// press and grows or shrinks symmetrically there, so the handle stays under the pointer, while on
/// the image the opposite edge stays put (the centre with ⌥), as in a plain resize. Everything is
/// computed from the press, so the view following the frame can't feed back into the drag.
pub fn live_resize(app: &mut PhotocraftApp, g: LiveResize, press: PressView) {
/// Returns the proposed frame and the camera's document-space offset without changing the app.
pub(crate) fn resized_frame(g: LiveResize) -> Option<([f64; 4], [f64; 2])> {
let LiveResize { rect, deg, hx, hy, start, q, ratio, alt } = g;
let c0 = crate::crop_ui::center(rect);
let (w0, h0) = (rect[2] - rect[0], rect[3] - rect[1]);
@@ -147,9 +148,12 @@ pub fn live_resize(app: &mut PhotocraftApp, g: LiveResize, press: PressView) {
let s = crate::crop_ui::turn(shift, [0.0, 0.0], deg);
let c1 = [c0[0] + s[0], c0[1] + s[1]];
let f = [c1[0] - w1 / 2.0, c1[1] - h1 / 2.0, c1[0] + w1 / 2.0, c1[1] + h1 / 2.0];
if !f.iter().all(|v| v.is_finite()) {
return;
}
f.iter().all(|v| v.is_finite()).then_some((f, s))
}
/// Move the camera with the resized frame to keep the crop box's centre stationary on screen.
pub fn live_resize(app: &mut PhotocraftApp, g: LiveResize, press: PressView) {
let Some((f, s)) = resized_frame(g) else { return };
app.ui.crop_rect = Some(f);
// The view follows the centre, so the box stays where it was on screen.
if let Some(v) = app.session.active_index().and_then(|i| app.ui.views.get_mut(i)) {
@@ -216,6 +220,14 @@ pub fn to_press(app: &PhotocraftApp, press: Option<PressView>, p: [f64; 2]) -> [
}
}
/// A press-view point mapped back into the current camera after snapping the crop handle.
pub(crate) fn from_press(app: &PhotocraftApp, press: PressView, p: [f64; 2]) -> [f64; 2] {
match press_view(app) {
Some(now) => remap(p, press, now, app.ui.view.flip_horizontal),
None => p,
}
}
/// The control channel's `ui.pointer` gives document points as the view showed them at the press
/// (it can't know how the view turns and pans with the image): during a default-mode drag, the
/// point the canvas would report for the same screen position now.
@@ -337,6 +349,45 @@ mod tests {
app.crop.default_frame = false;
}
#[test]
fn a_live_resize_snaps_the_handle_without_feedback_from_the_camera() {
for (hx, hy) in [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, -1), (1, -1), (-1, 1)] {
for alt in [false, true] {
for (zoom, ppp, flip, ratio) in [(1.0, 1.0, false, false), (0.5, 1.0, false, true), (3.0, 2.0, false, false), (3.0, 2.0, true, true)] {
let mut app = app();
app.ppp = ppp;
app.ui.views[0].zoom = zoom;
app.ui.view.flip_horizontal = flip;
if ratio {
app.ui.tool_options.crop_ratio = "original".into();
}
app.ui.extras.snap = true;
crate::crop_ui::ensure_frame(&mut app);
let box0 = screen_box(&app);
let c = mid_of(box0);
let from = xf(&app).to_screen(100.0 + hx as f32 * 100.0, 50.0 + hy as f32 * 50.0);
let direction = (from - c).normalized();
let mods = if alt { Modifiers::ALT } else { NONE };
let d = xf(&app).to_doc(from);
tool_event(&mut app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, mods);
// Leave the snap radius, then return just inside it. A still pointer must
// settle on the canvas edge instead of alternating on opposite sides of it.
for offset in [20.0, -2.0, -2.0, -2.0, -2.0, -2.0] {
let p = from + direction * offset;
let d = xf(&app).to_doc(p);
tool_event(&mut app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, mods);
if offset == -2.0 {
assert!(close(screen_box(&app), box0), "handle ({hx}, {hy}), Alt {alt}: {:?}", app.ui.crop_rect);
}
}
let d = xf(&app).to_doc(from + direction * -2.0);
tool_event(&mut app, ToolEvent::Up { x: d[0], y: d[1] }, mods);
assert!(close(screen_box(&app), box0), "release: {:?}", app.ui.crop_rect);
}
}
}
}
#[test]
fn dragging_outside_turns_the_image_and_the_box_stays_upright() {
let mut app = app();
+22 -7
View File
@@ -156,8 +156,8 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option<f64> {
/// Editable W and H shown for a custom aspect ratio. An existing valid ratio is authoritative;
/// when either side is zero, use the in-progress values retained in ToolOptions.
pub fn custom_ratio_fields(o: &ToolOptions) -> (f32, f32) {
crate::chrome_ui::crop_ratio(&o.crop_ratio, 0.0, 0.0)
pub fn custom_ratio_fields(o: &ToolOptions, doc_size: (f64, f64)) -> (f32, f32) {
crate::chrome_ui::crop_ratio(&o.crop_ratio, doc_size.0, doc_size.1)
.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0 && *w <= 99_999.0 && *h <= 99_999.0)
.map(|(w, h)| (w as f32, h as f32))
.unwrap_or((o.crop_custom_ratio_width, o.crop_custom_ratio_height))
@@ -247,6 +247,7 @@ pub fn chosen(app: &mut PhotocraftApp) {
o.crop_resolution = (*r).into();
o.crop_resolution_unit = PX_PER_IN.into();
}
crate::crop_ui::apply_ratio(app);
}
/// I with the Crop tool (no text field focused, which the caller checks): Front Image. Returns
@@ -257,6 +258,7 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
}
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, egui::Key::I)) {
front_image(app);
crate::crop_ui::apply_ratio(app);
return true;
}
false
@@ -343,12 +345,12 @@ mod tests {
set_custom_ratio_fields(&mut options, 4.0, 0.0);
assert_eq!(options.crop_ratio, "", "an incomplete ratio stays unconstrained");
assert_eq!(custom_ratio_fields(&options), (4.0, 0.0), "first entry survives the next frame");
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (4.0, 0.0), "first entry survives the next frame");
let (width, _) = custom_ratio_fields(&options);
let (width, _) = custom_ratio_fields(&options, (0.0, 0.0));
set_custom_ratio_fields(&mut options, width, 3.0);
assert_eq!(options.crop_ratio, "4:3");
assert_eq!(custom_ratio_fields(&options), (4.0, 3.0));
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (4.0, 3.0));
let (width, height) = crate::chrome_ui::crop_ratio(&options.crop_ratio, 0.0, 0.0).unwrap_or_default();
assert_eq!(width / height, 4.0 / 3.0);
}
@@ -357,11 +359,11 @@ mod tests {
fn selecting_free_ratio_clears_previous_custom_dimensions() {
let mut options = ToolOptions { crop_ratio: "16:9".into(), ..ToolOptions::default() };
select_custom_ratio(&mut options);
assert_eq!(custom_ratio_fields(&options), (16.0, 9.0));
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (16.0, 9.0));
options.crop_ratio.clear();
select_custom_ratio(&mut options);
assert_eq!(custom_ratio_fields(&options), (0.0, 0.0));
assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (0.0, 0.0));
}
#[test]
@@ -469,6 +471,19 @@ mod tests {
/// Committing a W x H x Resolution crop resamples the frame's area to exactly W x H px and sets
/// the resolution, in one undo step, at 8 and 16 bits.
#[test]
fn choosing_a_size_with_the_canvas_ratio_commits_without_a_resize() {
let mut app = app(SampleType::U8);
crate::crop_ui::ensure_frame(&mut app);
app.ui.tool_options = whr("800 px", "600 px", "92", PX_PER_IN);
chosen(&mut app);
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 400.0, 300.0]));
assert!(!app.crop.default_frame, "an output size is a pending crop even when the frame fits");
crate::canvas::commit_crop(&mut app);
let doc = &app.session.active().unwrap().doc;
assert_eq!((doc.size, doc.resolution_dpi), (Size::new(800, 600), 92.0));
}
#[test]
fn commit_resamples_to_the_target_size_and_resolution() {
for depth in [SampleType::U8, SampleType::U16] {
+105 -7
View File
@@ -200,7 +200,7 @@ fn size_ok(r: [f64; 4]) -> bool {
}
/// The options-bar ratio preset as width / height (W : H in W x H x Resolution mode).
fn preset_ratio(app: &PhotocraftApp) -> Option<f64> {
pub(crate) fn preset_ratio(app: &PhotocraftApp) -> Option<f64> {
if app.ui.tool_options.crop_ratio == crate::crop_size::WHR {
let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi));
return crate::crop_size::ratio(&app.ui.tool_options, dpi);
@@ -209,6 +209,43 @@ fn preset_ratio(app: &PhotocraftApp) -> Option<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)
}
/// Largest centred rectangle of `ratio` contained in the current frame; never adds area.
fn fitted_ratio(r: [f64; 4], ratio: f64) -> Option<[f64; 4]> {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
if !r.iter().all(|v| v.is_finite()) || !w.is_finite() || !h.is_finite() || w <= 0.0 || h <= 0.0 || !ratio.is_finite() || ratio <= 0.0 {
return None;
}
if ((w / h) / ratio - 1.0).abs() < 1e-12 {
return Some(r);
}
let c = [r[0] + w / 2.0, r[1] + h / 2.0];
let (w, h) = if w / h > ratio { (h * ratio, h) } else { (w, w / ratio) };
let fitted = [c[0] - w / 2.0, c[1] - h / 2.0, c[0] + w / 2.0, c[1] + h / 2.0];
(fitted.iter().all(|v| v.is_finite()) && fitted[2] > fitted[0] && fitted[3] > fitted[1]).then_some(fitted)
}
/// Apply committed options to the preview now, before a resize or confirmation is needed.
pub(crate) fn apply_ratio(app: &mut PhotocraftApp) {
if app.ui.tool != Tool::Crop || app.crop.drag.is_some() {
return;
}
frame(app);
fit_frame(app);
crate::crop_mode::sync_frame(app);
}
fn fit_frame(app: &mut PhotocraftApp) {
let dpi = app.session.active().map_or(72.0, |s| f64::from(s.doc.resolution_dpi));
if let Some(r) = app.ui.crop_rect
&& let Some(ratio) = preset_ratio(app)
&& let Some(fitted) = fitted_ratio(r, ratio)
// W x H x Resolution can change output size even when the frame already fits.
&& (fitted != r || crate::crop_size::target(&app.ui.tool_options, dpi).is_some())
{
keyed(app, fitted);
}
}
/// 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 {
@@ -308,6 +345,7 @@ fn frame(app: &mut PhotocraftApp) {
app.crop.frame_for = key;
app.crop.editing = false;
claim(app);
fit_frame(app);
}
/// A crop gesture is in progress: Space repositions the frame rather than panning.
@@ -408,7 +446,7 @@ pub fn press(app: &mut PhotocraftApp) -> bool {
new
}
fn tolerance(app: &PhotocraftApp) -> f64 {
pub(crate) fn tolerance(app: &PhotocraftApp) -> f64 {
HANDLE_PX / (app.point_zoom() as f64).max(0.01)
}
@@ -522,9 +560,13 @@ pub fn swap_orientation(app: &mut PhotocraftApp) -> bool {
{
app.ui.tool_options.crop_ratio = k;
}
keyed(app, swapped(r));
crate::crop_mode::sync_frame(app);
crate::crop_mode::auto_center(app);
if preset_ratio(app).is_some() {
apply_ratio(app);
} else {
keyed(app, swapped(r));
crate::crop_mode::sync_frame(app);
crate::crop_mode::auto_center(app);
}
true
}
@@ -668,8 +710,7 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
// Auto Center Preview: the box stays centred where it was while the handle follows
// the pointer.
let q = crate::crop_mode::to_press(app, Some(view), p);
let keep = (rect[2] - rect[0] > 0.0 && rect[3] - rect[1] > 0.0).then(|| (rect[2] - rect[0]) / (rect[3] - rect[1]));
let ratio = ratio.or(if mods.shift { keep } else { None });
let ratio = resize_ratio(app, rect, mods);
crate::crop_mode::live_resize(app, crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }, view);
return;
}
@@ -709,6 +750,12 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) {
app.ui.crop_rect = Some(rect);
}
/// Geometry and snapping must use the same ratio so constrained handles stay aligned.
pub(crate) fn resize_ratio(app: &PhotocraftApp, rect: [f64; 4], mods: Modifiers) -> Option<f64> {
let keep = (rect[2] - rect[0] > 0.0 && rect[3] - rect[1] > 0.0).then(|| (rect[2] - rect[0]) / (rect[3] - rect[1]));
preset_ratio(app).or(if mods.shift { keep } else { None })
}
/// 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 {
@@ -835,6 +882,57 @@ mod tests {
tool_event(app, ToolEvent::Up { x: last[0], y: last[1] }, mods);
}
#[test]
fn fitting_a_ratio_keeps_the_centre_angle_and_area() {
let mut app = app(SampleType::U8);
app.ui.crop_rect = Some([20.0, 10.0, 180.0, 90.0]);
app.ui.crop_angle = 25.0;
for key in ["1:1", "16:9", "9:16", "", "0:0"] {
let before = app.ui.crop_rect.unwrap();
app.ui.tool_options.crop_ratio = key.into();
crate::crop_size::chosen(&mut app);
let after = app.ui.crop_rect.unwrap();
assert_eq!(center(after), center(before));
assert_eq!(app.ui.crop_angle, 25.0);
assert!(after[0] >= before[0] && after[1] >= before[1] && after[2] <= before[2] && after[3] <= before[3]);
if let Some(ratio) = preset_ratio(&app) {
assert!(((after[2] - after[0]) / (after[3] - after[1]) - ratio).abs() < 1e-10);
} else {
assert_eq!(after, before);
}
}
for ratio in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert!(fitted_ratio([0.0, 0.0, 200.0, 100.0], ratio).is_none());
}
assert!(fitted_ratio([-f64::MAX, 0.0, f64::MAX, 100.0], 1.0).is_none());
}
#[test]
fn choosing_a_ratio_fits_and_commits_without_a_resize() {
for classic in [false, true] {
let mut app = app(SampleType::U8);
app.ui.tool_options.crop_shield.classic_mode = classic;
ensure_frame(&mut app);
app.ui.tool_options.crop_ratio = "1:1".into();
crate::crop_size::chosen(&mut app);
assert_eq!(app.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0]));
crate::canvas::commit_crop(&mut app);
assert_eq!(app.session.active().unwrap().doc.size, Size::new(100, 100));
}
}
#[test]
fn entering_crop_with_a_saved_ratio_fits_the_selection_or_canvas() {
let mut app = app(SampleType::U8);
app.ui.tool_options.crop_ratio = "1:1".into();
ensure_frame(&mut app);
assert_eq!(app.ui.crop_rect, Some([50.0, 0.0, 150.0, 100.0]));
cancel(&mut app);
app.run("select.rect", json!({"x": 20, "y": 10, "width": 80, "height": 40})).unwrap();
ensure_frame(&mut app);
assert_eq!(app.ui.crop_rect, Some([40.0, 10.0, 80.0, 50.0]));
}
#[test]
fn picking_the_tool_frames_the_canvas_or_the_selection() {
// #668: the Crop tool started with no frame.
+68 -4
View File
@@ -826,6 +826,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.separator));
ui.style_mut().spacing.scroll = egui::style::ScrollStyle { bar_width: 4.0, ..egui::style::ScrollStyle::floating() };
egui::ScrollArea::horizontal().id_salt("tool-options").auto_shrink([false, false]).show(ui, |ui| {
// Font ink can extend beyond its advance width at fractional positions.
// Include its gutter in the container layout, including right-aligned actions.
egui::Frame::NONE.inner_margin(egui::Margin { right: 2, ..Default::default() }).show(ui, |ui| {
ui.horizontal_centered(|ui| {
if t.pro {
crate::chrome_ui::home_button(app, ui);
@@ -1148,14 +1151,17 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::gradient_ui::options_changed(app, &before);
}
Tool::Crop if t.pro => {
let before_ratio = crate::crop_ui::preset_ratio(app);
let doc_size = app.session.active().map_or((0.0, 0.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height)));
// Ratios, sizes, saved presets, New / Delete Crop Preset… (#2443, #1919).
crate::crop_presets::dropdown(app, ui);
let o = &mut app.ui.tool_options;
if o.crop_ratio == crate::crop_size::WHR {
crate::crop_size::fields(o, ui);
} else {
// Keep partial edits in ToolOptions so the first side survives a frame.
let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o);
// Keep partial edits in ToolOptions so the first side survives a frame;
// Original Ratio shows the document's numbers, as Photoshop does.
let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o, doc_size);
let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed();
let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked();
let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed();
@@ -1178,6 +1184,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::crop_overlay::options_button(o, ui);
let pick_shield_color = crate::crop_shield::options_button(&mut o.crop_shield, ui);
widgets::checkbox(ui, &mut o.crop_delete, tl!("Delete Cropped Pixels"));
if crate::crop_ui::preset_ratio(app) != before_ratio {
crate::crop_ui::apply_ratio(app);
}
if pick_shield_color {
crate::crop_shield::pick_custom_color(app);
}
@@ -1362,10 +1371,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
crate::brush_panel::commit_gesture(app, ui.ctx(), &brush_before, &brush);
});
// Font ink can extend beyond its advance width at fractional positions.
ui.expand_to_include_x(ui.min_rect().right() + 2.0);
reveal_focused_control(ui);
});
});
});
}
@@ -4904,6 +4912,62 @@ mod compact_visual_regressions {
}
}
#[test]
fn a_wide_crop_options_bar_does_not_scroll() {
for kind in crate::theme::ThemeKind::ALL {
for scale in [1.0, 1.5, 2.0] {
for mode in ["", crate::crop_size::WHR] {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, kind);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if !Tokens::get(&ctx).pro {
continue;
}
app.ui.tool = Tool::Crop;
app.ui.tool_options.crop_ratio = mode.into();
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0));
let clear_x = |output: &egui::FullOutput| {
output
.shapes
.iter()
.find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.job.text == "Clear" => Some(t.pos.x),
_ => None,
})
.unwrap()
};
let mut before = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
before.textures_delta.clear();
for _ in 0..3 {
before = ctx.run_ui(input_at_scale(screen, scale), |ui| options_bar(&mut app, ui));
before.textures_delta.clear();
}
let mut after = before.clone();
for _ in 0..6 {
after = ctx.run_ui(
egui::RawInput {
events: vec![
egui::Event::PointerMoved(pos2(800.0, 20.0)),
egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(-100.0, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
},
],
..input_at_scale(screen, scale)
},
|ui| options_bar(&mut app, ui),
);
after.textures_delta.clear();
}
assert_eq!(clear_x(&before), clear_x(&after), "a roomy bar must not have horizontal overflow");
}
}
}
}
#[test]
fn narrow_tool_options_can_scroll_to_the_last_control() {
for kind in crate::theme::ThemeKind::ALL {
+79 -9
View File
@@ -169,7 +169,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
// outside the selection moves the layer as the Move tool does (#2681).
let tool = crate::canvas::event_tool(app, mods);
let moves_layer = crate::canvas::command_moves_layer(app, tool, p, mods);
let tol = tolerance(app);
let crop_tol = crate::crop_ui::tolerance(app);
let gesture = if let Some(t) = &app.ui.transform {
if t.mode == crate::state::TransformMode::Distort {
// Distort is freehand corner placement (fitting an image onto a screen, say): guides
@@ -189,7 +189,8 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
moving_rect(app, tool).map(|rect| (Gesture::Move { rect }, exclude))
} else if tool == Tool::Crop
&& (crate::crop_mode::classic(app) || app.crop.default_frame)
&& let Some(rect) = app.ui.crop_rect.filter(|r| crate::crop_ui::angle(app) == 0.0 && crate::crop_ui::hit(*r, p, tol) == crate::crop_ui::Hit::Inside)
&& let Some(rect) =
app.ui.crop_rect.filter(|r| crate::crop_ui::angle(app) == 0.0 && crate::crop_ui::hit(*r, p, crop_tol) == crate::crop_ui::Hit::Inside)
{
// Inside the untouched default frame draws a new crop (in both modes): snap its corner, not
// the old canvas-sized frame's bounds. An edited Classic Mode frame still moves and snaps
@@ -198,7 +199,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
Some((gesture, Vec::new()))
} else if tool == Tool::Crop
&& (crate::crop_ui::turns_at(app, p)
|| app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), p, tol) == crate::crop_ui::Hit::Inside))
|| app.ui.crop_rect.is_some_and(|r| crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), p, crop_tol) == crate::crop_ui::Hit::Inside))
{
// Turning the frame, moving a turned one (its edges don't line up with anything), or in
// the default mode moving or turning the image under the box: no snapping.
@@ -249,7 +250,7 @@ fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
}
/// Snap one pointer position for the active gesture; records the alignment lines.
fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2] {
let tol = tolerance(app);
let Some(snap) = app.prefs_rt.snap.as_ref() else { return p };
if snap.disabled {
@@ -257,7 +258,8 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
return p;
}
let (out, lines) = match &snap.gesture {
Gesture::Point | Gesture::Guide => snap.targets.snap_point(p, tol),
Gesture::Point => live_crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)),
Gesture::Guide => snap.targets.snap_point(p, tol),
Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => {
let d = [p[0] - snap.start[0], p[1] - snap.start[1]];
let moved = [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]];
@@ -270,6 +272,66 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] {
out
}
/// Snap the proposed handle on the image, calculated from the original camera. Snapping the
/// pointer in the moving camera feeds the previous frame's pan back into the resize and makes
/// a stationary pointer oscillate near the canvas edges.
fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
if app.ui.tool != Tool::Crop {
return None;
}
let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view: Some(view) } = app.crop.drag? else { return None };
let deg = crate::crop_ui::angle(app);
let q = crate::crop_mode::to_press(app, Some(view), p);
let g = crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio: crate::crop_ui::resize_ratio(app, rect, mods), alt: mods.alt };
let (frame, _) = crate::crop_mode::resized_frame(g)?;
let at = |r: [f64; 4]| {
let c = crate::crop_ui::center(r);
crate::crop_ui::turn([c[0] + f64::from(hx) * (r[2] - r[0]) / 2.0, c[1] + f64::from(hy) * (r[3] - r[1]) / 2.0], c, deg)
};
let handle = at(frame);
let (snapped, mut lines) = targets.snap_point(handle, tol);
let mut d = crate::crop_ui::turn([snapped[0] - handle[0], snapped[1] - handle[1]], [0.0, 0.0], -deg);
// An edge handle controls only its own axis, not the midpoint along the other one.
if hx == 0 {
d[0] = 0.0;
}
if hy == 0 {
d[1] = 0.0;
}
if deg == 0.0 {
lines.retain(|line| if line.vertical { hx != 0 } else { hy != 0 });
}
// Auto Center Preview moves both screen edges. On the image the opposite edge stays put,
// so the grabbed edge travels twice as far as the pointer (once with Alt).
let scale = if mods.alt { 1.0 } else { 2.0 };
if lines.is_empty() {
return Some((p, lines));
}
let original = at(rect);
let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg);
let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale];
if deg == 0.0
&& hx != 0
&& hy != 0
&& let Some(k) = g.ratio
{
// A corner with a ratio has one degree of freedom. Put the other axis on the same
// ratio, rather than letting the raw pointer's unused axis override the snapped edge.
if lines.iter().any(|line| line.vertical) {
local[1] = f64::from(hy) * ((rect[2] - rect[0] + 2.0 * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / 2.0;
} else {
local[0] = f64::from(hx) * ((rect[3] - rect[1] + 2.0 * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / 2.0;
}
}
let d = crate::crop_ui::turn(local, [0.0, 0.0], deg);
let q = [start[0] + d[0], start[1] + d[1]];
if let Some((snapped_frame, _)) = crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { q, ..g }) {
let h = at(snapped_frame);
lines.retain(|line| (if line.vertical { h[0] } else { h[1] } - line.pos).abs() < 1e-3);
}
Some((crate::crop_mode::from_press(app, view, q), lines))
}
/// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it).
pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent {
match ev {
@@ -277,8 +339,16 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
begin(app, [x, y], mods);
// Moves snap their delta, which is zero at the start: keep the press point.
let p = match app.prefs_rt.snap.as_ref().map(|s| &s.gesture) {
Some(Gesture::Point) => {
let q = apply(app, [x, y]);
Some(Gesture::Point)
if !(app.ui.tool == Tool::Crop
&& app.ui.crop_rect.is_some_and(|r| {
matches!(
crate::crop_ui::hit_turned(r, crate::crop_ui::angle(app), [x, y], crate::crop_ui::tolerance(app)),
crate::crop_ui::Hit::Handle(..)
)
})) =>
{
let q = apply(app, [x, y], mods);
pixel_round(app, q)
}
_ => [x, y],
@@ -292,7 +362,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
if let Some(s) = app.prefs_rt.snap.as_mut() {
s.disabled = override_held(mods);
}
let q = apply(app, [x, y]);
let q = apply(app, [x, y], mods);
let p = pixel_round(app, q);
ToolEvent::Move { x: p[0], y: p[1], pressure }
}
@@ -303,7 +373,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier
if let Some(s) = app.prefs_rt.snap.as_mut() {
s.disabled = override_held(mods);
}
let q = apply(app, [x, y]);
let q = apply(app, [x, y], mods);
let p = pixel_round(app, q);
app.prefs_rt.snap = None;
app.prefs_rt.snap_lines.clear();
+66
View File
@@ -11,6 +11,7 @@
//! adapter exists (like `crop_beyond_canvas.rs`).
use egui::{Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::kittest::Queryable;
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::canvas::ViewXform;
use photocraft_ui_egui::control::{ControlRequest, handle};
@@ -101,6 +102,71 @@ fn setup() -> Option<Harness> {
Some(h)
}
#[test]
fn a_ratio_dropdown_updates_the_preview_and_enter_without_touching_a_handle() {
let Some(mut h) = setup() else { return };
h.get_by_value("Ratio").click();
h.run_steps(3);
h.get_by_label("1 : 1 (Square)").click();
h.run_steps(3);
assert_eq!(h.state().ui.crop_rect, Some([50.0, 0.0, 250.0, 200.0]));
if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") {
let dir = std::path::PathBuf::from(dir);
std::fs::create_dir_all(&dir).unwrap();
h.render().unwrap().save(dir.join("photocraft-crop-square.png")).unwrap();
}
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.size, photocraft_doc::Size::new(200, 200));
}
#[test]
fn the_swap_button_and_x_apply_the_same_inscribed_ratio() {
let Some(mut h) = setup() else { return };
let original = [30.0, 20.0, 270.0, 180.0];
h.state_mut().ui.crop_rect = Some(original);
h.state_mut().ui.tool_options.crop_ratio = "3:2".into();
h.run_steps(3);
let before = mid(screen_box(&h));
h.get_by_label("Swaps height and width").click();
h.run_steps(3);
let button = h.state().ui.crop_rect.unwrap();
assert_eq!(h.state().ui.tool_options.crop_ratio, "2:3");
assert!(((button[2] - button[0]) / (button[3] - button[1]) - 2.0 / 3.0).abs() < 1e-10);
assert!(button[0] >= original[0] && button[2] <= original[2] && button[1] >= original[1] && button[3] <= original[3]);
assert!(mid(screen_box(&h)).distance(before) < 0.01);
h.state_mut().ui.crop_rect = Some(original);
h.state_mut().ui.tool_options.crop_ratio = "3:2".into();
h.run_steps(3);
h.key_press(Key::X);
h.run_steps(3);
assert_eq!(h.state().ui.crop_rect, Some(button));
assert!(mid(screen_box(&h)).distance(before) < 0.01);
}
#[test]
fn a_resize_renders_the_frame_and_image_with_the_same_camera() {
let Some(mut h) = setup() else { return };
let box0 = screen_box(&h);
let c = mid(box0);
let right = pos2(box0[1].x, c.y);
drag(&mut h, right, right - vec2(48.0, 0.0), 6, |h, i| {
let img = h.render().expect("render");
let b = screen_box(h);
// The left edge also moves inward with Auto Center Preview. Sample either side
// away from the handles and the red patch: white inside, dimmed white outside.
let y = c.y + 70.0;
for x in [b[0].x - 5.0, b[1].x + 5.0] {
let px = img.get_pixel(x.round() as u32, y.round() as u32);
assert!(px[0] < 230 && px[1] < 230 && px[2] < 230, "frame {i}, outside at {x}: {px:?}");
}
for x in [b[0].x + 5.0, b[1].x - 5.0] {
let px = img.get_pixel(x.round() as u32, y.round() as u32);
assert!(px[0] > 245 && px[1] > 245 && px[2] > 245, "frame {i}, inside at {x}: {px:?}");
}
});
}
#[test]
fn dragging_inside_moves_the_image_and_the_box_stays_centred() {
let Some(mut h) = setup() else { return };
+5 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: tools, transform, nudging, snapping, modifiers, navigation, shortcuts, panels audited from source and open issues) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Crop resize stability, immediate ratio previews, and options-bar overflow regression coverage) · **Target:** Adobe Photoshop 2026
How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the
handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation,
@@ -129,6 +129,9 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
|---|---|---|
| Drag outside rotates (⇧ 15°), angle readout; ↵ rotates and crops in one undo step | done | TOOL-214-18 |
| Straighten button and ⌘-drag line | done | TOOL-214-21 |
| Default-mode resize snaps handles without camera feedback; image and frame use the same camera in each rendered frame | done | `crop_mode.rs::a_live_resize_snaps_the_handle_without_feedback_from_the_camera`, `tests/crop_default_mode.rs::a_resize_renders_the_frame_and_image_with_the_same_camera` (2026-10-10) |
| Ratio presets and W × H changes immediately fit the current frame around its centre; Swap button and X share the fitted ratio, Enter needs no resize | done | `crop_ui.rs::choosing_a_ratio_fits_and_commits_without_a_resize`, `crop_size.rs::choosing_a_size_with_the_canvas_ratio_commits_without_a_resize`, `tests/crop_default_mode.rs` (2026-10-10) |
| Roomy Crop options bar does not scroll; narrow tool bars retain horizontal scrolling | done | `panels.rs::a_wide_crop_options_bar_does_not_scroll`, `panels.rs::narrow_tool_options_can_scroll_to_the_last_control` (2026-10-10) |
| Overlays (thirds, grid, diagonal, triangle, golden ratio/spiral), O / ⇧O | done | TOOL-214-20 |
| Crop shield, Show Cropped Area (H) | done | TOOL-214-22 |
| Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 |
@@ -204,3 +207,4 @@ Paragraph; the rest open as in-app floating windows.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | First version, from a source audit against Photoshop 2026 27.11.0's shortcuts file and the open issues |
| 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions |