diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index ca59e416..d37af949 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -616,7 +616,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name, "width": tw, "height": th })) } -fn set_brush(s: &mut Session, p: &Value) -> Result { +pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result { let cmd = "tools.setBrush"; let mut b = s.tools.brush.clone(); if let Some(name) = p.get("preset").and_then(Value::as_str) { diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs new file mode 100644 index 00000000..e4f20828 --- /dev/null +++ b/crates/engine/src/brush_key_cmds.rs @@ -0,0 +1,87 @@ +//! Photoshop's brush keys (#626): `[` / `]` step the brush size and ⇧[ / ⇧] the hardness. Photoshop +//! 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. + +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always}; +use crate::{Result, Session}; + +/// Photoshop's largest brush diameter, in pixels. +pub const MAX_SIZE: f32 = 5000.0; + +/// `[` / `]`: 25 % smaller or larger, at least a pixel larger so small brushes still grow. +fn size_step(size: f32, up: bool) -> f32 { + let next = if up { (size * 1.25).round().max(size + 1.0) } else { (size / 1.25).round() }; + next.clamp(1.0, MAX_SIZE) +} + +/// ⇧[ / ⇧]: to the previous or next quarter (0, 25, 50, 75, 100 %). +fn hardness_step(hardness: f32, up: bool) -> f32 { + let quarter = (hardness * 4.0).round(); + let next = if up { quarter + 1.0 } else { quarter - 1.0 }; + next.clamp(0.0, 4.0) / 4.0 +} + +fn size(s: &mut Session, up: bool) -> Result { + let size = size_step(s.tools.brush.size, up); + crate::brush_cmds::set_brush(s, &json!({ "brush": { "size": size } })) +} + +fn hardness(s: &mut Session, up: bool) -> Result { + let hardness = hardness_step(s.tools.brush.hardness, up); + crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } })) +} + +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 } + }; +} + +pub fn specs() -> Vec { + vec![ + key_cmd!("tools.decreaseBrushSize", "Decrease Brush Size", "[", |s, _| size(s, false)), + 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)), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps_like_photoshop() { + assert_eq!(size_step(1.0, false), 1.0, "never below 1 px"); + assert_eq!(size_step(1.0, true), 2.0, "small brushes still grow"); + assert_eq!(size_step(100.0, true), 125.0); + assert_eq!(size_step(125.0, false), 100.0); + assert_eq!(size_step(MAX_SIZE, true), MAX_SIZE, "never past the largest brush"); + assert_eq!(hardness_step(0.5, true), 0.75); + assert_eq!(hardness_step(0.6, false), 0.25, "snaps to the quarter below the nearest"); + assert_eq!(hardness_step(1.0, true), 1.0); + assert_eq!(hardness_step(0.0, false), 0.0); + } + + #[test] + fn commands_step_the_session_brush_and_journal() { + let mut s = Session::new(); + s.execute("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.5}})).unwrap(); + s.execute("tools.increaseBrushSize", json!({})).unwrap(); + assert_eq!(s.tools.brush.size, 50.0); + s.execute("tools.decreaseBrushSize", Value::Null).unwrap(); + assert_eq!(s.tools.brush.size, 40.0); + s.execute("tools.increaseBrushHardness", json!({})).unwrap(); + assert_eq!(s.tools.brush.hardness, 0.75); + s.execute("tools.decreaseBrushHardness", json!({"junk": [1, 2]})).unwrap(); + assert_eq!(s.tools.brush.hardness, 0.5); + assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("tools.decreaseBrushHardness")); + // They need no document and are bindable under Tools. + let sc = |id| crate::commands::find(id).and_then(|c| c.shortcut); + assert_eq!(sc("tools.decreaseBrushSize"), Some("[")); + assert_eq!(sc("tools.increaseBrushHardness"), Some("Shift+]")); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 102b7765..a921a7d1 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -27,7 +27,7 @@ pub struct CommandSpec { pub journal: bool, } -fn always(_: &Session) -> std::result::Result<(), String> { +pub(crate) fn always(_: &Session) -> std::result::Result<(), String> { Ok(()) } fn has_doc(s: &Session) -> std::result::Result<(), String> { @@ -1007,6 +1007,7 @@ fn build() -> Vec { v.extend(crate::prefs::specs()); v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); + v.extend(crate::brush_key_cmds::specs()); v.extend(crate::stamp_cmds::specs()); v.extend(crate::align_cmds::specs()); v.extend(crate::photo_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 71850cd7..82fec672 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod analysis_cmds; pub mod artboard_cmds; pub mod automate_cmds; pub mod brush_cmds; +pub mod brush_key_cmds; pub mod brush_preset_cmds; pub mod build_info; mod canvas_geom; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 28d26991..bccdf736 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -503,6 +503,8 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { .and_then(|s| s.active_layer.and_then(|id| s.doc.layer(id))) .is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Text(_))), "select.transformSelection" => app.ui.transform.is_none() && app.session.is_enabled("select.transformSelection"), + // ⇧[ / ⇧] only step the hardness of a tool that paints with the brush tip, as in Photoshop. + "tools.decreaseBrushHardness" | "tools.increaseBrushHardness" => app.ui.tool.is_brushlike(), i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => { app.session.active().is_some() } diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index ad8c809f..5e406f35 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -201,9 +201,11 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc /// Why `id` can't run now (the engine's reason when it has one). pub fn disabled_reason(app: &PhotocraftApp, id: &str) -> String { - photocraft_engine::commands::find(id) - .and_then(|_| app.session.disabled_reason(id)) - .unwrap_or_else(|| if app.session.active().is_none() { "no document open".into() } else { "not available in the current state".into() }) + photocraft_engine::commands::find(id).and_then(|_| app.session.disabled_reason(id)).unwrap_or_else(|| match id { + "tools.decreaseBrushHardness" | "tools.increaseBrushHardness" => "the current tool has no brush tip".into(), + _ if app.session.active().is_none() => "no document open".into(), + _ => "not available in the current state".into(), + }) } fn label(id: &str) -> String { diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index a3863342..98768b1e 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -313,6 +313,37 @@ fn alt_brackets_walk_the_layers_panel() { assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2, "⇧⌥[ adds to the selection"); } +/// #626: [ ] ⇧[ ⇧] are commands listed under Tools in Edit › Keyboard Shortcuts, so they rebind. +#[test] +fn brush_keys_can_be_rebound() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + let items = crate::prefs_ui::shortcut_items(h.state()); + let listed = |id: &str| items.iter().find(|i| i.0 == id).map(|i| (i.1.clone(), i.2.clone(), i.3.clone())); + assert_eq!(listed("tools.decreaseBrushSize"), Some(("Decrease Brush Size".into(), vec!["Tools".into()], Some("[".into())))); + assert_eq!(listed("tools.increaseBrushHardness").and_then(|i| i.2), Some("Shift+]".into())); + let app = h.state_mut(); + app.ui.tool = Tool::Brush; + app.run("tools.setBrush", json!({"brush": {"size": 40, "hardness": 0.5}})).unwrap(); + app.run("edit.keyboardShortcuts", json!({"set": {"tools.increaseBrushSize": "Alt+W", "tools.increaseBrushHardness": "Alt+Shift+W"}})).unwrap(); + let brush = |h: &Harness<'_, PhotocraftApp>| (h.state().session.tools.brush.size, h.state().session.tools.brush.hardness); + press(&mut h, "]"); + press(&mut h, "Shift+]"); + assert_eq!(brush(&h), (40.0, 0.5), "the old keys are free"); + press(&mut h, "Alt+W"); + press(&mut h, "Alt+Shift+W"); + assert_eq!(brush(&h), (50.0, 0.75), "the new keys step size and hardness"); + press(&mut h, "["); + press(&mut h, "Shift+["); + assert_eq!(brush(&h), (40.0, 0.5), "the others keep their defaults"); + // Hardness only steps for a tool with a brush tip; the press says why it did nothing. + h.state_mut().ui.tool = Tool::Move; + take_log(&h.ctx); + press(&mut h, "Alt+Shift+W"); + assert_eq!(brush(&h), (40.0, 0.5)); + assert!(matches!(take_log(&h.ctx).as_slice(), [(id, Outcome::Disabled(_))] if id == "tools.increaseBrushHardness")); +} + /// Bytes the app saved, newest last. type Saved = std::rc::Rc>>>; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cbafd9df..078c9f3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -305,8 +305,9 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { if editing { return; } - // Single-key tools (no modifiers). D and X are commands (`tools.defaultColors` / - // `tools.swapColors`), dispatched above with any Keyboard Shortcuts override. + // Single-key tools (no modifiers). D and X (`tools.defaultColors` / `tools.swapColors`) and the + // brush keys [ ] ⇧[ ⇧] (`tools.decreaseBrushSize`…) are commands, dispatched above with any + // Keyboard Shortcuts override. let pressed = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if crate::lasso_ui::active(app) { @@ -354,35 +355,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - // ⇧[ and ⇧] step a painting tool's hardness by 25% (#352). First: egui's `consume_key` - // ignores ⇧, so the size keys below would take them. - if app.ui.tool.is_brushlike() { - let hardness = app.session.tools.brush.hardness; - let step = |sc: &str| parse(sc).is_some_and(|sc| consume(ctx, &sc)); - let quarter = (hardness * 4.0).round(); - let next = if step("Shift+[") { - (quarter - 1.0).max(0.0) / 4.0 - } else if step("Shift+]") { - (quarter + 1.0).min(4.0) / 4.0 - } else { - hardness - }; - if next != hardness { - let _ = app.run("tools.setBrush", serde_json::json!({ "brush": { "hardness": next } })); - } - } - // [ and ] resize the brush through `tools.setBrush` (journaled, drivable). - let size = app.session.tools.brush.size; - let next = if pressed(Key::OpenBracket) { - (size / 1.25).max(1.0).round() - } else if pressed(Key::CloseBracket) { - (size * 1.25).min(2500.0).round().max(size + 1.0) - } else { - size - }; - if next != size { - let _ = app.run("tools.setBrush", serde_json::json!({ "brush": { "size": next } })); - } // 1–0 set opacity, ⇧1–0 flow or fill (`opacity_keys`). crate::opacity_keys::handle(app, ctx); }