Brush size and hardness keys are remappable in Keyboard Shortcuts (#626) (#828)

[ ] and Shift+[ Shift+] are now commands (tools.decreaseBrushSize,
tools.increaseBrushSize, tools.decreaseBrushHardness,
tools.increaseBrushHardness), listed under Tools in Edit > Keyboard
Shortcuts as in Photoshop, so they can be rebound, recorded and driven
over MCP/CLI. The shell dispatches them through the shortcut table
instead of hard-coding the bracket keys.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
BFlat 2026-10-08 08:49:00 +07:00 committed by GitHub
parent 52ccb8a800
commit 5cd50d50ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 132 additions and 36 deletions

View File

@ -616,7 +616,7 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "name": name, "width": tw, "height": th }))
}
fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "tools.setBrush";
let mut b = s.tools.brush.clone();
if let Some(name) = p.get("preset").and_then(Value::as_str) {

View File

@ -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<Value> {
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<Value> {
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<CommandSpec> {
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+]"));
}
}

View File

@ -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<CommandSpec> {
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());

View File

@ -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;

View File

@ -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()
}

View File

@ -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 {

View File

@ -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<std::cell::RefCell<Vec<Vec<u8>>>>;

View File

@ -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);
}