Allow toolbar tool shortcuts to be remapped (#4194)

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 22:16:06 -04:00
1 parent cee010ef5a
commit 575f8eb2bc
10 files changed
+221 -8

No files matched your search

+1
View File
@@ -199,6 +199,7 @@ mod tool_cursor;
pub mod tool_feedback;
pub(crate) mod tool_memory;
mod tool_reset;
mod tool_shortcuts;
pub mod transform_fx;
pub mod transform_tex;
pub mod transform_tool;
+8
View File
@@ -161,6 +161,10 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va
/// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it.
pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
if let Some(tool) = crate::tool_shortcuts::tool(id) {
app.ui.tool = tool;
return Ok(json!({"tool": tool}));
}
if id == "ui.symmetryTransform" {
return crate::symmetry_ui::begin(app).map(|_| Value::Null);
}
@@ -660,6 +664,9 @@ fn open_path(app: &mut PhotocraftApp, path: &str) -> Result<Value, String> {
}
pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
if crate::tool_shortcuts::tool(id).is_some() {
return true;
}
if id == "ui.symmetryTransform" {
return crate::symmetry_ui::can_transform(app);
}
@@ -890,6 +897,7 @@ pub struct MenuItem {
pub fn is_live(id: &str) -> bool {
static UI_IDS: std::sync::OnceLock<std::collections::HashSet<&'static str>> = std::sync::OnceLock::new();
photocraft_engine::commands::find(id).is_some()
|| crate::tool_shortcuts::tool(id).is_some()
|| UI_IDS.get_or_init(|| UI_COMMANDS.iter().map(|c| c.0).collect()).contains(id)
|| panel_alias(id).is_some()
|| workspace_name(id).is_some()
+6 -4
View File
@@ -157,8 +157,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let key = egui::Id::new(("tool-slot", *slot_index));
let tool = slot_tool(ui, app.ui.tool, slot, key);
let sel = slot.contains(&app.ui.tool);
let tip =
if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) };
let tip = match crate::tool_shortcuts::shortcut(app, tool) {
Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)),
None => tl!(tool.label()).to_string(),
};
let resp =
icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips);
if slot.len() > 1 {
@@ -247,11 +249,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
egui::FontId::proportional(12.5),
t.text,
);
if item.key() != '\0' {
if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) {
ui.painter().text(
pos2(r.right() - 8.0, r.center().y),
Align2::RIGHT_CENTER,
item.key().to_string(),
crate::shortcuts::pretty(&sc),
egui::FontId::proportional(12.0),
t.text_dim,
);
+8
View File
@@ -765,6 +765,11 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec<String>,
let at = out.iter().rposition(|i| i.2.first().map(String::as_str) == Some("Layer")).map_or(out.len(), |i| i + 1);
out.splice(at..at, layer);
out.extend(tools);
out.extend(
crate::state::Tool::ALL
.into_iter()
.map(|tool| (crate::tool_shortcuts::id(tool), tool.label().to_string(), vec!["Tools".into()], crate::tool_shortcuts::default(tool))),
);
for (id, label, def) in prefs::TEMPORARY_TOOLS {
if seen.insert(id.to_string()) {
out.push((id.to_string(), label.to_string(), vec!["Tools".into(), "Temporary".into()], Some(def.to_string())));
@@ -1670,6 +1675,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String
let mut last_top = String::new();
egui::Grid::new("shortcut-grid").num_columns(3).spacing([12.0, 3.0]).striped(true).show(ui, |ui| {
for (id, label, path, def) in &items {
if tab == 1 && crate::tool_shortcuts::tool(id).is_some() {
continue;
}
let cur = eff(&overrides, id, def);
let (shown_path, shown_label) = shown(id, label, path);
// Match what is shown and the English name (commands are documented in English).
@@ -32,6 +32,94 @@ fn field(h: &Harness<'_, PhotocraftApp>, id: u64, name: &str) -> Value {
h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null)
}
#[test]
fn toolbar_shortcut_can_be_captured_and_restored_on_restart() {
let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool");
h.get_by_role_and_label(egui::accesskit::Role::Button, "B").click();
h.run_steps(2);
h.key_press(Key::F6);
h.run_steps(2);
assert_eq!(field(&h, id, "overrides")["tools.select.pencil"], json!("F6"));
crate::dialogs::confirm(h.state_mut(), id).unwrap();
h.run_steps(2);
h.key_press(Key::F6);
h.run_steps(2);
assert_eq!(h.state().ui.tool, crate::state::Tool::Pencil);
let saved = h.state().session.prefs_to_json();
let mut session = photocraft_engine::Session::new();
session.load_prefs_json(&saved).unwrap();
let mut restarted = PhotocraftApp::new(session, crate::Services::default());
let ctx = egui::Context::default();
ctx.begin_pass(egui::RawInput {
events: vec![Event::Key { key: Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
..Default::default()
});
crate::shortcuts::handle(&mut restarted, &ctx);
ctx.end_pass().textures_delta.clear();
assert_eq!(restarted.ui.tool, crate::state::Tool::Pencil);
}
#[test]
fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() {
use crate::state::Tool;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let press = |app: &mut PhotocraftApp, key, modifiers| {
ctx.begin_pass(egui::RawInput {
// A tap: press and release, so the next press isn't a held key's repeat.
events: vec![
Event::ModifiersChanged(modifiers),
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
],
..Default::default()
});
crate::shortcuts::handle(app, &ctx);
ctx.end_pass().textures_delta.clear();
};
app.run("edit.keyboardShortcuts", json!({"set": {"tools.select.move": "F6", "tools.select.pencil": ""}, "allowUnknown": true})).unwrap();
app.ui.tool = Tool::Zoom;
press(&mut app, Key::V, Modifiers::NONE);
assert_eq!(app.ui.tool, Tool::Artboard, "the old key only selects unchanged tools");
press(&mut app, Key::F6, Modifiers::NONE);
assert_eq!(app.ui.tool, Tool::Move);
app.ui.tool = Tool::Pencil;
press(&mut app, Key::V, Modifiers::NONE);
press(&mut app, Key::B, Modifiers::NONE);
assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory");
press(&mut app, Key::B, Modifiers::SHIFT);
assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool");
app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap();
app.ui.tool = Tool::Brush;
press(&mut app, Key::B, Modifiers::SHIFT);
assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group");
app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false);
press(&mut app, Key::B, Modifiers::NONE);
assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference");
}
#[test]
fn toolbar_shortcuts_participate_in_conflict_resolution() {
let (mut h, id) = dialog(1.0, "edit.search", "search…");
capture(&mut h, &crate::shortcuts::pretty("Cmd+F"), Key::ControlLeft, Modifiers::COMMAND, Key::B);
// Remove Ctrl from the captured binding: a plain B belongs to the Brush group by default.
h.state_mut().ui.dialog_mut(id).unwrap().fields["overrides"]["edit.search"] = json!("B");
crate::dialogs::confirm(h.state_mut(), id).unwrap();
for tool in ["brush", "pencil", "mixerBrush", "colorReplacement"] {
assert_eq!(h.state().session.prefs().shortcuts.get(&format!("tools.select.{tool}")).map(String::as_str), Some(""));
}
let ctx = h.ctx.clone();
h.state_mut().ui.tool = crate::state::Tool::Move;
key(&mut h, Key::B, true, Modifiers::NONE);
h.run_steps(2);
assert!(h.state().ui.palette_open, "B now runs Search");
assert_eq!(h.state().ui.tool, crate::state::Tool::Move);
assert_eq!(crate::shortcut_dispatch::take_log(&ctx).last().map(|i| i.0.as_str()), Some("edit.search"));
}
/// Click the selected command's shortcut button, then press `modifier` and `k` like a keyboard:
/// the modifier key goes down first (its own key event), then the key with the modifier held.
fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held: Modifiers, k: Key) {
+1
View File
@@ -302,6 +302,7 @@ fn label(id: &str) -> String {
.map(|c| c.1)
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1))
.or_else(|| photocraft_engine::commands::find(id).map(|c| c.label))
.or_else(|| crate::tool_shortcuts::tool(id).map(|tool| tool.label()))
.unwrap_or(id)
.trim_end_matches('…')
.to_string()
+6 -2
View File
@@ -108,6 +108,7 @@ pub fn default_shortcut(id: &str) -> Option<String> {
.or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).and_then(|c| c.2))
.or_else(|| photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|t| t.0 == id).map(|t| t.2))
.map(str::to_string)
.or_else(|| crate::tool_shortcuts::tool(id).and_then(crate::tool_shortcuts::default))
}
/// With ⇧ held the OS reports the shifted character as the logical key (US layout): ⇧; is `:`.
@@ -580,15 +581,18 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key))));
let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch;
for t in Tool::ALL {
if !crate::tool_shortcuts::uses_default(app, t) {
continue;
}
let Some(k) = crate::spring_tools::key_of(t.key()) else { continue };
let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k));
if cycle_shift || pressed(k) {
let before = app.ui.tool;
let group: Vec<Tool> = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect();
let group: Vec<Tool> = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect();
app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) {
Some(i) if shift_switch && !cycle_shift => group[i],
Some(i) => group[(i + 1) % group.len()],
None => app.ui.group_tool(t.key()).unwrap_or(group[0]),
None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]),
};
// `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded.
let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time));
+96
View File
@@ -0,0 +1,96 @@
//! Toolbar tools in Edit › Keyboard Shortcuts (#4086). Explicit assignments select an
//! individual tool; unchanged tools keep Photoshop's shared keys and Shift cycling.
use crate::{PhotocraftApp, state::Tool};
pub(crate) fn id(tool: Tool) -> String {
let mut name = format!("{tool:?}");
if let Some(first) = name.get_mut(..1) {
first.make_ascii_lowercase();
}
format!("tools.select.{name}")
}
pub(crate) fn tool(id: &str) -> Option<Tool> {
let name = id.strip_prefix("tools.select.")?;
let tool = Tool::from_name(name)?;
(id == self::id(tool)).then_some(tool)
}
pub(crate) fn default(tool: Tool) -> Option<String> {
(tool.key() != '\0').then(|| tool.key().to_string())
}
pub(crate) fn shortcut(app: &PhotocraftApp, tool: Tool) -> Option<String> {
crate::shortcuts::effective_shortcut(app, &id(tool), default(tool).as_deref())
}
pub(crate) fn uses_default(app: &PhotocraftApp, tool: Tool) -> bool {
!app.session.prefs().shortcuts.contains_key(&id(tool))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn every_toolbar_tool_has_a_unique_live_shortcut_row() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
let items = crate::prefs_ui::shortcut_items(&app);
let mut ids = std::collections::HashSet::new();
for t in Tool::ALL {
let id = id(t);
assert!(ids.insert(id.clone()));
assert_eq!(tool(&id), Some(t));
assert!(crate::menus::is_live(&id));
assert!(crate::menus::is_enabled(&app, &id));
let rows: Vec<_> = items.iter().filter(|i| i.0 == id).collect();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].1, t.label());
assert_eq!(rows[0].2, ["Tools"]);
assert_eq!(rows[0].3, default(t));
assert_eq!(crate::shortcuts::default_shortcut(&id), default(t));
crate::menus::invoke(&mut app, &ctx, &id, json!({})).unwrap();
assert_eq!(app.ui.tool, t);
}
for id in ["tools.select.", "tools.select.missing", "tools.select.Brush"] {
assert!(!crate::menus::is_live(id));
assert!(crate::menus::invoke(&mut app, &ctx, id, json!({})).is_err());
}
}
#[test]
fn toolbar_hints_follow_assignments_removal_and_reset() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B"));
assert_eq!(shortcut(&app, Tool::SingleRowMarquee), None);
app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "F6"})).unwrap();
assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("F6"));
app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": ""})).unwrap();
assert_eq!(shortcut(&app, Tool::Pencil), None);
app.run("prefs.reset", json!({"path": "shortcuts.tools.select.pencil"})).unwrap();
assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B"));
}
#[test]
fn a_remapped_tool_letter_does_not_steal_text_input() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "Q"})).unwrap();
let mut h = egui_kittest::Harness::builder().build_ui_state(
|ui, app: &mut PhotocraftApp| {
ui.text_edit_singleline(&mut app.ui.status).request_focus();
crate::shortcuts::handle(app, ui.ctx());
},
app,
);
h.run_steps(3);
assert!(h.ctx.text_edit_focused());
h.key_press(egui::Key::Q);
h.event(egui::Event::Text("q".into()));
h.run_steps(2);
assert_eq!(h.state().ui.tool, Tool::Brush);
assert_eq!(h.state().ui.status, "q");
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
@@ -170,6 +170,8 @@ what an agent can't finish alone.
- **Estimate:** 60–110 h.
### G9. Panels, preferences and workspace
- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086),
with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`).
- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't
float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and
slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard
@@ -240,6 +242,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G9: toolbar tool shortcut editing (#4086); the remaining workspace gaps stay open |
| 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed |
| 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) |
| 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **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,
@@ -183,6 +183,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme
| Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here |
| `.kys` import | done (`engine/src/kys.rs`) | |
| Shortcut glyphs per platform | done (UI-217-19) | |
| Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` |
| 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) | |
@@ -224,3 +225,4 @@ Paragraph; the rest open as in-app floating windows.
| 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release |
| 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse |
| 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) |
| 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions |