feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725)

* feat: Left and Right arrows turn the brush tip

With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): Shift-click with the Type tool starts new text over existing type

In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ui): Space+F cycles the canvas colour

Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
FinnNkandClaude Opus 5.5 authored and GitHub committed 2026-10-12 09:17:43 -04:00
1 parent aa4d4859b4
commit 8d49ecf50d
8 files changed
+185 -6

No files matched your search

+47 -1
View File
@@ -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<Value> {
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<Value> {
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<CommandSpec> {
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();
+14
View File
@@ -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] = &[
+2
View File
@@ -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;
+50 -2
View File
@@ -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));
}
+11
View File
@@ -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`]).
+39
View File
@@ -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<egui::Event>| {
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");
}
+19 -2
View File
@@ -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();
+3 -1
View File
@@ -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 |