Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 635de04284 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 <[email protected]>
2026-10-04 14:54:52 -04:00
Brandon ThomasandClaude Opus 5.5 15020e0d6e 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 <[email protected]>
2026-10-04 14:54:52 -04:00
Brandon ThomasandClaude Opus 5.5 282fb59452 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 <[email protected]>
2026-10-04 14:54:52 -04:00
8 changed files with 474 additions and 14 deletions

No files matched your search

+13 -1
View File
@@ -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 };
+42 -1
View File
@@ -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<egui::Rect> {
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<egui::Pos2> {
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<egui::Vec2> {
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<bool> = 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 {
+134
View File
@@ -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());
}
+8 -1
View File
@@ -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::*;
+60 -1
View File
@@ -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<egui::Id> = 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<String>) {
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());
+21 -4
View File
@@ -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) {
+173 -1
View File
@@ -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<Key> {
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::<bool>(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<egui::Event>, 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() {
+23 -5
View File
@@ -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]