mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
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:
1 parent
aa4d4859b4
commit
8d49ecf50d
8 files changed
+185
-6
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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] = &[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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 |
|
||||
Reference in new issue
Block a user