mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-08 03:23:11 +00:00
W: Keyboard Shortcuts editor (window.setShortcut), macOS modifier order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bfbbe7187
commit
0f86308791
5 files changed
+210
-24
No files matched your search
@@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
|
||||
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`.
|
||||
- Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type).
|
||||
- Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.
|
||||
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
|
||||
|
||||
@@ -102,6 +102,80 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Keyboard Shortcuts: every command and its shortcut; click a shortcut, then press the
|
||||
/// new keys (Esc cancels, Backspace clears).
|
||||
fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let mut q = d.s("query");
|
||||
ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands").desired_width(f32::INFINITY));
|
||||
d.fields.insert("query".into(), json!(q));
|
||||
let recording = d.s("recording");
|
||||
// Capture the next key press for the command being recorded.
|
||||
if !recording.is_empty() {
|
||||
let pressed = ui.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some((key, m)) = pressed {
|
||||
let sc = match key {
|
||||
egui::Key::Escape => None,
|
||||
egui::Key::Backspace if !m.any() => Some(String::new()),
|
||||
_ => Some(crate::menus::shortcut_string(m, key)),
|
||||
};
|
||||
if let Some(sc) = sc {
|
||||
let msg = match app.run("window.setShortcut", json!({"id": recording, "shortcut": sc})) {
|
||||
Ok(v) => match v["conflicts"].as_array().filter(|c| !c.is_empty()) {
|
||||
Some(c) => format!("Also used by: {}", c.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", ")),
|
||||
None => String::new(),
|
||||
},
|
||||
Err(e) => e,
|
||||
};
|
||||
d.fields.insert("message".into(), json!(msg));
|
||||
}
|
||||
d.fields.insert("recording".into(), json!(""));
|
||||
}
|
||||
}
|
||||
let ql = q.to_lowercase();
|
||||
let mut rows: Vec<(String, String, String)> = designcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| !c.menu.is_empty() || c.shortcut.is_some())
|
||||
.map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › ")))
|
||||
.chain(crate::menus::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), String::new())))
|
||||
.filter(|(id, l, _)| ql.is_empty() || l.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql))
|
||||
.collect();
|
||||
rows.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").num_columns(3).striped(true).spacing([12.0, 4.0]).show(ui, |ui| {
|
||||
for (id, label, menu) in &rows {
|
||||
ui.label(label.trim_end_matches('…'))
|
||||
.on_hover_text(format!("{id}{}", if menu.is_empty() { String::new() } else { format!(" ({menu})") }));
|
||||
let cur = crate::menus::shortcut_of(app, id).map(|s| crate::menus::shortcut_text(&s)).unwrap_or_else(|| "—".into());
|
||||
let text = if recording == *id { "Press keys…".to_string() } else { cur };
|
||||
if ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0))).clicked() {
|
||||
d.fields.insert("recording".into(), json!(id));
|
||||
d.fields.insert("message".into(), json!(""));
|
||||
}
|
||||
if app.ui.shortcuts.contains_key(id) {
|
||||
if ui.small_button("Default").clicked() {
|
||||
let _ = app.run("window.setShortcut", json!({"id": id, "shortcut": null}));
|
||||
}
|
||||
} else {
|
||||
ui.label("");
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
let msg = d.s("message");
|
||||
if !msg.is_empty() {
|
||||
ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60)));
|
||||
}
|
||||
if ui.button("Reset All to Defaults").clicked() {
|
||||
let _ = app.run("window.resetShortcuts", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Document Setup, filled from the document.
|
||||
pub fn open_document_setup(app: &mut DesignApp) {
|
||||
let Ok(cur) = app.session.execute("layout.documentSetup", &json!({})) else { return };
|
||||
@@ -406,6 +480,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"findFont" => "Find/Replace Font",
|
||||
"polygonSettings" => "Polygon Settings",
|
||||
"preferences" => "Preferences",
|
||||
"keyboardShortcuts" => "Keyboard Shortcuts",
|
||||
"colorPicker" => "Color Picker",
|
||||
id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) {
|
||||
Some(c) => c.label.trim_end_matches('…'),
|
||||
@@ -543,6 +618,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"findFont" => find_font(app, ui, &mut d),
|
||||
"colorPicker" => color_picker(ui, &mut d),
|
||||
"preferences" => preferences(ui, &mut d),
|
||||
"keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d),
|
||||
"polygonSettings" => {
|
||||
egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
ui.label("Number of Sides:");
|
||||
|
||||
@@ -87,6 +87,8 @@ pub struct UiState {
|
||||
pub display_quality: designcraft_render::DisplayQuality,
|
||||
/// Preferences › Interface › UI scaling (1 = 100%).
|
||||
pub ui_scale: f32,
|
||||
/// Edit › Keyboard Shortcuts: command id → shortcut ("" = none), over the defaults.
|
||||
pub shortcuts: std::collections::BTreeMap<String, String>,
|
||||
pub control_bar: bool,
|
||||
pub tools_double_column: bool,
|
||||
/// Expanded right-dock panel group tab.
|
||||
@@ -136,6 +138,7 @@ impl Default for UiState {
|
||||
hidden_characters: false,
|
||||
display_quality: designcraft_render::DisplayQuality::High,
|
||||
ui_scale: 1.0,
|
||||
shortcuts: Default::default(),
|
||||
control_bar: false,
|
||||
tools_double_column: false,
|
||||
dock_tab: "properties".into(),
|
||||
|
||||
+127
-21
@@ -21,6 +21,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+K"), "{}"),
|
||||
("window.uiScale", "UI Scaling", None, "{scale: 0.5–3 (1 = 100%)} — the size of the whole interface"),
|
||||
("app.keyboardShortcuts", "Keyboard Shortcuts…", None, "{} — view and change the shortcut of any command"),
|
||||
("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"),
|
||||
("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"),
|
||||
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
|
||||
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
|
||||
("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"),
|
||||
@@ -135,6 +138,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"<",
|
||||
"-",
|
||||
"ui:app.palette",
|
||||
"ui:app.keyboardShortcuts",
|
||||
"ui:app.preferences",
|
||||
],
|
||||
),
|
||||
@@ -709,6 +713,39 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.keyboardShortcuts" => {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.setShortcut" => {
|
||||
let Some(cmd) = p.get("id").and_then(Value::as_str) else { return Some(Err("missing id".into())) };
|
||||
if designcraft_engine::find_command(cmd).is_none() && ui_label(cmd).is_none() {
|
||||
return Some(Err(format!("unknown command `{cmd}`")));
|
||||
}
|
||||
match p.get("shortcut") {
|
||||
None | Some(Value::Null) => {
|
||||
app.ui.shortcuts.remove(cmd);
|
||||
}
|
||||
Some(Value::String(sc)) if sc.is_empty() => {
|
||||
app.ui.shortcuts.insert(cmd.to_string(), String::new());
|
||||
}
|
||||
Some(Value::String(sc)) => {
|
||||
if parse_shortcut(sc).is_none() {
|
||||
return Some(Err(format!("can't read shortcut `{sc}`")));
|
||||
}
|
||||
app.ui.shortcuts.insert(cmd.to_string(), sc.clone());
|
||||
}
|
||||
Some(_) => return Some(Err("shortcut: a string or null".into())),
|
||||
}
|
||||
let sc = shortcut_of(app, cmd);
|
||||
let conflicts: Vec<String> =
|
||||
effective_shortcuts(app).into_iter().filter(|(i, s)| i != cmd && Some(s) == sc.as_ref()).map(|(i, _)| i).collect();
|
||||
Ok(json!({"shortcut": sc, "conflicts": conflicts}))
|
||||
}
|
||||
"window.resetShortcuts" => {
|
||||
app.ui.shortcuts.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.uiScale" => {
|
||||
let v = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0) as f32;
|
||||
app.ui.ui_scale = v.clamp(0.5, 3.0);
|
||||
@@ -868,18 +905,65 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut_text(sc: &str) -> String {
|
||||
let mac = cfg!(target_os = "macos");
|
||||
sc.split('+')
|
||||
.map(|k| match k {
|
||||
"Cmd" => if mac { "⌘" } else { "Ctrl+" }.to_string(),
|
||||
"Shift" => if mac { "⇧" } else { "Shift+" }.to_string(),
|
||||
"Alt" => if mac { "⌥" } else { "Alt+" }.to_string(),
|
||||
k => k.to_string(),
|
||||
})
|
||||
/// The default shortcut of a command.
|
||||
pub fn default_shortcut(id: &str) -> Option<&'static str> {
|
||||
designcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| ui_label(id).and_then(|(_, s)| s))
|
||||
}
|
||||
|
||||
/// The shortcut in effect for a command (the user's, else the default).
|
||||
pub fn shortcut_of(app: &DesignApp, id: &str) -> Option<String> {
|
||||
match app.ui.shortcuts.get(id) {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s.clone()),
|
||||
None => default_shortcut(id).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command with a shortcut in effect: (id, shortcut).
|
||||
pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> {
|
||||
designcraft_engine::command_specs()
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.chain(UI_COMMANDS.iter().map(|c| c.0))
|
||||
.filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), s)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A shortcut as written in this app ("Cmd+Alt+Shift+K") from a key press.
|
||||
pub fn shortcut_string(m: egui::Modifiers, key: egui::Key) -> String {
|
||||
let mut s = String::new();
|
||||
if m.command {
|
||||
s += "Cmd+";
|
||||
}
|
||||
if m.alt {
|
||||
s += "Alt+";
|
||||
}
|
||||
if m.shift {
|
||||
s += "Shift+";
|
||||
}
|
||||
s += key.name();
|
||||
s
|
||||
}
|
||||
|
||||
/// A shortcut for display: ⌃⌥⇧⌘ order on macOS, Ctrl+Alt+Shift+ elsewhere.
|
||||
pub fn shortcut_text(sc: &str) -> String {
|
||||
let mac = cfg!(target_os = "macos");
|
||||
// The key is the last part ("Cmd+-" ends in an empty part before "-").
|
||||
let (mods, key) = match sc.rsplit_once('+') {
|
||||
Some((m, "")) => (m.trim_end_matches('+'), "+"),
|
||||
Some((m, k)) => (m, k),
|
||||
None => ("", sc),
|
||||
};
|
||||
let has = |m: &str| mods.split('+').any(|x| x == m);
|
||||
let mut out = String::new();
|
||||
for (m, sym, word) in [("Ctrl", "⌃", "Ctrl+"), ("Alt", "⌥", "Alt+"), ("Shift", "⇧", "Shift+"), ("Cmd", "⌘", "Ctrl+")] {
|
||||
if has(m) && !(m == "Cmd" && !mac && has("Ctrl")) {
|
||||
out += if mac { sym } else { word };
|
||||
}
|
||||
}
|
||||
out + key
|
||||
}
|
||||
|
||||
/// A menu entry, shared by the in-window menu bar and the native macOS menu.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Item {
|
||||
@@ -1036,15 +1120,15 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item]) {
|
||||
Item::Sub(name, children) => {
|
||||
ui.menu_button(name, |ui| menu_items(app, ui, children));
|
||||
}
|
||||
Item::Cmd { label, id, params, shortcut } => {
|
||||
Item::Cmd { label, id, params, shortcut: _ } => {
|
||||
let text = match checked(app, id, params) {
|
||||
Some(true) => format!("✓ {label}"),
|
||||
Some(false) => format!(" {label}"),
|
||||
None => label.clone(),
|
||||
};
|
||||
let mut b = egui::Button::new(text);
|
||||
if let Some(sc) = shortcut {
|
||||
b = b.shortcut_text(shortcut_text(sc));
|
||||
if let Some(sc) = shortcut_of(app, id) {
|
||||
b = b.shortcut_text(shortcut_text(&sc));
|
||||
}
|
||||
if ui.add_enabled(menu_enabled(app, id), b).clicked() {
|
||||
activate(app, id, params);
|
||||
@@ -1093,12 +1177,10 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
for e in events {
|
||||
let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue };
|
||||
// Command shortcuts.
|
||||
let mut fired = None;
|
||||
let all = designcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter_map(|c| c.shortcut.map(|s| (c.id, s)))
|
||||
.chain(UI_COMMANDS.iter().filter_map(|c| c.2.map(|s| (c.0, s))));
|
||||
for (id, sc) in all {
|
||||
let mut fired: Option<String> = None;
|
||||
let all = effective_shortcuts(app);
|
||||
for (id, sc) in &all {
|
||||
let id = id.as_str();
|
||||
if let Some((m, k)) = parse_shortcut(sc)
|
||||
&& k == key
|
||||
&& m.command == modifiers.command
|
||||
@@ -1114,15 +1196,15 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) {
|
||||
continue; // Handled by the active tool / text clipboard events.
|
||||
}
|
||||
fired = Some(id);
|
||||
fired = Some(id.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(id) = fired {
|
||||
if app.native_shortcuts.contains(id) {
|
||||
if app.native_shortcuts.contains(&id) && !app.ui.shortcuts.contains_key(&id) {
|
||||
continue; // The native menu handles it.
|
||||
}
|
||||
let _ = app.run(id, json!({}));
|
||||
let _ = app.run(&id, json!({}));
|
||||
continue;
|
||||
}
|
||||
// Tool shortcuts (single keys, Shift+key).
|
||||
@@ -1239,6 +1321,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_shortcuts_override_defaults() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
assert_eq!(shortcut_of(&app, "app.preferences").as_deref(), Some("Cmd+K"));
|
||||
let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "app.preferences", "shortcut": "Cmd+Alt+Shift+9"})).unwrap().unwrap();
|
||||
assert_eq!(r["shortcut"], "Cmd+Alt+Shift+9");
|
||||
assert!(effective_shortcuts(&app).contains(&("app.preferences".into(), "Cmd+Alt+Shift+9".into())));
|
||||
// Taking another command's keys reports the conflict.
|
||||
let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+Shift+9"})).unwrap().unwrap();
|
||||
assert_eq!(r["conflicts"], json!(["app.preferences"]));
|
||||
run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": ""})).unwrap().unwrap();
|
||||
assert_eq!(shortcut_of(&app, "app.palette"), None);
|
||||
assert!(run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Nope"})).unwrap().is_err());
|
||||
run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
|
||||
assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K");
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(shortcut_text("Cmd+Alt+3"), "⌥⌘3");
|
||||
assert_eq!(shortcut_text("Cmd+Shift+."), "⇧⌘.");
|
||||
} else {
|
||||
assert_eq!(shortcut_text("Cmd+Alt+3"), "Alt+Ctrl+3");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_float_and_dock() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
+3
-3
@@ -10,12 +10,12 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 102 | 99 | 3 | 0 | 99% |
|
||||
| P1 | 97 | 51 | 23 | 23 | 64% |
|
||||
| P1 | 97 | 52 | 23 | 22 | 65% |
|
||||
| P2 | 76 | 8 | 7 | 61 | 15% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell & workspace | 74% |
|
||||
| Application shell & workspace | 80% |
|
||||
| Documents, pages, spreads | 74% |
|
||||
| Layers | 95% |
|
||||
| Frames, shapes & paths | 85% |
|
||||
@@ -51,7 +51,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
|
||||
| Application shell & workspace | Contextual task bar | P2 | D |
|
||||
| Application shell & workspace | Themes | P0 | D |
|
||||
| Application shell & workspace | UI scaling | P1 | D |
|
||||
| Application shell & workspace | Keyboard shortcut editor | P1 | M |
|
||||
| Application shell & workspace | Keyboard shortcut editor | P1 | D |
|
||||
| Application shell & workspace | Menu customization | P2 | M |
|
||||
| Application shell & workspace | Show Full Menus | P2 | M |
|
||||
| Application shell & workspace | Recovery / autosave | P0 | D |
|
||||
|
||||
Reference in new issue
Block a user