From b17c239db8f598b06e5807641048c32704fd9663 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 14:56:12 -0400 Subject: [PATCH] UI input: Ctrl+'+' zooms the canvas, dialogs don't cancel on outside click, menu hover-switch (#62) * Shortcuts match like Photoshop: Ctrl/Cmd + '+' zooms, exact modifiers, clipboard keys - Cmd/Ctrl+'=' also matches '+' (Shift+= on US layouts, numpad +, the '+' key on Nordic/German layouts); Delete also matches Backspace (the Mac delete key). - Modifiers match exactly (Cmd+Alt+I no longer runs Cmd+I); Shift shortcuts on punctuation match the shifted character (Cmd+Shift+; arrives as Cmd+':'). - Zoom In/Out, Fit on Screen and 100% stay live while a dialog or a non-text widget has focus. - clipboard_keys: egui-winit turns Cmd+C/X/V into Copy/Cut/Paste events (and drops Cmd+V when the clipboard holds an image); outside text fields they become key presses again so Edit > Copy/Cut/Paste/Copy Merged run. - Snapping: Ctrl (Windows) / Control (Mac) disables snapping only while held during the drag, not at the press (Ctrl-click belongs to the tool). Co-Authored-By: Claude Opus 5.5 * Dialogs: no cancel on outside click; canvas pans and zooms under them - Disable egui's keyboard UI zoom: Cmd/Ctrl +/-/0 never scale the interface. - A click outside a dialog no longer silently cancels it (Esc still does). - With a dialog open the canvas still scrolls, pinch/Cmd-scroll zooms and Space/middle/Hand-drag pans, like Photoshop; everything else stays modal. - Clipboard key presses restored in raw_input_hook. - Studio Properties card: Order::Middle (menus, popups, dialogs stay above it) and draggable by its title off the area being worked on. - Magic Wand options (tolerance, anti-alias, contiguous, sample all layers) in the Studio themes too. - egui_kittest tests: Ctrl+Plus zooms the canvas and leaves zoom_factor at 1, zoom works with a dialog open, outside clicks keep the dialog, scroll and space-drag pan under it. Co-Authored-By: Claude Opus 5.5 * Menu bar: hovering another title switches the open menu Like Windows and macOS menu bars: with a menu open, moving to another top-level title opens that menu. Tests for the switch and dropdown row spacing. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 14 ++- crates/ui-egui/src/dialogs.rs | 43 +++++++- crates/ui-egui/src/input_tests.rs | 134 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 9 +- crates/ui-egui/src/menus.rs | 61 ++++++++++- crates/ui-egui/src/panels.rs | 25 ++++- crates/ui-egui/src/shortcuts.rs | 174 +++++++++++++++++++++++++++++- crates/ui-egui/src/snap_ui.rs | 28 ++++- 8 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/input_tests.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f3ccd7b0..dcba2c66 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -880,8 +880,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } } + // Under an open dialog the canvas widget is inert, but the image still pans and zooms. + let under_dialog = !app.ui.dialogs.is_empty(); + let free_hover = under_dialog && crate::dialogs::free_pointer_over(&ctx, rect).is_some(); // Navigation: scroll pans, pinch / ⌘-scroll zooms around the pointer. - if response.hovered() { + if response.hovered() || free_hover { let (scroll, zoom_delta, pointer) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos())); if zoom_delta != 1.0 && let Some(p) = pointer @@ -905,6 +908,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let middle = ui.input(|i| i.pointer.middle_down()); let tool = if space_pan || middle { Tool::Hand } else { app.ui.tool }; + if under_dialog { + if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, app.ui.tool == Tool::Hand) { + view.center[0] -= d.x / view.zoom * if flip { -1.0 } else { 1.0 }; + view.center[1] -= d.y / view.zoom; + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } else if free_hover && (space_pan || app.ui.tool == Tool::Hand) { + ctx.set_cursor_icon(egui::CursorIcon::Grab); + } + } if tool == Tool::Hand && response.dragged() { let d = response.drag_delta(); view.center[0] -= d.x / view.zoom * if flip { -1.0 } else { 1.0 }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9f2415fb..6b8401ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,8 +6,40 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::state::{Dialog, DialogKind}; +/// Where this frame's dialogs are on screen (the canvas reads last frame's: it draws first). +const RECTS: &str = "pc-dialog-rects"; + +fn rects(ctx: &egui::Context) -> Vec { + ctx.data(|m| m.get_temp(egui::Id::new(RECTS))).unwrap_or_default() +} + +/// With a dialog open the rest of the window is inert (egui's modal layer), but like Photoshop the +/// image can still be panned and zoomed under it. The pointer position when it is over `canvas` +/// and not over a dialog or one of its popups. +pub fn free_pointer_over(ctx: &egui::Context, canvas: egui::Rect) -> Option { + if egui::Popup::is_any_open(ctx) { + return None; + } + let p = ctx.pointer_hover_pos()?; + let rects = rects(ctx); + (canvas.contains(p) && !rects.iter().any(|r| r.contains(p))).then_some(p) +} + +/// Pan drag under an open dialog: Space-drag, middle-drag, or a drag with the Hand tool, started on +/// the free canvas. Returns this frame's pointer movement. +pub fn pan_delta(ctx: &egui::Context, canvas: egui::Rect, hand: bool) -> Option { + let rects = rects(ctx); + let (origin, panning, delta) = ctx.input(|i| { + let p = &i.pointer; + (p.press_origin(), p.middle_down() || (p.primary_down() && (hand || i.key_down(egui::Key::Space))), p.delta()) + }); + let origin = origin?; + (panning && canvas.contains(origin) && !rects.iter().any(|r| r.contains(origin))).then_some(delta) +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let dialogs = app.ui.dialogs.clone(); + let mut shown = Vec::new(); for d in dialogs { let mut fields = d.fields.clone(); let mut outcome: Option = None; // Some(true)=OK, Some(false)=Cancel @@ -95,13 +127,21 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } } }); + // The frame around the content (Frame::popup's margin and stroke). + ui.min_rect().expand(ui.spacing().menu_margin.sum().max_elem() + 2.0) }); + shown.push(modal.inner); if drag != egui::Vec2::ZERO { // Keep the whole dialog (and so its title bar) on screen. let room = ((ctx.content_rect().size() - modal.response.rect.size()) / 2.0).max(egui::Vec2::ZERO); ctx.data_mut(|m| m.insert_temp(id, (offset + drag).clamp(-room, room))); } - if modal.should_close() && outcome.is_none() { + // Esc cancels (topmost dialog, no popup open). A click outside does nothing: Photoshop keeps + // the dialog, and the pointer may be panning or zooming the canvas under it. + if outcome.is_none() + && (modal.response.should_close() + || (modal.is_top_modal && !modal.any_popup_open && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))) + { outcome = Some(false); } if let Some(dm) = app.ui.dialog_mut(d.id) { @@ -118,6 +158,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { None => {} } } + ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECTS), shown)); } pub fn title(d: &Dialog) -> String { diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs new file mode 100644 index 00000000..7a92a2f6 --- /dev/null +++ b/crates/ui-egui/src/input_tests.rs @@ -0,0 +1,134 @@ +//! Input routing through the real canvas, dialogs and shortcuts (#44, #45): keyboard zoom hits the +//! canvas (never egui's UI scale), and an open dialog keeps the canvas pannable and zoomable but +//! is not cancelled by a click outside it. + +use egui::{Key, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::PhotocraftApp; + +fn harness() -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + // Fonts set up after the first frame only apply from the next one. + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + crate::dialogs::show(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + h +} + +fn zoom(h: &Harness<'static, PhotocraftApp>) -> f32 { + h.state().current_zoom() +} + +fn open_dialog(h: &mut Harness<'static, PhotocraftApp>) { + crate::dialogs::open_command_dialog(h.state_mut(), "image.adjustments.brightnessContrast", "Brightness/Contrast…"); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1); +} + +/// A point on the canvas well away from the centred dialog. +fn free_canvas(h: &Harness<'static, PhotocraftApp>) -> Pos2 { + let r = h.state().last_canvas_rect; + pos2(r.left() + 60.0, r.bottom() - 60.0) +} + +#[test] +fn ctrl_plus_zooms_the_canvas_not_the_interface() { + let mut h = harness(); + let z0 = zoom(&h); + // ⌘+ / Ctrl+'+' as typed with ⇧ on a US layout, and as the numpad / Nordic `+`. + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus); + h.run_steps(2); + let z1 = zoom(&h); + assert!(z1 > z0, "⌘+ should zoom the canvas in ({z0} -> {z1})"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Plus); + h.run_steps(2); + assert!(zoom(&h) > z1); + h.key_press_modifiers(Modifiers::COMMAND, Key::Minus); + h.run_steps(2); + assert!((zoom(&h) - z1).abs() < 1e-4); + // ⌘1 is 100%, ⌘0 fits on screen again. + h.key_press_modifiers(Modifiers::COMMAND, Key::Num1); + h.run_steps(2); + assert_eq!(zoom(&h), 1.0); + h.key_press_modifiers(Modifiers::COMMAND, Key::Num0); + h.run_steps(3); + assert!((zoom(&h) - z0).abs() < 1e-3, "⌘0 fits on screen: {} vs {z0}", zoom(&h)); + assert_eq!(h.ctx.zoom_factor(), 1.0, "the interface must never scale"); +} + +#[test] +fn keyboard_zoom_works_with_a_dialog_open() { + let mut h = harness(); + open_dialog(&mut h); + let z0 = zoom(&h); + h.key_press_modifiers(Modifiers::COMMAND, Key::Equals); + h.run_steps(2); + assert!(zoom(&h) > z0); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus); + h.run_steps(2); + assert_eq!(h.ctx.zoom_factor(), 1.0); + assert_eq!(h.state().ui.dialogs.len(), 1); + // Other commands stay blocked while the dialog is open (⌘J would duplicate the layer). + let layers = h.state().session.active().unwrap().doc.layers.len(); + h.key_press_modifiers(Modifiers::COMMAND, Key::J); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers); +} + +#[test] +fn clicking_outside_a_dialog_keeps_it_open_and_the_canvas_pans_and_zooms() { + let mut h = harness(); + open_dialog(&mut h); + let p = free_canvas(&h); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "a click outside must not cancel the dialog"); + + // Scrolling over the canvas pans it. + let c0 = h.state().ui.views[0].center; + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }); + h.run_steps(8); + let c1 = h.state().ui.views[0].center; + assert!(c1[1] > c0[1], "scroll pans under a dialog: {c0:?} -> {c1:?}"); + + // Space-drag pans too. + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=5 { + h.hover_at(p + vec2(10.0 * i as f32, 0.0)); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: p + vec2(50.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let c2 = h.state().ui.views[0].center; + let z = zoom(&h); + assert!((c2[0] - (c1[0] - 50.0 / z)).abs() < 0.5, "space-drag pans by 50 pt: {c1:?} -> {c2:?}"); + assert_eq!(h.state().ui.dialogs.len(), 1); + + // Esc still cancels. + h.key_press(Key::Escape); + h.run_steps(2); + assert!(h.state().ui.dialogs.is_empty()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a058b28c..a89ab9b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -531,7 +531,8 @@ impl eframe::App for PhotocraftApp { } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + shortcuts::clipboard_keys(ctx, ctx.text_edit_focused() || self.ui.text_edit.is_some(), raw_input); raw_input.events.extend(self.take_synthetic_step()); } @@ -717,6 +718,9 @@ impl PhotocraftApp { theme::install_fonts(ctx); egui_extras::install_image_loaders(ctx); theme::apply(ctx, kind); + // egui's own ⌘+ / ⌘- / ⌘0 scale the whole interface; PhotoCraft zooms the canvas instead + // (shortcuts.rs), like Photoshop. + ctx.options_mut(|o| o.zoom_with_keyboard = false); } } @@ -850,6 +854,9 @@ impl PhotocraftApp { } } +#[cfg(test)] +mod input_tests; + #[cfg(test)] mod clipboard_tests { use super::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f0eff45..52bf131c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -687,16 +687,19 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { v.widgets.hovered.bg_stroke = egui::Stroke::NONE; egui::MenuBar::new().ui(ui, |ui| { ui.spacing_mut().button_padding = egui::vec2(6.0, 3.0); + let mut buttons = Vec::with_capacity(TOP_MENUS.len()); for top in TOP_MENUS { let mine: Vec<&MenuItem> = items.iter().filter(|i| i.path.first().map(String::as_str) == Some(top)).collect(); - ui.menu_button(egui::RichText::new(top).color(t.text_dim), |ui| { + let r = ui.menu_button(egui::RichText::new(top).color(t.text_dim), |ui| { ui.set_min_width(220.0); if mine.is_empty() { ui.weak("(coming soon)"); } render_level(ui, &mine, 1, &mut clicked); }); + buttons.push(r.response); } + switch_on_hover(ui.ctx(), &buttons); }); }); if let Some(id) = clicked { @@ -705,6 +708,22 @@ pub fn menu_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } +/// Like a native menu bar (Windows, macOS): while one top-level menu is open, hovering another +/// top-level title opens that menu instead. +fn switch_on_hover(ctx: &egui::Context, buttons: &[egui::Response]) { + let ids: Vec = buttons.iter().map(egui::Popup::default_response_id).collect(); + let Some(open) = ids.iter().position(|id| egui::Popup::is_id_open(ctx, *id)) else { return }; + // `Response::hovered` is false while the menu's popup layer is open, so test the pointer + // against the titles directly (the bar is never under its own dropdowns). + let Some(p) = ctx.pointer_hover_pos() else { return }; + if let Some(i) = buttons.iter().position(|b| b.interact_rect.contains(p)) + && i != open + { + egui::Popup::open_id(ctx, ids[i]); + ctx.request_repaint(); + } +} + fn render_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option) { let t = crate::theme::Tokens::get(ui.ctx()); if t.pro { @@ -813,6 +832,46 @@ mod tests { } } + #[test] + fn hovering_another_title_switches_the_open_menu() { + use egui_kittest::{Harness, kittest::Queryable}; + + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 700.0)).build_ui_state(|ui, app| menu_bar(app, ui), app); + PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]); + harness.run_steps(3); + // Nothing open: hovering a title does not open it. + harness.get_by_label("Image").hover(); + harness.run_steps(3); + assert!(harness.query_by_label_contains("Open…").is_none() && harness.query_by_label_contains("Duplicate…").is_none()); + harness.get_by_label("File").click(); + harness.run_steps(3); + assert!(harness.query_by_label_contains("Open…").is_some()); + harness.get_by_label("Image").hover(); + harness.run_steps(4); + assert!(harness.query_by_label_contains("Open…").is_none(), "File menu should close"); + assert!(harness.query_by_label_contains("Duplicate…").is_some(), "Image menu should open on hover"); + } + + #[test] + fn dropdown_items_have_room() { + use egui_kittest::{Harness, kittest::Queryable}; + + for theme in crate::theme::ThemeKind::ALL { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut harness = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| menu_bar(app, ui), app); + PhotocraftApp::setup_context(&harness.ctx, theme); + harness.run_steps(3); + harness.get_by_label("File").click(); + harness.run_steps(3); + let a = harness.get_by_label_contains("New…").rect(); + let b = harness.get_by_label_contains("Open…").rect(); + let font = egui::TextStyle::Button.resolve(&harness.ctx.global_style()).size; + assert!(a.height() >= font + 8.0, "{theme:?}: item height {} for a {font} pt font", a.height()); + assert!(b.top() - a.top() >= font + 10.0, "{theme:?}: rows {} apart", b.top() - a.top()); + } + } + #[test] fn window_panel_and_workspace_ids_drive_the_shell() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a58300e1..b4223160 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -487,7 +487,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { hint(ui, "Click the first point or press ↵ to close · Esc cancels"); } } - Tool::MagicWand if t.pro => { + Tool::MagicWand => { selection_mode_buttons(app, ui); widgets::vline(ui, 22.0); opt_label(ui, "Tolerance"); @@ -624,7 +624,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } Tool::Hand => hint(ui, "Drag to pan · hold Space with any tool"), Tool::Lasso | Tool::PolygonLasso => hint(ui, "Drag (lasso) or click points (polygonal) · ⇧ add · ⌥ subtract"), - Tool::MagicWand => hint(ui, "Click to select similar colours"), Tool::Crop => hint(ui, "Drag a crop box · ↵ commits · Esc cancels"), Tool::Gradient => hint(ui, "Drag to draw a gradient"), Tool::PaintBucket => hint(ui, "Click to fill similar colours"), @@ -1667,10 +1666,15 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { .corner_radius(CornerRadius::same(t.radius_lg as u8)) .shadow(egui::Shadow { offset: [0, 12], blur: 36, spread: 0, color: t.shadow }) .inner_margin(egui::Margin::same(14)); - egui::Area::new(egui::Id::new("properties-card")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + // A floating panel (Order::Middle like other panels, so menus, popups and dialogs stay above + // it), anchored to the canvas corner and dragged by its title off the part being worked on. + let card_id = egui::Id::new("properties-card"); + let offset: egui::Vec2 = ctx.data(|m| m.get_temp(card_id)).unwrap_or_default(); + let mut drag = egui::Vec2::ZERO; + let shown = egui::Area::new(card_id).order(egui::Order::Middle).fixed_pos(pos + offset).show(ctx, |ui| { frame.show(ui, |ui| { ui.set_width(width - 28.0); - ui.horizontal(|ui| { + let title = ui.horizontal(|ui| { ui.label(RichText::new("Properties").font(theme::semibold(13.5)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "minus", 22.0, false, "Hide Properties").clicked() { @@ -1678,6 +1682,12 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); + let bar = title.response.rect.with_max_x(title.response.rect.right() - 28.0); + let grip = ui.interact(bar, card_id.with("title"), Sense::drag()); + if grip.hovered() || grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + drag = grip.drag_delta(); ui.add_space(6.0); let icon = match &layer.content { LayerContent::Adjustment(_) => "sliders-horizontal", @@ -1708,6 +1718,13 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } }); }); + if drag != egui::Vec2::ZERO && canvas.is_positive() { + // Keep the title bar inside the canvas. + let r = shown.response.rect.translate(drag); + let dx = (canvas.left() - r.left()).max(0.0) - (r.right() - canvas.right()).max(0.0); + let dy = (canvas.top() - r.top()).max(0.0) - (r.top() + 40.0 - canvas.bottom()).max(0.0); + ctx.data_mut(|m| m.insert_temp(card_id, offset + drag + egui::vec2(dx, dy))); + } } fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 3d2f1799..fc09069b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -48,8 +48,119 @@ pub fn pretty(s: &str) -> String { .join(if mac { "" } else { "+" }) } +/// With ⇧ held the OS reports the shifted character as the logical key (US layout): ⇧; is `:`. +fn shifted(k: Key) -> Option { + Some(match k { + Key::Equals => Key::Plus, + Key::Semicolon => Key::Colon, + Key::OpenBracket => Key::OpenCurlyBracket, + Key::CloseBracket => Key::CloseCurlyBracket, + Key::Slash => Key::Questionmark, + Key::Backslash => Key::Pipe, + Key::Num1 => Key::Exclamationmark, + _ => return None, + }) +} + +/// Does a key press match a shortcut, the way Photoshop matches them? +/// +/// - Modifiers match exactly (⌘⌥I is not ⌘I), with ⌘ = Ctrl off the Mac. +/// - `=` also matches `+` (⌘+ with ⇧ on a US layout, the numpad `+`, the `+` key of Nordic and +/// German layouts): Photoshop zooms in on both. +/// - Delete also matches Backspace (the Mac's "delete" key). +/// - A ⇧ shortcut on a punctuation key matches the shifted character (⌘⇧; arrives as ⌘`:`). +pub fn key_matches(sc: &KeyboardShortcut, key: Key, mods: Modifiers) -> bool { + let k = sc.logical_key; + let direct = key == k || (k == Key::Delete && key == Key::Backspace); + let plus = k == Key::Equals && key == Key::Plus; + let via_shift = sc.modifiers.shift && shifted(k) == Some(key); + if !(direct || plus || via_shift) { + return false; + } + // Typing `+` (or another shifted symbol) may need ⇧ on this layout: don't require it absent. + let shifted_symbol = + matches!(key, Key::Plus | Key::Colon | Key::OpenCurlyBracket | Key::CloseCurlyBracket | Key::Questionmark | Key::Pipe | Key::Exclamationmark); + let shift_ok = mods.shift == sc.modifiers.shift || (shifted_symbol && !sc.modifiers.shift); + mods.alt == sc.modifiers.alt && shift_ok && mods.cmd_ctrl_matches(sc.modifiers) +} + +/// Consume a press of `sc` (see [`key_matches`]); true when one was found. +pub fn consume(ctx: &egui::Context, sc: &KeyboardShortcut) -> bool { + ctx.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, modifiers, .. } if key_matches(sc, *key, *modifiers))); + i.events.len() != before + }) +} + +/// View navigation that Photoshop keeps live while a dialog is open (so the image can be judged +/// at any zoom): Zoom In / Out, Fit on Screen, 100%. +pub const NAV_COMMANDS: [&str; 4] = ["view.zoomIn", "view.zoomOut", "view.fitOnScreen", "view.actualPixels"]; + +/// ⌘+ / ⌘- / ⌘0 / ⌘1 (or their Edit › Keyboard Shortcuts overrides). Returns true when one ran. +fn nav_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + for id in NAV_COMMANDS { + let sc = crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| parse(app.session.prefs().shortcut(id, c.3)?)); + if let Some(sc) = sc + && consume(ctx, &sc) + { + if crate::menus::is_enabled(app, id) { + let _ = crate::menus::invoke(app, ctx, id, json!({})); + } + return true; + } + } + false +} + +/// egui-winit turns ⌘C / ⌘X / ⌘V into `Event::Copy` / `Cut` / `Paste` and drops the key press, and +/// drops ⌘V entirely when the clipboard holds no text (an image). Outside text fields, give the +/// pixel commands their key presses back: Copy/Cut/Paste become ⌘C/⌘X/⌘V presses (with ⇧/⌥ as +/// held, so ⇧⌘C is Copy Merged), and a ⌘V release without a paste event becomes a ⌘V press. +pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInput) { + if typing { + return; + } + let seen = egui::Id::new("pc-paste-key-seen"); + // The modifiers held at each event: last frame's, updated as the events report changes. + let mut mods = ctx.input(|i| i.modifiers); + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut out = Vec::with_capacity(raw.events.len()); + for e in raw.events.drain(..) { + if let egui::Event::ModifiersChanged(m) | egui::Event::Key { modifiers: m, .. } = &e { + mods = *m; + } + let held = if mods.command { mods } else { Modifiers::COMMAND }; + let key = match &e { + egui::Event::Copy => Some(Key::C), + egui::Event::Cut => Some(Key::X), + egui::Event::Paste(_) => Some(Key::V), + _ => None, + }; + if let Some(key) = key { + if key == Key::V { + ctx.data_mut(|d| d.insert_temp(seen, true)); + } + out.push(press(key, held)); + continue; + } + if let egui::Event::Key { key: Key::V, pressed: false, modifiers, .. } = &e + && modifiers.command + && !ctx.data_mut(|d| d.remove_temp::(seen)).unwrap_or(false) + { + out.push(press(Key::V, *modifiers)); + } + out.push(e); + } + raw.events = out; +} + pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if ctx.egui_wants_keyboard_input() || !app.ui.dialogs.is_empty() || app.discard.is_some() { + // Dialogs and focused sliders keep canvas zoom; a focused text field keeps its keys. + if !ctx.text_edit_focused() { + nav_keys(app, ctx); + } return; } // Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels. @@ -104,7 +215,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if editing && matches!(id.as_str(), "edit.copy" | "edit.cut" | "edit.paste" | "edit.copyMerged" | "select.all" | "edit.pasteSpecial.pasteInPlace") { continue; } - if ctx.input_mut(|i| i.consume_shortcut(&sc)) { + if consume(ctx, &sc) { if crate::menus::is_enabled(app, &id) { let r = if id.starts_with("image.adjustments.") && !crate::panels::adjustment_sliders(id.rsplit('.').next().unwrap_or("")).is_empty() { let label = photocraft_engine::commands::find(&id).map(|c| c.label).unwrap_or(""); @@ -188,6 +299,67 @@ mod tests { assert!(parse("Cmd+Nonsense").is_none()); } + #[test] + fn keys_match_like_photoshop() { + let cmd = Modifiers::COMMAND; + let zoom_in = parse("Cmd+=").unwrap(); + // ⌘= , ⌘+ typed with ⇧ (US), ⌘+ from the numpad or a Nordic/German `+` key. + assert!(key_matches(&zoom_in, Key::Equals, cmd)); + assert!(key_matches(&zoom_in, Key::Plus, cmd | Modifiers::SHIFT)); + assert!(key_matches(&zoom_in, Key::Plus, cmd)); + assert!(!key_matches(&zoom_in, Key::Plus, Modifiers::NONE)); + assert!(!key_matches(&zoom_in, Key::Equals, cmd | Modifiers::ALT)); + assert!(key_matches(&parse("Cmd+-").unwrap(), Key::Minus, cmd)); + assert!(key_matches(&parse("Cmd+0").unwrap(), Key::Num0, cmd)); + // Ctrl (Windows) is ⌘. + assert!(key_matches(&zoom_in, Key::Plus, Modifiers::CTRL | Modifiers { command: true, ..Default::default() })); + // Exact modifiers: ⌘⌥I is Image Size, never Invert (⌘I); ⇧⌘Z is not ⌘Z. + let invert = parse("Cmd+I").unwrap(); + assert!(key_matches(&invert, Key::I, cmd)); + assert!(!key_matches(&invert, Key::I, cmd | Modifiers::ALT)); + assert!(!key_matches(&parse("Cmd+Z").unwrap(), Key::Z, cmd | Modifiers::SHIFT)); + // ⇧ shortcuts on punctuation arrive as the shifted character. + assert!(key_matches(&parse("Cmd+Shift+;").unwrap(), Key::Colon, cmd | Modifiers::SHIFT)); + assert!(key_matches(&parse("Cmd+Shift+]").unwrap(), Key::CloseCurlyBracket, cmd | Modifiers::SHIFT)); + assert!(!key_matches(&parse("Cmd+;").unwrap(), Key::Colon, cmd | Modifiers::SHIFT)); + // Delete and the Mac's delete (Backspace) both clear. + let clear = parse("Delete").unwrap(); + assert!(key_matches(&clear, Key::Delete, Modifiers::NONE) && key_matches(&clear, Key::Backspace, Modifiers::NONE)); + assert!(!key_matches(&clear, Key::Backspace, cmd)); + } + + #[test] + fn clipboard_events_become_pixel_shortcuts_outside_text() { + let ctx = egui::Context::default(); + let raw = |mut events: Vec, modifiers| { + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + egui::RawInput { events, ..Default::default() } + }; + let keys = |r: &egui::RawInput| -> Vec<(Key, bool, bool)> { + r.events + .iter() + .filter_map(|e| if let egui::Event::Key { key, pressed, modifiers, .. } = e { Some((*key, *pressed, modifiers.shift)) } else { None }) + .collect() + }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + let mut r = raw(vec![egui::Event::Copy, egui::Event::Cut], cmd_shift); + clipboard_keys(&ctx, false, &mut r); + assert_eq!(keys(&r), vec![(Key::C, true, true), (Key::X, true, true)]); + // A text paste becomes ⌘V; its key release then adds nothing. + let up = egui::Event::Key { key: Key::V, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }; + let mut r = raw(vec![egui::Event::Paste("x".into()), up.clone()], Modifiers::COMMAND); + clipboard_keys(&ctx, false, &mut r); + assert_eq!(keys(&r), vec![(Key::V, true, false), (Key::V, false, false)]); + // An image on the clipboard: egui-winit sends only the release. It still pastes. + let mut r = raw(vec![up.clone()], Modifiers::COMMAND); + clipboard_keys(&ctx, false, &mut r); + assert_eq!(keys(&r), vec![(Key::V, true, false), (Key::V, false, false)]); + // In a text field the events stay text clipboard events. + let mut r = raw(vec![egui::Event::Paste("x".into())], Modifiers::COMMAND); + clipboard_keys(&ctx, true, &mut r); + assert!(matches!(r.events.as_slice(), [_, egui::Event::Paste(_)])); + } + #[test] fn every_registered_shortcut_parses() { for c in photocraft_engine::command_specs() { diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 14da92a3..b3649a36 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -124,8 +124,15 @@ fn near_handle(q: &[[f64; 2]; 4], p: [f64; 2], tol: f64) -> bool { }) } +/// Photoshop: holding Ctrl (Windows) / Control (macOS) *during* a drag temporarily turns snapping +/// off. Only the held state while dragging counts: Ctrl at the press belongs to the tool (Ctrl-click +/// auto-selects with the Move tool), and ⌘ on the Mac never overrides snapping. +fn override_held(mods: egui::Modifiers) -> bool { + mods.ctrl && !mods.mac_cmd +} + /// Start snapping for a drag beginning at `p` (called on pointer down). -fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { +fn begin(app: &mut PhotocraftApp, p: [f64; 2]) { app.prefs_rt.snap = None; app.prefs_rt.snap_lines.clear(); if app.session.active().is_none() { @@ -158,7 +165,7 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) { targets = targets.filtered(|k| k != SnapKind::Guide); } let smart = if matches!(gesture, Gesture::Move { .. }) && smart_on(app) { build(app, &exclude, true) } else { SnapTargets::default() }; - app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: mods.ctrl && !mods.mac_cmd }); + app.prefs_rt.snap = Some(ActiveSnap { gesture, start: p, targets, smart, disabled: false }); } /// Round to whole pixels when Preferences › Tools asks vector tools and transforms to snap to @@ -199,7 +206,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2]) -> [f64; 2] { pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> ToolEvent { match ev { ToolEvent::Down { x, y, pressure } => { - begin(app, [x, y], mods); + begin(app, [x, y]); // 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) => { @@ -215,7 +222,7 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier return ev; } if let Some(s) = app.prefs_rt.snap.as_mut() { - s.disabled = mods.ctrl && !mods.mac_cmd; + s.disabled = override_held(mods); } let q = apply(app, [x, y]); let p = pixel_round(app, q); @@ -225,6 +232,9 @@ pub fn filter_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifier if app.prefs_rt.snap.is_none() { return ev; } + if let Some(s) = app.prefs_rt.snap.as_mut() { + s.disabled = override_held(mods); + } let q = apply(app, [x, y]); let p = pixel_round(app, q); app.prefs_rt.snap = None; @@ -341,9 +351,17 @@ mod tests { assert_eq!(mover_bounds(&app).x0, 303); app.ui.extras.snap = true; let ctrl = egui::Modifiers { ctrl: true, ..Default::default() }; - crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, ctrl); + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, m); crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 62.0, y: 60.0 }, ctrl); assert_eq!(mover_bounds(&app).x0, 305, "no snap to the target's right edge at 350? (moved freely)"); + // Ctrl only at the press (Ctrl-click auto-select) doesn't turn snapping off for the drag. + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 60.0, y: 60.0, pressure: 1.0 }, ctrl); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 61.0, y: 60.0, pressure: 1.0 }, m); + assert!(!app.prefs_rt.snap.as_ref().is_some_and(|s| s.disabled)); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 61.0, y: 60.0 }, m); + // ⌘ on the Mac is not the override. + let cmd = egui::Modifiers { mac_cmd: true, command: true, ..Default::default() }; + assert!(!override_held(cmd) && override_held(ctrl)); } #[test]