[ ] 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:
parent
52ccb8a800
commit
5cd50d50ec
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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+]"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>>>>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue