diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index e4887bee3..56663b400 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -2,11 +2,15 @@ //! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush //! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound, //! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush. +//! +//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean +//! other things with other tools, so the shell gives them to it only for the painting tools +//! (`move_mods::arrow_keys`). use serde_json::{Value, json}; use crate::commands::{CommandSpec, always}; -use crate::{Result, Session}; +use crate::{EngineError, Result, Session}; /// Photoshop's largest brush diameter, in pixels. pub const MAX_SIZE: f32 = 5000.0; @@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result { crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } })) } +/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts), +/// wrapped into Photoshop's -180..=180. +fn turned(angle: f32, degrees: f32) -> f32 { + let a = (angle + degrees).rem_euclid(360.0); + if a > 180.0 { a - 360.0 } else { a } +} + +fn rotate(s: &mut Session, p: &Value) -> Result { + let by = p + .get("degrees") + .and_then(Value::as_f64) + .filter(|d| d.is_finite() && d.abs() <= 360.0) + .ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?; + let angle = turned(s.tools.brush.angle, by as f32); + crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } })) +} + macro_rules! key_cmd { ($id:literal, $label:literal, $sc:literal, $run:expr) => { CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true } @@ -47,6 +68,16 @@ pub fn specs() -> Vec { key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)), key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)), key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)), + CommandSpec { + id: "tools.rotateBrush", + label: "Rotate Brush Tip", + menu: &[], + shortcut: None, + params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##, + enabled: always, + run: rotate, + journal: true, + }, ] } @@ -74,6 +105,21 @@ mod tests { assert_eq!(hardness_step(0.0, false), 0.0); } + #[test] + fn rotate_brush_turns_the_tip_and_wraps_at_180() { + assert_eq!(turned(0.0, 1.0), 1.0); + assert_eq!(turned(179.0, 15.0), -166.0); + assert_eq!(turned(-175.0, -15.0), 170.0); + assert_eq!(turned(180.0, 0.0), 180.0); + let mut s = Session::new(); + s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap(); + assert_eq!(s.tools.brush.angle, -15.0); + for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] { + assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.tools.brush.angle, -15.0); + } + #[test] fn commands_step_the_session_brush_and_journal() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 13af25d84..b6b86111e 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -123,6 +123,20 @@ const ENDS_TYPE_EDIT: [&str; 5] = /// The pasteboard menu's Select Custom Color… row. pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; +/// The pasteboard colours in the menu's order, which Space+F steps through. +const CANVAS_COLORS: [CanvasColor; 6] = + [CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom]; + +/// Photoshop's Space+F (⇧Space+F backwards): the next pasteboard colour, round and round. Saved +/// like a choice in the pasteboard menu (`interface.canvasColor`). +pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), String> { + let n = CANVAS_COLORS.len(); + let at = CANVAS_COLORS.iter().position(|c| *c == app.session.prefs().interface.canvas_color).unwrap_or(0); + let next = if backward { (at + n - 1) % n } else { (at + 1) % n }; + let name = CANVAS_COLORS.get(next).map_or("default", |c| c.name()); + app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ()) +} + /// The Zoom and Hand tools' context menu: the zoom steps and fixed zooms of the View menu, plus /// the Hand tool's Fill Screen. pub const VIEW_MENU: &[Row] = &[ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1ba0bce83..d6309afcc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2289,6 +2289,8 @@ mod color_replacement_tests; mod layer_shortcut_tests; #[cfg(test)] mod pencil_tests; +#[cfg(test)] +mod tool_shortcut_tests; #[cfg(test)] mod pixel_aspect_tests; diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 193a70bb5..b3e7820dd 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -175,7 +175,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::{Key, Modifiers}; let cmd = ctx.input(|i| i.modifiers.command) && crate::hold_keys::cmd_nudges(app); if app.ui.tool != Tool::Move && app.ui.transform.is_none() && !cmd { - return selection_arrow_keys(app, ctx); + return selection_arrow_keys(app, ctx) || brush_arrow_keys(app, ctx); } // `consume_key` never ignores an extra ⌘, so it is part of every pattern while it moves. let base = if cmd { Modifiers::COMMAND } else { Modifiers::NONE }; @@ -193,6 +193,30 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// ← / → with a painting tool: turn the brush tip 1° (⇧ 15°), as Photoshop does; → turns it +/// clockwise. Returns true when a key was used. +fn brush_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use egui::{Key, Modifiers}; + // Only plain or ⇧ arrows (`consume_key` ignores an extra ⌥, which kerns type), and never + // while type is being edited. + let m = ctx.input(|i| i.modifiers); + if !app.ui.tool.is_brushlike() || app.ui.text_edit.is_some() || m.alt || m.command || m.ctrl { + return false; + } + for (key, sign) in [(Key::ArrowLeft, 1.0), (Key::ArrowRight, -1.0)] { + // ⇧ first: `consume_key` ignores an extra ⇧. + for (mods, step) in [(Modifiers::SHIFT, 15.0), (Modifiers::NONE, 1.0)] { + if ctx.input_mut(|i| i.consume_key(mods, key)) { + if let Err(e) = app.run("tools.rotateBrush", json!({"degrees": sign * step})) { + app.ui.status = e; + } + return true; + } + } + } + false +} + /// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)` /// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { @@ -404,6 +428,27 @@ mod tests { arrow_keys(app, &ctx) } + /// Photoshop's ← / → with a painting tool turn the brush tip 1°, ⇧ 15°; → is clockwise. Other + /// tools keep their arrows (the Hand has none, the marquee nudges the selection). + #[test] + fn arrows_turn_the_brush_tip_for_painting_tools_only() { + let mut app = app_with_layer(); + for tool in [Tool::Brush, Tool::Eraser, Tool::CloneStamp] { + app.ui.tool = tool; + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); + assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE), "{tool:?}"); + assert_eq!(app.session.tools.brush.angle, 1.0, "{tool:?}: ← anticlockwise"); + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::SHIFT)); + assert_eq!(app.session.tools.brush.angle, -14.0, "{tool:?}: ⇧→ 15° clockwise"); + } + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); + // ⌥-arrows are not the brush's (they kern type, and ⌘ moves). + assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT)); + app.ui.tool = Tool::Hand; + assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE)); + assert_eq!(app.session.tools.brush.angle, 0.0); + } + /// Real key events: ⇧ nudges 10 px and ⌥ duplicates first (egui ignores extra ⇧ / ⌥ when /// matching, so the plain arrow must not be tried first). #[test] @@ -433,8 +478,11 @@ mod tests { app.ui.tool = Tool::Brush; let id = app.session.active().unwrap().active_layer.unwrap(); let n0 = app.session.active().unwrap().doc.layers.len(); - assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "a plain arrow is not the Brush's"); + // A plain arrow turns the Brush's tip (below), never the layer. + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24)); + assert_eq!(app.session.tools.brush.angle, -1.0); + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); for cmd in [CTRL, MAC_CMD] { assert!(press(&mut app, egui::Key::ArrowRight, cmd)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 511265e1a..7c0c8145f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -456,6 +456,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the + // Hand key is held, before F's own Cycle Screen Mode. + if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { + let back = ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::F)); + if back || ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::F)) { + if let Err(e) = crate::canvas_tool_menu::cycle_canvas_color(app, back) { + app.ui.status = e; + } + return; + } + } // Inline type editing eats text and navigation keys; ⌘-shortcuts still reach the menus. let editing = crate::type_tool::handle_keys(app, ctx); // Registry, UI and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]). diff --git a/crates/ui-egui/src/tool_shortcut_tests.rs b/crates/ui-egui/src/tool_shortcut_tests.rs new file mode 100644 index 000000000..712e7765a --- /dev/null +++ b/crates/ui-egui/src/tool_shortcut_tests.rs @@ -0,0 +1,39 @@ +//! Photoshop's tool keys that aren't plain shortcuts: Space+F cycles the pasteboard colour. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app +} + +/// Space+F steps the pasteboard colour forward and ⇧Space+F back, round and round; F alone is +/// still Cycle Screen Mode. +#[test] +fn space_f_cycles_the_canvas_colour() { + use photocraft_engine::prefs::CanvasColor; + let mut app = app(); + let ctx = egui::Context::default(); + let frame = |app: &mut PhotocraftApp, events: Vec| { + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let color = |app: &PhotocraftApp| app.session.prefs().interface.canvas_color; + frame(&mut app, vec![key(Key::Space, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::F, true, Modifiers::NONE), key(Key::F, false, Modifiers::NONE)]); + assert_eq!(color(&app), CanvasColor::Black); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), key(Key::F, true, Modifiers::SHIFT), key(Key::F, false, Modifiers::SHIFT)]); + frame(&mut app, vec![key(Key::F, true, Modifiers::SHIFT), key(Key::F, false, Modifiers::SHIFT)]); + assert_eq!(color(&app), CanvasColor::Custom, "backwards past Default wraps round"); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE), key(Key::Space, false, Modifiers::NONE)]); + let mode = app.ui.view.screen_mode.clone(); + frame(&mut app, vec![key(Key::F, true, Modifiers::NONE), key(Key::F, false, Modifiers::NONE)]); + assert_eq!(color(&app), CanvasColor::Custom, "F alone leaves the canvas colour"); + assert_ne!(app.ui.view.screen_mode, mode, "and cycles the screen mode"); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index e1e3573e0..28d4d7c56 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -251,8 +251,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } - // The Type Mask tools always start new text: existing type layers are just pixels to them. - if app.ui.tool.is_type_mask() { + // The Type Mask tools always start new text: existing type layers are just pixels to them; + // so does a ⇧-click with the Type tool, as in Photoshop, to start new text over old. + if app.ui.tool.is_type_mask() || mods.shift { return false; } if let Some(id) = hit_layer(app, x, y) { @@ -2220,6 +2221,22 @@ mod tests { current_text(app, LayerId(ed.layer)).unwrap() } + /// Photoshop: a click on existing type edits it; a ⇧-click there starts a new type layer. + #[test] + fn shift_click_on_existing_type_starts_new_text() { + let mut app = app(); + pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]); + insert(&mut app, "Existing words"); + commit(&mut app); + let existing = app.session.active().unwrap().doc.layers.last().unwrap().id; + let (x, y) = (60.0, 95.0); + assert_eq!(hit_layer(&mut app, x, y), Some(existing), "the click lands on the existing text"); + assert!(!pointer_down(&mut app, x, y, egui::Modifiers::SHIFT), "⇧: not consumed, so a new type layer starts"); + assert!(app.ui.text_edit.is_none()); + assert!(pointer_down(&mut app, x, y, egui::Modifiers::NONE), "a plain click edits the existing text"); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(existing.0)); + } + #[test] fn click_creates_placeholder_selected_and_typing_replaces_it() { let mut app = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3daee822c..76b59de71 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-11 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys) · **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, @@ -184,6 +184,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Shortcut glyphs per platform | done (UI-217-19) | | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | +| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | ## Panels and workspace @@ -217,3 +218,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | +| 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour |