diff --git a/contributors/people.toml b/contributors/people.toml index 0adfc2f4a1..0248505ba6 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -93,3 +93,6 @@ sync_github_name = true [people.FinnNk] sync_github_name = true + +[people.TheTeslak] +sync_github_name = true diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b67fadbbfe..2fcf73a330 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index 5250d1403b..e19f01334a 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -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, 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(); diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 42b9675a58..2e58647432 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -156,8 +156,8 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option { /// 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] { diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 1c8d3db827..194cf60476 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -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 { +pub(crate) fn preset_ratio(app: &PhotocraftApp) -> Option { 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 { 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, 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 { + 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 { 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. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0a4515f07a..e0b963e7e9 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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 { diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 99dc614ed9..a1cdf8e366 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -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)> { + 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(); diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/crop_default_mode.rs index 7792c68b7c..d7867c945c 100644 --- a/crates/ui-egui/tests/crop_default_mode.rs +++ b/crates/ui-egui/tests/crop_default_mode.rs @@ -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 { 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 }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 27fe0bf318..b078fd3f86 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |