mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
Merge pull request #1298 from aiongg/keymap-adoption
Keyboard shortcuts on the shared craft-keymap crate: panel shortcuts, editable tool keys
This commit is contained in:
Generated
+11
@@ -1013,6 +1013,16 @@ name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1538,6 +1548,7 @@ dependencies = [
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-prepress",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
|
||||
@@ -13,6 +13,8 @@ repository = "https://github.com/storytold/designcraft"
|
||||
[workspace.dependencies]
|
||||
# Shared reader and writer; publish the immutable revision before this consumer.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0", default-features = false }
|
||||
# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch.
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0", features = ["egui"] }
|
||||
# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch.
|
||||
craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", version = "=0.1.0", features = ["flatten"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
@@ -380,6 +380,9 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
|
||||
- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
|
||||
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
|
||||
- **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change.
|
||||
- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change.
|
||||
- **2026-10-20** · Separations Preview shows the plates the exported PDF prints, spot plates included: each visible spread is exported in memory with the Export PDF settings and separated by craft-prepress off the UI thread, then shown as all inks, one plate or an ink limit. The Ink Manager builds craft-prepress's `InkSet` from the document for export and preview. On the sample magazine the first preview of a spread takes about 0.6–1.1 s, most of it the PDF export. No readiness percentage change.
|
||||
|
||||
@@ -188,6 +188,7 @@ fn load_prefs(app: &mut DesignApp) {
|
||||
}
|
||||
Err(_) => keep_unreadable_prefs(&p, &bytes),
|
||||
}
|
||||
app.sync_keymap();
|
||||
}
|
||||
// Engine preferences (Preferences dialog, favourites…) live beside the UI state.
|
||||
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
|
||||
|
||||
@@ -85,8 +85,3 @@ pub const SPLIT_IN_TWO_COLUMNS: &[&str] = &["contentCollector"];
|
||||
pub fn tool_info(id: &str) -> Option<ToolInfo> {
|
||||
TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.id == id).copied()
|
||||
}
|
||||
|
||||
/// Tool for a single-key shortcut (`"T"`, `"Shift+P"`).
|
||||
pub fn tool_for_shortcut(key: &str) -> Option<&'static str> {
|
||||
TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut == Some(key)).map(|t| t.id)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info};
|
||||
pub use catalog::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS, ToolInfo, tool_info};
|
||||
pub use layout::{CanvasLayout, SpreadSlot};
|
||||
pub use nav::ZoomTool;
|
||||
/// The exact text an IME commit types (see [`Tool::ime_commit`]).
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
craft-window = { workspace = true }
|
||||
craft-keymap = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
|
||||
@@ -275,16 +275,27 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
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)),
|
||||
egui::Key::Backspace if !m.any() => Some(Some(String::new())),
|
||||
_ => Some(crate::keys::chord_text(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,
|
||||
let msg = match (sc, craft_keymap::CommandRef::parse_key(&recording)) {
|
||||
(Some(sc), Some(cmd)) => {
|
||||
// The recorded keys move to this command; the commands that held them lose them.
|
||||
match app.run("window.setShortcut", json!({"id": cmd.id, "params": cmd.params, "shortcut": sc, "steal": true})) {
|
||||
Ok(v) => match v["conflicts"].as_array().filter(|c| !c.is_empty()) {
|
||||
Some(c) => {
|
||||
let labels: Vec<String> =
|
||||
c.iter().filter_map(|x| x.as_str()).map(|k| crate::keys::label(&app.keymap, k)).collect();
|
||||
format!("Taken from: {}", labels.join(", "))
|
||||
}
|
||||
None => String::new(),
|
||||
},
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
(None, _) => "That key can't be used in a shortcut".to_string(),
|
||||
(_, None) => String::new(),
|
||||
};
|
||||
d.fields.insert("message".into(), json!(msg));
|
||||
}
|
||||
@@ -292,11 +303,20 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
let ql = q.to_lowercase();
|
||||
let mut rows: Vec<(String, String, String)> = designcraft_engine::command_specs()
|
||||
// (command key, label, menu path): every command with a menu row or a default shortcut, the
|
||||
// UI commands, the panels and the tools.
|
||||
let mut rows: Vec<(String, String, String)> = app
|
||||
.keymap
|
||||
.catalog()
|
||||
.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(|b| {
|
||||
let ui_command = crate::menus::UI_COMMANDS.iter().any(|c| c.0 == b.command.id);
|
||||
ui_command || !b.paths.is_empty() || !b.defaults.is_empty() || b.command.id == crate::keys::PANEL_COMMAND
|
||||
})
|
||||
.map(|b| {
|
||||
let menu = b.paths.first().map(|p| p.get(..p.len().saturating_sub(1)).unwrap_or_default().join(" › ")).unwrap_or_default();
|
||||
(b.command.key(), b.label.clone(), menu)
|
||||
})
|
||||
.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));
|
||||
@@ -305,15 +325,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
for (id, label, menu) in &rows {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, 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 cmd = craft_keymap::CommandRef::parse_key(id);
|
||||
let cur = cmd.as_ref().and_then(|c| crate::keys::shortcut_display(&app.keymap, c)).unwrap_or_else(|| "—".into());
|
||||
let text = if recording == *id { crate::i18n::tr(&app.ui.language, "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 let Some(cmd) = cmd.filter(|_| app.keymap.overrides().get(id).is_some()) {
|
||||
if ui.small_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Default"))).clicked() {
|
||||
let _ = app.run("window.setShortcut", json!({"id": id, "shortcut": null}));
|
||||
let _ = app.run("window.setShortcut", json!({"id": cmd.id, "params": cmd.params, "shortcut": null}));
|
||||
}
|
||||
} else {
|
||||
ui.label("");
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Keyboard shortcuts on `craft-keymap`: the catalog (UI commands, engine commands, panels and
|
||||
//! tools with their default keys and menu paths), shortcut text, and the edits that
|
||||
//! `window.setShortcut`, the Keyboard Shortcuts dialog and the shortcut-set importer make.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use craft_keymap::{AssignError, Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::menus::{self, Item};
|
||||
|
||||
/// The context bindings such as `Text: Cmd+B` name; active while text is being typed on the canvas.
|
||||
pub const TEXT_CONTEXT: &str = "Text";
|
||||
|
||||
/// The command that opens a panel; a binding on it carries the panel id.
|
||||
pub const PANEL_COMMAND: &str = "window.panel";
|
||||
/// The command that selects a tool; a binding on it carries the tool id.
|
||||
pub const TOOL_COMMAND: &str = "tool.select";
|
||||
|
||||
/// InDesign's default keys for the panels DesignCraft has, where DesignCraft's own defaults leave
|
||||
/// the key free.
|
||||
const PANEL_KEYS: &[(&str, &str)] = &[
|
||||
("swatches", "F5"),
|
||||
("color", "F6"),
|
||||
("layers", "F7"),
|
||||
("info", "F8"),
|
||||
("stroke", "F10"),
|
||||
("paragraphStyles", "F11"),
|
||||
("characterStyles", "Shift+F11"),
|
||||
("pages", "F12"),
|
||||
("align", "Shift+F7"),
|
||||
];
|
||||
|
||||
/// Other names shortcut sets use for a command (InDesign's feature names), by command id.
|
||||
const IMPORT_ALIASES: &[(&str, &str)] = &[
|
||||
("app.quit", "Exit"),
|
||||
("app.preferences", "Preferences: General…"),
|
||||
("app.insertTableDialog", "Insert Table"),
|
||||
("view.togglePresentation", "Presentation"),
|
||||
("color.defaultFillStroke", "Apply Default Fill and Stroke Colors"),
|
||||
("color.swapFillStroke", "Swap Fill and Stroke Colors"),
|
||||
("view.togglePreview", "Toggle View Setting Between Default and Preview"),
|
||||
("type.bold", "Apply Bold"),
|
||||
("type.italic", "Apply Italic"),
|
||||
("window.hidePanels", "Show/Hide All Panels"),
|
||||
("window.hidePanelsExceptTools", "Show/Hide All Panels Except Toolbox"),
|
||||
("window.nextDocument", "Next Window"),
|
||||
("window.previousDocument", "Previous Window"),
|
||||
];
|
||||
|
||||
/// Commands that show and hide something without "Show/Hide" in their label. A shortcut set may
|
||||
/// name a toggle by what it does now (`Hide Hidden Characters`); the importer matches such names
|
||||
/// only to toggles.
|
||||
const TOGGLES: &[&str] = &["view.hiddenCharacters", "view.tagMarkers"];
|
||||
|
||||
/// Extra menu paths for import, where an alias would leave two commands on one name: InDesign's
|
||||
/// Tools area has Apply Color, which is also `object.color`'s label.
|
||||
const IMPORT_PATHS: &[(&str, &[&str])] = &[("color.applyColor", &["Tools", "Apply Color"])];
|
||||
|
||||
/// Commands on text-editing chords (Cmd+Z, Cmd+A, and Ctrl+Y off macOS) that run while typing on the
|
||||
/// canvas too: the text tool has no undo or select-all of its own, and Story Editor toggles from the
|
||||
/// text. Their defaults get a copy in the Text context, which takes the chords from the text field.
|
||||
const TYPING_COMMANDS: &[&str] = &["edit.undo", "edit.redo", "edit.selectAll", "app.storyEditor"];
|
||||
|
||||
/// Commands that open a panel, by panel id. The panel's binding gets the command's default key
|
||||
/// too, so the two are twins: a shortcut set that gives either one the key leaves both with it.
|
||||
const PANEL_TWINS: &[(&str, &str)] = &[
|
||||
("character", "app.characterPanel"),
|
||||
("paragraph", "app.paragraphPanel"),
|
||||
("glyphs", "app.glyphsPanel"),
|
||||
("characterStyles", "app.characterStylesPanel"),
|
||||
("paragraphStyles", "app.paragraphStylesPanel"),
|
||||
("table", "app.tablePanel"),
|
||||
("textWrap", "object.textWrap"),
|
||||
];
|
||||
|
||||
pub fn panel(id: &str) -> CommandRef {
|
||||
CommandRef::with_params(PANEL_COMMAND, json!({ "panel": id }))
|
||||
}
|
||||
|
||||
pub fn tool(id: &str) -> CommandRef {
|
||||
CommandRef::with_params(TOOL_COMMAND, json!({ "tool": id }))
|
||||
}
|
||||
|
||||
/// The command a menu row or a `window.setShortcut` call names.
|
||||
pub fn command_ref(id: &str, params: &Value) -> CommandRef {
|
||||
match params {
|
||||
Value::Null => CommandRef::new(id),
|
||||
Value::Object(m) if m.is_empty() => CommandRef::new(id),
|
||||
p => CommandRef::with_params(id, p.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's operating system as [`note_platform`] recorded it, by [`platform_code`]; 0
|
||||
/// before the first frame.
|
||||
static WEB_PLATFORM: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
fn platform_code(p: Platform) -> u8 {
|
||||
match p {
|
||||
Platform::Mac => 1,
|
||||
Platform::Windows => 2,
|
||||
Platform::Linux => 3,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform shortcuts follow: the build's, and on the web the browser's operating system, so a
|
||||
/// browser on a Mac reads ⌘ as Cmd and leaves Control as Control.
|
||||
pub fn platform() -> Platform {
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
return Platform::current();
|
||||
}
|
||||
match WEB_PLATFORM.load(Ordering::Relaxed) {
|
||||
1 => Platform::Mac,
|
||||
2 => Platform::Windows,
|
||||
3 => Platform::Linux,
|
||||
_ => Platform::current(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the platform egui reports, which the web build reads from the browser's user agent.
|
||||
pub fn note_platform(ctx: &egui::Context) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
WEB_PLATFORM.store(platform_code(craft_keymap::egui::platform(ctx)), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The keymap with the user's overrides, for [`platform`].
|
||||
pub fn new_keymap(overrides: Overrides) -> Keymap {
|
||||
keymap_for(platform(), overrides)
|
||||
}
|
||||
|
||||
/// The keymap for `platform`: the system's own chords are reserved, so neither a user edit nor an
|
||||
/// imported set can take them.
|
||||
pub fn keymap_for(platform: Platform, overrides: Overrides) -> Keymap {
|
||||
Keymap::new(catalog().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), platform)
|
||||
.with_reserved(craft_keymap::reserved::system(platform))
|
||||
.with_overrides(overrides)
|
||||
}
|
||||
|
||||
/// Every bindable command, in dispatch order: where two share keys the earlier one runs. UI
|
||||
/// commands come first (⌘N is the New Document dialog, not a bare `file.new`), then engine
|
||||
/// commands, panels and tools.
|
||||
pub fn catalog() -> &'static [Bindable] {
|
||||
static CATALOG: OnceLock<Vec<Bindable>> = OnceLock::new();
|
||||
CATALOG.get_or_init(build_catalog)
|
||||
}
|
||||
|
||||
fn build_catalog() -> Vec<Bindable> {
|
||||
let paths = menu_paths();
|
||||
// An engine command's own menu placement counts only where no menu row stands: a row there
|
||||
// runs a UI command in its place (File › Save is the Save dialog when needed), and two
|
||||
// commands on one path leave the importer unable to pick either.
|
||||
let normalized = |path: &[String]| path.iter().map(|s| craft_keymap::import::normalize_label(s)).collect::<Vec<_>>();
|
||||
let rows: HashSet<Vec<String>> = paths.values().flatten().map(|p| normalized(p)).collect();
|
||||
let app = Scope::app();
|
||||
let mut out = Vec::new();
|
||||
let mut add = |command: CommandRef, label: &str, defaults: &[&str], fallback_path: Option<Vec<String>>, kind: Kind| {
|
||||
let mut b = Bindable::new(command, label, app.clone()).with_kind(kind);
|
||||
match paths.get(&b.command.key()) {
|
||||
Some(found) => b.paths.extend(found.iter().cloned()),
|
||||
None => b.paths.extend(fallback_path.filter(|p| !rows.contains(&normalized(p)))),
|
||||
}
|
||||
for binding in defaults.iter().filter_map(|d| Binding::parse(d).ok()) {
|
||||
b = b.with_default(binding);
|
||||
}
|
||||
out.push(b);
|
||||
};
|
||||
for (id, label, _, _) in menus::UI_COMMANDS {
|
||||
add(CommandRef::new(*id), label, menus::default_shortcut(id).as_slice(), None, Kind::Command);
|
||||
}
|
||||
for c in designcraft_engine::command_specs() {
|
||||
let path = (!c.menu.is_empty()).then(|| c.menu.iter().map(|s| s.to_string()).chain([c.label.to_string()]).collect());
|
||||
add(CommandRef::new(c.id), c.label, c.shortcut.as_slice(), path, Kind::Command);
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
for (id, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) {
|
||||
if seen.contains(id) {
|
||||
continue;
|
||||
}
|
||||
seen.push(id);
|
||||
let own = PANEL_KEYS.iter().find(|(p, _)| p == id).map(|(_, k)| *k);
|
||||
let twin = PANEL_TWINS.iter().find(|(p, _)| p == id).and_then(|(_, c)| menus::default_shortcut(c));
|
||||
let defaults: Vec<&str> = own.into_iter().chain(twin).collect();
|
||||
add(panel(id), label, &defaults, Some(vec!["Window".into(), (*label).into()]), Kind::Command);
|
||||
}
|
||||
for group in designcraft_tools::TOOL_GROUPS {
|
||||
let Some(first) = group.first() else { continue };
|
||||
for t in group.iter() {
|
||||
let kind = Kind::Tool { group: Some(first.id.to_string()) };
|
||||
add(tool(t.id), t.label, t.shortcut.as_slice(), Some(vec!["Tools".into(), t.label.into()]), kind);
|
||||
}
|
||||
}
|
||||
for b in &mut out {
|
||||
if !b.command.params.is_null() {
|
||||
continue;
|
||||
}
|
||||
let id = b.command.id.as_str();
|
||||
b.is_toggle = b.label.starts_with("Show/Hide ") || TOGGLES.contains(&id);
|
||||
if TYPING_COMMANDS.contains(&id)
|
||||
&& let Some(text) = Scope::new(TEXT_CONTEXT)
|
||||
{
|
||||
let copies: Vec<Binding> = b.defaults.iter().filter(|d| d.scope.is_none()).map(|d| Binding::in_scope(d.trigger, text.clone())).collect();
|
||||
b.defaults.extend(copies);
|
||||
}
|
||||
b.aliases.extend(IMPORT_ALIASES.iter().filter(|(c, _)| *c == id).map(|(_, a)| a.to_string()));
|
||||
b.paths.extend(IMPORT_PATHS.iter().filter(|(c, _)| *c == id).map(|(_, p)| p.iter().map(|s| s.to_string()).collect()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Menu paths (menu, submenus, label) of every menu row, by command key.
|
||||
fn menu_paths() -> HashMap<String, Vec<Vec<String>>> {
|
||||
fn walk(items: &[Item], path: &mut Vec<String>, out: &mut HashMap<String, Vec<Vec<String>>>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Sub(name, children) => {
|
||||
path.push(name.clone());
|
||||
walk(children, path, out);
|
||||
path.pop();
|
||||
}
|
||||
Item::Cmd { label, id, params, .. } => {
|
||||
let mut full = path.clone();
|
||||
full.push(label.clone());
|
||||
out.entry(command_ref(id, params).key()).or_default().push(full);
|
||||
}
|
||||
Item::Sep => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for (menu, entries) in menus::menu_tree() {
|
||||
walk(&entries, &mut vec![menu.to_string()], &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The binding a command shows: the first in its home scope, else its first.
|
||||
fn shown_binding(keymap: &Keymap, cmd: &CommandRef) -> Option<Binding> {
|
||||
let bindings = keymap.bindings(cmd);
|
||||
bindings.iter().find(|b| b.scope.is_none()).or_else(|| bindings.first()).cloned()
|
||||
}
|
||||
|
||||
/// The shortcut in effect for a command, as stored and sent ("Cmd+Alt+K", "Text: F7").
|
||||
pub fn shortcut(keymap: &Keymap, cmd: &CommandRef) -> Option<String> {
|
||||
let b = shown_binding(keymap, cmd)?;
|
||||
let home = keymap.bindable(cmd).map_or_else(Scope::app, |c| c.scope.clone());
|
||||
Some(b.text(&home))
|
||||
}
|
||||
|
||||
/// The shortcut in effect for a command, for menus and lists (⌥⌘K on macOS, Ctrl+Alt+K elsewhere).
|
||||
pub fn shortcut_display(keymap: &Keymap, cmd: &CommandRef) -> Option<String> {
|
||||
shown_binding(keymap, cmd).map(|b| b.trigger.display(keymap.platform()))
|
||||
}
|
||||
|
||||
/// A key press as shortcut text ("Cmd+Shift+K"); `None` for a key the keymap has no name for.
|
||||
pub fn chord_text(m: egui::Modifiers, key: egui::Key) -> Option<String> {
|
||||
let key = Key::from_egui(key)?;
|
||||
Some(Chord::new(Mods::from_egui(&m, platform()), key).to_string())
|
||||
}
|
||||
|
||||
/// Copies the keymap's overrides to the saved UI state.
|
||||
pub fn store(app: &mut DesignApp) {
|
||||
if app.ui.shortcuts != *app.keymap.overrides() {
|
||||
app.ui.shortcuts = app.keymap.overrides().clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a command keys (replacing its others), no keys (`""`), or its defaults back (`None`).
|
||||
/// Other commands on the same keys keep them unless `policy` is [`Policy::Steal`]. Returns the
|
||||
/// commands it took the keys from.
|
||||
pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy: Policy) -> Result<Vec<CommandRef>, String> {
|
||||
app.sync_keymap();
|
||||
if app.keymap.bindable(cmd).is_none() {
|
||||
return Err(format!("unknown command `{cmd}`"));
|
||||
}
|
||||
let mut taken = Vec::new();
|
||||
match shortcut.map(str::trim) {
|
||||
None => {
|
||||
app.keymap.reset(cmd);
|
||||
}
|
||||
Some("") => {
|
||||
if !app.keymap.clear(cmd) {
|
||||
return Err("too many changed shortcuts".into());
|
||||
}
|
||||
}
|
||||
Some(sc) => {
|
||||
let binding = Binding::parse(sc).map_err(|e| format!("can't read shortcut `{sc}`: {e}"))?;
|
||||
let done = app.keymap.set_bindings(cmd, vec![binding], policy).map_err(|e| assign_error(&app.keymap, sc, &e))?;
|
||||
taken = done.taken_from.into_iter().map(|h| h.command).collect();
|
||||
}
|
||||
}
|
||||
store(app);
|
||||
Ok(taken)
|
||||
}
|
||||
|
||||
/// Why the keymap refused `sc`. A chord the system takes is named as the menus show it
|
||||
/// ("⌘Tab is reserved by the system").
|
||||
fn assign_error(keymap: &Keymap, sc: &str, e: &AssignError) -> String {
|
||||
match e {
|
||||
AssignError::Reserved(t) => format!("{} is reserved by the system", t.display(keymap.platform())),
|
||||
e => format!("can't assign `{sc}`: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are a panel and the command that opens it ([`PANEL_TWINS`]).
|
||||
pub fn twins(a: &CommandRef, b: &CommandRef) -> bool {
|
||||
let one_way = |p: &CommandRef, c: &CommandRef| {
|
||||
p.id == PANEL_COMMAND
|
||||
&& c.params.is_null()
|
||||
&& p.params.get("panel").and_then(Value::as_str).is_some_and(|id| PANEL_TWINS.contains(&(id, c.id.as_str())))
|
||||
};
|
||||
one_way(a, b) || one_way(b, a)
|
||||
}
|
||||
|
||||
/// Other commands on the keys `cmd` holds, by command key. A panel's twin is not a conflict: both
|
||||
/// open the panel.
|
||||
pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for c in keymap.conflicts_with(cmd) {
|
||||
if twins(cmd, &c.b.0) {
|
||||
continue;
|
||||
}
|
||||
let key = c.b.0.key();
|
||||
if !out.contains(&key) {
|
||||
out.push(key);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A command's label, by its key.
|
||||
pub fn label(keymap: &Keymap, key: &str) -> String {
|
||||
CommandRef::parse_key(key).and_then(|c| keymap.bindable(&c).map(|b| b.label.clone())).unwrap_or_else(|| key.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PLATFORMS: [Platform; 3] = [Platform::Mac, Platform::Windows, Platform::Linux];
|
||||
|
||||
/// No default shortcut sits on a chord an operating system takes before the app sees it.
|
||||
#[test]
|
||||
fn defaults_avoid_reserved_chords() {
|
||||
for p in PLATFORMS {
|
||||
let km = keymap_for(p, Overrides::new());
|
||||
let reserved: Vec<String> = km
|
||||
.catalog()
|
||||
.iter()
|
||||
.flat_map(|b| km.default_bindings(&b.command).into_iter().map(move |d| (b.command.key(), d)))
|
||||
.filter(|(_, d)| km.is_reserved(&d.trigger.chord))
|
||||
.map(|(k, d)| format!("{k} {}", d.trigger))
|
||||
.collect();
|
||||
assert_eq!(reserved, Vec::<String>::new(), "{p:?}: defaults on reserved chords");
|
||||
}
|
||||
}
|
||||
|
||||
/// A chord the operating system takes, per platform.
|
||||
fn os_chord(p: Platform) -> &'static str {
|
||||
match p {
|
||||
Platform::Mac => "Cmd+Tab",
|
||||
Platform::Windows => "Alt+F4",
|
||||
_ => "Cmd+Alt+F2",
|
||||
}
|
||||
}
|
||||
|
||||
/// Binding a chord the system takes is refused with a message naming it, and a saved override
|
||||
/// on one leaves the command its default.
|
||||
#[test]
|
||||
fn a_reserved_chord_is_refused() {
|
||||
let cmd = CommandRef::new("app.palette");
|
||||
for p in PLATFORMS {
|
||||
let mut km = keymap_for(p, Overrides::new());
|
||||
let before = km.bindings(&cmd);
|
||||
let sc = os_chord(p);
|
||||
let binding = Binding::parse(sc).unwrap();
|
||||
let shown = binding.trigger.display(p);
|
||||
let err = km.set_bindings(&cmd, vec![binding], Policy::Steal).unwrap_err();
|
||||
assert_eq!(assign_error(&km, sc, &err), format!("{shown} is reserved by the system"), "{p:?}");
|
||||
assert_eq!(km.bindings(&cmd), before, "{p:?}");
|
||||
let mut saved = Overrides::new();
|
||||
saved.insert(cmd.key(), vec![sc.to_string()]);
|
||||
assert_eq!(keymap_for(p, saved).bindings(&cmd), before, "{p:?}: a saved reserved chord");
|
||||
}
|
||||
// Through `window.setShortcut`, as the Keyboard Shortcuts dialog and the control channel set keys.
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let sc = os_chord(platform());
|
||||
let r = menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": sc, "steal": true})).unwrap();
|
||||
assert!(r.unwrap_err().ends_with("is reserved by the system"));
|
||||
assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter"));
|
||||
}
|
||||
|
||||
/// The web build follows the operating system in the browser's user agent; an iPad counts as a
|
||||
/// Mac.
|
||||
#[test]
|
||||
fn browser_user_agent_picks_the_platform() {
|
||||
use egui::os::OperatingSystem as Os;
|
||||
let ctx = egui::Context::default();
|
||||
let of = |ua: &str| {
|
||||
ctx.set_os(Os::from_user_agent(ua));
|
||||
craft_keymap::egui::platform(&ctx)
|
||||
};
|
||||
let mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15";
|
||||
let ipad = "Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1";
|
||||
let windows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
let linux = "Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0";
|
||||
let android = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36";
|
||||
assert_eq!(of(mac), Platform::Mac);
|
||||
assert_eq!(of(ipad), Platform::Mac);
|
||||
assert_eq!(of(windows), Platform::Windows);
|
||||
assert_eq!(of(linux), Platform::Linux);
|
||||
assert_eq!(of(android), Platform::Linux);
|
||||
// Native builds keep their own platform whatever egui reports.
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
ctx.set_os(Os::Mac);
|
||||
note_platform(&ctx);
|
||||
assert_eq!(platform(), Platform::current());
|
||||
}
|
||||
}
|
||||
|
||||
/// An app typing in a text frame holding "Page ", with the caret at its end, and a function
|
||||
/// that runs one frame of the whole UI with `events` and `modifiers` held.
|
||||
fn typing_in_a_text_frame() -> (DesignApp, Value, impl FnMut(&mut DesignApp, egui::Modifiers, Vec<egui::Event>)) {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let story = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Page "})).unwrap()["story"].clone();
|
||||
app.select_tool("type");
|
||||
app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap();
|
||||
assert!(app.session.wants_text());
|
||||
let ctx = egui::Context::default();
|
||||
let frame = move |app: &mut DesignApp, modifiers: egui::Modifiers, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events: [egui::Event::ModifiersChanged(modifiers)].into_iter().chain(events).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
(app, story, frame)
|
||||
}
|
||||
|
||||
fn story_text(app: &mut DesignApp, story: &Value) -> String {
|
||||
app.session.execute("story.get", &json!({"story": story})).unwrap()["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// A key press as egui sends it, with its release.
|
||||
fn press(key: egui::Key, modifiers: egui::Modifiers) -> Vec<egui::Event> {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).into()
|
||||
}
|
||||
|
||||
/// The command modifier as egui reports it on this platform.
|
||||
fn cmd() -> egui::Modifiers {
|
||||
if platform().is_mac() { egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND } else { egui::Modifiers::CTRL | egui::Modifiers::COMMAND }
|
||||
}
|
||||
|
||||
/// Off macOS, Ctrl+Alt+Shift+N inserts the page number while typing; AltGr+E, which a browser
|
||||
/// on Windows reports as Ctrl+Alt+E (Fit Content to Frame) followed by the text it typed,
|
||||
/// types `€`.
|
||||
#[test]
|
||||
fn ctrl_alt_chords_run_while_typing_unless_they_type_text() {
|
||||
if platform().is_mac() {
|
||||
return;
|
||||
}
|
||||
let (mut app, story, mut frame) = typing_in_a_text_frame();
|
||||
frame(&mut app, Default::default(), vec![]);
|
||||
let ctrl_alt = egui::Modifiers::CTRL | egui::Modifiers::COMMAND | egui::Modifiers::ALT;
|
||||
frame(&mut app, ctrl_alt | egui::Modifiers::SHIFT, press(egui::Key::N, ctrl_alt | egui::Modifiers::SHIFT));
|
||||
assert_eq!(story_text(&mut app, &story), "Page \u{e000}");
|
||||
let mut altgr = press(egui::Key::E, ctrl_alt);
|
||||
altgr.insert(1, egui::Event::Text("€".into()));
|
||||
frame(&mut app, ctrl_alt, altgr);
|
||||
assert_eq!(story_text(&mut app, &story), "Page \u{e000}€");
|
||||
}
|
||||
|
||||
/// While typing, Cmd+Z undoes the document's last edit and Cmd+A selects the story's text: the
|
||||
/// text tool has neither of its own.
|
||||
#[test]
|
||||
fn undo_and_select_all_work_while_typing() {
|
||||
let (mut app, story, mut frame) = typing_in_a_text_frame();
|
||||
frame(&mut app, Default::default(), vec![]);
|
||||
frame(&mut app, Default::default(), vec![egui::Event::Text("X".into())]);
|
||||
assert_eq!(story_text(&mut app, &story), "Page X");
|
||||
frame(&mut app, cmd(), press(egui::Key::Z, cmd()));
|
||||
assert_eq!(story_text(&mut app, &story), "Page ");
|
||||
assert!(app.session.wants_text(), "the caret stays");
|
||||
frame(&mut app, cmd(), press(egui::Key::A, cmd()));
|
||||
frame(&mut app, Default::default(), vec![egui::Event::Text("Y".into())]);
|
||||
assert_eq!(story_text(&mut app, &story), "Y");
|
||||
}
|
||||
|
||||
/// Cmd+Shift+C centers the paragraph while typing, both as egui-winit sends it (a Copy event
|
||||
/// with the modifiers held) and as the web build does (the press, then Copy); nothing is
|
||||
/// copied.
|
||||
#[test]
|
||||
fn cmd_shift_c_centers_the_paragraph_while_typing() {
|
||||
let cmd_shift = cmd() | egui::Modifiers::SHIFT;
|
||||
let mut web = press(egui::Key::C, cmd_shift);
|
||||
web.insert(1, egui::Event::Copy);
|
||||
for (form, events) in [("desktop", vec![egui::Event::Copy]), ("web", web)] {
|
||||
let (mut app, _, mut frame) = typing_in_a_text_frame();
|
||||
frame(&mut app, Default::default(), vec![]);
|
||||
frame(&mut app, cmd_shift, events);
|
||||
let align = crate::panels::text_attrs(&mut app).unwrap()["para"]["align"].clone();
|
||||
assert_eq!(align, "center", "{form}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A panel and the command that opens it share their key without a reported conflict.
|
||||
#[test]
|
||||
fn a_panels_twin_is_not_a_conflict() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
for steal in [false, true] {
|
||||
let r = menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.characterPanel", "shortcut": "Cmd+T", "steal": steal}))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r["conflicts"], json!([]), "steal: {steal}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod dock;
|
||||
pub mod effects_dialog;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod keys;
|
||||
pub mod menus;
|
||||
mod modal_drag;
|
||||
mod new_document;
|
||||
@@ -321,8 +322,10 @@ pub struct UiState {
|
||||
pub rich_black: bool,
|
||||
/// View › Overprint Preview.
|
||||
pub overprint_preview: bool,
|
||||
/// Edit › Keyboard Shortcuts: command id → shortcut ("" = none), over the defaults.
|
||||
pub shortcuts: std::collections::BTreeMap<String, String>,
|
||||
/// Edit › Keyboard Shortcuts: the user's changes to the default shortcuts. Saved as
|
||||
/// `shortcuts` and `shortcutsMore` (see `prefs::saved_shortcuts`).
|
||||
#[serde(flatten, with = "prefs::saved_shortcuts")]
|
||||
pub shortcuts: craft_keymap::Overrides,
|
||||
pub control_bar: bool,
|
||||
pub tools_double_column: bool,
|
||||
/// The front tab of the default layout's Properties · Pages · Layers group. `dock_tab`,
|
||||
@@ -795,6 +798,9 @@ pub struct DesignApp {
|
||||
pub native_menu: bool,
|
||||
/// Shortcuts the native menu handles (skip them in the egui shortcut handler).
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
/// Every command's shortcuts, with the user's changes (`ui.shortcuts`) applied.
|
||||
pub keymap: craft_keymap::Keymap,
|
||||
pub(crate) key_dispatcher: craft_keymap::egui::Dispatcher,
|
||||
last_time: f64,
|
||||
/// When recovery data was last written (seconds on the monotonic clock; see [`now_ms`]).
|
||||
pub last_recovery: f64,
|
||||
@@ -919,6 +925,8 @@ impl DesignApp {
|
||||
pending_window_commands: vec![],
|
||||
native_menu: false,
|
||||
native_shortcuts: Default::default(),
|
||||
keymap: keys::new_keymap(craft_keymap::Overrides::new()),
|
||||
key_dispatcher: craft_keymap::egui::Dispatcher::new(),
|
||||
last_time: now,
|
||||
last_recovery: now,
|
||||
egui_ctx: None,
|
||||
@@ -1364,6 +1372,17 @@ impl DesignApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `ui.shortcuts` to the keymap when they differ, e.g. after preferences were loaded,
|
||||
/// and rebuilds it when the shortcut platform changed (the web build learns the browser's
|
||||
/// operating system on the first frame).
|
||||
pub fn sync_keymap(&mut self) {
|
||||
if self.keymap.platform() != keys::platform() {
|
||||
self.keymap = keys::new_keymap(self.ui.shortcuts.clone());
|
||||
} else if *self.keymap.overrides() != self.ui.shortcuts {
|
||||
self.keymap.set_overrides(self.ui.shortcuts.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_tool(&mut self, id: &str) {
|
||||
let _ = self.run("tool.select", json!({"tool": id}));
|
||||
self.ui.flyout = None;
|
||||
@@ -1462,6 +1481,8 @@ impl DesignApp {
|
||||
if self.egui_ctx.is_none() {
|
||||
self.egui_ctx = Some(ctx.clone());
|
||||
}
|
||||
keys::note_platform(ctx);
|
||||
self.sync_keymap();
|
||||
self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
self.apply_saved_color_settings();
|
||||
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
|
||||
|
||||
+241
-232
@@ -88,13 +88,18 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("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.richBlack", "Appearance of Black", None, "{on: bool} — show 100% black as rich black on screen (off: accurately)"),
|
||||
("window.setShortcut", "Set Shortcut", None, "{id, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default)} → {shortcut, conflicts: [ids]}"),
|
||||
(
|
||||
"window.setShortcut",
|
||||
"Set Shortcut",
|
||||
None,
|
||||
"{id, params?, shortcut: \"Cmd+Alt+J\" | \"\" (none) | null (default), steal?} — id is a command; a panel is id \"window.panel\" with params {panel: \"layers\"}, a tool is id \"tool.select\" with params {tool: \"pen\"} (or its tool id, \"pen\"); steal: true takes the keys from the commands holding them → {shortcut, conflicts: [command keys: ids, or `window.panel {\"panel\":\"layers\"}`]}",
|
||||
),
|
||||
("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"),
|
||||
(
|
||||
"window.importShortcuts",
|
||||
"Import InDesign Shortcut Set",
|
||||
None,
|
||||
"{path | text} — a set saved by InDesign's Show Set (.txt): matched commands take its Default shortcuts (or none), other commands lose keys it takes → {set, imported, changed, conflictsCleared, skipped (an earlier item whose keys a later one took), unmatched, unmatchedWithoutShortcut, unreadable}",
|
||||
"{path | text} — a set saved by InDesign's Show Set (.txt): matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)",
|
||||
),
|
||||
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
|
||||
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
|
||||
@@ -1774,32 +1779,34 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
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}`")));
|
||||
let Some(id) = p.get("id").and_then(Value::as_str) else { return Some(Err("missing id".into())) };
|
||||
let mut cmd = crate::keys::command_ref(id, p.get("params").unwrap_or(&Value::Null));
|
||||
// A bare tool id ("pen") names its tool.
|
||||
if app.keymap.bindable(&cmd).is_none() && designcraft_tools::tool_info(id).is_some() {
|
||||
cmd = crate::keys::tool(id);
|
||||
}
|
||||
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());
|
||||
}
|
||||
let shortcut = match p.get("shortcut") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(sc)) => Some(sc.as_str()),
|
||||
Some(_) => return Some(Err("shortcut: a string or null".into())),
|
||||
};
|
||||
let steal = p.get("steal").and_then(Value::as_bool).unwrap_or(false);
|
||||
let policy = if steal { craft_keymap::Policy::Steal } else { craft_keymap::Policy::Keep };
|
||||
let taken = match crate::keys::set(app, &cmd, shortcut, policy) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let mut conflicts: Vec<String> = taken.iter().filter(|c| !crate::keys::twins(&cmd, c)).map(|c| c.key()).collect();
|
||||
for key in crate::keys::conflicts(&app.keymap, &cmd) {
|
||||
if !conflicts.contains(&key) {
|
||||
conflicts.push(key);
|
||||
}
|
||||
}
|
||||
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}))
|
||||
Ok(json!({"shortcut": crate::keys::shortcut(&app.keymap, &cmd), "conflicts": conflicts}))
|
||||
}
|
||||
"window.resetShortcuts" => {
|
||||
app.ui.shortcuts.clear();
|
||||
app.keymap.reset_all();
|
||||
crate::keys::store(app);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"window.importShortcuts" => {
|
||||
@@ -2299,59 +2306,29 @@ 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).
|
||||
/// The shortcut in effect for a command (the user's, else the default), as stored ("Cmd+Alt+K").
|
||||
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),
|
||||
}
|
||||
crate::keys::shortcut(&app.keymap, &craft_keymap::CommandRef::new(id))
|
||||
}
|
||||
|
||||
/// Every command with a shortcut in effect: (id, shortcut). UI commands come first: where one
|
||||
/// shares keys with an engine command (⌘N, ⌘O…) it's the one with the dialog.
|
||||
pub fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> {
|
||||
UI_COMMANDS
|
||||
.iter()
|
||||
.map(|c| c.0)
|
||||
.chain(designcraft_engine::command_specs().iter().map(|c| c.id))
|
||||
.filter_map(|id| shortcut_of(app, id).map(|s| (id.to_string(), s)))
|
||||
.collect()
|
||||
/// The shortcut a menu row shows: its own binding (a panel's), else its command's.
|
||||
pub fn shortcut_label(app: &DesignApp, id: &str, params: &Value) -> Option<String> {
|
||||
let cmd = crate::keys::command_ref(id, params);
|
||||
let cmd = if app.keymap.bindable(&cmd).is_some() { cmd } else { craft_keymap::CommandRef::new(id) };
|
||||
crate::keys::shortcut_display(&app.keymap, &cmd)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// The shortcut of a tool, for its button's tooltip.
|
||||
pub fn tool_shortcut_label(app: &DesignApp, tool: &str) -> Option<String> {
|
||||
crate::keys::shortcut_display(&app.keymap, &crate::keys::tool(tool))
|
||||
}
|
||||
|
||||
/// A shortcut for display: ⌃⌥⇧⌘ order on macOS, Ctrl+Alt+Shift+ elsewhere.
|
||||
/// A shortcut for display: ⌃⌥⇧⌘K on macOS, Ctrl+Alt+Shift+K 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 };
|
||||
}
|
||||
match craft_keymap::Binding::parse(sc) {
|
||||
Ok(b) => b.trigger.display(crate::keys::platform()),
|
||||
Err(_) => sc.to_string(),
|
||||
}
|
||||
out + key
|
||||
}
|
||||
|
||||
/// A menu entry, shared by the in-window menu bar and the native macOS menu.
|
||||
@@ -3129,8 +3106,8 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str
|
||||
None => label.to_owned(),
|
||||
};
|
||||
let mut b = egui::Button::new(crate::rtl::widget(ui, text));
|
||||
if let Some(sc) = shortcut_of(app, id) {
|
||||
b = b.shortcut_text(shortcut_text(&sc));
|
||||
if let Some(sc) = shortcut_label(app, id, params) {
|
||||
b = b.shortcut_text(sc);
|
||||
}
|
||||
if ui.add_enabled(menu_enabled(app, id), b).clicked() {
|
||||
activate(app, id, params);
|
||||
@@ -3209,78 +3186,36 @@ pub fn all_menu_items() -> Vec<(String, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> {
|
||||
let mut m = egui::Modifiers::NONE;
|
||||
let mut key = None;
|
||||
for part in sc.split('+').filter(|s| !s.is_empty()) {
|
||||
match part {
|
||||
"Cmd" => m.command = true,
|
||||
"Shift" => m.shift = true,
|
||||
"Alt" => m.alt = true,
|
||||
"Ctrl" => m.ctrl = true,
|
||||
"=" => key = Some(egui::Key::Equals),
|
||||
"-" => key = Some(egui::Key::Minus),
|
||||
"[" => key = Some(egui::Key::OpenBracket),
|
||||
"]" => key = Some(egui::Key::CloseBracket),
|
||||
";" => key = Some(egui::Key::Semicolon),
|
||||
"'" => key = Some(egui::Key::Quote),
|
||||
"," => key = Some(egui::Key::Comma),
|
||||
"." => key = Some(egui::Key::Period),
|
||||
"\\" => key = Some(egui::Key::Backslash),
|
||||
"/" => key = Some(egui::Key::Slash),
|
||||
"Delete" => key = Some(egui::Key::Delete),
|
||||
k => key = egui::Key::from_name(k),
|
||||
}
|
||||
}
|
||||
if sc.ends_with("+-") || sc == "-" {
|
||||
key = Some(egui::Key::Minus);
|
||||
}
|
||||
key.map(|k| (m, k))
|
||||
}
|
||||
|
||||
/// Whether a key press matches a parsed shortcut `m`+`k`: `None` when it doesn't, `Some(true)` for
|
||||
/// the shortcut's own key and modifiers, `Some(false)` for the Plus key standing in for `=`. On
|
||||
/// layouts with a dedicated + key (German) or Shift+= (US), Cmd+= arrives as Plus.
|
||||
fn key_matches(m: egui::Modifiers, k: egui::Key, key: egui::Key, modifiers: egui::Modifiers) -> Option<bool> {
|
||||
let plus_for_equals = key == egui::Key::Plus;
|
||||
let matches = (k == key || (plus_for_equals && k == egui::Key::Equals))
|
||||
&& m.command == modifiers.command
|
||||
&& (m.shift == modifiers.shift || (plus_for_equals && !m.shift))
|
||||
&& m.alt == modifiers.alt;
|
||||
matches.then_some(k == key && m.shift == modifiers.shift)
|
||||
}
|
||||
|
||||
/// The command a paste event stands for besides plain Paste. egui-winit turns every ⌘+V chord into
|
||||
/// `Event::Paste` with the clipboard's text and drops the key press, so the shortcut table never
|
||||
/// sees Paste in Place's chord; the modifiers held when it went down tell them apart. `None` when
|
||||
/// the chord is another one, or the native menu's accelerator takes it.
|
||||
/// `Event::Paste` with the clipboard's text and drops the key press, and the keymap reads the chord
|
||||
/// from the frame's final modifiers, which miss a chord released within the frame; the modifiers
|
||||
/// held when it went down tell them apart. `None` when the chord is another one, or the native
|
||||
/// menu's accelerator takes it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Option<&'static str> {
|
||||
let id = "edit.pasteInPlace";
|
||||
if app.native_menu && !app.ui.shortcuts.contains_key(id) {
|
||||
if app.native_menu && app.keymap.overrides().get(id).is_none() {
|
||||
return None;
|
||||
}
|
||||
let (m, k) = shortcut_of(app, id).as_deref().and_then(parse_shortcut)?;
|
||||
(k == egui::Key::V && key_matches(m, k, egui::Key::V, modifiers) == Some(true)).then_some(id)
|
||||
let platform = app.keymap.platform();
|
||||
let held = craft_keymap::Mods::from_egui(&modifiers, platform);
|
||||
let pressed = craft_keymap::Chord::new(held, craft_keymap::Key::V);
|
||||
let bound =
|
||||
app.keymap.bindings(&craft_keymap::CommandRef::new(id)).iter().any(|b| !b.trigger.double && b.trigger.chord.effective(platform) == pressed);
|
||||
bound.then_some(id)
|
||||
}
|
||||
|
||||
/// Global keyboard shortcuts: menu commands and single-key tool shortcuts.
|
||||
/// Global keyboard shortcuts: menu commands, panels and tools, through the keymap.
|
||||
pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
app.sync_keymap();
|
||||
// Only a focused text field takes the keys: the canvas (or a button) having focus after a click
|
||||
// must not swallow tool shortcuts until Esc clears it.
|
||||
if ctx.text_edit_focused() || keys_taken(app, ctx) {
|
||||
return;
|
||||
}
|
||||
let typing = app.session.wants_text();
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
for e in events {
|
||||
let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue };
|
||||
if !shortcut_key(app, key, modifiers, typing, &mut || {
|
||||
ctx.input_mut(|i| i.consume_key(modifiers, key));
|
||||
}) {
|
||||
escape_to_selection(app, key, modifiers, typing);
|
||||
}
|
||||
}
|
||||
let keys = ctx.input_mut(|i| take_keys(app, i, typing));
|
||||
run_keys(app, keys, typing);
|
||||
}
|
||||
|
||||
/// Keys that a floating panel in its own OS window left over (craft-ui's
|
||||
@@ -3292,24 +3227,16 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec
|
||||
if events.is_empty() || keys_taken(app, ctx) {
|
||||
return;
|
||||
}
|
||||
app.sync_keymap();
|
||||
let typing = app.session.wants_text();
|
||||
let mut rest = Vec::with_capacity(events.len());
|
||||
let mut escape = None;
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e {
|
||||
if shortcut_key(app, key, modifiers, typing, &mut || {}) {
|
||||
continue;
|
||||
}
|
||||
if key == egui::Key::Escape {
|
||||
escape = Some(modifiers);
|
||||
}
|
||||
}
|
||||
rest.push(e);
|
||||
}
|
||||
crate::canvas::forwarded_keys(app, rest, modifiers);
|
||||
if let Some(m) = escape {
|
||||
escape_to_selection(app, egui::Key::Escape, m, typing);
|
||||
}
|
||||
// The keymap dispatches from an input state: the main window's, with the floating window's
|
||||
// keys and modifiers in place of its own.
|
||||
let mut input = ctx.input(|i| i.clone());
|
||||
input.events = events;
|
||||
input.modifiers = modifiers;
|
||||
let keys = take_keys(app, &mut input, typing);
|
||||
run_keys(app, keys, typing);
|
||||
crate::canvas::forwarded_keys(app, input.events, modifiers);
|
||||
}
|
||||
|
||||
/// Whether something other than the shortcuts has the keyboard: a dialog or Quick Apply, an input
|
||||
@@ -3323,98 +3250,106 @@ fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool {
|
||||
|| crate::chrome::search_had_focus(ctx)
|
||||
}
|
||||
|
||||
/// The command whose shortcut `key` with `modifiers` stands for, and whether the modifiers match
|
||||
/// that shortcut exactly.
|
||||
fn command_shortcut(app: &DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool) -> Option<(String, bool)> {
|
||||
for (id, sc) in &effective_shortcuts(app) {
|
||||
let id = id.as_str();
|
||||
if let Some((m, k)) = parse_shortcut(sc)
|
||||
&& let Some(exact) = key_matches(m, k, key, modifiers)
|
||||
&& (m.command || m.alt || !typing)
|
||||
&& !(id == "edit.clear" && typing)
|
||||
/// The key presses [`take_keys`] took from an input, run by [`run_keys`] once the input is
|
||||
/// released: commands may read the context.
|
||||
struct Keys {
|
||||
/// Presentation keys in order: `None` leaves Presentation, `Some(step)` moves through the spreads.
|
||||
presentation: Vec<Option<isize>>,
|
||||
hits: Vec<craft_keymap::egui::Hit>,
|
||||
/// An Esc press that nothing took.
|
||||
escape: bool,
|
||||
}
|
||||
|
||||
/// Takes the presentation keys and the command, panel and tool shortcuts out of `input`'s key
|
||||
/// presses.
|
||||
fn take_keys(app: &mut DesignApp, input: &mut egui::InputState, typing: bool) -> Keys {
|
||||
let presentation = if app.ui.screen_mode == ScreenMode::Presentation { presentation_keys(input) } else { Vec::new() };
|
||||
let contexts: &[&str] = if typing { &[crate::keys::TEXT_CONTEXT] } else { &[] };
|
||||
let active = craft_keymap::Active { panel: None, contexts, typing };
|
||||
let native = &app.native_shortcuts;
|
||||
// Presses the dispatcher leaves alone: Clear (the active tool deletes) and the keys the
|
||||
// native menu's accelerators already handled. The native menu bar carries the shortcuts in
|
||||
// effect, the user's included, and an accelerator fires only for its shortcut exactly: the
|
||||
// Plus key standing in for = never reaches it.
|
||||
// Copy, Cut and Paste stay too (the dispatcher's default): the canvas and text tools
|
||||
// handle them, and Paste carries the text.
|
||||
let accept = |hit: &craft_keymap::egui::Hit| {
|
||||
let c = &hit.command;
|
||||
let leave = hit.exact && hit.cycle.is_empty() && (c.id == "edit.clear" || (c.params.is_null() && native.contains(&c.id)));
|
||||
!leave
|
||||
};
|
||||
let hits = app.key_dispatcher.dispatch_filtered(input, &app.keymap, &active, accept);
|
||||
let escape = input.events.iter().any(
|
||||
|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, repeat: false, modifiers, .. } if !modifiers.command && !modifiers.alt),
|
||||
);
|
||||
Keys { presentation, hits, escape }
|
||||
}
|
||||
|
||||
/// Runs what [`take_keys`] took, then Esc's return to the Selection tool.
|
||||
fn run_keys(app: &mut DesignApp, keys: Keys, typing: bool) {
|
||||
for step in keys.presentation {
|
||||
match step {
|
||||
None => {
|
||||
let _ = app.run("view.togglePresentation", json!({}));
|
||||
}
|
||||
Some(step) => crate::canvas::step_spread(app, step),
|
||||
}
|
||||
}
|
||||
for hit in keys.hits {
|
||||
if !hit.cycle.is_empty() {
|
||||
// Tools sharing a key: the press after the group's current tool selects the next one.
|
||||
let current = app.session.tool_id();
|
||||
let tools: Vec<&str> = hit.cycle.iter().filter_map(|c| c.params.get("tool").and_then(Value::as_str)).collect();
|
||||
let next = tools.iter().position(|t| *t == current).and_then(|i| tools.get((i + 1) % tools.len())).or(tools.first());
|
||||
if let Some(tool) = next.map(|t| t.to_string()) {
|
||||
log::debug!("tool shortcut -> {tool}");
|
||||
app.select_tool(&tool);
|
||||
}
|
||||
} else if hit.command.id == crate::keys::TOOL_COMMAND
|
||||
&& let Some(tool) = hit.command.params.get("tool").and_then(Value::as_str).map(str::to_string)
|
||||
{
|
||||
// Single-key shortcuts (W) only when not typing.
|
||||
if !m.command && !m.alt && !m.shift && typing {
|
||||
continue;
|
||||
}
|
||||
if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) {
|
||||
continue; // Handled by the active tool / text clipboard events.
|
||||
}
|
||||
return Some((id.to_string(), exact));
|
||||
log::debug!("tool shortcut -> {tool}");
|
||||
app.select_tool(&tool);
|
||||
} else {
|
||||
// Like choosing the menu item: "…" commands open their dialog.
|
||||
activate(app, &hit.command.id, &hit.command.params);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Runs the presentation key, command shortcut or tool shortcut a key press stands for. `consume`
|
||||
/// takes the press from the input where nothing else may see it. Returns whether the press was used.
|
||||
fn shortcut_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool, consume: &mut dyn FnMut()) -> bool {
|
||||
// Presentation hides every control, so Esc always leaves it, and only that: the canvas must
|
||||
// not also see the press and switch tools or deselect.
|
||||
if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation {
|
||||
consume();
|
||||
let _ = app.run("view.togglePresentation", json!({}));
|
||||
return true;
|
||||
}
|
||||
// Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End.
|
||||
if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt {
|
||||
let step = match key {
|
||||
egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1),
|
||||
egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1),
|
||||
egui::Key::Home => Some(isize::MIN),
|
||||
egui::Key::End => Some(isize::MAX),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(step) = step {
|
||||
consume();
|
||||
crate::canvas::step_spread(app, step);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((id, exact)) = command_shortcut(app, key, modifiers, typing) {
|
||||
// The native menu bar carries the shortcuts in effect, the user's included, and its
|
||||
// accelerator fires only for the shortcut's own key and modifiers; the Plus key standing
|
||||
// in for = never reaches it.
|
||||
if exact && app.native_shortcuts.contains(&id) {
|
||||
return true; // The native menu handles it.
|
||||
}
|
||||
// The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's
|
||||
// default button) must not see the same press as Enter or Space this frame.
|
||||
consume();
|
||||
// Like choosing the menu item: "…" commands open their dialog.
|
||||
activate(app, &id, &Value::Null);
|
||||
return true;
|
||||
}
|
||||
// Tool shortcuts (single keys, Shift+key).
|
||||
if !typing && !modifiers.command && !modifiers.alt {
|
||||
let name = key.name();
|
||||
let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() };
|
||||
if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) {
|
||||
log::debug!("tool shortcut {sc} -> {tool}");
|
||||
if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() {
|
||||
eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}");
|
||||
}
|
||||
app.select_tool(tool);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Esc with no shortcut of its own goes back to the Selection tool.
|
||||
fn escape_to_selection(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool) {
|
||||
if key == egui::Key::Escape
|
||||
&& !typing
|
||||
&& !modifiers.command
|
||||
&& !modifiers.alt
|
||||
&& app.session.tool_id() != "selection"
|
||||
&& !app.session.tool_busy()
|
||||
&& app.ui.flyout.is_none()
|
||||
{
|
||||
// Esc with no shortcut of its own goes back to the Selection tool.
|
||||
if keys.escape && !typing && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() {
|
||||
app.select_tool("selection");
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the Presentation keys out of `input`. Presentation hides every control, so Esc always
|
||||
/// leaves it, and only that: the canvas must not also see the press and switch tools or deselect.
|
||||
/// The arrow keys, Page Up/Down, Home and End go through the spreads.
|
||||
fn presentation_keys(input: &mut egui::InputState) -> Vec<Option<isize>> {
|
||||
let mut steps = Vec::new();
|
||||
for e in input.events.clone() {
|
||||
let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue };
|
||||
if key == egui::Key::Escape {
|
||||
input.consume_key(modifiers, key);
|
||||
steps.push(None);
|
||||
continue;
|
||||
}
|
||||
if !modifiers.command && !modifiers.alt {
|
||||
let step = match key {
|
||||
egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1),
|
||||
egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1),
|
||||
egui::Key::Home => Some(isize::MIN),
|
||||
egui::Key::End => Some(isize::MAX),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(step) = step {
|
||||
input.consume_key(modifiers, key);
|
||||
steps.push(Some(step));
|
||||
}
|
||||
}
|
||||
}
|
||||
steps
|
||||
}
|
||||
|
||||
/// Quick Apply (⌘Return): the document's styles and every command, searched by name.
|
||||
pub fn palette(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
let Some(mut q) = app.ui.palette.clone() else { return };
|
||||
@@ -3834,6 +3769,13 @@ mod tests {
|
||||
assert_eq!(r["zone"], 0.0, "a negative zone turns snapping off");
|
||||
}
|
||||
|
||||
/// Every command with a shortcut in effect: (command key, shortcut), in dispatch order. UI
|
||||
/// commands come first: where one shares keys with an engine command (⌘N, ⌘O…) it's the one
|
||||
/// with the dialog.
|
||||
fn effective_shortcuts(app: &DesignApp) -> Vec<(String, String)> {
|
||||
app.keymap.catalog().iter().filter_map(|b| crate::keys::shortcut(&app.keymap, &b.command).map(|s| (b.command.key(), s))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_shortcuts_override_defaults() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -3851,16 +3793,83 @@ mod tests {
|
||||
// ⌘N is the New Document dialog, not a bare file.new.
|
||||
let first_cmd_n = effective_shortcuts(&app).into_iter().find(|(_, s)| s == "Cmd+N").map(|(i, _)| i);
|
||||
assert_eq!(first_cmd_n.as_deref(), Some("app.newDocumentDialog"));
|
||||
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");
|
||||
assert_eq!(shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter"));
|
||||
assert_eq!(
|
||||
crate::keys::chord_text(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K).as_deref(),
|
||||
Some("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");
|
||||
assert_eq!(shortcut_text("Cmd+Alt+3"), "Ctrl+Alt+3");
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of key presses through the shortcut dispatcher.
|
||||
fn press_keys(app: &mut crate::DesignApp, ctx: &egui::Context, keys: &[(egui::Key, egui::Modifiers)]) {
|
||||
let events = keys
|
||||
.iter()
|
||||
.flat_map(|&(key, modifiers)| {
|
||||
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
|
||||
})
|
||||
.collect();
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts(app, &ui.ctx().clone())).textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_saved_as_a_map_by_older_versions_load_unchanged() {
|
||||
let saved = br#"{"shortcuts": {"app.preferences": "Cmd+Alt+Shift+9", "app.palette": "", "file.close": [""]}}"#;
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui = crate::UiState::from_preferences_json(saved).unwrap();
|
||||
app.sync_keymap();
|
||||
assert_eq!(shortcut_of(&app, "app.preferences").as_deref(), Some("Cmd+Alt+Shift+9"));
|
||||
assert_eq!(shortcut_of(&app, "app.palette"), None, "a removed default stays removed");
|
||||
assert_eq!(shortcut_of(&app, "file.close"), None);
|
||||
assert_eq!(shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S"));
|
||||
// Saved again it reads back the same, in the map form an older version reads.
|
||||
let resaved = serde_json::to_vec(&app.ui).unwrap();
|
||||
assert_eq!(crate::UiState::from_preferences_json(&resaved).unwrap().shortcuts, app.ui.shortcuts);
|
||||
let saved: Value = serde_json::from_slice(&resaved).unwrap();
|
||||
assert_eq!(saved["shortcuts"], json!({"app.preferences": "Cmd+Alt+Shift+9", "app.palette": "", "file.close": ""}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shortcuts_open_their_panels() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let shown = |app: &crate::DesignApp, p: &str| crate::panel_docking::showing(app, p);
|
||||
assert!(!shown(&app, "layers"));
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::F7, egui::Modifiers::NONE)]);
|
||||
assert!(shown(&app, "layers"), "F7 opens Layers");
|
||||
// A panel without a default key takes one.
|
||||
let r = run_ui(&mut app, "window.setShortcut", &json!({"id": "window.panel", "params": {"panel": "links"}, "shortcut": "Shift+F12"}))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r["shortcut"], "Shift+F12");
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::F12, egui::Modifiers::SHIFT)]);
|
||||
assert!(shown(&app, "links"), "the new key opens Links");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reassigned_tool_key_selects_the_tool_and_the_old_key_does_not() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let none = egui::Modifiers::NONE;
|
||||
run_ui(&mut app, "window.setShortcut", &json!({"id": "pen", "shortcut": "Y"})).unwrap().unwrap();
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::P, none)]);
|
||||
assert_eq!(app.session.tool_id(), "selection", "P no longer selects the Pen");
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::Y, none)]);
|
||||
assert_eq!(app.session.tool_id(), "pen");
|
||||
// Tools sharing a key take turns.
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::B, none)]);
|
||||
assert_eq!(app.session.tool_id(), "contentCollector");
|
||||
press_keys(&mut app, &ctx, &[(egui::Key::B, none)]);
|
||||
assert_eq!(app.session.tool_id(), "contentPlacer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_window_panes_have_their_own_views() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -5480,7 +5489,7 @@ mod tests {
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0)));
|
||||
app.run("file.new", json!({"pages": 2})).unwrap();
|
||||
app.session.prefs.recent_files.push("/nowhere/recent.dcraft".into());
|
||||
app.ui.shortcuts.insert("view.rulers".into(), "Ctrl+Alt+R".into());
|
||||
app.ui.shortcuts.insert("view.rulers", vec!["Ctrl+Alt+R".into()]);
|
||||
app.ui.hidden_menu_items.push("view.guides".into());
|
||||
app.ui.ui_scale = 1.25;
|
||||
app.ui.brightness = crate::theme::Brightness::Light;
|
||||
|
||||
@@ -42,6 +42,60 @@ impl UiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `UiState::shortcuts` in ui.json. `shortcuts` maps each changed command to its first
|
||||
/// shortcut (`""` for none): the map of one text per command that earlier DesignCraft versions
|
||||
/// read, and fail on with anything else, which drops the whole file. A command's further
|
||||
/// shortcuts go in `shortcutsMore` (command → texts), which those versions ignore. Loading
|
||||
/// appends them to the command's entry in `shortcuts`; extra shortcuts for a command missing
|
||||
/// there are ignored, because an earlier version that saved the file left that command at its
|
||||
/// defaults. `shortcuts` also loads in the shared keymap's array form, which the first builds
|
||||
/// on it wrote.
|
||||
pub(crate) mod saved_shortcuts {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use craft_keymap::{MAX_BINDINGS, Overrides};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(overrides: &Overrides, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut first = BTreeMap::new();
|
||||
let mut more = BTreeMap::new();
|
||||
for (command, texts) in overrides.iter() {
|
||||
// A blank text is no shortcut, as `""` is in the map.
|
||||
let mut texts = texts.iter().map(String::as_str).filter(|t| !t.trim().is_empty());
|
||||
first.insert(command, texts.next().unwrap_or_default());
|
||||
let rest: Vec<&str> = texts.collect();
|
||||
if !rest.is_empty() {
|
||||
more.insert(command, rest);
|
||||
}
|
||||
}
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("shortcuts", &first)?;
|
||||
if !more.is_empty() {
|
||||
map.serialize_entry("shortcutsMore", &more)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
// `Overrides` loads leniently (never fails), so neither field can make ui.json unreadable.
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
struct Saved {
|
||||
shortcuts: Overrides,
|
||||
shortcuts_more: Overrides,
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Overrides, D::Error> {
|
||||
let Saved { mut shortcuts, shortcuts_more } = Saved::deserialize(deserializer)?;
|
||||
for (command, rest) in shortcuts_more.iter() {
|
||||
let Some(first) = shortcuts.get(command) else { continue };
|
||||
let texts: Vec<String> = first.iter().chain(rest).take(MAX_BINDINGS).cloned().collect();
|
||||
shortcuts.insert(command, texts);
|
||||
}
|
||||
Ok(shortcuts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Files from before the canvas joined the panel layout saved the flyout open at quit as
|
||||
/// `openPanel`, which is the panel a workspace opens below Properties · Pages · Layers. Only a
|
||||
/// workspace's own panel stays: a built-in workspace's (Printing and Proofing: Swatches), and in a
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
//!
|
||||
//! The file lists items under `Product area : <area>` headers, one per line:
|
||||
//! `<name> --- [none defined]` or `<name> --- <context>: <keys>, <context>: <keys>`. Item names
|
||||
//! use `: ` between submenu levels. Only the Default context applies (DesignCraft has no
|
||||
//! shortcut contexts); a type command with no Default entry takes its Text entry.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
//! use `: ` between submenu levels. This module reads the file into a
|
||||
//! [`craft_keymap::ForeignSet`]; `craft_keymap::import` matches and applies it. Default keys bind
|
||||
//! in the command's home scope; an item without Default keys takes its Text keys.
|
||||
|
||||
use craft_keymap::import::{MAX_IMPORT_ENTRIES, MAX_IMPORT_TEXT};
|
||||
use craft_keymap::{ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportReport};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::DesignApp;
|
||||
use crate::menus::{self, Item};
|
||||
|
||||
/// Largest set file read. InDesign's full set is a few hundred kilobytes (twice that as UTF-16).
|
||||
pub const MAX_BYTES: usize = 8 << 20;
|
||||
@@ -25,14 +25,20 @@ struct Entry {
|
||||
}
|
||||
|
||||
struct SetItem {
|
||||
area: String,
|
||||
/// Index into [`ParsedSet::areas`]; `None` before the first `Product area` line.
|
||||
area: Option<usize>,
|
||||
name: String,
|
||||
entries: Vec<Entry>,
|
||||
/// 1-based line in the file.
|
||||
line: usize,
|
||||
}
|
||||
|
||||
struct ParsedSet {
|
||||
name: String,
|
||||
areas: Vec<String>,
|
||||
items: Vec<SetItem>,
|
||||
/// Items past [`MAX_IMPORT_ENTRIES`], not read.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or
|
||||
@@ -68,26 +74,34 @@ fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> {
|
||||
rest.trim_start().strip_prefix(':').map(str::trim)
|
||||
}
|
||||
|
||||
/// The first [`MAX_IMPORT_TEXT`] chars of `s`.
|
||||
fn capped(s: &str) -> &str {
|
||||
s.char_indices().nth(MAX_IMPORT_TEXT).and_then(|(end, _)| s.get(..end)).unwrap_or(s)
|
||||
}
|
||||
|
||||
fn parse(text: &str) -> Result<ParsedSet, String> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let mut set = ParsedSet { name: String::new(), items: Vec::new() };
|
||||
let mut area = String::new();
|
||||
let mut set = ParsedSet { name: String::new(), areas: Vec::new(), items: Vec::new(), dropped: 0 };
|
||||
let mut header_seen = false;
|
||||
for line in text.lines() {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let trimmed = line.trim();
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim_end_matches('\r').trim();
|
||||
if let Some(name) = header(trimmed, "Shortcut Set") {
|
||||
set.name = name.to_string();
|
||||
set.name = capped(name).to_string();
|
||||
header_seen = true;
|
||||
} else if let Some(a) = header(trimmed, "Product area") {
|
||||
area = a.to_string();
|
||||
set.areas.push(capped(a).to_string());
|
||||
header_seen = true;
|
||||
} else if let Some((name, keys)) = trimmed.split_once(" --- ") {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
set.items.push(SetItem { area: area.clone(), name: name.to_string(), entries: entries(keys) });
|
||||
if set.items.len() >= MAX_IMPORT_ENTRIES {
|
||||
set.dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let area = set.areas.len().checked_sub(1);
|
||||
set.items.push(SetItem { area, name: name.to_string(), entries: entries(keys), line: n.saturating_add(1) });
|
||||
}
|
||||
}
|
||||
if set.items.is_empty() && !header_seen {
|
||||
@@ -105,17 +119,18 @@ fn entries(keys: &str) -> Vec<Entry> {
|
||||
}
|
||||
let mut pieces: Vec<String> = Vec::new();
|
||||
for piece in keys.split(", ") {
|
||||
let full = pieces.len() >= MAX_ENTRIES;
|
||||
match pieces.last_mut() {
|
||||
Some(last) if !piece.contains(": ") => {
|
||||
last.push_str(", ");
|
||||
last.push_str(piece);
|
||||
}
|
||||
_ if full => break,
|
||||
_ => pieces.push(piece.to_string()),
|
||||
}
|
||||
}
|
||||
pieces
|
||||
.into_iter()
|
||||
.take(MAX_ENTRIES)
|
||||
.filter_map(|p| {
|
||||
let (context, keys) = p.split_once(": ")?;
|
||||
Some(Entry { context: context.trim().to_string(), keys: keys.trim().to_string() })
|
||||
@@ -123,425 +138,141 @@ fn entries(keys: &str) -> Vec<Entry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the keys use Mac modifier names, where Ctrl is the Control key rather than the
|
||||
/// command modifier.
|
||||
fn is_mac_set(set: &ParsedSet) -> bool {
|
||||
set.items.iter().flat_map(|i| &i.entries).any(|e| {
|
||||
let k = e.keys.to_lowercase();
|
||||
k.contains("cmd") || k.contains("command") || k.contains('⌘') || k.contains("opt") || k.contains('⌥')
|
||||
})
|
||||
/// `s` without a trailing `suffix`, compared without ASCII case.
|
||||
fn strip_suffix_ignore_case<'a>(s: &'a str, suffix: &str) -> Option<&'a str> {
|
||||
let at = s.len().checked_sub(suffix.len())?;
|
||||
s.get(at..).filter(|end| end.eq_ignore_ascii_case(suffix)).and_then(|_| s.get(..at))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Mod {
|
||||
Command,
|
||||
Alt,
|
||||
Shift,
|
||||
Control,
|
||||
}
|
||||
|
||||
/// Keys as InDesign writes them (`Shift+Ctrl+S`, `Ctrl+Num 5`, `Page Down`, `⇧⌘S`) as a
|
||||
/// DesignCraft shortcut string (`Cmd+Shift+S`). Ctrl is the command modifier in a Windows set.
|
||||
fn read_keys(keys: &str, mac: bool) -> Result<String, &'static str> {
|
||||
const SYMBOLS: &[(&str, Mod)] = &[("⌘", Mod::Command), ("⌥", Mod::Alt), ("⇧", Mod::Shift), ("⌃", Mod::Control)];
|
||||
const WORDS: &[(&str, Mod)] = &[
|
||||
("command", Mod::Command),
|
||||
("cmd", Mod::Command),
|
||||
("option", Mod::Alt),
|
||||
("opt", Mod::Alt),
|
||||
("alt", Mod::Alt),
|
||||
("shift", Mod::Shift),
|
||||
("control", Mod::Control),
|
||||
("ctrl", Mod::Control),
|
||||
];
|
||||
let mut m = egui::Modifiers::NONE;
|
||||
let mut set = |md: Mod| match md {
|
||||
Mod::Command => m.command = true,
|
||||
Mod::Alt => m.alt = true,
|
||||
Mod::Shift => m.shift = true,
|
||||
Mod::Control if mac => m.ctrl = true,
|
||||
Mod::Control => m.command = true,
|
||||
};
|
||||
let mut rest = keys.trim();
|
||||
'strip: loop {
|
||||
for (sym, md) in SYMBOLS {
|
||||
if let Some(r) = rest.strip_prefix(sym) {
|
||||
set(*md);
|
||||
rest = r.strip_prefix('+').unwrap_or(r).trim_start();
|
||||
continue 'strip;
|
||||
/// The set as `craft_keymap::import` reads it. An area named `<menu> Menu` is that menu; the
|
||||
/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of the Panel Menus
|
||||
/// area name their panel first (`Character: All Caps`) and match a command under that panel.
|
||||
/// Items of other areas (text and tables, views) have no menu, so they match only by a one-segment
|
||||
/// name. The keys are read with Mac modifier names when any key in the set uses one.
|
||||
fn foreign(set: &ParsedSet) -> ForeignSet {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Area<'a> {
|
||||
menu: Option<&'a str>,
|
||||
tools: bool,
|
||||
panel_menu: bool,
|
||||
}
|
||||
let flavor = Flavor::detect(set.items.iter().flat_map(|i| &i.entries).map(|e| e.keys.as_str()));
|
||||
let areas: Vec<Area> = set
|
||||
.areas
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let a = a.trim();
|
||||
if a.eq_ignore_ascii_case("tools") {
|
||||
Area { menu: Some(a), tools: true, panel_menu: false }
|
||||
} else if a.eq_ignore_ascii_case("panel menus") {
|
||||
Area { panel_menu: true, ..Area::default() }
|
||||
} else {
|
||||
let menu = strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':'));
|
||||
Area { menu, ..Area::default() }
|
||||
}
|
||||
}
|
||||
for (word, md) in WORDS {
|
||||
let Some(head) = rest.get(..word.len()) else { continue };
|
||||
let Some(after) = rest.get(word.len()..) else { continue };
|
||||
// The word must be followed by a separator and a key: "Alt" alone, or "Altx", is a key name.
|
||||
let mut chars = after.chars();
|
||||
if head.eq_ignore_ascii_case(word) && matches!(chars.next(), Some('+' | ' ' | '-')) && !chars.as_str().is_empty() {
|
||||
set(*md);
|
||||
rest = chars.as_str().trim_start();
|
||||
continue 'strip;
|
||||
})
|
||||
.collect();
|
||||
let entries = set
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let area = item.area.and_then(|a| areas.get(a)).copied().unwrap_or_default();
|
||||
let mut entry = ForeignEntry::new(area.menu, item.name.split(": ").map(str::trim)).at_line(item.line);
|
||||
if area.panel_menu {
|
||||
entry = entry.in_panel_menu();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
if m.ctrl {
|
||||
return Err("Control-key shortcuts are not supported");
|
||||
}
|
||||
let key = key_named(rest.trim()).ok_or("unknown key")?;
|
||||
let sc = menus::shortcut_string(m, key);
|
||||
// Only a string the shortcut dispatcher reads back as these keys is imported.
|
||||
match menus::parse_shortcut(&sc) {
|
||||
Some((pm, pk)) if pk == key && same_mods(pm, m) => Ok(sc),
|
||||
_ => Err("unknown key"),
|
||||
}
|
||||
entry.is_tool = area.tools && strip_suffix_ignore_case(&item.name, " tool").is_some();
|
||||
entry.keys = item.entries.iter().map(|e| ForeignKey::read(Some(&e.context), &e.keys, flavor)).collect();
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
ForeignSet::new(entries).with_name(set.name.clone())
|
||||
}
|
||||
|
||||
fn same_mods(a: egui::Modifiers, b: egui::Modifiers) -> bool {
|
||||
a.command == b.command && a.alt == b.alt && a.shift == b.shift && a.ctrl == b.ctrl
|
||||
}
|
||||
|
||||
/// A key by the name InDesign gives it (Windows words, Mac symbols), case- and space-insensitive.
|
||||
fn key_named(name: &str) -> Option<egui::Key> {
|
||||
use egui::Key;
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase();
|
||||
// The numeric keypad: egui doesn't tell its keys apart from the main ones.
|
||||
let n = squashed.strip_prefix("numpad").or_else(|| squashed.strip_prefix("num").filter(|r| !r.is_empty() && *r != "lock")).unwrap_or(&squashed);
|
||||
let key = match n {
|
||||
"up" | "uparrow" | "arrowup" | "↑" => Key::ArrowUp,
|
||||
"down" | "downarrow" | "arrowdown" | "↓" => Key::ArrowDown,
|
||||
"left" | "leftarrow" | "arrowleft" | "←" => Key::ArrowLeft,
|
||||
"right" | "rightarrow" | "arrowright" | "→" => Key::ArrowRight,
|
||||
"pageup" | "pgup" | "⇞" => Key::PageUp,
|
||||
"pagedown" | "pgdn" | "⇟" => Key::PageDown,
|
||||
"home" | "↖" => Key::Home,
|
||||
"end" | "↘" => Key::End,
|
||||
"esc" | "escape" | "⎋" => Key::Escape,
|
||||
"tab" | "⇥" => Key::Tab,
|
||||
"space" | "spacebar" => Key::Space,
|
||||
"enter" | "return" | "↩" | "⏎" | "⌤" => Key::Enter,
|
||||
"backspace" | "⌫" => Key::Backspace,
|
||||
"delete" | "del" | "forwarddelete" | "⌦" => Key::Delete,
|
||||
"insert" | "ins" | "help" => Key::Insert,
|
||||
_ => return Key::from_name(n).or_else(|| Key::from_name(&n.to_uppercase())).or_else(|| Key::from_name(name)),
|
||||
};
|
||||
Some(key)
|
||||
}
|
||||
|
||||
/// Matching form of a label or menu name: no ellipsis, no `&`, single spaces, lower case, and
|
||||
/// no Show/Hide in front (InDesign names a toggle by what choosing it does now).
|
||||
fn norm(s: &str) -> String {
|
||||
let s = s.replace('…', "").replace("...", "").replace('&', "");
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase();
|
||||
["show/hide ", "show ", "hide "].iter().find_map(|p| s.strip_prefix(p).filter(|r| !r.is_empty())).map_or(s.clone(), str::to_string)
|
||||
}
|
||||
|
||||
/// DesignCraft's commands by menu path and label.
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
/// `menu\nsubmenu\nlabel` → ids, in the menu bar.
|
||||
path: HashMap<String, BTreeSet<String>>,
|
||||
/// The same from the engine commands' own menu placement, which a UI command (the one with
|
||||
/// the dialog) may stand in for in the menu bar.
|
||||
engine_path: HashMap<String, BTreeSet<String>>,
|
||||
/// (menu, label) → ids, at any depth of that menu.
|
||||
in_menu: HashMap<(String, String), BTreeSet<String>>,
|
||||
/// label → ids, anywhere.
|
||||
label: HashMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
fn build() -> Self {
|
||||
fn walk(ix: &mut Index, items: &[Item], path: &mut Vec<String>) {
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Sub(name, children) => {
|
||||
path.push(norm(name));
|
||||
walk(ix, children, path);
|
||||
path.pop();
|
||||
}
|
||||
// A shortcut runs the command without parameters; a fixed-parameter variant can't hold one.
|
||||
Item::Cmd { label, id, params, .. } if params.is_null() => {
|
||||
let label = norm(label);
|
||||
let top = path.first().cloned().unwrap_or_default();
|
||||
add_path(&mut ix.path, path, &label, id);
|
||||
ix.in_menu.entry((top, label.clone())).or_default().insert(id.clone());
|
||||
ix.label.entry(label).or_default().insert(id.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ix = Index::default();
|
||||
for (menu, entries) in menus::menu_tree() {
|
||||
walk(&mut ix, &entries, &mut vec![norm(menu)]);
|
||||
}
|
||||
for c in designcraft_engine::command_specs() {
|
||||
let label = norm(c.label);
|
||||
if !c.menu.is_empty() {
|
||||
let path: Vec<String> = c.menu.iter().map(|m| norm(m)).collect();
|
||||
add_path(&mut ix.engine_path, &path, &label, c.id);
|
||||
}
|
||||
ix.label.entry(label).or_default().insert(c.id.to_string());
|
||||
}
|
||||
for (id, label, _, _) in menus::UI_COMMANDS {
|
||||
ix.label.entry(norm(label)).or_default().insert((*id).to_string());
|
||||
}
|
||||
ix
|
||||
}
|
||||
|
||||
/// The command an item names, trying the most specific match first. Matches by label alone
|
||||
/// only count when exactly one command has that label.
|
||||
fn find(&self, area: &str, name: &str) -> Option<String> {
|
||||
let one = |s: Option<&BTreeSet<String>>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned());
|
||||
let segments: Vec<String> = name.split(": ").map(norm).collect();
|
||||
let last = segments.last().cloned().unwrap_or_default();
|
||||
let area = norm(area);
|
||||
if let Some(menu) = area.strip_suffix(" menu").filter(|m| !m.contains(':')) {
|
||||
let mut key = menu.to_string();
|
||||
for s in &segments {
|
||||
key.push('\n');
|
||||
key.push_str(s);
|
||||
}
|
||||
if let Some(id) = one(self.path.get(&key)) {
|
||||
return Some(id);
|
||||
}
|
||||
if segments.len() > 1
|
||||
&& let Some(id) = one(self.in_menu.get(&(menu.to_string(), segments.join(" "))))
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
if let Some(id) = one(self.engine_path.get(&key)) {
|
||||
return Some(id);
|
||||
}
|
||||
if let Some(id) = one(self.in_menu.get(&(menu.to_string(), last.clone()))) {
|
||||
return Some(id);
|
||||
}
|
||||
// Menus are arranged differently: the item's own label, if no other command has it.
|
||||
return one(self.label.get(&last));
|
||||
}
|
||||
if segments.len() == 1 { one(self.label.get(&last)) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
fn add_path(map: &mut HashMap<String, BTreeSet<String>>, path: &[String], label: &str, id: &str) {
|
||||
let mut key = path.join("\n");
|
||||
key.push('\n');
|
||||
key.push_str(label);
|
||||
map.entry(key).or_default().insert(id.to_string());
|
||||
}
|
||||
|
||||
/// What the set says about one DesignCraft command, over every item matched to it.
|
||||
struct Decision {
|
||||
id: String,
|
||||
item: String,
|
||||
/// The first readable shortcut.
|
||||
shortcut: Option<String>,
|
||||
/// Every matched item said `[none defined]`.
|
||||
all_none: bool,
|
||||
}
|
||||
|
||||
fn command_label(id: &str) -> String {
|
||||
menus::ui_label(id)
|
||||
.map(|(l, _)| l.to_string())
|
||||
.or_else(|| designcraft_engine::find_command(id).map(|c| c.label.to_string()))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> {
|
||||
menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k))
|
||||
}
|
||||
|
||||
/// What the first pass makes of a set's items, before any shortcut changes.
|
||||
#[derive(Default)]
|
||||
struct Sorted {
|
||||
decisions: Vec<Decision>,
|
||||
unmatched: Vec<Value>,
|
||||
unmatched_without_shortcut: usize,
|
||||
unreadable: Vec<Value>,
|
||||
}
|
||||
|
||||
/// The first readable shortcut among `entries`, and the reason for the last unreadable one before it.
|
||||
fn first_readable(entries: &[&Entry], mac: bool) -> (Option<String>, &'static str) {
|
||||
let mut why = "";
|
||||
for e in entries {
|
||||
match read_keys(&e.keys, mac) {
|
||||
Ok(sc) => return (Some(sc), why),
|
||||
Err(w) => why = w,
|
||||
}
|
||||
}
|
||||
(None, why)
|
||||
}
|
||||
|
||||
/// Sorts one item of a set: matched to a command (its first readable shortcut becomes that
|
||||
/// command's decision), unmatched, or matched with keys that cannot be read.
|
||||
fn sort_item(sorted: &mut Sorted, ix: &Index, item: &SetItem, mac: bool) {
|
||||
let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) };
|
||||
let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::<Vec<_>>().join(", ");
|
||||
let Some(id) = ix.find(&item.area, &item.name) else {
|
||||
if item.entries.is_empty() {
|
||||
sorted.unmatched_without_shortcut += 1;
|
||||
} else {
|
||||
let tool = norm(&item.area) == "tools"
|
||||
&& designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name));
|
||||
let mut row = json!({"item": shown, "keys": all_keys});
|
||||
if tool {
|
||||
row["reason"] = json!("tool keys are fixed");
|
||||
}
|
||||
sorted.unmatched.push(row);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::<Vec<&Entry>>();
|
||||
let text_command = id.starts_with("type.") || id.starts_with("text.");
|
||||
let mut considered = in_context("Default");
|
||||
if considered.is_empty() && text_command {
|
||||
considered = in_context("Text");
|
||||
}
|
||||
let (read, why) = first_readable(&considered, mac);
|
||||
if read.is_none() && !considered.is_empty() {
|
||||
sorted.unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why}));
|
||||
}
|
||||
let pos = match sorted.decisions.iter().position(|d| d.id == id) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
sorted.decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true });
|
||||
sorted.decisions.len() - 1
|
||||
}
|
||||
};
|
||||
let Some(d) = sorted.decisions.get_mut(pos) else { return };
|
||||
d.all_none &= item.entries.is_empty();
|
||||
if d.shortcut.is_none()
|
||||
&& let Some(sc) = read
|
||||
{
|
||||
d.shortcut = Some(sc);
|
||||
d.item = shown;
|
||||
}
|
||||
}
|
||||
|
||||
/// What applying a set's decisions did.
|
||||
struct Applied {
|
||||
imported: usize,
|
||||
/// The keys each command took, with that command.
|
||||
taken: Vec<((bool, bool, bool, bool, egui::Key), String)>,
|
||||
skipped: Vec<Value>,
|
||||
/// The commands the set decided, whether or not it changed them.
|
||||
decided: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// Sets or clears each decided command's shortcut. When two items want the same keys, the later
|
||||
/// one takes them (the rule shared by the Craft apps' importers).
|
||||
fn apply_decisions(app: &mut DesignApp, decisions: &[Decision]) -> Applied {
|
||||
let mut applied = Applied { imported: 0, taken: Vec::new(), skipped: Vec::new(), decided: BTreeSet::new() };
|
||||
let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new();
|
||||
for (i, d) in decisions.iter().enumerate() {
|
||||
if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) {
|
||||
winner.insert(keys, i);
|
||||
}
|
||||
}
|
||||
for (i, d) in decisions.iter().enumerate() {
|
||||
match &d.shortcut {
|
||||
Some(sc) => {
|
||||
let Some(keys) = keys_of(sc) else { continue };
|
||||
if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) {
|
||||
applied.skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id}));
|
||||
continue;
|
||||
}
|
||||
applied.taken.push((keys, d.id.clone()));
|
||||
if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) {
|
||||
app.ui.shortcuts.remove(&d.id);
|
||||
} else {
|
||||
app.ui.shortcuts.insert(d.id.clone(), sc.clone());
|
||||
}
|
||||
}
|
||||
None if d.all_none => {
|
||||
if menus::default_shortcut(&d.id).is_none() {
|
||||
app.ui.shortcuts.remove(&d.id);
|
||||
} else {
|
||||
app.ui.shortcuts.insert(d.id.clone(), String::new());
|
||||
}
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
applied.imported += 1;
|
||||
applied.decided.insert(d.id.clone());
|
||||
}
|
||||
applied
|
||||
}
|
||||
|
||||
/// Clears the shortcut of each command outside the set that still holds keys the set took.
|
||||
/// Returns the report of the commands cleared.
|
||||
fn clear_conflicts(app: &mut DesignApp, applied: &Applied) -> Vec<Value> {
|
||||
let mut conflicts = Vec::new();
|
||||
for (id, sc) in menus::effective_shortcuts(app) {
|
||||
if applied.decided.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
// A command sharing its default keys with the one taking them (file.new and the New
|
||||
// Document dialog) keeps them: the dispatcher already prefers the other.
|
||||
let twin = |by: &str| {
|
||||
let default = |id: &str| menus::default_shortcut(id).and_then(keys_of);
|
||||
default(&id).is_some() && default(&id) == default(by)
|
||||
};
|
||||
if let Some(keys) = keys_of(&sc)
|
||||
&& applied.taken.iter().any(|(k, by)| *k == keys && !twin(by))
|
||||
{
|
||||
app.ui.shortcuts.insert(id.clone(), String::new());
|
||||
conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc}));
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// Imports a set: every matched command takes the set's shortcut (or none), another command
|
||||
/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report.
|
||||
/// Imports a set: every matched command takes the set's shortcuts (or none, when every item
|
||||
/// matched to it has none), other commands lose keys it takes (unless they share them as
|
||||
/// defaults), and the rest keep theirs. Returns the report.
|
||||
pub fn import(app: &mut DesignApp, text: &str) -> Result<Value, String> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20));
|
||||
}
|
||||
let set = parse(text)?;
|
||||
let mac = is_mac_set(&set);
|
||||
let ix = Index::build();
|
||||
let mut sorted = Sorted::default();
|
||||
for item in &set.items {
|
||||
sort_item(&mut sorted, &ix, item, mac);
|
||||
}
|
||||
let Sorted { decisions, unmatched, unmatched_without_shortcut, unreadable } = sorted;
|
||||
let before: HashMap<String, Option<String>> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect();
|
||||
let applied = apply_decisions(app, &decisions);
|
||||
let conflicts = clear_conflicts(app, &applied);
|
||||
let changed: Vec<Value> = decisions
|
||||
.iter()
|
||||
.filter(|d| applied.decided.contains(&d.id))
|
||||
.filter_map(|d| {
|
||||
let from = before.get(&d.id).cloned().flatten();
|
||||
let to = menus::shortcut_of(app, &d.id);
|
||||
(from != to).then(|| json!({"id": d.id, "label": command_label(&d.id), "item": d.item, "from": from, "to": to}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"set": set.name,
|
||||
"imported": applied.imported,
|
||||
"changed": changed,
|
||||
"conflictsCleared": conflicts,
|
||||
"skipped": applied.skipped,
|
||||
"unmatched": unmatched,
|
||||
"unmatchedWithoutShortcut": unmatched_without_shortcut,
|
||||
"unreadable": unreadable,
|
||||
}))
|
||||
let parsed = parse(text)?;
|
||||
let set = foreign(&parsed);
|
||||
let options = ImportOptions {
|
||||
contexts: vec![("Default".into(), ContextRule::Home), (crate::keys::TEXT_CONTEXT.into(), ContextRule::HomeWithoutHomeEntry)],
|
||||
suggest: true,
|
||||
..ImportOptions::default()
|
||||
};
|
||||
app.sync_keymap();
|
||||
let mut report = craft_keymap::plan(&set, &app.keymap, &options).apply(&mut app.keymap);
|
||||
report.truncated = report.truncated.saturating_add(parsed.dropped);
|
||||
crate::keys::store(app);
|
||||
Ok(report_json(&report))
|
||||
}
|
||||
|
||||
/// An enum of the report as its serde name (`noMatch`, `numpad`).
|
||||
fn name_of<T: serde::Serialize>(v: &T) -> Value {
|
||||
serde_json::to_value(v).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// The report as `window.importShortcuts` returns it. Commands are command keys (`file.save`,
|
||||
/// `window.panel {"panel":"layers"}`) under `id`.
|
||||
fn report_json(r: &ImportReport) -> Value {
|
||||
json!({
|
||||
"set": r.set,
|
||||
"imported": r.imported,
|
||||
"changed": Value::Array(r.changed.iter().map(|c| json!({
|
||||
"id": c.command.key(), "label": c.label, "item": c.item, "line": c.line, "from": c.from, "to": c.to,
|
||||
})).collect()),
|
||||
"conflictsCleared": Value::Array(r.conflicts_cleared.iter().map(|c| json!({
|
||||
"id": c.command.key(), "label": c.label, "shortcut": c.shortcut,
|
||||
})).collect()),
|
||||
"skipped": Value::Array(r.skipped.iter().map(|s| json!({
|
||||
"id": s.command.key(), "item": s.item, "line": s.line, "shortcut": s.shortcut,
|
||||
"takenBy": s.kept_by.as_ref().map(|c| c.key()), "reason": name_of(&s.reason),
|
||||
})).collect()),
|
||||
"failed": Value::Array(r.failed.iter().map(|f| json!({
|
||||
"id": f.command.key(), "label": f.label, "change": name_of(&f.change), "shortcuts": f.shortcuts,
|
||||
})).collect()),
|
||||
"unmatched": Value::Array(r.unmatched.iter().map(|u| json!({
|
||||
"item": u.item, "line": u.line, "keys": u.keys, "reason": name_of(&u.reason),
|
||||
})).collect()),
|
||||
"unmatchedWithoutShortcut": r.unmatched_without_shortcut,
|
||||
"unreadable": Value::Array(r.unreadable.iter().map(|u| json!({
|
||||
"item": u.item, "line": u.line, "keys": u.keys, "reason": u.reason.to_string(),
|
||||
})).collect()),
|
||||
"approximated": Value::Array(r.approximated.iter().map(|a| json!({
|
||||
"item": a.item, "line": a.line, "keys": a.keys, "note": name_of(&a.note),
|
||||
})).collect()),
|
||||
"suggestions": Value::Array(r.suggestions.iter().map(|s| json!({
|
||||
"item": s.item, "line": s.line, "candidates": s.candidates.iter().map(|c| c.key()).collect::<Vec<_>>(),
|
||||
})).collect()),
|
||||
"matchedWithoutChange": Value::Array(r.matched_without_change.iter().map(|m| json!({
|
||||
"id": m.command.key(), "label": m.label, "item": m.item, "line": m.line, "reason": name_of(&m.reason),
|
||||
})).collect()),
|
||||
"truncated": r.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// One line for the Keyboard Shortcuts dialog about an import's report.
|
||||
pub fn summary(lang: &str, report: &Value) -> String {
|
||||
let n = |k: &str| report[k].as_u64().unwrap_or_default().to_string();
|
||||
let len = |k: &str| report[k].as_array().map_or(0, Vec::len).to_string();
|
||||
crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.")
|
||||
// Rows for keys in contexts DesignCraft lacks belong to items that did match.
|
||||
let unmatched = report["unmatched"].as_array().map_or(0, |rows| rows.iter().filter(|r| r["reason"] != "context").count());
|
||||
let mut out = crate::i18n::tr(lang, "Imported {count} shortcuts from “{set}”; {unmatched} items not matched, {unreadable} unreadable.")
|
||||
.replace("{count}", &n("imported"))
|
||||
.replace("{set}", report["set"].as_str().unwrap_or_default())
|
||||
.replace("{unmatched}", &len("unmatched"))
|
||||
.replace("{unreadable}", &len("unreadable"))
|
||||
.replace("{unmatched}", &unmatched.to_string())
|
||||
.replace("{unreadable}", &len("unreadable"));
|
||||
let reserved = report["skipped"].as_array().map_or(0, |rows| rows.iter().filter(|r| r["reason"] == "reserved").count());
|
||||
if reserved > 0 {
|
||||
out.push(' ');
|
||||
out.push_str(
|
||||
&crate::i18n::tr(lang, "{count} shortcuts are reserved by the system and were left out.").replace("{count}", &reserved.to_string()),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and
|
||||
@@ -579,6 +310,7 @@ pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::menus;
|
||||
|
||||
fn app() -> DesignApp {
|
||||
DesignApp::new(designcraft_engine::Session::new(), crate::Services::default())
|
||||
@@ -597,10 +329,16 @@ mod tests {
|
||||
\tFrobnicate Everything --- Default: Ctrl+F12\r\n\
|
||||
\tNever Heard Of It --- [none defined]\r\n\
|
||||
Product area : File Menu\r\n\
|
||||
\tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\
|
||||
\tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4, Tables: Ctrl+3\r\n\
|
||||
\tSave --- Default: Ctrl+Hyperspace\r\n\
|
||||
\tRevert --- Default: Shift+Ctrl+F9\r\n\
|
||||
\tSave As... --- Default: Shift+Ctrl+F9\r\n\
|
||||
Product area : Window Menu\r\n\
|
||||
\tColor: Swatches --- Default: Shift+Ctrl+F2\r\n\
|
||||
Product area : Tools\r\n\
|
||||
\tPen Tool --- Default: Shift+Ctrl+F3\r\n\
|
||||
Product area : File Menu\r\n\
|
||||
\tExit --- Default: Ctrl+Alt+F13\r\n\
|
||||
garbage line without a separator\r\n";
|
||||
let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
|
||||
assert_eq!(r["set"], "My Set");
|
||||
@@ -613,17 +351,82 @@ mod tests {
|
||||
assert_eq!(menus::shortcut_of(&app, "app.palette"), None);
|
||||
assert!(r["conflictsCleared"].as_array().unwrap().iter().any(|c| c["id"] == "app.palette"), "{r}");
|
||||
// Unknown items with shortcuts and unreadable keys are reported; Save keeps its shortcut.
|
||||
assert_eq!(r["unmatched"], json!([{"item": "Edit Menu › Frobnicate Everything", "keys": "Default: Ctrl+F12"}]));
|
||||
assert_eq!(
|
||||
r["unmatched"],
|
||||
json!([
|
||||
{"item": "Edit > Frobnicate Everything", "line": 7, "keys": "Default: Ctrl+F12", "reason": "noMatch"},
|
||||
{"item": "File > Close", "line": 10, "keys": "Tables: Ctrl+3", "reason": "context"},
|
||||
])
|
||||
);
|
||||
// Keys in a context DesignCraft lacks don't make their matched item count as unmatched.
|
||||
assert!(summary("en", &r).contains("; 1 items not matched"), "{}", summary("en", &r));
|
||||
assert_eq!(r["unmatchedWithoutShortcut"], 1);
|
||||
assert_eq!(r["unreadable"][0]["item"], "File Menu › Save");
|
||||
assert_eq!(r["unreadable"][0]["item"], "File > Save");
|
||||
assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S"));
|
||||
// Two items want the same keys: the later one takes them.
|
||||
assert_eq!(menus::shortcut_of(&app, "app.saveDialog").as_deref(), Some("Cmd+Shift+F9"));
|
||||
assert_eq!(r["skipped"][0]["takenBy"], "app.saveDialog", "{r}");
|
||||
assert_eq!(r["imported"], 4);
|
||||
// A Window menu panel item and a Tools item apply to the panel and the tool.
|
||||
assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("swatches")).as_deref(), Some("Cmd+Shift+F2"));
|
||||
assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::tool("pen")).as_deref(), Some("Cmd+Shift+F3"));
|
||||
// InDesign's name for a command, as an alias.
|
||||
assert_eq!(menus::shortcut_of(&app, "app.quit").as_deref(), Some("Cmd+Alt+F13"));
|
||||
// The numeric keypad key is bound as the main key of the same name, and reported.
|
||||
assert_eq!(r["approximated"][0]["item"], "File > Close", "{r}");
|
||||
assert_eq!(r["imported"], 7, "{r}");
|
||||
// Reset Shortcuts is the way back.
|
||||
menus::run_ui(&mut app, "window.resetShortcuts", &json!({})).unwrap().unwrap();
|
||||
assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Return"));
|
||||
assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter"));
|
||||
}
|
||||
|
||||
/// Two keys for one command still save a ui.json that earlier versions read: they deserialize
|
||||
/// `shortcuts` as a map of one text per command and drop the whole file otherwise.
|
||||
#[test]
|
||||
fn a_command_with_two_imported_shortcuts_saves_a_ui_json_older_versions_read() {
|
||||
let mut app = app();
|
||||
let set = "Shortcut Set : Two\r\nProduct area : File Menu\r\n\tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n";
|
||||
menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
|
||||
let keys = app.ui.shortcuts.get("file.close").unwrap().to_vec();
|
||||
assert_eq!(keys.len(), 2, "{keys:?}");
|
||||
let saved = serde_json::to_vec_pretty(&app.ui).unwrap();
|
||||
let fields: Value = serde_json::from_slice(&saved).unwrap();
|
||||
let old: std::collections::BTreeMap<String, String> = serde_json::from_value(fields["shortcuts"].clone()).unwrap();
|
||||
assert_eq!(old.get("file.close"), Some(&keys[0]));
|
||||
assert_eq!(fields["shortcutsMore"], json!({"file.close": [keys[1]]}));
|
||||
let loaded = crate::UiState::from_preferences_json(&saved).unwrap();
|
||||
assert_eq!(loaded.shortcuts, app.ui.shortcuts);
|
||||
// The array form an earlier build of the shared keymap wrote.
|
||||
let draft = json!({"shortcuts": [{"command": "file.close", "keys": keys}, {"command": "app.palette", "keys": []}]});
|
||||
let loaded = crate::UiState::from_preferences_json(&serde_json::to_vec(&draft).unwrap()).unwrap();
|
||||
assert_eq!(loaded.shortcuts.get("file.close"), Some(keys.as_slice()));
|
||||
assert_eq!(loaded.shortcuts.get("app.palette"), Some(&[][..]));
|
||||
// Extra keys for a command an earlier version reset are left out.
|
||||
let reset = json!({"shortcuts": {}, "shortcutsMore": {"file.close": ["Cmd+F4"]}});
|
||||
assert!(crate::UiState::from_preferences_json(&serde_json::to_vec(&reset).unwrap()).unwrap().shortcuts.is_empty());
|
||||
}
|
||||
|
||||
/// A set's key on a chord the system takes is left out and the summary says so.
|
||||
#[test]
|
||||
fn a_reserved_chord_in_a_set_is_left_out() {
|
||||
let mut app = app();
|
||||
// Ctrl in a Windows set is ⌘ on a Mac.
|
||||
let keys = if crate::keys::platform().is_mac() { "Ctrl+Tab" } else { "Alt+F4" };
|
||||
let set = format!("Shortcut Set : OS\r\nProduct area : File Menu\r\n\tExit --- Default: {keys}\r\n");
|
||||
let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({ "text": set })).unwrap().unwrap();
|
||||
assert_eq!(r["skipped"][0]["reason"], "reserved", "{r}");
|
||||
assert_eq!(r["imported"], 0, "{r}");
|
||||
assert_eq!(menus::shortcut_of(&app, "app.quit"), menus::default_shortcut("app.quit").map(str::to_string));
|
||||
assert!(summary("en", &r).ends_with("1 shortcuts are reserved by the system and were left out."), "{}", summary("en", &r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_item_shares_its_key_with_the_command_that_opens_the_panel() {
|
||||
let mut app = app();
|
||||
let set = "Shortcut Set : Panels\r\nProduct area : Window Menu\r\n\tType & Tables: Character --- Default: Ctrl+T\r\n";
|
||||
let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": set})).unwrap().unwrap();
|
||||
assert_eq!(crate::keys::shortcut(&app.keymap, &crate::keys::panel("character")).as_deref(), Some("Cmd+T"));
|
||||
assert_eq!(menus::shortcut_of(&app, "app.characterPanel").as_deref(), Some("Cmd+T"));
|
||||
assert!(!r["conflictsCleared"].as_array().into_iter().flatten().any(|c| c["id"] == "app.characterPanel"), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]);
|
||||
let active = g.iter().any(|tl| tl.id == current);
|
||||
let name = crate::i18n::tr(&app.ui.language, shown.label);
|
||||
let tip = match shown.shortcut {
|
||||
let tip = match crate::menus::tool_shortcut_label(app, shown.id) {
|
||||
Some(s) => format!("{name} ({s})"),
|
||||
None => name.to_string(),
|
||||
};
|
||||
@@ -157,7 +157,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
egui::FontId::proportional(12.5),
|
||||
t.text,
|
||||
);
|
||||
if let Some(sc) = tl.shortcut {
|
||||
if let Some(sc) = crate::menus::tool_shortcut_label(app, tl.id) {
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
@@ -432,7 +432,7 @@ fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: u
|
||||
}
|
||||
let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false);
|
||||
let label = VIEW_OPTIONS[k];
|
||||
let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or("");
|
||||
let sc = crate::menus::shortcut_label(app, id, &Value::Null).unwrap_or_default();
|
||||
let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label));
|
||||
if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
|
||||
@@ -426,7 +426,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Spring-loaded Zoom: hold Cmd+Space (Ctrl+Space on Windows/Linux) for the Zoom cursor and click/scrubby zoom; Alt-click zooms out. Release returns to the previous tool without changing the document or undo history. Text-frame and text-field typing suppress the shortcut; Space remains Hand.
|
||||
- Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it.
|
||||
- Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`).
|
||||
- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt) and reports what it changed and what it could not match; `window.importShortcuts`.
|
||||
- Edit › Keyboard Shortcuts: every command, panel and tool, click and press the new keys (they move from the commands that held them, which the dialog lists), per-command and global reset; `window.setShortcut` (panels as `window.panel` with `{panel}`, tools by id). Panels open with InDesign's keys where DesignCraft leaves them free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tools sharing a key take turns (B: Content Collector, Content Placer). Shortcuts run on the shared craft-keymap crate. Import InDesign Set… reads a set saved by InDesign's Show Set (.txt), including its Window menu panel items and Tools items, and reports what it changed and what it could not match, with likely commands for unmatched items; `window.importShortcuts`.
|
||||
- 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.
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ basics of editing fail for too many users on Windows and Linux.
|
||||
| Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) |
|
||||
| IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) |
|
||||
| Font menu: search, preview, favourites, type-ahead, filters (class, recently added), styles per family, sample text and size, recently used, similar fonts | ✓ | ✓ | ~ | search field focus on macOS (#335, #86); Adobe Fonts (Find More, activated) out of scope |
|
||||
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; imports an InDesign Show Set `.txt` | ✓ | tool keys are fixed, so a set's Tools items are reported, not applied |
|
||||
| Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md; panels take keys (InDesign's F5–F12 defaults where free) and tool keys are editable; imports an InDesign Show Set `.txt`, panel and tool items included | ✓ | no Object Styles or Transform panel to give Cmd+F7 or F9; Links (Cmd+Shift+D) keeps DesignCraft's owner of that key |
|
||||
| Quick Apply | ✓ | ✓ | ✓ | |
|
||||
| Numeric fields: units, arithmetic, scrubbing | ✓ | 21 pt spinner fields with arithmetic | ✓ | |
|
||||
| Colour fields: hex, picker from any swatch field | ✓ | ✓ | ~ | hex entry and picker issues (#323, #190) |
|
||||
|
||||
Reference in New Issue
Block a user