diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index db11fc730e..5f378a56d3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3913,6 +3913,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if app.ui.view.shows(app.ui.view.show.canvas_guides) || crate::guide_layout_ui::preview(app, &doc).is_some() { crate::rulers::draw_guides(app, &painter, &xf, &doc); } + if app.session.active_index() == Some(idx) { + crate::rulers::draw_guide_drag_readout(app, &ctx); + } // The pending crop frame shows on its own document only, not on the other tiles (#1918). let foreign_crop = if app.session.active_index() == Some(idx) { None } else { app.ui.crop_rect.take() }; draw_drag_preview(app, &painter, &xf); diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 6caa6f00a9..3c5e33c290 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -213,6 +213,30 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, } } +/// The position readout of a guide being dragged: `X:` for a vertical guide, `Y:` for a horizontal +/// one, measured from the ruler zero point in the ruler unit. Pixels keep one decimal, as +/// Photoshop's "X: 103.2 px". +pub fn guide_drag_readout(app: &PhotocraftApp, d: GuideDrag) -> Option<(&'static str, String)> { + let doc = &app.session.active()?.doc; + let ur = &app.session.prefs().units_and_rulers; + let axis = usize::from(!d.vertical); + let px = d.pos - doc.ruler_origin.get(axis).copied().unwrap_or(0.0); + let extent = if d.vertical { doc.size.width } else { doc.size.height } as f64; + let value = match ur.rulers { + Unit::Pixels => photocraft_engine::prefs::fmt_decimals(px, 1), + _ => ur.format(px, f64::from(doc.resolution_dpi), extent), + }; + Some((if d.vertical { "X:" } else { "Y:" }, format!("{value} {}", ur.rulers.suffix()))) +} + +/// Draw [`guide_drag_readout`] beside the pointer while a guide is dragged. +pub fn draw_guide_drag_readout(app: &PhotocraftApp, ctx: &egui::Context) { + let (Some(d), Some(at)) = (app.guide_drag, ctx.pointer_latest_pos()) else { return }; + if let Some((label, value)) = guide_drag_readout(app, d) { + crate::canvas::draw_value_readout(app, ctx, "guide-drag-readout", at, [label], [value]); + } +} + /// Existing guide under a document point (within 4 screen px). pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> { // Hidden canvas guides are not interactive: the Move tool must reach the layer below. @@ -737,6 +761,84 @@ mod tests { assert!(h.query_by_label("17").is_some(), "horizontal guide: Y readout"); } + /// A dragged guide shows its position beside the pointer: X for a vertical guide, Y for a + /// horizontal one, from the zero point, in the ruler unit (pixels to one decimal). + #[test] + fn guide_drag_shows_its_position_beside_the_pointer() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let v = GuideDrag { vertical: true, index: Some(0), pos: 103.24, press: 50.0 }; + assert_eq!(guide_drag_readout(&app, v), Some(("X:", "103.2 px".to_owned()))); + let h = GuideDrag { vertical: false, index: None, pos: 40.0, press: 40.0 }; + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "40 px".to_owned()))); + app.run("view.setRulerOrigin", json!({"x": 3.0, "y": 10.0})).unwrap(); + assert_eq!(guide_drag_readout(&app, h), Some(("Y:", "30 px".to_owned())), "from the zero point"); + app.run("view.resetRulerOrigin", json!({})).unwrap(); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + assert_eq!(guide_drag_readout(&app, GuideDrag { vertical: true, index: None, pos: 103.0, press: 0.0 }).map(|r| r.0), Some("X:")); + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "pixels"})).unwrap(); + + app.guide_drag = Some(v); + let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(|ui, app| draw_guide_drag_readout(app, ui.ctx()), app); + h.event(egui::Event::PointerMoved(pos2(50.0, 50.0))); + h.run_steps(2); + assert!(h.query_by_label("X:").is_some() && h.query_by_label("103.2 px").is_some()); + h.state_mut().guide_drag = None; + h.run_steps(2); + assert!(h.query_by_label("X:").is_none(), "no readout once the drag ends"); + } + + /// Moving a horizontal guide with the Move tool on the real canvas: `Y:` follows the pointer. + #[test] + fn moving_a_guide_on_the_canvas_shows_its_y() { + use egui_kittest::kittest::Queryable; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("view.newGuide", json!({"orientation": "horizontal", "position": 100})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.extras.snap = false; + app.ui.tool = crate::state::Tool::Move; + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + (v.zoom, v.center, v.fit_pending) = (1.0, [200.0, 150.0], false); + h.run_steps(2); + let at = |h: &egui_kittest::Harness<'static, PhotocraftApp>, x: f32, y: f32| { + let app = h.state(); + let v = &app.ui.views[0]; + let rect = content_rect(app, app.last_canvas_rect); + ViewXform { rect, zoom: v.zoom, center: v.center, flip: false, rotation: 0.0, aspect: 1.0 }.to_screen(x, y) + }; + let press = at(&h, 200.0, 100.0); + h.event(egui::Event::PointerMoved(press)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: press, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for y in [110.0, 125.0, 140.0] { + h.event(egui::Event::PointerMoved(at(&h, 210.0, y))); + h.run_steps(2); + } + assert!(h.state().guide_drag.is_some_and(|d| !d.vertical), "a horizontal guide is being dragged"); + assert!(h.query_by_label("Y:").is_some() && h.query_by_label("140 px").is_some(), "Y readout at the guide"); + assert!(h.query_by_label("X:").is_none()); + let up = at(&h, 210.0, 140.0); + h.event(egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.guides.horizontal, vec![140.0]); + assert!(h.query_by_label("Y:").is_none(), "gone after the drop"); + } + #[test] fn percentage_grid_spacing_uses_both_canvas_dimensions() { let mut g = GuidesGridAndSlices { grid_unit: Unit::Percent, gridline_every: 20.0, subdivisions: 2, ..Default::default() }; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..d68aec572f 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-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (a dragged guide shows its X or Y beside the pointer, #7680; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **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, @@ -156,6 +156,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Smart guide distance and spacing labels | missing | | | Ruler units px/in/cm/mm/pt/pica/% | done | | | ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) | +| A dragged guide shows its position beside the pointer (X for vertical, Y for horizontal, in the ruler unit) | done | `rulers.rs` `guide_drag_readout` (#7680) | | Status bar follows the ruler unit | partial | #2602 | | Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | | Guides stored as f64 | missing | `Vec` (UI-217-20) | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | A guide being moved or dragged out of a ruler shows an `X:` / `Y:` position readout beside the pointer, from the zero point in the ruler unit (#7680) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |