diff --git a/contributors/people.toml b/contributors/people.toml index 7f5cc6b01f..410fe6fa14 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -57,3 +57,6 @@ sync_github_name = true [people.Hathor875] real_name = "Krzysztof Cieślik" sync_github_name = true + +[people.maxm11] +sync_github_name = true diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 453a7215fe..67ac9f59e6 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -623,7 +623,7 @@ mod tests { let idx = app.session.active_index().unwrap(); assert!(app.ui.views[idx].fit_pending); let doc = &app.session.active().unwrap().doc; - crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0)); + crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0); assert_eq!(app.ui.views[idx].zoom, 0.75); assert_eq!(app.ui.views[idx].center, [40.0, 30.0]); assert!(!app.ui.views[idx].fit_pending); diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index cd8b2d4c77..4fe6146c16 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -203,7 +203,7 @@ fn export_log(app: &mut PhotocraftApp, rows: Option>) -> Result f64 { - 7.0 / f64::from(app.current_zoom().max(0.01)) + 7.0 / f64::from(app.point_zoom().max(0.01)) } fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { @@ -293,7 +293,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } } (Tool::Note, ToolEvent::Down { x, y, .. }) => { - let z = f64::from(app.current_zoom().max(0.01)); + let z = f64::from(app.point_zoom().max(0.01)); let hit = doc.notes.iter().rposition(|n| x >= n.position[0] && x <= n.position[0] + 16.0 / z && y >= n.position[1] && y <= n.position[1] + 20.0 / z); match hit { diff --git a/crates/ui-egui/src/artboard_ui.rs b/crates/ui-egui/src/artboard_ui.rs index 02d1df9507..3ea482c7c6 100644 --- a/crates/ui-egui/src/artboard_ui.rs +++ b/crates/ui-egui/src/artboard_ui.rs @@ -63,9 +63,11 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result { let size = [st.doc.size.width, st.doc.size.height]; let area = app.last_canvas_rect.size(); let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) }; + let ppp = app.canvas_ppp(); let v = &mut app.ui.views[i]; - // Leave room for the name above the board. - v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32); + // Leave room for the name above the board. The area is in egui points; the stored zoom is + // device pixels per document pixel. + v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32) * ppp; v.zoom = crate::zoom_levels::clamp(v.zoom, size); // Widen before adding: a board near ±2^30, or one whose far edge saturated at i32::MAX, // overflows an i32 sum (#981). diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index 753ee79480..7e73adc635 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -108,7 +108,7 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) { fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { // Right is bigger on screen, also in a flipped view (View › Flip Horizontal). let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }; - let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.current_zoom()); + let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.point_zoom()); let b = &app.session.tools.brush; if b.size == size && b.hardness == hardness { return; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aa442f17c1..8c3a0bbdef 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -325,7 +325,7 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> { /// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The /// stroke smoothing is the session brush's (the options bar's Smoothing %). fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec]) -> serde_json::Value { - let mut p = json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) }); + let mut p = json!({ "points": points, "erase": erase, "zoom": app.point_zoom(), "target": paint_target(app) }); if tool == Tool::Pencil { p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase); } @@ -573,7 +573,7 @@ impl ViewXform { let v = app.ui.views.get(app.session.active_index()?)?; Some(Self { rect: crate::rulers::content_rect(app, app.last_canvas_rect), - zoom: v.zoom, + zoom: v.zoom / app.canvas_ppp(), center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, @@ -627,9 +627,12 @@ impl ViewXform { } } -pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) { +/// Fit the document into `area` (egui points) for a display with `ppp` physical pixels per +/// point; `View::zoom` is stored in physical pixels per document pixel. +pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { let (w, h) = (doc.size.width as f32, doc.size.height as f32); - let zoom = crate::zoom_levels::clamp(((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0), [doc.size.width, doc.size.height]); + let points = ((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0); + let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]); view.zoom = zoom; view.center = [w / 2.0, h / 2.0]; view.fit_pending = false; @@ -638,9 +641,10 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) { /// Centre the document and zoom until it fills the canvas. One axis can extend beyond the /// viewport, matching the Hand tool's Fill Screen action. -pub fn fill_view(view: &mut View, doc: &Document, area: Vec2) { +pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { let (w, h) = (doc.size.width as f32, doc.size.height as f32); - let zoom = crate::zoom_levels::clamp((area.x / w).max(area.y / h), [doc.size.width, doc.size.height]); + let points = (area.x / w).max(area.y / h); + let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]); view.zoom = zoom; view.center = [w / 2.0, h / 2.0]; view.fit_pending = false; @@ -2104,6 +2108,14 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f /// Draw one canvas view and handle its input. `primary` = main window (tools active). pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View { let ctx = ui.ctx().clone(); + // `View::zoom` is device pixels per document pixel; this canvas's geometry is in its own + // viewport's egui points. A document window can sit on another display with a different + // scale, so only the primary canvas records `app.ppp` for the frame's shared helpers + // (`point_zoom`, the navigator, control-channel coordinate mapping). + let ppp = ctx.pixels_per_point(); + if primary { + app.ppp = ppp; + } // The display this window is on: its monitor profile (#569). A document window on a display // the last reading didn't know asks for a new one. let output = crate::monitor_status::view_display(app, &ctx); @@ -2122,17 +2134,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view.doc_size = size; } if view.fit_pending && rect.width() > 50.0 { - fit_view(&mut view, &doc, rect.size()); + fit_view(&mut view, &doc, rect.size(), ppp); } if view.fill_pending && rect.width() > 50.0 { - fill_view(&mut view, &doc, rect.size()); + fill_view(&mut view, &doc, rect.size(), ppp); } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size()) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size(), ppp) { ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; - let xf = ViewXform { rect, zoom: view.zoom, center: view.center, flip, rotation: view.rotation }; + // Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points. + let point_zoom = view.zoom / ppp; + let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation }; let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid); let response = ui.allocate_rect(rect, Sense::click_and_drag()); let painter = ui.painter_at(rect); @@ -2159,16 +2173,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation); if app.gpu.is_some() && gpu_ok - && let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point()) - .or_else(|| ensure_filter_preview(app, idx, &ctx)) - .or_else(|| ensure_proxy_preview(app, idx)) + && 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, + 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, @@ -2193,7 +2206,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let params = crate::gpu_canvas::ViewParams { doc: doc.id.0, doc_size: [doc.size.width, doc.size.height], - zoom: view.zoom, + zoom: point_zoom, center: view.center, rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 }, shadow: { @@ -2304,7 +2317,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // 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. + // (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])]; @@ -2373,11 +2388,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // zooms nor slides the image towards the pointer. let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size); if nz != view.zoom { - zoom_about(&mut view, &xf, p, nz, false); + zoom_about(&mut view, &xf, p, nz, false, ppp); } } (Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => { - let d = xf.unmap_vec(scroll) / view.zoom; + let d = xf.unmap_vec(scroll) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; } @@ -2436,7 +2451,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling); let hand = app.ui.tool == Tool::Hand && !picking && !range_picking; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { - let d = xf.unmap_vec(d) / view.zoom; + let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; ctx.set_cursor_icon(egui::CursorIcon::Grabbing); @@ -2468,7 +2483,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } if tool == Tool::Hand && response.dragged() { - let d = xf.unmap_vec(response.drag_delta()) / view.zoom; + let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp); view.center[0] -= d.x; view.center[1] -= d.y; } else if primary { @@ -2667,7 +2682,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::Zoom => { let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size); let center = app.session.prefs().tools.zoom_clicked_point_to_center; - zoom_about(&mut view, &xf, p, nz, center); + zoom_about(&mut view, &xf, p, nz, center, ppp); } // A click with the (temporary) Hand does nothing, never the tool underneath. Tool::Hand | Tool::RotateView => {} @@ -2793,7 +2808,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let cur = app.session.prefs().cursors.clone(); let painting = app.drag.is_some(); let brush = &app.session.tools.brush; - let full = (brush.size / 2.0 * view.zoom).max(1.0); + let full = (brush.size / 2.0 * xf.zoom).max(1.0); let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full }; match cur.painting { PaintingCursor::Standard => egui::CursorIcon::Default, @@ -2850,11 +2865,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } Tool::Type | Tool::VerticalType => egui::CursorIcon::Text, - Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, view.zoom), + Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom), Tool::RedEye => { let pupil = app.ui.tool_options.red_eye_pupil_size.clamp(1.0, 100.0); let r_doc = photocraft_algo::redeye::search_radius(pupil); - let r = (r_doc * view.zoom).max(2.0); + let r = (r_doc * xf.zoom).max(2.0); painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220))); for (w, c) in [(2.5, Color32::from_black_alpha(140)), (1.0, Color32::from_white_alpha(220))] { @@ -2904,14 +2919,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: /// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under /// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the /// centre of the view instead (#204). -fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool) { +fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) { let before = xf.to_doc(p); view.zoom = new_zoom; if center_on_point { view.center = [before[0] as f32, before[1] as f32]; return; } - let u = xf.unmap_vec((p - xf.rect.center()) / new_zoom); + // `new_zoom` is device px per document pixel; the pointer vector is in egui points. + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let u = xf.unmap_vec((p - xf.rect.center()) / (new_zoom / ppp)); view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y]; } @@ -3448,7 +3465,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: if tool == Tool::Pen { crate::vector_ui::pen_move(app, x, y); } - let zoom = app.current_zoom(); + let zoom = app.point_zoom(); if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) { d.track(mods); d.shift_to([x, y]); @@ -3735,7 +3752,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) if tool == Tool::Pen { crate::vector_ui::pen_up(app); } - let zoom = app.current_zoom(); + let zoom = app.point_zoom(); let Some(mut d) = app.drag.take() else { return }; d.track(mods); if d.reposition { @@ -3987,7 +4004,7 @@ fn polygon_click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) .ui .polygon .first() - .is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64); + .is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.point_zoom().max(0.01) as f64); if close { commit_polygon(app); return; @@ -4905,6 +4922,57 @@ mod tests { } } + #[test] + fn a_one_pixel_selection_traces_at_hundred_percent_on_a_scaled_display() { + // #1943 review: the outline step is display resolution in device pixels, not points, so a + // 1 px selection still traces at 100 % on a 2x display (a points-based step sampled every + // second pixel and lost it: zero segments). + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("select.rect", json!({"x": 50, "y": 50, "width": 1, "height": 100})).unwrap(); + app.run("view.actualPixels", json!({})).unwrap(); + app.sync_views(); + let view = app.ui.views[0].clone(); + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(2.0); + let mut out = ctx.run_ui(input, |ui| { + canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), view.clone(), true); + }); + out.textures_delta.clear(); + assert_eq!(app.ppp, 2.0, "the primary canvas records the display scale"); + let segs = app.outline_cache.as_ref().map_or(0, |(_, _, s)| s.len()); + assert!(segs > 0, "a 1 px selection traces at 100 % on a 2x display"); + } + + #[test] + fn a_secondary_canvas_does_not_overwrite_the_main_canvas_scale() { + // #1943 review: a document window can sit on a display with another scale; only the + // primary canvas records `app.ppp`, so the main mapping doesn't change under it. + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.actualPixels", json!({})).unwrap(); + app.sync_views(); + let view = app.ui.views[0].clone(); + let frame = |app: &mut PhotocraftApp, ppp: f32, primary: bool| { + let mut input = egui::RawInput::default(); + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + let mut out = ctx.run_ui(input, |ui| { + canvas_view(app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), view.clone(), primary); + }); + out.textures_delta.clear(); + }; + frame(&mut app, 2.0, true); + assert_eq!(app.ppp, 2.0); + frame(&mut app, 1.0, false); + assert_eq!(app.ppp, 2.0, "a secondary window's scale doesn't replace the main canvas's"); + // The main canvas mapping still uses its own scale: 100 % is 1 doc px per physical px. + let xf = ViewXform::active(&app).unwrap(); + let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0); + assert!((step.x - 0.5).abs() < 1e-3, "{step:?}"); + } + #[test] fn brush_drags_paint_no_stand_in_shape_over_the_canvas() { // #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide @@ -4998,30 +5066,66 @@ mod tests { let doc = app.session.active().unwrap().doc.clone(); let mut view = View { fill_pending: true, ..View::default() }; - fill_view(&mut view, &doc, vec2(600.0, 600.0)); + fill_view(&mut view, &doc, vec2(600.0, 600.0), 1.0); assert_eq!(view.zoom, 3.0, "the short edge fills the available height"); assert_eq!(view.center, [200.0, 100.0]); assert!(!view.fill_pending); + + // A scaled display stores the physical factor: the same point zoom is 2× the device px. + let mut view = View { fill_pending: true, ..View::default() }; + fill_view(&mut view, &doc, vec2(600.0, 600.0), 2.0); + assert_eq!(view.zoom, 6.0); + + // Fit never magnifies past the 1 point-per-pixel cap; on a scaled display the cap is 2. + let mut view = View { fit_pending: true, ..View::default() }; + fit_view(&mut view, &doc, vec2(600.0, 600.0), 1.0); + assert_eq!(view.zoom, 1.0); + let mut view = View { fit_pending: true, ..View::default() }; + fit_view(&mut view, &doc, vec2(600.0, 600.0), 2.0); + assert_eq!(view.zoom, 2.0); + } + + #[test] + fn a_hundred_percent_is_one_physical_pixel_per_document_pixel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + app.sync_views(); + for ppp in [1.0f32, 1.25, 2.0] { + app.ppp = ppp; + let xf = ViewXform::active(&app).unwrap(); + // 100% zoom: one document pixel is one physical pixel, i.e. 1/ppp egui points. + let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0); + // `to_screen` differences around the view centre cancel in f32; 1e-3 is still exact + // enough to see a wrong ppp factor (which would be a 20–100% error). + assert!((step.x - 1.0 / ppp).abs() < 1e-3, "ppp {ppp}: {step:?} want {:?}", 1.0 / ppp); + assert!((app.point_zoom() - 1.0 / ppp).abs() < 1e-5, "ppp {ppp}: {}", app.point_zoom()); + // Screen ↔ document stays an exact round trip. + let back = xf.to_doc(xf.to_screen(123.0, 45.0)); + assert!((back[0] - 123.0).abs() < 1e-3 && (back[1] - 45.0).abs() < 1e-3, "ppp {ppp}: {back:?}"); + } } #[test] fn zoom_about_centres_the_clicked_point_with_the_preference() { let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); - let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0 }; let p = pos2(600.0, 200.0); - let doc = xf.to_doc(p); - // Off: the document point under the pointer stays under it. - let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() }; - zoom_about(&mut view, &xf, p, 2.0, false); - let after = ViewXform { rect, zoom: 2.0, center: view.center, flip: false, rotation: 0.0 }; - let s = after.to_screen(doc[0] as f32, doc[1] as f32); - assert!((s - p).length() < 0.5, "{s:?} vs {p:?}"); - // On: the clicked point is the view centre. - let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() }; - zoom_about(&mut view, &xf, p, 2.0, true); - assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "{:?}", view.center); - assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "{:?}", view.center); + for ppp in [1.0f32, 2.0] { + // `View::zoom` is physical; the transform works in points. + let xf = ViewXform { rect, zoom: 1.0 / ppp, center: [400.0, 300.0], flip: false, rotation: 0.0 }; + let doc = xf.to_doc(p); + // Off: the document point under the pointer stays under it. + let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() }; + zoom_about(&mut view, &xf, p, 2.0, false, ppp); + let after = ViewXform { rect, zoom: 2.0 / ppp, center: view.center, flip: false, rotation: 0.0 }; + let s = after.to_screen(doc[0] as f32, doc[1] as f32); + assert!((s - p).length() < 0.5, "ppp {ppp}: {s:?} vs {p:?}"); + // On: the clicked point is the view centre. + let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() }; + zoom_about(&mut view, &xf, p, 2.0, true, ppp); + assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center); + assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center); + } } #[test] diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index cafa27f97f..519e11bf55 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -385,7 +385,7 @@ pub fn press(app: &mut PhotocraftApp) -> bool { } fn tolerance(app: &PhotocraftApp) -> f64 { - HANDLE_PX / (app.current_zoom() as f64).max(0.01) + HANDLE_PX / (app.point_zoom() as f64).max(0.01) } /// The pending frame's angle (degrees), 0 when it isn't a finite number. diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index f5c58a5e37..7cccf1dde9 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -191,7 +191,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool /// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press /// that hit nothing. fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool { - let tol = 6.0 / f64::from(app.current_zoom().max(0.01)); + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); let ds = &app.ui.direct_selection; let found = candidates(app).into_iter().find_map(|(r, path)| { let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; @@ -251,7 +251,7 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - /// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except /// Pen + ⌥ on an anchor, which makes it a corner. fn release(app: &mut PhotocraftApp, target: PathRef, edit: &Edit, start: [f64; 2], end: [f64; 2]) { - let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.current_zoom()) >= 1.0; + let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.point_zoom()) >= 1.0; let (id, mut params) = match edit { Edit::Convert([s, k]) if !moved => ("path.convertPoint", json!({"subpath": s, "knot": k})), _ if !moved => return, diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 42b2329d6b..b3bda3a7f5 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -217,6 +217,6 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { /// Screen distance (document px) that counts as "on" a handle at the current zoom. pub(crate) fn tolerance(app: &PhotocraftApp) -> f64 { - let z = app.session.active_index().and_then(|i| app.ui.views.get(i)).map_or(1.0, |v| v.zoom.max(0.01)); + let z = app.point_zoom().max(0.01); 8.0 / f64::from(z) } diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index d61e259ad7..a67d1a8572 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1637,9 +1637,10 @@ impl CanvasCallback { let p = &self.params; let (origin, scale) = self.placement(ppp); let (mode, lod) = filter_mode(scale); - // The pixel grid shows above 500% and lightens a dark pixel by about a quarter (the - // constants are shared with the CPU path, pixel_grid.rs). - let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom) { crate::pixel_grid::STRENGTH } else { 0.0 }; + // The pixel grid shows above 500% (the constants are shared with the CPU path, + // pixel_grid.rs). `p.zoom` is screen points per document pixel; the shared threshold is + // in device pixels, so scale by the display's pixels per point. + let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom * ppp) { crate::pixel_grid::STRENGTH } else { 0.0 }; let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 }; let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color); // 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma. diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index f7049e7262..65f11e0162 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -210,7 +210,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> app.gradient.drag = None; return false; } - let zoom = app.current_zoom().max(0.01); + let zoom = app.point_zoom().max(0.01); match ev { ToolEvent::Down { x, y, .. } => { let p = [x as f32, y as f32]; @@ -231,7 +231,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) { - let zoom = app.current_zoom().max(0.01); + let zoom = app.point_zoom().max(0.01); let active = active_gradient(app); let Some(drag) = app.gradient.drag.as_mut() else { return }; // ⇧: 45° gradient angles, the same snap as the classic drag (stroke_constraint.rs). @@ -284,7 +284,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) { fn finish(app: &mut PhotocraftApp) { let Some(drag) = app.gradient.drag.take() else { return }; - let zoom = app.current_zoom().max(0.01); + let zoom = app.point_zoom().max(0.01); match drag { Drag::Draw { from, to, redraw } => { if dist(from, to) * zoom < 2.0 { diff --git a/crates/ui-egui/src/lasso_ui.rs b/crates/ui-egui/src/lasso_ui.rs index 369267a97a..7b5a3a6874 100644 --- a/crates/ui-egui/src/lasso_ui.rs +++ b/crates/ui-egui/src/lasso_ui.rs @@ -122,7 +122,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool ToolEvent::Down { .. } => { if active(app) { // A click on the first point, or on the last one again, closes the outline. - let reach = 8.0 / app.current_zoom().max(0.01) as f64; + let reach = 8.0 / app.point_zoom().max(0.01) as f64; let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach; let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]])))); if close { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dd9e4c32a9..e0097e02fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -396,6 +396,10 @@ pub struct PhotocraftApp { fonts_ready: bool, /// Screen rect of the main canvas last frame (for overlays and the navigator). pub last_canvas_rect: egui::Rect, + /// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`). The + /// canvas maps document pixels to physical pixels, so point-space geometry divides the view + /// zoom by this (see [`Self::point_zoom`]). + pub ppp: f32, /// The document area showing the active document's canvas last frame (not the tabs, the /// start screen or an opening file's card): files dropped here are placed as layers. pub(crate) drop_canvas_rect: Option, @@ -549,6 +553,7 @@ impl PhotocraftApp { last_window_title: String::new(), fonts_ready: false, last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)), + ppp: 1.0, drop_canvas_rect: None, tab_strip: None, drop_places: Default::default(), @@ -1478,10 +1483,23 @@ impl PhotocraftApp { } impl PhotocraftApp { - /// Zoom of the active document's main view (screen points per document pixel). + /// Zoom of the active document's main view: screen (device) pixels per document pixel, the + /// user-facing factor (`100%` is `1.0`). pub fn current_zoom(&self) -> f32 { self.session.active_index().and_then(|i| self.ui.views.get(i)).map_or(1.0, |v| v.zoom) } + + /// Screen (device) pixels per egui point of the canvas (`ctx.pixels_per_point`), as of the + /// last canvas frame. `1.0` before the first frame. + pub fn canvas_ppp(&self) -> f32 { + if self.ppp.is_finite() && self.ppp > 0.0 { self.ppp } else { 1.0 } + } + + /// The active view's zoom in egui points per document pixel: the unit every screen-space + /// distance, tolerance and texture-level choice on the canvas is measured in. + pub fn point_zoom(&self) -> f32 { + self.current_zoom() / self.canvas_ppp() + } } impl PhotocraftApp { diff --git a/crates/ui-egui/src/magnetic_lasso_ui.rs b/crates/ui-egui/src/magnetic_lasso_ui.rs index 6da0478082..6477178015 100644 --- a/crates/ui-egui/src/magnetic_lasso_ui.rs +++ b/crates/ui-egui/src/magnetic_lasso_ui.rs @@ -73,12 +73,12 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings { fn spacing(app: &PhotocraftApp) -> f64 { let f = app.ui.tool_options.magnetic_frequency; let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 }; - (8.0 + (100.0 - f) * 0.9) / f64::from(app.current_zoom().max(0.01)) + (8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01)) } /// Within this distance of the first point (document px), a click closes the border. fn close_radius(app: &PhotocraftApp) -> f64 { - 8.0 / f64::from(app.current_zoom().max(0.01)) + 8.0 / f64::from(app.point_zoom().max(0.01)) } fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d6beed0d4c..ab68c23dd6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -389,7 +389,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let visible = !external && app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| { let v = &app.ui.views[i]; - let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / v.zoom, app.last_canvas_rect.height() / 2.0 / v.zoom); + let pz = app.point_zoom(); + let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / pz, app.last_canvas_rect.height() / 2.0 / pz); let r = photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32); !clip.bounds.intersect(&r).is_empty() diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 74aa2ab3b4..cda6a2915d 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -142,7 +142,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; d = constrain(d, app.move_mods.last, if pixels { TRANSFORM_DIRECTIONS } else { MOVE_DIRECTIONS }); } app.move_mods.last = Some(d); - let zoom = f64::from(app.current_zoom().max(0.01)); + let zoom = f64::from(app.point_zoom().max(0.01)); if app.move_mods.alt && !pixels && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD { app.move_mods.dup_from = duplicate(app); // Only once per drag, even if duplicating failed. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 54d88fb000..ef8e71040e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1468,8 +1468,9 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Visible-area rectangle. let v = app.ui.views[idx].clone(); let canvas = app.last_canvas_rect; - let vw = canvas.width() / v.zoom * s; - let vh = canvas.height() / v.zoom * s; + let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6); + let vw = canvas.width() / point_zoom * s; + let vh = canvas.height() / point_zoom * s; let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s); let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0)); ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle); diff --git a/crates/ui-egui/src/patch_preview.rs b/crates/ui-egui/src/patch_preview.rs index 84bc7dcffb..3876343cef 100644 --- a/crates/ui-egui/src/patch_preview.rs +++ b/crates/ui-egui/src/patch_preview.rs @@ -63,7 +63,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc bool { zoom > MIN_ZOOM } diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index a376104296..9c1a6663ce 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -515,7 +515,13 @@ fn empty(ui: &mut egui::Ui, s: &str) { fn doc_point(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> { let i = app.session.active_index()?; let v = app.ui.views.get(i)?; - let xf = crate::canvas::ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation }; + let xf = crate::canvas::ViewXform { + rect: app.last_canvas_rect, + zoom: v.zoom / app.canvas_ppp(), + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + }; Some(xf.to_doc(pos)) } diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 84b5cfa4aa..0bd6132d9e 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -180,7 +180,7 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { return None; } let doc = &app.session.active()?.doc; - let tol = 4.0 / app.current_zoom().max(0.01) as f64; + let tol = 4.0 / app.point_zoom().max(0.01) as f64; for (i, g) in doc.guides.vertical.iter().enumerate() { if (*g as f64 - x).abs() <= tol { return Some((true, i)); diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 48eba77126..77948d4ef9 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -87,14 +87,15 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { center.clamp(a.min(b), a.max(b)) } -/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points. True -/// when the view moved (the caller then repaints). -pub fn clamp_view(view: &mut View, size: egui::Vec2) -> bool { +/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a +/// display with `ppp` physical pixels per point. True when the view moved (then repaint). +pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool { let [w, h] = view.doc_size; if w == 0 || h == 0 { return false; } - let c = [clamp_axis(view.center[0], w as f32, size.x, view.zoom), clamp_axis(view.center[1], h as f32, size.y, view.zoom)]; + let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)]; // Ignore sub-pixel float noise so the clamp never keeps requesting frames. let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; if moved { @@ -127,7 +128,9 @@ pub fn bar_rects(rect: Rect, horizontal: bool, vertical: bool) -> (Option, /// Returns true when the pointer is over a bar (the canvas then leaves the pointer alone). pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, key: Id) -> bool { let [w, h] = view.doc_size; - let zoom = view.zoom; + // `Span` works in egui points; `View::zoom` is device pixels per document pixel. + let ppp = ui.ctx().pixels_per_point(); + let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; // Horizontal axis in "screen order": with the view flipped, run the image backwards. let hx = |c: f32| if flip { w as f32 - c } else { c }; let with_extent = |s: Span| (s, s.extent(overscroll)); @@ -285,9 +288,9 @@ mod tests { assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's"); assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred"); let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 }; - assert!(clamp_view(&mut v, vec2(500.0, 400.0))); + assert!(clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); assert_eq!(v.center, [250.0, 200.0]); - assert!(!clamp_view(&mut v, vec2(500.0, 400.0)), "stable: no repaint loop"); + assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop"); } #[test] @@ -304,7 +307,7 @@ mod tests { assert!(at.is_finite() && len.is_finite()); assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0); let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0], rotation: 0.0 }; - assert!(!clamp_view(&mut v, vec2(500.0, 400.0))); + assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0)); } mod canvas { diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 07c843a6fa..65e5a214e6 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -56,7 +56,7 @@ pub fn options(app: &PhotocraftApp) -> SnapOptions { /// Snap tolerance in document pixels at the current zoom. pub fn tolerance(app: &PhotocraftApp) -> f64 { - SNAP_PX / app.current_zoom().max(0.01) as f64 + SNAP_PX / app.point_zoom().max(0.01) as f64 } fn smart_on(app: &PhotocraftApp) -> bool { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6010df9d0a..e047125329 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -381,7 +381,9 @@ pub enum DialogKind { /// Per-document view (camera) state. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct View { - /// Screen pixels per document pixel. + /// Screen (device) pixels per document pixel: the user-facing zoom (`100%` is `1.0`). + /// Canvas geometry works in egui points and divides by `ctx.pixels_per_point` + /// (`PhotocraftApp::point_zoom`), so a scaled display doesn't magnify the image. pub zoom: f32, /// Document-space point shown at the canvas centre. pub center: [f32; 2], diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 83af1f8974..5ba46beb2a 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -77,7 +77,7 @@ pub const HANDLE_PX: f64 = 12.0; /// [`HANDLE_PX`] in document pixels at the current zoom. pub fn handle_tolerance(app: &PhotocraftApp) -> f64 { - HANDLE_PX / app.current_zoom().max(0.01) as f64 + HANDLE_PX / app.point_zoom().max(0.01) as f64 } /// The box as one undo step restores it. diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f595732e0a..94cb2b3ca6 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { /// Topmost visible type layer whose laid-out text contains the document point. fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); - let slop = 6.0 / app.current_zoom().max(0.01); + let slop = 6.0 / app.point_zoom().max(0.01); let mut ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); ids.reverse(); // walk() is bottom-up; hit the topmost first @@ -160,7 +160,7 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64 let (r, b) = (bx + w, by + h); let (mx, my) = (bx + w / 2.0, by + h / 2.0); let spots = [(bx, by), (r, by), (r, b), (bx, b), (mx, by), (r, my), (mx, b), (bx, my)]; - let tol = f64::from(6.0 / app.current_zoom().max(0.01)); + let tol = f64::from(6.0 / app.point_zoom().max(0.01)); spots .iter() .position(|&(sx, sy)| { @@ -254,7 +254,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { return; } let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); - let min = 4.0 / app.current_zoom().max(0.01) as f64; + let min = 4.0 / app.point_zoom().max(0.01) as f64; let o = app.ui.tool_options.clone(); // Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default). let text = if app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" }; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index fd3c4bb649..6936f9c84b 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -50,7 +50,7 @@ fn xf(app: &PhotocraftApp) -> ViewXform { let v = &app.ui.views[0]; ViewXform { rect: crate::rulers::content_rect(app, app.last_canvas_rect), - zoom: v.zoom, + zoom: v.zoom / app.canvas_ppp(), center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation, @@ -616,7 +616,8 @@ fn frame_point(app: &PhotocraftApp, p: [f64; 2]) -> Pos2 { fn assert_affine(a: Affine, b: Affine) { for (x, y) in a.m.into_iter().zip(b.m) { - assert!((x - y).abs() < 2e-4, "{a:?} != {b:?}"); + // Translations around 500 doc px at a scaled ppp cancel to ~2.7e-4 in f32 rounding. + assert!((x - y).abs() < 1e-3, "{a:?} != {b:?}"); } } diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index eabd71c153..a51c450eb1 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -139,7 +139,7 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool { fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option { let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)); - (h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.current_zoom().max(0.01)))).then_some(h) + (h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.point_zoom().max(0.01)))).then_some(h) } /// Type uses legacy scaling: Shift constrains; Ctrl/Cmd is the mode key, never corner Distort. diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index ae68129838..6e6ac4fe2f 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -132,7 +132,7 @@ fn pen_to_json(pen: &PenPath, closed: bool) -> Value { /// Pen press: close on the first anchor, reshape the last anchor's outgoing handle, /// or add a new anchor (dragging a new anchor pulls symmetrical handles). pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) { - let tol = 6.0 / app.current_zoom().max(0.01) as f64; + let tol = 6.0 / app.point_zoom().max(0.01) as f64; let pen = app.ui.pen.get_or_insert_with(PenPath::default); if let Some(first) = pen.knots.first().map(|k| k[0]) && pen.knots.len() >= 2 diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index d4fe76c10b..774b464b07 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -607,8 +607,11 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result { let size = [st.doc.size.width, st.doc.size.height]; let area = app.last_canvas_rect.size(); let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) }; + // The area is in egui points; the stored zoom is device pixels per document pixel. + let ppp = app.canvas_ppp(); + let points = ((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32); let v = &mut app.ui.views[i]; - v.zoom = crate::zoom_levels::clamp(((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32), size); + v.zoom = crate::zoom_levels::clamp(points * ppp, size); v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0]; v.fit_pending = false; Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]})) diff --git a/crates/ui-egui/src/zoom_tool.rs b/crates/ui-egui/src/zoom_tool.rs index bbfafaf04c..30034348c1 100644 --- a/crates/ui-egui/src/zoom_tool.rs +++ b/crates/ui-egui/src/zoom_tool.rs @@ -39,10 +39,12 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 { crate::zoom_levels::clamp(zoom0 * e.exp2(), size) } -/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`. -pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32) { +/// Set `view` to `zoom` (device pixels per document pixel) with document point `doc` shown at +/// screen point `screen` (egui points) on a display with `ppp` physical pixels per point. +pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) { view.zoom = zoom; - let d = (screen - xf.rect.center()) / zoom; + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let d = (screen - xf.rect.center()) / (zoom / ppp); let dx = if xf.flip { -d.x } else { d.x }; view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y]; } @@ -62,14 +64,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { match z.rect_to.as_mut() { Some(to) => *to = p, - None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size)), + None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp), } ctx.data_mut(|d| d.insert_temp(id(), z)); } if b.stopped { ctx.data_mut(|d| d.remove::(id())); if let Some(to) = z.rect_to { - zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt)); + zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt), app.ppp); } } true @@ -77,16 +79,17 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View /// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom /// at the press point; ⌥ steps out). -fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) { +fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool, ppp: f32) { if out || r.width() < 4.0 || r.height() < 4.0 { let doc = xf.to_doc(anchor); - anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size)); + anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size), ppp); return; } + // `k` is the ratio of two screen-point lengths, so it scales the zoom in any unit. let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height()); let zoom = crate::zoom_levels::clamp(view.zoom * k, view.doc_size); let c = xf.to_doc(r.center()); - anchor_view(view, xf, xf.rect.center(), c, zoom); + anchor_view(view, xf, xf.rect.center(), c, zoom, ppp); } /// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants. diff --git a/crates/ui-egui/tests/drag_preview_canvas.rs b/crates/ui-egui/tests/drag_preview_canvas.rs index 32c32f2959..b3c8cc3f09 100644 --- a/crates/ui-egui/tests/drag_preview_canvas.rs +++ b/crates/ui-egui/tests/drag_preview_canvas.rs @@ -84,15 +84,9 @@ fn setup(h: &mut Harness, background: &str, tool: &str) { } fn xf(h: &Harness) -> ViewXform { - let app = h.state(); - let v = &app.ui.views[0]; - ViewXform { - rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect), - zoom: v.zoom, - center: v.center, - flip: app.ui.view.flip_horizontal, - rotation: v.rotation, - } + // The same mapping the canvas draws with: `View::zoom` is device pixels per document pixel, + // and the transform works in the viewport's egui points. + ViewXform::active(h.state()).expect("an active document view") } /// Screen point (egui points) of document point `(x, y)`. @@ -182,11 +176,11 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() { } } else { // The ellipse touches its box at the edge midpoints: check short arcs there. - let c = screen(&h, 200.0, 150.0); - let (rx, ry) = (100.0, 70.0); - for (dx, dy) in [(0.0, -ry), (rx, 0.0), (0.0, ry), (-rx, 0.0)] { - let m = c + egui::vec2(dx, dy); - let along = if dx == 0.0 { egui::vec2(14.0, 0.0) } else { egui::vec2(0.0, 14.0) }; + // The arcs keep a physical length at any scale, so their sagitta stays inside + // `coverage`'s ±2 physical px search. + for (mx, my) in [(200.0, 80.0), (300.0, 150.0), (200.0, 220.0), (100.0, 150.0)] { + let m = screen(&h, mx, my); + let along = if mx == 200.0 { egui::vec2(14.0 / ppp, 0.0) } else { egui::vec2(0.0, 14.0 / ppp) }; let now = coverage(&img, ppp, m - along, m + along, ink(bg)); assert!(now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {now:.2} contrasting"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7d61641e6e..8aa60c07af 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -37,7 +37,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement - `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) -- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) +- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. diff --git a/docs/roadmap.md b/docs/roadmap.md index ef36482a8c..1597939892 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -85,7 +85,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either | PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). | | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | -| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418) | low–medium | Needs recurring visual QA with realistic documents. | +| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | | Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |