From 3c862cc20fabcfdd0554650c9ffecd5f13aa2387 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 19:53:29 +0000 Subject: [PATCH 001/315] #1180: import an InDesign keyboard shortcut set Edit > Keyboard Shortcuts gets Import InDesign Set..., which reads the .txt that InDesign's Show Set writes (window.importShortcuts {path|text}). Matched commands take the set's Default shortcut, or lose theirs when every occurrence says [none defined]; other commands give up keys the set takes. The report lists changes, cleared conflicts, and the items that could not be matched or read. Tool keys stay fixed. --- ROADMAP.md | 1 + apps/designcraft-web/src/web.rs | 2 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/src/dialogs.rs | 13 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 19 +- crates/ui-egui/src/shortcut_import.rs | 601 ++++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 10 files changed, 641 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/shortcut_import.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..5af957ba6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe0f4a7fa..27d236a2f 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -395,6 +395,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let ctx = ctx.clone(); let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) + } else if request.purpose == "shortcuts" { + rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"]) } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index a1b4d8276..da483931d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -187,6 +187,7 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { match purpose { "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], + "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }], "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4b..edef4affa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -315,9 +315,16 @@ fn keyboard_shortcuts(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { if !msg.is_empty() { ui.label(egui::RichText::new(msg).color(egui::Color32::from_rgb(240, 180, 60))); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { - let _ = app.run("window.resetShortcuts", json!({})); - } + ui.horizontal(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset All to Defaults"))).clicked() { + let _ = app.run("window.resetShortcuts", json!({})); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Import InDesign Set…"))).clicked() + && let Some(msg) = crate::shortcut_import::pick_and_import(app) + { + d.fields.insert("message".into(), json!(msg)); + } + }); } /// File › Print, with the printers the system knows. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79eb2c44c..2cb1503f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod prefs; pub mod render_worker; mod rtl; mod section_options; +mod shortcut_import; pub mod story_editor; pub mod taskbar; pub mod theme; @@ -1058,6 +1059,10 @@ impl DesignApp { let Some(inbox) = self.services.inbox.clone() else { return }; let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())); for file in files { + if file.request.purpose == "shortcuts" { + shortcut_import::import_delivered(self, &file.bytes); + continue; + } if self.ui.dialog.is_some() { self.status( crate::i18n::tr(&self.ui.language, "{name}: import cancelled while a dialog is open; choose the file again") diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e42..e8cf910ba 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -85,6 +85,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("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.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, unmatched, unmatchedWithoutShortcut, unreadable}", + ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ( @@ -1705,6 +1711,17 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let text = match (p.get("text").and_then(Value::as_str), p.get("path").and_then(Value::as_str)) { + (Some(text), _) => text.to_string(), + (None, Some(path)) => match crate::shortcut_import::read_file(path) { + Ok(text) => text, + Err(e) => return Some(Err(e)), + }, + (None, None) => return Some(Err("give the set's path or text".into())), + }; + crate::shortcut_import::import(app, &text) + } "view.snapPreferences" => { let b = |k: &str| p.get(k).and_then(Value::as_bool); let ui = &mut app.ui; @@ -2976,7 +2993,7 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } -fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { +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()) { diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs new file mode 100644 index 000000000..bb00198b6 --- /dev/null +++ b/crates/ui-egui/src/shortcut_import.rs @@ -0,0 +1,601 @@ +//! Importing a keyboard shortcut set that InDesign's Keyboard Shortcuts dialog saved as text +//! (Show Set): `window.importShortcuts`. +//! +//! The file lists items under `Product area : ` headers, one per line: +//! ` --- [none defined]` or ` --- : , : `. 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 std::io::Read; + +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; +/// Entries read per item: a real item has one or two per context. +const MAX_ENTRIES: usize = 64; + +/// One `: ` entry of an item. +struct Entry { + context: String, + keys: String, +} + +struct SetItem { + area: String, + name: String, + entries: Vec, +} + +struct ParsedSet { + name: String, + items: Vec, +} + +/// Text from the bytes of a set file: UTF-8 (with or without a BOM) or UTF-16 (by its BOM, or +/// by the zero bytes of ASCII text without one). +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > MAX_BYTES { + return Err(format!("the file is larger than {} MB", MAX_BYTES >> 20)); + } + let utf16 = |rest: &[u8], le: bool| -> String { + let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).map(|r| r.unwrap_or(char::REPLACEMENT_CHARACTER)).collect() + }; + Ok(match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, true), + [0xFE, 0xFF, rest @ ..] => utf16(rest, false), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + [a, 0, ..] if *a != 0 => utf16(bytes, true), + [0, b, ..] if *b != 0 => utf16(bytes, false), + _ => String::from_utf8_lossy(bytes).into_owned(), + }) +} + +/// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. +pub fn read_file(path: &str) -> Result { + let file = std::fs::File::open(path).map_err(|e| format!("can't open {path}: {e}"))?; + let mut bytes = Vec::new(); + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX).saturating_add(1); + file.take(cap).read_to_end(&mut bytes).map_err(|e| format!("can't read {path}: {e}"))?; + decode(&bytes) +} + +/// `Label : value` with any spacing around the colon, when the line starts with `label`. +fn header<'a>(line: &'a str, label: &str) -> Option<&'a str> { + let rest = line.get(..label.len()).filter(|h| h.eq_ignore_ascii_case(label)).and_then(|_| line.get(label.len()..))?; + rest.trim_start().strip_prefix(':').map(str::trim) +} + +fn parse(text: &str) -> Result { + 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 header_seen = false; + for line in text.lines() { + let line = line.trim_end_matches('\r'); + let trimmed = line.trim(); + if let Some(name) = header(trimmed, "Shortcut Set") { + set.name = name.to_string(); + header_seen = true; + } else if let Some(a) = header(trimmed, "Product area") { + area = 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.is_empty() && !header_seen { + return Err("not a shortcut set: no `Product area` or ` --- ` lines".into()); + } + Ok(set) +} + +/// The entries after ` --- `: none for `[none defined]`. A comma is also a key (`Ctrl+,`), so a +/// piece without `: ` belongs to the entry before it. +fn entries(keys: &str) -> Vec { + let keys = keys.trim(); + if keys.eq_ignore_ascii_case("[none defined]") || keys.is_empty() { + return Vec::new(); + } + let mut pieces: Vec = Vec::new(); + for piece in keys.split(", ") { + match pieces.last_mut() { + Some(last) if !piece.contains(": ") => { + last.push_str(", "); + last.push_str(piece); + } + _ => 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() }) + }) + .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('⌥') + }) +} + +#[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 { + 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; + } + } + 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; + } + } + 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"), + } +} + +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 { + use egui::Key; + if name.is_empty() { + return None; + } + let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().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::>().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>, + /// 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>, + /// (menu, label) → ids, at any depth of that menu. + in_menu: HashMap<(String, String), BTreeSet>, + /// label → ids, anywhere. + label: HashMap>, +} + +impl Index { + fn build() -> Self { + fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { + 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 = 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 { + let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); + let segments: Vec = 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>, 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, + /// 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)) +} + +/// 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. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + 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 decisions: Vec = Vec::new(); + let mut unmatched = Vec::new(); + let mut unmatched_without_shortcut = 0usize; + let mut unreadable = Vec::new(); + for item in &set.items { + 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::>().join(", "); + let Some(id) = ix.find(&item.area, &item.name) else { + if item.entries.is_empty() { + 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"); + } + unmatched.push(row); + } + continue; + }; + let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); + 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 mut read = None; + let mut why = ""; + for e in &considered { + match read_keys(&e.keys, mac) { + Ok(sc) => { + read = Some(sc); + break; + } + Err(w) => why = w, + } + } + if read.is_none() && !considered.is_empty() { + unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); + } + let pos = match decisions.iter().position(|d| d.id == id) { + Some(p) => p, + None => { + decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); + decisions.len() - 1 + } + }; + let Some(d) = decisions.get_mut(pos) else { continue }; + d.all_none &= item.entries.is_empty(); + if d.shortcut.is_none() + && let Some(sc) = read + { + d.shortcut = Some(sc); + d.item = shown; + } + } + + let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); + let mut imported = 0usize; + let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); + let mut skipped = Vec::new(); + let mut decided: BTreeSet = BTreeSet::new(); + for d in &decisions { + match &d.shortcut { + Some(sc) => { + let Some(keys) = keys_of(sc) else { continue }; + if let Some((_, by)) = taken.iter().find(|(k, _)| *k == keys) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "keptBy": by})); + continue; + } + 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, + } + imported += 1; + decided.insert(d.id.clone()); + } + let mut conflicts = Vec::new(); + for (id, sc) in menus::effective_shortcuts(app) { + if 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) + && 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})); + } + } + let changed: Vec = decisions + .iter() + .filter(|d| 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": imported, + "changed": changed, + "conflictsCleared": conflicts, + "skipped": skipped, + "unmatched": unmatched, + "unmatchedWithoutShortcut": unmatched_without_shortcut, + "unreadable": unreadable, + })) +} + +/// 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.") + .replace("{count}", &n("imported")) + .replace("{set}", report["set"].as_str().unwrap_or_default()) + .replace("{unmatched}", &len("unmatched")) + .replace("{unreadable}", &len("unreadable")) +} + +/// The Keyboard Shortcuts dialog's Import button: a native file picker imports at once and +/// returns the summary; in a browser the chosen file arrives later through the import inbox. +pub fn pick_and_import(app: &mut DesignApp) -> Option { + if let Some(pick) = app.services.pick_open.as_mut() { + let path = pick("shortcuts")?; + return Some(match app.run("window.importShortcuts", json!({"path": path})) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }); + } + let request = app.import_request("shortcuts"); + if let Some(open) = app.services.open_async.as_mut() { + open(request); + } + None +} + +/// A set file chosen in the browser: import it and say how it went in the open Keyboard +/// Shortcuts dialog, or the status bar. +pub fn import_delivered(app: &mut DesignApp, bytes: &[u8]) { + let msg = match decode(bytes).and_then(|text| app.run("window.importShortcuts", json!({"text": text}))) { + Ok(report) => summary(&app.ui.language, &report), + Err(e) => e, + }; + match app.ui.dialog.as_mut().filter(|d| d.id == "keyboardShortcuts") { + Some(d) => { + d.fields.insert("message".into(), json!(msg)); + } + None => app.status(msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> DesignApp { + DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()) + } + + #[test] + fn imports_a_windows_set_and_reports_what_it_could_not_use() { + let mut app = app(); + // Something else holds Ctrl+Alt+K before the import; the set gives it to Check Spelling. + menus::run_ui(&mut app, "window.setShortcut", &json!({"id": "app.palette", "shortcut": "Cmd+Alt+K"})).unwrap().unwrap(); + let set = "\u{feff}Shortcut Set : My Set\r\n\r\n\ + Product area : Edit Menu\r\n\ + \tSpelling: Check Spelling... --- Default: Ctrl+Alt+K, Text: F7\r\n\ + \tDuplicate --- [none defined]\r\n\ + \tDuplicate --- [none defined]\r\n\ + \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\ + \tSave --- Default: Ctrl+Hyperspace\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"); + // A submenu item, with Ctrl as the command modifier. + assert_eq!(menus::shortcut_of(&app, "spelling.check").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+5")); + // `[none defined]` in every occurrence clears a matched command. + assert_eq!(menus::shortcut_of(&app, "edit.duplicate"), None); + // The command that held the imported keys loses them. + 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["unmatchedWithoutShortcut"], 1); + assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); + assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); + assert_eq!(r["imported"], 3); + // 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")); + } + + #[test] + fn hostile_input_returns_a_report_or_an_error() { + let mut app = app(); + for text in ["", "\0\0\0", "--- ---", " --- Default: Ctrl+", "Product area : \n\t: --- Default: +, Default: ,,,", "x --- Default: ⌘⇧"] { + let _ = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": text})).unwrap(); + } + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({})).unwrap().is_err()); + assert!(menus::run_ui(&mut app, "window.importShortcuts", &json!({"path": "/nonexistent/set.txt"})).unwrap().is_err()); + // UTF-16 with a BOM, and Mac modifier spellings. + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend("Shortcut Set : Mac\nProduct area : File Menu\n\tClose --- Default: ⇧⌘W\n".encode_utf16().flat_map(u16::to_le_bytes)); + let r = menus::run_ui(&mut app, "window.importShortcuts", &json!({"text": decode(&bytes).unwrap()})).unwrap().unwrap(); + assert_eq!(r["set"], "Mac"); + assert_eq!(menus::shortcut_of(&app, "file.close").as_deref(), Some("Cmd+Shift+W")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 507358dec..381498fb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -85,7 +85,7 @@ layer survive interchange; literal XML and round-trip tests are in | PDF presets | `.joboptions`, `.pdfs` | read/write | ✗ | | Print / flattener / document presets | `.prst`, `.flst`, `.dcst` | read/write | ✗ | | Preflight profiles | `.idpp` | read/write | ✗ (own profiles in the document) | -| Keyboard shortcut sets | `.indk` | read/write | ✗ (own JSON) | +| Keyboard shortcut sets | `.indk`; Show Set `.txt` | read/write; write | ~ (imports a Show Set `.txt`; own JSON) | | User dictionary | `.udc` | read/write | ✗ (own, in the document and prefs) | | Fonts | OTF, TTF, TTC, variable | ✓ | ✓ (Type 1 retired by both) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991edd..71a95b341 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,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`. +- 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`. - 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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2dcf4687a..b66fb585c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | ✓ | | +| 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 | | 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) | From fb3fd8ad3b27a38116e074f8ca42e62c898fd907 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 20:03:14 +0000 Subject: [PATCH 002/315] #1180: read the set file through the checked reader --- crates/ui-egui/src/shortcut_import.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index bb00198b6..ba6457647 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -7,7 +7,6 @@ //! shortcut contexts); a type command with no Default entry takes its Text entry. use std::collections::{BTreeSet, HashMap}; -use std::io::Read; use serde_json::{Value, json}; @@ -58,10 +57,8 @@ pub fn decode(bytes: &[u8]) -> Result { /// Reads and decodes a set file, refusing one over [`MAX_BYTES`]. pub fn read_file(path: &str) -> Result { - let file = std::fs::File::open(path).map_err(|e| format!("can't open {path}: {e}"))?; - let mut bytes = Vec::new(); - let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX).saturating_add(1); - file.take(cap).read_to_end(&mut bytes).map_err(|e| format!("can't read {path}: {e}"))?; + let cap = u64::try_from(MAX_BYTES).unwrap_or(u64::MAX); + let bytes = designcraft_engine::io::read_capped(path, cap).map_err(|e| format!("can't read {path}: {e}"))?; decode(&bytes) } From 68e2044b918ccf0e3b919b456cdf3cf8c8b433a1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 03:42:41 +0000 Subject: [PATCH 003/315] #1180: when two items want the same keys, the later one takes them --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcut_import.rs | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e8cf910ba..c261ceb4a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,7 +89,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "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, unmatched, unmatchedWithoutShortcut, unreadable}", + "{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}", ), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index ba6457647..9ccbef1b9 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -426,12 +426,20 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); let mut skipped = Vec::new(); let mut decided: BTreeSet = BTreeSet::new(); - for d in &decisions { + // When two items in the set want the same keys, the later one takes them (the rule shared by + // the Craft apps' importers). + 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((_, by)) = taken.iter().find(|(k, _)| *k == keys) { - skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "keptBy": by})); + if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { + skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); continue; } taken.push((keys, d.id.clone())); @@ -558,6 +566,8 @@ mod tests { Product area : File Menu\r\n\ \tClose --- Default: Shift+Ctrl+Num 5, Default: Ctrl+F4\r\n\ \tSave --- Default: Ctrl+Hyperspace\r\n\ + \tRevert --- Default: Shift+Ctrl+F9\r\n\ + \tSave As... --- Default: Shift+Ctrl+F9\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"); @@ -574,7 +584,10 @@ mod tests { assert_eq!(r["unmatchedWithoutShortcut"], 1); assert_eq!(r["unreadable"][0]["item"], "File Menu › Save"); assert_eq!(menus::shortcut_of(&app, "app.save").as_deref(), Some("Cmd+S")); - assert_eq!(r["imported"], 3); + // 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); // 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")); From 8383d828ad5808f9ca51deec36902b78ea6fcc2b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:14:59 +0000 Subject: [PATCH 004/315] keymap: DesignCraft shortcuts on craft-keymap The shortcut model, dispatch and storage move onto the shared craft-keymap crate. The catalogue is built from UI_COMMANDS, the engine command specs, the panels (window.panel {panel}) and the tools (tool.select {tool}, one group per Tools-panel group), with menu paths from the menu tree. - Dispatch goes through craft_keymap::egui::Dispatcher; presentation keys, Esc-to-selection, Clear and the native-menu skip stay app-side, and the clipboard events stay with the canvas. - Panels take InDesign's default keys where DesignCraft leaves them free. - Tool keys are editable; tools sharing a key take turns. - ui.json keeps its shortcuts as craft-keymap Overrides; the map form older versions saved loads unchanged. - window.setShortcut accepts params (panels), tool ids and steal; the Keyboard Shortcuts dialog lists panels and tools and moves recorded keys. --- Cargo.lock | 11 + Cargo.toml | 2 + ROADMAP.md | 1 + apps/designcraft/src/main.rs | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 51 ++-- crates/ui-egui/src/keys.rs | 214 ++++++++++++++++ crates/ui-egui/src/lib.rs | 19 +- crates/ui-egui/src/menus.rs | 356 ++++++++++++++------------ crates/ui-egui/src/shortcut_import.rs | 19 +- crates/ui-egui/src/toolbar.rs | 6 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- 13 files changed, 499 insertions(+), 186 deletions(-) create mode 100644 crates/ui-egui/src/keys.rs diff --git a/Cargo.lock b/Cargo.lock index bc2369d12..91e7a6b89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=d6c7b45350f7a4352b845c9fdd2e63913d1af27d#d6c7b45350f7a4352b845c9fdd2e63913d1af27d" +dependencies = [ + "egui", + "serde", + "serde_json", +] + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1212,6 +1222,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.8.0" dependencies = [ + "craft-keymap", "craft-ui", "designcraft-color", "designcraft-compose", diff --git a/Cargo.toml b/Cargo.toml index a9aeccd3e..4767c4ee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared writer; the immutable revision must be published before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } +# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch. +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "d6c7b45350f7a4352b845c9fdd2e63913d1af27d", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 5af957ba6..6ca512064 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **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-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index da483931d..381010a6d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -152,6 +152,7 @@ fn load_prefs(app: &mut DesignApp) { && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { app.ui = ui; + 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")) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c9d85253f..c780f3647 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] craft-ui = { workspace = true } +craft-keymap = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index edef4affa..35779b3ec 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -264,16 +264,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::>().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 = + 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)); } @@ -281,11 +292,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 + .catalogue() .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)); @@ -294,15 +314,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(""); diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs new file mode 100644 index 000000000..bc3c53b54 --- /dev/null +++ b/crates/ui-egui/src/keys.rs @@ -0,0 +1,214 @@ +//! Keyboard shortcuts on `craft-keymap`: the catalogue (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; +use std::sync::OnceLock; + +use craft_keymap::{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"), +]; + +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 keymap with the user's overrides. +pub fn new_keymap(overrides: Overrides) -> Keymap { + Keymap::new(catalogue().to_vec(), Scopes::new().with_context(TEXT_CONTEXT), Platform::current()).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 catalogue() -> &'static [Bindable] { + static CATALOGUE: OnceLock> = OnceLock::new(); + CATALOGUE.get_or_init(build_catalogue) +} + +fn build_catalogue() -> Vec { + let paths = menu_paths(); + let app = Scope::app(); + let mut out = Vec::new(); + let mut add = |command: CommandRef, label: &str, default: Option<&str>, fallback_path: Option>, 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), + } + if let Some(binding) = default.and_then(|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), 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, 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 default = PANEL_KEYS.iter().find(|(p, _)| p == id).map(|(_, k)| *k); + add(panel(id), label, default, 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, Some(vec!["Tools".into(), t.label.into()]), kind); + } + } + out +} + +/// Menu paths (menu, submenus, label) of every menu row, by command key. +fn menu_paths() -> HashMap>> { + fn walk(items: &[Item], path: &mut Vec, out: &mut HashMap>>) { + 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 { + 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 { + 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 { + 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 { + let key = Key::from_egui(key)?; + Some(Chord::new(Mods::from_egui(&m, Platform::current()), 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, 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| format!("can't assign `{sc}`: {e}"))?; + taken = done.taken_from.into_iter().map(|h| h.command).collect(); + } + } + store(app); + Ok(taken) +} + +/// Other commands on the keys `cmd` holds, by command key. +pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { + let mut out: Vec = Vec::new(); + for c in keymap.conflicts() { + let other = if c.a.0 == *cmd { + &c.b.0 + } else if c.b.0 == *cmd { + &c.a.0 + } else { + continue; + }; + let key = other.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()) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2cb1503f3..0fd461867 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; @@ -250,8 +251,9 @@ 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, + /// Edit › Keyboard Shortcuts: the user's changes to the default shortcuts. Older files store a + /// map of command id → shortcut ("" = none), which loads unchanged. + pub shortcuts: craft_keymap::Overrides, pub control_bar: bool, pub tools_double_column: bool, /// Expanded right-dock panel group tab. @@ -605,6 +607,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, + /// 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, egui time). pub last_recovery: f64, @@ -691,6 +696,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: 0.0, last_recovery: 0.0, egui_ctx: None, @@ -1118,6 +1125,13 @@ impl DesignApp { } } + /// Applies `ui.shortcuts` to the keymap when they differ, e.g. after preferences were loaded. + pub fn sync_keymap(&mut self) { + 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; @@ -1128,6 +1142,7 @@ impl DesignApp { if self.egui_ctx.is_none() { self.egui_ctx = Some(ctx.clone()); } + self.sync_keymap(); self.window_maximized = ctx.input(|i| i.viewport().maximized.unwrap_or(false)); if !self.color_applied { self.color_applied = true; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c261ceb4a..87bbfd4fa 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -83,7 +83,12 @@ 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", @@ -1683,32 +1688,34 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - 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 = taken.iter().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 = - 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" => { @@ -2139,59 +2146,41 @@ 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 { - 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. +/// 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 { + 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) +} + +/// The shortcut of a tool, for its button's tooltip. +pub fn tool_shortcut_label(app: &DesignApp, tool: &str) -> Option { + crate::keys::shortcut_display(&app.keymap, &crate::keys::tool(tool)) +} + +/// 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. 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() + app.keymap.catalogue().iter().filter_map(|b| crate::keys::shortcut(&app.keymap, &b.command).map(|s| (b.command.key(), s))).collect() } /// A shortcut as written in this app ("Cmd+Alt+Shift+K") from a key press. pub fn shortcut_string(m: egui::Modifiers, key: egui::Key) -> String { - let mut s = String::new(); - if m.command { - s += "Cmd+"; - } - if m.alt { - s += "Alt+"; - } - if m.shift { - s += "Shift+"; - } - s += key.name(); - s + crate::keys::chord_text(m, key).unwrap_or_else(|| key.name().to_string()) } -/// 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(craft_keymap::Platform::current()), + Err(_) => sc.to_string(), } - out + key } /// A menu entry, shared by the in-window menu bar and the native macOS menu. @@ -2913,8 +2902,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); @@ -2993,49 +2982,17 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } +/// Reads shortcut text into egui's modifiers and key (`command` for Cmd, `ctrl` for the Mac +/// Control key). 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)) + let chord = craft_keymap::Chord::parse(sc).ok()?; + let s = chord.to_egui_shortcut(); + Some((s.modifiers, s.logical_key)) } -/// 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 { - 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) -} - -/// 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. // While an IME composes at the text caret, the keyboard is its own. An open canvas context menu @@ -3050,17 +3007,87 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { return; } let typing = app.session.wants_text(); + if app.ui.screen_mode == ScreenMode::Presentation { + presentation_keys(app, ctx); + } + let contexts: &[&str] = if typing { &[crate::keys::TEXT_CONTEXT] } else { &[] }; + let active = craft_keymap::Active { panel: None, contexts, typing }; + let hits = ctx.input_mut(|i| { + let platform = app.keymap.platform(); + // Presses the dispatcher must not take: the clipboard events the canvas and text tools + // handle themselves (Paste carries the text), Clear (the active tool deletes), and the + // keys the native menu's accelerators already handled. They go back afterwards. + let mut held = Vec::new(); + i.events.retain(|e| { + let keep = match e { + egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) => false, + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let exact = + craft_keymap::Key::from_egui(*key).map(|k| craft_keymap::Chord::new(craft_keymap::Mods::from_egui(modifiers, platform), k)); + let run = match exact.map(|c| app.keymap.lookup(&c, false, &active)) { + Some(craft_keymap::Lookup::Run(c)) => Some(c), + _ => None, + }; + // The native menu's accelerator holds the command's default keys and fires + // only for them exactly: the Plus key standing in for = never reaches it. + !run.is_some_and(|c| { + c.id == "edit.clear" + || (c.params.is_null() && app.native_shortcuts.contains(&c.id) && app.keymap.overrides().get(&c.id).is_none()) + }) + } + _ => true, + }; + if !keep { + held.push(e.clone()); + } + keep + }); + let hits = app.key_dispatcher.dispatch(i, &app.keymap, &active); + i.events.extend(held); + hits + }); + for hit in 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) + { + 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); + } + } + // Esc with no shortcut of its own goes back to the Selection tool. + let escape = ctx.input(|i| { + i.events.iter().any( + |e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, repeat: false, modifiers, .. } if !modifiers.command && !modifiers.alt), + ) + }); + if escape && !typing && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { + app.select_tool("selection"); + } +} + +/// Presentation's keys: Esc 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(app: &mut DesignApp, ctx: &egui::Context) { 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 }; - // 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 { ctx.input_mut(|i| i.consume_key(modifiers, key)); let _ = app.run("view.togglePresentation", json!({})); continue; } - // 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), @@ -3072,55 +3099,6 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { if let Some(step) = step { ctx.input_mut(|i| i.consume_key(modifiers, key)); crate::canvas::step_spread(app, step); - continue; - } - } - // Command shortcuts. - let mut fired: Option<(String, bool)> = None; - let all = effective_shortcuts(app); - for (id, sc) in &all { - 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) - { - // 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. - } - fired = Some((id.to_string(), exact)); - break; - } - } - if let Some((id, exact)) = fired { - // The native menu's 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) && !app.ui.shortcuts.contains_key(&id) { - continue; // 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. - ctx.input_mut(|i| i.consume_key(modifiers, key)); - // Like choosing the menu item: "…" commands open their dialog. - activate(app, &id, &Value::Null); - continue; - } - // 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); - } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { - app.select_tool("selection"); } } } @@ -3512,16 +3490,78 @@ 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_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); assert_eq!(shortcut_string(egui::Modifiers { command: true, shift: true, ..Default::default() }, egui::Key::K), "Cmd+Shift+K"); if cfg!(target_os = "macos") { assert_eq!(shortcut_text("Cmd+Alt+3"), "⌥⌘3"); assert_eq!(shortcut_text("Cmd+Shift+."), "⇧⌘."); } else { - assert_eq!(shortcut_text("Cmd+Alt+3"), "Alt+Ctrl+3"); + 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 in the current form, it reads back the same. + let resaved = serde_json::to_vec(&app.ui).unwrap(); + assert_eq!(crate::UiState::from_preferences_json(&resaved).unwrap().shortcuts, app.ui.shortcuts); + } + + #[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| app.ui.dock_tab == p || app.ui.open_panel.as_deref() == Some(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()); diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 9ccbef1b9..8654bf553 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -350,6 +350,13 @@ fn command_label(id: &str) -> String { .unwrap_or_else(|| id.to_string()) } +/// Gives the command with this key the shortcut, none (`""`), or its default (`None`). +fn store(app: &mut DesignApp, key: &str, shortcut: Option<&str>) { + if let Some(cmd) = craft_keymap::CommandRef::parse_key(key) { + let _ = crate::keys::set(app, &cmd, shortcut, craft_keymap::Policy::Keep); + } +} + 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)) } @@ -444,16 +451,16 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { } taken.push((keys, d.id.clone())); if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { - app.ui.shortcuts.remove(&d.id); + store(app, &d.id, None); } else { - app.ui.shortcuts.insert(d.id.clone(), sc.clone()); + store(app, &d.id, Some(sc.as_str())); } } None if d.all_none => { if menus::default_shortcut(&d.id).is_none() { - app.ui.shortcuts.remove(&d.id); + store(app, &d.id, None); } else { - app.ui.shortcuts.insert(d.id.clone(), String::new()); + store(app, &d.id, Some("")); } } None => continue, @@ -475,7 +482,7 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { if let Some(keys) = keys_of(&sc) && taken.iter().any(|(k, by)| *k == keys && !twin(by)) { - app.ui.shortcuts.insert(id.clone(), String::new()); + store(app, &id, Some("")); conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); } } @@ -590,7 +597,7 @@ mod tests { assert_eq!(r["imported"], 4); // 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")); } #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6b7d2c1c2..47098e8ce 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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!({})); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 71a95b341..c38b86d59 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,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) and reports what it changed and what it could not match; `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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b66fb585c..72729afef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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` | ✓ | the importer applies menu items only, so a set's Tools and panel items are reported, not applied; no Object Styles or Transform panel to give Cmd+F7 or F9, and Text Wrap (Cmd+Alt+W) and Links (Cmd+Shift+D) keep DesignCraft's owners of those keys | | 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) | From 8bb887a188d7ef10a7f9388d9fb3e8c7a18ff070 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:26:41 +0000 Subject: [PATCH 005/315] keymap: the InDesign set import runs on craft_keymap::import shortcut_import.rs keeps the Show Set reader (decoding, the 8 MB cap, header and item lines, entry splitting that keeps the comma key) and maps each item to a craft_keymap::ForeignSet entry: ` Menu` areas become menus, the Tools area marks `... Tool` items as tool entries, and keys are read with ForeignKey::read in the set's Windows or Mac flavour. Matching, conflicts and apply are craft_keymap::plan + ImportPlan::apply with Default as the home context and Text used when an item has no Default keys; the keymap's overrides are stored afterwards. - Window menu panel items and Tools items set panel and tool shortcuts. - An item's Default keys all apply, not only the first readable one. - The window.importShortcuts result keeps its field names and adds failed, approximated, suggestions and truncated; rows carry line and reason, ids are command keys. - An engine command's own menu path is left out of the catalogue where a menu row of another command already stands, so the importer can pick the menu row's command. - The dialog summary counts unmatched items without the rows for keys in contexts DesignCraft lacks. --- ROADMAP.md | 1 + crates/ui-egui/src/keys.rs | 9 +- crates/ui-egui/src/menus.rs | 10 +- crates/ui-egui/src/shortcut_import.rs | 535 +++++++------------------- docs/target-app-parity.md | 2 +- 5 files changed, 153 insertions(+), 404 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6ca512064..0ba1879fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ 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 280 of 1,629 items (was 233) and changes 27 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-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index bc3c53b54..8e3c16452 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -2,7 +2,7 @@ //! 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; +use std::collections::{HashMap, HashSet}; use std::sync::OnceLock; use craft_keymap::{Bindable, Binding, Chord, CommandRef, Key, Keymap, Kind, Mods, Overrides, Platform, Policy, Scope, Scopes}; @@ -65,13 +65,18 @@ pub fn catalogue() -> &'static [Bindable] { fn build_catalogue() -> Vec { 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::>(); + let rows: HashSet> = paths.values().flatten().map(|p| normalized(p)).collect(); let app = Scope::app(); let mut out = Vec::new(); let mut add = |command: CommandRef, label: &str, default: Option<&str>, fallback_path: Option>, 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), + None => b.paths.extend(fallback_path.filter(|p| !rows.contains(&normalized(p)))), } if let Some(binding) = default.and_then(|d| Binding::parse(d).ok()) { b = b.with_default(binding); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87bbfd4fa..fb61be02e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -94,7 +94,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "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), 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)"), @@ -2982,14 +2982,6 @@ pub fn all_menu_items() -> Vec<(String, String)> { out } -/// Reads shortcut text into egui's modifiers and key (`command` for Cmd, `ctrl` for the Mac -/// Control key). -pub(crate) fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { - let chord = craft_keymap::Chord::parse(sc).ok()?; - let s = chord.to_egui_shortcut(); - Some((s.modifiers, s.logical_key)) -} - /// Global keyboard shortcuts: menu commands, panels and tools, through the keymap. pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { app.sync_keymap(); diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 8654bf553..0d62a535a 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -3,15 +3,15 @@ //! //! The file lists items under `Product area : ` headers, one per line: //! ` --- [none defined]` or ` --- : , : `. 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, name: String, entries: Vec, + /// 1-based line in the file. + line: usize, } struct ParsedSet { name: String, + areas: Vec, items: Vec, + /// 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 { 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 { } let mut pieces: Vec = 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,398 +138,116 @@ fn entries(keys: &str) -> Vec { .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 { - 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` is that menu; the +/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of other areas +/// (panel menus, 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 { + let flavor = Flavor::detect(set.items.iter().flat_map(|i| &i.entries).map(|e| e.keys.as_str())); + let menus: Vec<(Option<&str>, bool)> = set + .areas + .iter() + .map(|a| { + let a = a.trim(); + if a.eq_ignore_ascii_case("tools") { + (Some(a), true) + } else { + (strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')), false) } - } - 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; - } - } - 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"), - } + }) + .collect(); + let entries = set + .items + .iter() + .map(|item| { + let (menu, tools) = item.area.and_then(|a| menus.get(a)).copied().unwrap_or((None, false)); + let mut entry = ForeignEntry::new(menu, item.name.split(": ").map(str::trim)).at_line(item.line); + entry.is_tool = 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 { name: Some(set.name.clone()), entries } } -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 { - use egui::Key; - if name.is_empty() { - return None; - } - let squashed: String = name.chars().filter(|c| !c.is_whitespace()).collect::().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::>().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>, - /// 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>, - /// (menu, label) → ids, at any depth of that menu. - in_menu: HashMap<(String, String), BTreeSet>, - /// label → ids, anywhere. - label: HashMap>, -} - -impl Index { - fn build() -> Self { - fn walk(ix: &mut Index, items: &[Item], path: &mut Vec) { - 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 = 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 { - let one = |s: Option<&BTreeSet>| s.filter(|s| s.len() == 1).and_then(|s| s.first().cloned()); - let segments: Vec = 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>, 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, - /// 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()) -} - -/// Gives the command with this key the shortcut, none (`""`), or its default (`None`). -fn store(app: &mut DesignApp, key: &str, shortcut: Option<&str>) { - if let Some(cmd) = craft_keymap::CommandRef::parse_key(key) { - let _ = crate::keys::set(app, &cmd, shortcut, craft_keymap::Policy::Keep); - } -} - -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)) -} - -/// 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 { 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 decisions: Vec = Vec::new(); - let mut unmatched = Vec::new(); - let mut unmatched_without_shortcut = 0usize; - let mut unreadable = Vec::new(); - for item in &set.items { - 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::>().join(", "); - let Some(id) = ix.find(&item.area, &item.name) else { - if item.entries.is_empty() { - 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"); - } - unmatched.push(row); - } - continue; - }; - let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); - 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 mut read = None; - let mut why = ""; - for e in &considered { - match read_keys(&e.keys, mac) { - Ok(sc) => { - read = Some(sc); - break; - } - Err(w) => why = w, - } - } - if read.is_none() && !considered.is_empty() { - unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); - } - let pos = match decisions.iter().position(|d| d.id == id) { - Some(p) => p, - None => { - decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); - decisions.len() - 1 - } - }; - let Some(d) = decisions.get_mut(pos) else { continue }; - d.all_none &= item.entries.is_empty(); - if d.shortcut.is_none() - && let Some(sc) = read - { - d.shortcut = Some(sc); - d.item = shown; - } - } + 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)) +} - let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); - let mut imported = 0usize; - let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); - let mut skipped = Vec::new(); - let mut decided: BTreeSet = BTreeSet::new(); - // When two items in the set want the same keys, the later one takes them (the rule shared by - // the Craft apps' importers). - 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)) { - skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); - continue; - } - taken.push((keys, d.id.clone())); - if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { - store(app, &d.id, None); - } else { - store(app, &d.id, Some(sc.as_str())); - } - } - None if d.all_none => { - if menus::default_shortcut(&d.id).is_none() { - store(app, &d.id, None); - } else { - store(app, &d.id, Some("")); - } - } - None => continue, - } - imported += 1; - decided.insert(d.id.clone()); - } - let mut conflicts = Vec::new(); - for (id, sc) in menus::effective_shortcuts(app) { - if 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) - && taken.iter().any(|(k, by)| *k == keys && !twin(by)) - { - store(app, &id, Some("")); - conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); - } - } - let changed: Vec = decisions - .iter() - .filter(|d| 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": imported, - "changed": changed, - "conflictsCleared": conflicts, - "skipped": skipped, - "unmatched": unmatched, - "unmatchedWithoutShortcut": unmatched_without_shortcut, - "unreadable": unreadable, - })) +/// An enum of the report as its serde name (`noMatch`, `numpad`). +fn name_of(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::>(), + })).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(); + // 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()); 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("{unmatched}", &unmatched.to_string()) .replace("{unreadable}", &len("unreadable")) } @@ -553,6 +286,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()) @@ -571,10 +305,14 @@ 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\ 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"); @@ -587,14 +325,27 @@ 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")); + // 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"], 6, "{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+Enter")); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c38b86d59..daeb1eadd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,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, 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) 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. From 506c429c652591ba55f37f551575b91bb2b64ce8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:35:32 +0000 Subject: [PATCH 006/315] keymap: dispatch_filtered, panel twins and import aliases on craft-keymap 66f2202 - shortcuts() declines Clear and native-menu presses through dispatch_filtered; clipboard events stay with the app by default. - keys::conflicts uses Keymap::conflicts_with. - Panel bindings share the default key of the UI command that opens the same panel, so importing InDesign's set leaves both with it. - Import aliases and a Tools path for InDesign feature names DesignCraft has commands for. - Overrides with one key per command save in the map form older versions read. --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/keys.rs | 68 ++++++++++++++++++++------- crates/ui-egui/src/menus.rs | 52 ++++++++------------ crates/ui-egui/src/shortcut_import.rs | 10 ++++ 5 files changed, 83 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91e7a6b89..863e963d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=d6c7b45350f7a4352b845c9fdd2e63913d1af27d#d6c7b45350f7a4352b845c9fdd2e63913d1af27d" +source = "git+https://github.com/aiongg/craft-libs?rev=66f22029ba71dad29dcda156f01f680763aa6659#66f22029ba71dad29dcda156f01f680763aa6659" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4767c4ee5..0936e75a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # Shared writer; the immutable revision must be published before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch. -craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "d6c7b45350f7a4352b845c9fdd2e63913d1af27d", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "66f22029ba71dad29dcda156f01f680763aa6659", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 8e3c16452..ee85a0400 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -33,6 +33,39 @@ const PANEL_KEYS: &[(&str, &str)] = &[ ("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", "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"), +]; + +/// 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 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 })) } @@ -72,23 +105,23 @@ fn build_catalogue() -> Vec { let rows: HashSet> = paths.values().flatten().map(|p| normalized(p)).collect(); let app = Scope::app(); let mut out = Vec::new(); - let mut add = |command: CommandRef, label: &str, default: Option<&str>, fallback_path: Option>, kind: Kind| { + let mut add = |command: CommandRef, label: &str, defaults: &[&str], fallback_path: Option>, 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)))), } - if let Some(binding) = default.and_then(|d| Binding::parse(d).ok()) { + 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), None, Kind::Command); + 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, path, Kind::Command); + 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) { @@ -96,16 +129,26 @@ fn build_catalogue() -> Vec { continue; } seen.push(id); - let default = PANEL_KEYS.iter().find(|(p, _)| p == id).map(|(_, k)| *k); - add(panel(id), label, default, Some(vec!["Window".into(), (*label).into()]), Kind::Command); + 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, Some(vec!["Tools".into(), t.label.into()]), kind); + 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.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 } @@ -197,15 +240,8 @@ pub fn set(app: &mut DesignApp, cmd: &CommandRef, shortcut: Option<&str>, policy /// Other commands on the keys `cmd` holds, by command key. pub fn conflicts(keymap: &Keymap, cmd: &CommandRef) -> Vec { let mut out: Vec = Vec::new(); - for c in keymap.conflicts() { - let other = if c.a.0 == *cmd { - &c.b.0 - } else if c.b.0 == *cmd { - &c.a.0 - } else { - continue; - }; - let key = other.key(); + for c in keymap.conflicts_with(cmd) { + let key = c.b.0.key(); if !out.contains(&key) { out.push(key); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb61be02e..74571bf4f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3005,38 +3005,21 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { let contexts: &[&str] = if typing { &[crate::keys::TEXT_CONTEXT] } else { &[] }; let active = craft_keymap::Active { panel: None, contexts, typing }; let hits = ctx.input_mut(|i| { - let platform = app.keymap.platform(); - // Presses the dispatcher must not take: the clipboard events the canvas and text tools - // handle themselves (Paste carries the text), Clear (the active tool deletes), and the - // keys the native menu's accelerators already handled. They go back afterwards. - let mut held = Vec::new(); - i.events.retain(|e| { - let keep = match e { - egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) => false, - egui::Event::Key { key, pressed: true, modifiers, .. } => { - let exact = - craft_keymap::Key::from_egui(*key).map(|k| craft_keymap::Chord::new(craft_keymap::Mods::from_egui(modifiers, platform), k)); - let run = match exact.map(|c| app.keymap.lookup(&c, false, &active)) { - Some(craft_keymap::Lookup::Run(c)) => Some(c), - _ => None, - }; - // The native menu's accelerator holds the command's default keys and fires - // only for them exactly: the Plus key standing in for = never reaches it. - !run.is_some_and(|c| { - c.id == "edit.clear" - || (c.params.is_null() && app.native_shortcuts.contains(&c.id) && app.keymap.overrides().get(&c.id).is_none()) - }) - } - _ => true, - }; - if !keep { - held.push(e.clone()); - } - keep - }); - let hits = app.key_dispatcher.dispatch(i, &app.keymap, &active); - i.events.extend(held); - hits + let native = &app.native_shortcuts; + let overrides = app.keymap.overrides(); + // Presses the dispatcher leaves alone: Clear (the active tool deletes) and the keys the + // native menu's accelerators already handled. An accelerator holds the command's default + // keys and fires only for them 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) && overrides.get(&c.id).is_none())); + !leave + }; + app.key_dispatcher.dispatch_filtered(i, &app.keymap, &active, accept) }); for hit in hits { if !hit.cycle.is_empty() { @@ -3513,9 +3496,12 @@ mod tests { 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 in the current form, it reads back the same. + // Saved again it reads back the same, and with one key per command it keeps 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] diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 0d62a535a..e413369d6 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -351,6 +351,16 @@ mod tests { assert_eq!(menus::shortcut_of(&app, "app.palette").as_deref(), Some("Cmd+Enter")); } + #[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] fn hostile_input_returns_a_report_or_an_error() { let mut app = app(); From 8e20bdfe2d2a5fcf8eb9905d59c2b5fb2939bad4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:40:50 +0000 Subject: [PATCH 007/315] keymap: panel menus, toggles and aliases on craft-keymap 6e136c9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The importer marks Panel Menus items as panel-menu entries. - Show/Hide commands and the other show-and-hide toggles are marked as toggles, so Show and Hide names match only them. - The Preferences alias names its submenu (Preferences: General…). - The import test checks an alias match (Exit). --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/keys.rs | 8 +++++- crates/ui-egui/src/shortcut_import.rs | 37 +++++++++++++++++++-------- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 863e963d9..9743d4872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=66f22029ba71dad29dcda156f01f680763aa6659#66f22029ba71dad29dcda156f01f680763aa6659" +source = "git+https://github.com/aiongg/craft-libs?rev=6e136c9114d4461e791f0599c4976fc0c01e0048#6e136c9114d4461e791f0599c4976fc0c01e0048" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0936e75a1..776f89724 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # Shared writer; the immutable revision must be published before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch. -craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "66f22029ba71dad29dcda156f01f680763aa6659", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "6e136c9114d4461e791f0599c4976fc0c01e0048", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index ee85a0400..8600dbae5 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -36,7 +36,7 @@ const PANEL_KEYS: &[(&str, &str)] = &[ /// 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", "General"), + ("app.preferences", "Preferences: General…"), ("app.insertTableDialog", "Insert Table"), ("view.togglePresentation", "Presentation"), ("color.defaultFillStroke", "Apply Default Fill and Stroke Colors"), @@ -50,6 +50,11 @@ const IMPORT_ALIASES: &[(&str, &str)] = &[ ("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"])]; @@ -146,6 +151,7 @@ fn build_catalogue() -> Vec { continue; } let id = b.command.id.as_str(); + b.is_toggle = b.label.starts_with("Show/Hide ") || TOGGLES.contains(&id); 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())); } diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index e413369d6..d5161bf65 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -145,20 +145,30 @@ fn strip_suffix_ignore_case<'a>(s: &'a str, suffix: &str) -> Option<&'a str> { } /// The set as `craft_keymap::import` reads it. An area named ` Menu` is that menu; the -/// Tools area is the Tools menu, where items named `… Tool` select tools. Items of other areas -/// (panel menus, 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. +/// 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 menus: Vec<(Option<&str>, bool)> = set + let areas: Vec = set .areas .iter() .map(|a| { let a = a.trim(); if a.eq_ignore_ascii_case("tools") { - (Some(a), true) + 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 { - (strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')), false) + let menu = strip_suffix_ignore_case(a, " menu").map(str::trim).filter(|m| !m.is_empty() && !m.contains(':')); + Area { menu, ..Area::default() } } }) .collect(); @@ -166,9 +176,12 @@ fn foreign(set: &ParsedSet) -> ForeignSet { .items .iter() .map(|item| { - let (menu, tools) = item.area.and_then(|a| menus.get(a)).copied().unwrap_or((None, false)); - let mut entry = ForeignEntry::new(menu, item.name.split(": ").map(str::trim)).at_line(item.line); - entry.is_tool = tools && strip_suffix_ignore_case(&item.name, " tool").is_some(); + 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(); + } + 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 }) @@ -313,6 +326,8 @@ mod tests { \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+F12\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"); @@ -343,9 +358,11 @@ mod tests { // 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+F12")); // 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"], 6, "{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+Enter")); From e50d5983e124ca45a0825b6fb8f6c6747a105454 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:41:50 +0000 Subject: [PATCH 008/315] keymap: parity row lists what the importer still can't apply --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 72729afef..6c49aa314 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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; panels take keys (InDesign's F5–F12 defaults where free) and tool keys are editable; imports an InDesign Show Set `.txt` | ✓ | the importer applies menu items only, so a set's Tools and panel items are reported, not applied; no Object Styles or Transform panel to give Cmd+F7 or F9, and Text Wrap (Cmd+Alt+W) and Links (Cmd+Shift+D) keep DesignCraft's owners of those keys | +| 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) | From 4f7c7e85bec85b4d7518ae6c3bdbbbe9cb7f717c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 04:48:25 +0000 Subject: [PATCH 009/315] keymap: craft-keymap 1e4ab21; report lists keyless panel-menu matches --- Cargo.lock | 2 +- Cargo.toml | 2 +- ROADMAP.md | 2 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/shortcut_import.rs | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9743d4872..19e31782d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=6e136c9114d4461e791f0599c4976fc0c01e0048#6e136c9114d4461e791f0599c4976fc0c01e0048" +source = "git+https://github.com/aiongg/craft-libs?rev=1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305#1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 776f89724..def2203aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/storytold/designcraft" # Shared writer; the immutable revision must be published before this consumer. craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false } # Shared keyboard bindings: chords, overrides, conflicts, egui dispatch. -craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "6e136c9114d4461e791f0599c4976fc0c01e0048", version = "=0.1.0", features = ["egui"] } +craft-keymap = { git = "https://github.com/aiongg/craft-libs", rev = "1e4ab21708793dbe66ff7e1cfc5b5094c3c9a305", version = "=0.1.0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 0ba1879fc..b303c4e5c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,7 +348,7 @@ 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 280 of 1,629 items (was 233) and changes 27 commands. No readiness percentage change. +- **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-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 74571bf4f..6cce528bc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -94,7 +94,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.importShortcuts", "Import InDesign Shortcut Set", None, - "{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), truncated}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)", + "{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)"), diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index d5161bf65..f7b30ecac 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -247,6 +247,9 @@ fn report_json(r: &ImportReport) -> Value { "suggestions": Value::Array(r.suggestions.iter().map(|s| json!({ "item": s.item, "line": s.line, "candidates": s.candidates.iter().map(|c| c.key()).collect::>(), })).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, }) } From 687ea314b6ffdb92d1e70724285a03f71f8c30fb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 05:16:28 +0000 Subject: [PATCH 010/315] Docking trial: craft-ui docking branch, group drag by the drag bar Patch craft-ui to aiongg/craft-libs 4bf1164 (docking branch). Floating windows hold groups (Floating::single, panels()); matches on Node and Action get wildcard arms; apply_action validates MoveGroup, MoveColumn, MoveWindow, the group close actions, minimize and resize, and records return locations for every panel a close action removes. PanelContent gives preferred floating sizes. A pointer test drags the docked Character Styles | Paragraph Styles group by its drag bar to the canvas and onto another strip: order and the front tab are kept. --- Cargo.lock | 2 +- Cargo.toml | 4 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panel_docking.rs | 84 +++++++++++++++++++---- crates/ui-egui/src/panel_docking_tests.rs | 66 +++++++++++++++--- 5 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3db056251..7e9ae36d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/aiongg/craft-libs?rev=4bf11647adb05e1b6f5a49c1ae5f7105bf746c71#4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index ca5d0baaf..8f169d311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,3 +123,7 @@ hayro = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e hayro-interpret = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } hayro-syntax = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0ffaa5e5e6658bccdf8655a3ba689c9" } + +# Docking trial: craft-ui from the craft-libs `docking` branch. +[patch."https://github.com/storytold/craft-libs"] +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index acb8e3e42..382e259af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5059,7 +5059,7 @@ mod tests { // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); let layout = app.ui.docking.as_ref().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index bec168ea1..4be709b70 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1,6 +1,6 @@ //! Application-owned panel IDs and commands around the shared docking layout. -use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Target, Zone}; use serde_json::{Value, json}; use crate::DesignApp; @@ -53,7 +53,7 @@ fn legacy(app: &DesignApp) -> Layout { let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; for (id, at) in &app.ui.floating { if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { - layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); + layout.floating.push(Floating::single(vec![id.clone()], 0, [at[0], at[1], 280.0, 360.0])); } } if let Some(id) = &app.ui.open_panel @@ -72,7 +72,7 @@ fn legacy(app: &DesignApp) -> Layout { /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. fn tab_group(layout: &Layout, panel: &str) -> Vec { - if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == panel)) { + if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); } let mut visit: Vec<&Node> = layout.root.iter().collect(); @@ -88,6 +88,11 @@ fn tab_group(layout: &Layout, panel: &str) -> Vec { visit.push(second); } Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + Node::Dock { columns, .. } => { + if let Some(group) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return group.panels.clone(); + } + } _ => {} } } @@ -113,7 +118,7 @@ fn open_in_stack(layout: &Layout, panel: &str) -> bool { return e.open; } } - Node::Tabs { .. } => {} + _ => {} } } false @@ -216,6 +221,29 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result registered(panel), Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, + Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + registered(panel) + && match to { + Target::At { anchor, placement } => { + registered(anchor) + && match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + } + } + _ => true, + } + } + Action::CloseGroup { panel } + | Action::CloseWindow { panel } + | Action::CloseOthers { panel } + | Action::SetMinimized { panel, .. } + | Action::SetIconic { panel, .. } + | Action::ResizeColumn { panel, .. } + | Action::ResizeGroup { panel, .. } + | Action::ResizeIconic { panel, .. } => registered(panel), + // Native OS windows are not enabled in DesignCraft; newer actions are rejected. + _ => false, }; if !known { return Err("docking action contains an unknown panel or an excessive split path".into()); @@ -224,19 +252,24 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result layout.location(panel).ok().map(|location| (panel.clone(), location)), + // Window › a closed panel, and Dock Panel after a float, bring it back where it was. + let returning: Vec<_> = match &action { Action::Float { panel, .. } => { - layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() } - _ => None, + _ => layout + .closed_by(&action) + .unwrap_or_default() + .into_iter() + .filter_map(|panel| layout.location(&panel).ok().map(|location| (panel, location))) + .collect(), }; let reveal = match &action { Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), _ => None, }; layout.apply(action).map_err(|error| error.to_string())?; - if let Some((panel, location)) = returning { + for (panel, location) in returning { app.ui.docking_hidden.insert(panel, location); } app.ui.docking = Some(layout); @@ -266,7 +299,8 @@ fn front_tabs(layout: &Layout) -> Vec { visit.push(second); } Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), - Node::Stack { .. } => {} + Node::Dock { columns, .. } => out.extend(columns.iter().flat_map(|c| &c.groups).filter_map(|g| g.active_panel().cloned())), + _ => {} } } out @@ -291,7 +325,14 @@ fn restore_front_tabs(layout: &mut Layout, front: &[String]) { *active = i; } } - Node::Stack { .. } => {} + Node::Dock { columns, .. } => { + for group in columns.iter_mut().flat_map(|c| &mut c.groups) { + if let Some(i) = group.panels.iter().position(|p| front.contains(p)) { + group.active = i; + } + } + } + _ => {} } } } @@ -523,7 +564,9 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { app.ui.status = error.to_string(); } if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. } | Action::MoveGroup { .. })) + && cancel_legacy_drag(app, ui.ctx()) + { return app.ui.docking.is_some(); } ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); @@ -548,7 +591,7 @@ fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 2.0; for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { if app.ui.docking.as_ref().is_some_and(|layout| { - layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) + layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels().any(|p| p == id))) }) { continue; } @@ -652,6 +695,16 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { }); } +/// The size a panel floats at (tab strip included), whatever the size of the dock it left. +fn floating_size(panel: &str) -> egui::Vec2 { + match panel { + "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), + "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), + "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), + _ => egui::vec2(280.0, 360.0), + } +} + struct PanelContent<'a>(&'a mut DesignApp); impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { @@ -674,6 +727,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { ui.separator(); } } + fn floating_size(&self, id: &String) -> Option { + Some(floating_size(id)) + } fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 { 24.0 } @@ -750,7 +806,7 @@ fn sync_legacy(app: &mut DesignApp) { app.ui.floating = layout .floating .iter() - .flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))) + .flat_map(|window| window.panels().map(|id| (id.clone(), [window.rect[0], window.rect[1]]))) .chain(float_only) .collect(); } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f436cfb5e..8d8e34fdd 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -13,7 +13,7 @@ fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_owners app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); let before_float = app.ui.docking.clone().unwrap(); app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); - assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"])); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["properties"])); app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); @@ -87,12 +87,12 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); drag(&mut h, source, egui::pos2(160.0, 140.0)); let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); - assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); let source = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); let destination = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); - assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); + assert!(!layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels.contains(&"layers".into()))); assert!(valid(layout)); } @@ -217,7 +217,7 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); assert_eq!( - app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect, + app.ui.docking.as_ref().unwrap().floating.iter().find(|window| window.panels().any(|p| p == "properties")).unwrap().rect, [44.0, 66.0, 360.0, 450.0] ); app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) @@ -712,7 +712,7 @@ fn paragraph_styles_open_floating_with_character_styles() { let mut app = app(); app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); + let group = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. @@ -721,11 +721,11 @@ fn paragraph_styles_open_floating_with_character_styles() { app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"])); + assert!(layout.floating.iter().flat_map(|w| &w.groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"])); // Once showing, Window › Character Styles brings that tab forward. app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); + let group = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); assert_eq!(group.panels[group.active], "characterStyles"); assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); } @@ -744,7 +744,7 @@ fn paragraph_styles_closed_from_the_dock_reopen_there() { app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); assert!(layout.contains(&"paragraphStyles".to_string())); - assert!(layout.floating.iter().all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); + assert!(layout.floating.iter().flat_map(|w| &w.groups).all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); } fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { @@ -787,3 +787,53 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(h.query_by_label("Close Panel").is_none()); assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu"); } + +fn group_with<'a>(layout: &'a Layout, panel: &str) -> (bool, &'a [String], usize) { + if let Some(g) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (true, &g.panels, g.active); + } + let mut visit: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + match node { + Node::Split { first, second, .. } => visit.extend([&**first, &**second]), + Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels, *active), + _ => {} + } + } + panic!("{panel} is not in {layout:?}"); +} + +fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { + h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("drag-bar", Some(&first.to_string())))).expect("the group's drag bar").rect +} + +/// The docked Character Styles | Paragraph Styles group moves as one by its drag bar: dropped on +/// the canvas it floats, dropped on another strip it joins as tabs. Order and the tab in front +/// are kept. +#[test] +fn dragging_a_docked_group_by_its_bar_moves_every_tab() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Split": "Bottom"}}}}}); + app.run("window.panel.layout", json!({"action": below})).unwrap(); + let styles = ["characterStyles", "paragraphStyles"]; + assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, &styles.map(String::from)[..], 1)); + + let mut h = harness(app, vec2(1280.0, 800.0)); + let bar = drag_bar(&h, "characterStyles"); + drag(&mut h, bar.center(), egui::pos2(400.0, 400.0)); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(group_with(layout, "paragraphStyles"), (true, &styles.map(String::from)[..], 1), "{layout:?}"); + assert!(valid(layout)); + + let bar = drag_bar(&h, "characterStyles"); + let properties = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, bar.center(), properties); + let layout = h.state().ui.docking.as_ref().unwrap(); + let (floating, panels, active) = group_with(layout, "characterStyles"); + assert!(!floating, "{layout:?}"); + let at = panels.iter().position(|p| p == "characterStyles").unwrap(); + assert_eq!(panels.get(at + 1).map(String::as_str), Some("paragraphStyles"), "{panels:?}"); + assert_eq!(panels[active], "paragraphStyles"); + assert!(layout.floating.is_empty(), "{:?}", layout.floating); +} From 5f5b6164c2644cef045db16c2269c1468d93bd7f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:09:25 +0000 Subject: [PATCH 011/315] Docking trial: one dock path, ui.docking, localized dock labels --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/chrome.rs | 10 +- crates/ui-egui/src/control.rs | 5 +- crates/ui-egui/src/dock.rs | 11 +- crates/ui-egui/src/lib.rs | 122 ++--- crates/ui-egui/src/menus.rs | 27 +- crates/ui-egui/src/panel_docking.rs | 535 ++++++++++++++++------ crates/ui-egui/src/panel_docking_tests.rs | 131 +++++- crates/ui-egui/src/panels/mod.rs | 9 + crates/ui-egui/src/panels/properties.rs | 4 +- docs/control-protocol.md | 55 ++- 12 files changed, 644 insertions(+), 269 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e9ae36d8..1e77d5344 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=4bf11647adb05e1b6f5a49c1ae5f7105bf746c71#4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" +source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8f169d311..9ecd08b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "4bf11647adb05e1b6f5a49c1ae5f7105bf746c71" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0df5361f9..1f865a3dc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1250,15 +1250,15 @@ mod tests { h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - // Preflight is not a dock tab: `window.panel` shows it as its own panel. - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + // Preflight is in the iconic column: `window.panel` shows it. + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); h.get_by_label("Preflight"); // With the panel already showing, the entry keeps it open. h.get_by_label("No errors ▾").click(); h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. @@ -1272,7 +1272,7 @@ mod tests { count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); h.get_by_label("Preflight"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); // A double-click with the panel already showing keeps it open. @@ -1280,7 +1280,7 @@ mod tests { count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// A very long document name made its tab grow past the window, hiding the close button diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..ea81c278e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -4,6 +4,7 @@ //! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command //! - `engine.commands`: engine + UI commands with enablement //! - `document.inspect`: document summary; `ui.inspect`: UI state +//! - `ui.docking`: the panel layout in use and the focused panel //! - `ui.menu.list`: flattened menu tree //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: @@ -77,6 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "docking": crate::panel_docking::inspect(app), "window": [r.width(), r.height()], "documents": app .session @@ -169,6 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", json!({}))), "ui.inspect" => ok(inspect(app, ctx)), + "ui.docking" => ok(crate::panel_docking::inspect(app)), "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()), @@ -361,7 +364,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { - app.ui.dock_expanded = v; + r = crate::panel_docking::set_iconic(app, !v); } wrap(r) } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fb9a7cf17..a825e3502 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -58,10 +58,8 @@ pub fn panel_label(id: &str) -> &'static str { DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1) } +/// The classic dock, replaced by the docking layout (`panel_docking::show`) and no longer drawn. pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - if crate::panel_docking::show(app, ui) { - return; - } let t = Tokens::get(ui.ctx()); // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. if app.ui.dock_expanded { @@ -331,7 +329,7 @@ pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { /// Put a floating panel back in the dock's icon column. pub fn dock_panel(app: &mut DesignApp, id: &str) { - if app.ui.docking.is_some() && !float_only(id) { + if !float_only(id) { if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { app.status(error); } @@ -342,11 +340,10 @@ pub fn dock_panel(app: &mut DesignApp, id: &str) { /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { - // A customized layout draws its own floating groups; float-only panels are drawn here. - let customized = app.ui.docking.is_some(); + // The docking layout draws its own floating groups; float-only panels are drawn here. let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { - if customized && !float_only(&id) { + if !float_only(&id) { continue; } let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcb764c5a..0a76e123c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -259,11 +259,15 @@ pub struct UiState { pub open_panel: Option, /// Panels torn off the dock into their own floating windows: (panel id, top-left position). pub floating: Vec<(String, [f32; 2])>, - /// Shared panel layout after the user customizes the legacy dock. + /// The panel layout once the user customizes it; `None` is the workspace's default layout + /// (`panel_docking::default_layout`). #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, + /// The panel (or the canvas) last clicked or selected in the docking layout. + #[serde(skip)] + pub docking_focused: Option, /// Invalidates in-flight gestures when a workspace is replaced. #[serde(skip)] pub docking_generation: u64, @@ -404,6 +408,7 @@ impl Default for UiState { docking_generation: 0, docking_raise: None, docking_opened: None, + docking_focused: None, dock_expanded: true, icon_groups: None, units: Unit::Picas, @@ -1488,6 +1493,63 @@ impl DesignApp { raw.events.extend(events.drain(..n)); } + /// The document area: the start screen, or the document tabs over one or two canvas panes. + pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) { + let t = theme::Tokens::get(ui.ctx()); + if self.session.active().is_none() { + chrome::start_screen(self, ui); + return; + } + if !presenting { + chrome::doc_tabs(self, ui); + } + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { + // Two panes with a divider; each has its own view (zoom, scroll) and render cache. + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { + self.switch_pane(k as u8); + // A new pane starts at the other pane's view. + if k == 1 && self.view().is_none() { + self.switch_pane(0); + let v = self.view().copied(); + self.switch_pane(1); + if let (Some(mut v), Some(m)) = (v, self.view_mut()) { + v.fitted = false; + *m = v; + } + } + ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); + } + self.switch_pane(self.focus_pane); + } else { + self.switch_pane(0); + // With a second window, the main one takes the focus back when clicked. + if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { + self.focus_pane = 0; + } + canvas::show(self, ui); + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1523,62 +1585,13 @@ impl DesignApp { if self.ui.hidden_panels != 1 { toolbar::show(self, ui); } - if self.ui.hidden_panels == 0 { - dock::show(self, ui); - } } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.session.active().is_none() { - chrome::start_screen(self, ui); - return; - } - if !presenting { - chrome::doc_tabs(self, ui); - } - let split_layout = (self.split && !presenting) - .then(|| { - craft_ui::layout::split_rect( - ui.available_rect_before_wrap(), - craft_ui::layout::SplitAxis::Horizontal, - craft_ui::layout::SplitSize::Ratio(0.5), - 2.0, - 0.0, - ) - }) - .flatten(); - if let Some(layout) = split_layout { - // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - ui.painter().rect_filled(layout.divider, 0.0, t.border); - // Resolve the clicked pane before either canvas handles this frame's keys. - if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { - if layout.first.contains(p) { - self.focus_pane = 0; - } else if layout.second.contains(p) { - self.focus_pane = 1; - } - } - for (k, half) in [layout.first, layout.second].into_iter().enumerate() { - self.switch_pane(k as u8); - // A new pane starts at the other pane's view. - if k == 1 && self.view().is_none() { - self.switch_pane(0); - let v = self.view().copied(); - self.switch_pane(1); - if let (Some(mut v), Some(m)) = (v, self.view_mut()) { - v.fitted = false; - *m = v; - } - } - ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - } - self.switch_pane(self.focus_pane); + // The panel layout holds the canvas as its protected `canvas` panel. + if presenting || self.ui.hidden_panels != 0 { + self.canvas_area(ui, presenting); } else { - self.switch_pane(0); - // With a second window, the main one takes the focus back when clicked. - if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { - self.focus_pane = 0; - } - canvas::show(self, ui); + panel_docking::show(self, ui); } }); // New Window: pane 1 in a window of its own. @@ -1618,7 +1631,6 @@ impl DesignApp { } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { - dock::flyout(self, &ctx); dock::floating(self, &ctx); } story_editor::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 382e259af..3558be93a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1826,11 +1826,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), - "window.collapseToIcons" => { - app.ui.dock_expanded = false; - app.ui.open_panel = None; - Ok(Value::Null) - } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = @@ -4003,8 +3998,9 @@ mod tests { out.textures_delta.clear(); } }; - // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). - let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("panel_flyout")); + // The docked panel's tab and a floating panel group. + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"preflight".to_string())); + let docked = || ctx.read_response(tab).is_some(); let group = egui::LayerId::new(egui::Order::Middle, egui::Id::new("designcraft-panel-docking").with(("floating", "preflight"))); let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); let toggles = [ @@ -4014,14 +4010,14 @@ mod tests { ]; app.run("window.panel", json!({"panel": "preflight"})).unwrap(); frame(&mut app); - assert!(app.ui.docking.is_none() && shown(flyout)); + assert!(docked()); for (id, hide, show) in &toggles { app.run(id, hide.clone()).unwrap(); frame(&mut app); - assert!(!shown(flyout), "{id} hides the flyout"); + assert!(!docked(), "{id} hides the dock"); app.run(id, show.clone()).unwrap(); frame(&mut app); - assert!(shown(flyout), "{id} again brings it back"); + assert!(docked(), "{id} again brings it back"); } app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap(); frame(&mut app); @@ -5055,7 +5051,7 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); activate(&mut app, "app.characterPanel", &Value::Null); - assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + assert!(crate::panel_docking::showing(&app, "character")); // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); let layout = app.ui.docking.as_ref().unwrap(); @@ -5132,7 +5128,10 @@ mod tests { assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false)); // Window › Pages opens Pages from its icon group, not the Properties stack. run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap(); - assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false)); + assert!(crate::panel_docking::showing(&app, "pages")); + let layout = app.ui.docking.clone().unwrap(); + assert!(!layout.contains(&"properties".to_string())); + assert_eq!(crate::panel_docking::tab_group(&layout, "pages"), ["pages", "layers", "links"]); // A workspace saved from it keeps the groups. run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); @@ -5154,8 +5153,8 @@ mod tests { assert_eq!(groups[5], ["character", "characterStyles"]); for panel in ["pages", "paragraphStyles", "characterStyles"] { run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap(); - assert_eq!(app.ui.open_panel.as_deref(), Some(panel)); - assert!(!app.ui.dock_expanded); + assert!(crate::panel_docking::showing(&app, panel), "{panel}"); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty(), "{panel} opens from its icon group"); } run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.icon_groups, Some(groups)); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 4be709b70..393c977e5 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1,6 +1,10 @@ //! Application-owned panel IDs and commands around the shared docking layout. +//! +//! The window's panels and the canvas are one craft-ui layout: a horizontal split of the +//! protected `canvas` panel and a dock at the logical end edge (the right in left-to-right +//! languages, the left in right-to-left ones). -use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Target, Zone}; +use craft_ui::docking::{Action, Column, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, Zone}; use serde_json::{Value, json}; use crate::DesignApp; @@ -43,35 +47,144 @@ fn normalize(raw: &str) -> Option<&'static str> { .map(|(id, _, _)| *id) } -pub(crate) fn valid(layout: &Layout) -> bool { - layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +/// The panel that holds the document area. It never moves, floats, closes or takes tabs. +pub(crate) const CANVAS: &str = "canvas"; +/// Width of an expanded dock column. +const COLUMN_WIDTH: f32 = 260.0; +/// Width of an iconic column of bare icons, and of one whose groups show their names. +const ICON_WIDTH: f32 = 37.0; +const NAMED_ICON_WIDTH: f32 = 150.0; +/// Height of the group a workspace opens below Properties · Pages · Layers. +const OPEN_PANEL_HEIGHT: f32 = 260.0; + +fn registered(id: &str) -> bool { + id == CANVAS || normalize(id) == Some(id) } -fn legacy(app: &DesignApp) -> Layout { - let panels: Vec = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); - let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); - let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; - for (id, at) in &app.ui.floating { - if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { - layout.floating.push(Floating::single(vec![id.clone()], 0, [at[0], at[1], 280.0, 360.0])); +/// The policy commands and the renderer apply: the canvas is protected. +fn model_permissions(id: &String) -> Permissions { + if id == CANVAS { Permissions::PROTECTED } else { Permissions::default() } +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() + && layout.panels().iter().all(|id| registered(id)) + && Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&CANVAS.to_string()) +} + +/// The layout of a workspace that has not been customized, from its panel settings: the +/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an +/// open flyout panel stacked below it) and whose inner column is iconic. Essentials Classic +/// and Typography (`icon_groups`) have the iconic column only. +pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { + let mut placed: Vec = Vec::new(); + let take = |id: &str, placed: &mut Vec| -> Option { + let id = normalize(id)?; + (!placed.iter().any(|p| p == id)).then(|| { + placed.push(id.to_string()); + id.to_string() + }) + }; + let mut floating = Vec::new(); + for (id, at) in &ui.floating { + if at.iter().all(|v| v.is_finite()) + && let Some(id) = take(id, &mut placed) + { + floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); } } - if let Some(id) = &app.ui.open_panel - && normalize(id).is_some() - && !layout.contains(id) - { - // Preserve the visible flyout when the first unrelated docking gesture succeeds. - if let Some(Node::Tabs { panels, active }) = &mut layout.root { - panels.push(id.clone()); - *active = panels.len().saturating_sub(1); + let mut columns = Vec::new(); + match &ui.icon_groups { + Some(groups) => { + let groups: Vec> = groups + .iter() + .map(|g| g.iter().filter_map(|id| take(id, &mut placed)).collect::>()) + .filter(|panels| !panels.is_empty()) + .map(|panels| Group::new(panels, 0)) + .collect(); + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(NAMED_ICON_WIDTH) }); + } + } + None => { + let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| take(p.0, &mut placed)).collect(); + let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); + let mut groups = Vec::new(); + if !panels.is_empty() { + groups.push(Group::new(panels, active)); + } + // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed + // height, so Properties keeps most of the column. + if let Some(id) = ui.open_panel.as_deref().and_then(|id| take(id, &mut placed)) { + groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); + } + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); + } + // Window › Styles opens the style panels as a floating group, so they start outside. + let icons: Vec> = crate::dock::ICON_PANELS + .iter() + .filter(|p| !FLOATING_GROUPS.iter().any(|g| g.contains(&p.0))) + .filter_map(|p| take(p.0, &mut placed)) + .map(|id| Group::new(vec![id], 0)) + .collect(); + if !icons.is_empty() { + columns.push(Column { groups: icons, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(ICON_WIDTH) }); + } } } - layout + let width: f32 = columns.iter().map(|c| if c.iconic { c.iconic_width.unwrap_or(ICON_WIDTH) } else { c.width }).sum(); + Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedSecond(width), + first: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + second: Box::new(Node::Dock { side: Side::End, columns }), + }), + floating, + } +} + +/// The layout in use: the customized one, or the workspace's default. +fn layout_of(app: &DesignApp) -> Layout { + app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) +} + +/// Is `panel` showing: in front of its group, and not in a column collapsed to icons? +pub(crate) fn showing(app: &DesignApp, panel: &str) -> bool { + layout_of(app).panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front +} + +/// Is `panel` in a dock column collapsed to icons? +fn in_iconic_column(layout: &Layout, panel: &str) -> bool { + dock_columns(layout).iter().any(|c| c.iconic && c.groups.iter().any(|g| g.panels.iter().any(|p| p == panel))) +} + +/// Every dock column, from each dock's window edge inwards. +fn dock_columns(layout: &Layout) -> Vec<&Column> { + let mut out = Vec::new(); + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(second); + visit.push(first); + } + Node::Dock { columns, .. } => out.extend(columns), + _ => {} + } + } + out } /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. -fn tab_group(layout: &Layout, panel: &str) -> Vec { +pub(crate) fn tab_group(layout: &Layout, panel: &str) -> Vec { if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); } @@ -124,14 +237,26 @@ fn open_in_stack(layout: &Layout, panel: &str) -> bool { false } -fn first_root(layout: &Layout, except: &str) -> Option { - let root = Layout { root: layout.root.clone(), floating: Vec::new() }; - root.panels().into_iter().find(|p| p.as_str() != except).cloned() +/// Where a panel with no other destination goes: a panel of the first expanded dock column, +/// else of any column, else of the root. +fn first_dock_panel(layout: &Layout, except: &str) -> Option { + let usable = |p: &&String| p.as_str() != except && p.as_str() != CANVAS; + let columns = dock_columns(layout); + let in_columns = + |iconic: bool| columns.iter().filter(|c| c.iconic == iconic).flat_map(|c| &c.groups).flat_map(|g| &g.panels).find(usable).cloned(); + in_columns(false).or_else(|| in_columns(true)).or_else(|| { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(usable).cloned() + }) } fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { - let anchor = first_root(layout, panel); - layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) + let anchor = first_dock_panel(layout, panel); + apply(layout, Action::Open { panel: panel.into(), anchor }) +} + +fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + layout.apply_with_permissions(action, model_permissions).map_err(|e| e.to_string()) } fn number(params: &Value, key: &str, default: f32) -> Result { @@ -145,7 +270,33 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } -fn placement(params: &Value) -> Result, String> { +/// The side of the dock holding `panel`, if a dock column holds it. +fn dock_side(layout: &Layout, panel: &str) -> Option { + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(first); + visit.push(second); + } + Node::Dock { side, columns } if columns.iter().flat_map(|c| &c.groups).any(|g| g.panels.iter().any(|p| p == panel)) => { + return Some(*side); + } + _ => {} + } + } + None +} + +/// The placement a `zone` names beside `anchor`. In a dock column or a floating window, top and +/// bottom add a group above or below the anchor's group; in a dock column, left and right add a +/// column on that side of the anchor's. Elsewhere the sides split the anchor's group. +fn placement(params: &Value, layout: &Layout, anchor: &str, rtl: bool) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; return Ok(Placement::Tab { before: Some(id.into()) }); @@ -154,9 +305,18 @@ fn placement(params: &Value) -> Result, String> { None => "tab", Some(value) => value.as_str().ok_or("zone must be a string")?, }; + let side = dock_side(layout, anchor); + let floating = layout.floating.iter().any(|w| w.contains(anchor)); Ok(match zone { "tab" => Placement::Tab { before: None }, "center" => Placement::Split(Zone::Center), + "top" | "bottom" if side.is_some() || floating => Placement::Group { below: zone == "bottom" }, + "left" | "right" if floating => return Err("a floating window has no columns; use top, bottom or tab".into()), + "left" | "right" if let Some(side) = side => { + // Columns count from the window edge inwards; `after` is away from the edge. + let edge_is_right = (side == Side::End) != rtl; + Placement::Column { after: (zone == "right") != edge_is_right } + } "left" => Placement::Split(Zone::Left), "right" => Placement::Split(Zone::Right), "top" => Placement::Split(Zone::Top), @@ -166,10 +326,11 @@ fn placement(params: &Value) -> Result, String> { } pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option> { - if id == "app.tablePanel" && app.ui.docking.is_some() { - let panel = "table".to_string(); - let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); - return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + if id == "app.tablePanel" { + return Some(change(app, if showing(app, "table") { "close" } else { "activate" }, &json!({"panel": "table"}))); + } + if id == "window.collapseToIcons" { + return Some(set_iconic(app, true)); } if id == "window.panel.closeGroup" { return Some(close_group(app, params)); @@ -194,7 +355,8 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option "dock", "window.panel.activate" => "activate", "window.panel.close" => "close", - "window.panel" if app.ui.docking.is_some() => "activate", + // Float-only panels (Type › Tabs) are not in the layout. + "window.panel" if params.get("panel").and_then(Value::as_str).and_then(normalize).is_some() => "activate", _ => return None, }; Some(change(app, operation, params)) @@ -203,7 +365,7 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result { - let registered = |id: &String| normalize(id) == Some(id.as_str()); + let registered = |id: &String| self::registered(id); let known = match &action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { registered(panel) @@ -251,28 +413,25 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result = match &action { Action::Float { panel, .. } => { layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() } - _ => layout - .closed_by(&action) - .unwrap_or_default() - .into_iter() - .filter_map(|panel| layout.location(&panel).ok().map(|location| (panel, location))) - .collect(), + _ => closed.iter().filter_map(|panel| layout.location(panel).ok().map(|location| (panel.clone(), location))).collect(), }; let reveal = match &action { Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), _ => None, }; - layout.apply(action).map_err(|error| error.to_string())?; + apply(&mut layout, action)?; for (panel, location) in returning { app.ui.docking_hidden.insert(panel, location); } app.ui.docking = Some(layout); + release_closed(app, &closed); sync_legacy(app); if let Some(panel) = reveal { app.ui.docking_opened = Some(panel.clone()); @@ -355,7 +514,7 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Result = app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); let mut restored = false; @@ -403,7 +562,7 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Result { if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { @@ -429,24 +588,24 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), - None => first_root(&layout, panel), + None => first_dock_panel(&layout, panel), }; - let placement = placement(params)?; if let Some(anchor) = anchor { + let placement = placement(params, &layout, &anchor, crate::i18n::is_rtl(&app.ui.language))?; ensure(&mut layout, panel)?; - layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Move { panel: panel.into(), anchor, placement })?; } else if operation == "dock" { if layout.contains(&panel.to_string()) { - layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Close { panel: panel.into() })?; } - layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Open { panel: panel.into(), anchor: None })?; } else { return Err("move requires an existing destination panel".into()); } } "close" => { hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); - layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + apply(&mut layout, Action::Close { panel: panel.into() })?; } _ => { if !layout.contains(&panel.to_string()) @@ -457,10 +616,17 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result Result>(), "layout": app.ui.docking})) } -pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { +/// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last +/// opened (for `ui.set closePanel`) and a pending raise. +fn release_closed(app: &mut DesignApp, closed: &[String]) { + for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise] { + if slot.as_ref().is_some_and(|panel| closed.contains(panel)) { + *slot = None; + } + } +} + +/// Window › Collapse to Icons collapses every dock column; `iconic: false` (the control +/// channel's `dockExpanded`) expands the outer column. +pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = layout_of(app); + let columns = dock_columns(&layout); + let firsts = |c: &&Column| c.groups.first().and_then(|g| g.panels.first()).cloned(); + let targets: Vec = if iconic { + columns.iter().filter(|c| !c.iconic).filter_map(firsts).collect() + } else { + columns.first().filter(|c| c.iconic).and_then(firsts).into_iter().collect() + }; + for panel in targets { + apply(&mut layout, Action::SetIconic { panel, iconic })?; + } + app.ui.docking = Some(layout); + app.ui.dock_expanded = !iconic; + app.ui.open_panel = None; + Ok(Value::Null) +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let area = egui::Id::new("designcraft-panel-docking"); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); @@ -482,22 +681,11 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { craft_ui::docking::cancel_drag::(ui.ctx(), area); ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } - - let cancelled = - ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released())); - if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() { - return false; - } - let Some(layout) = app.ui.docking.as_ref() else { return false }; - if !valid(layout) { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { app.ui.docking = None; app.status(crate::i18n::tr(&app.ui.language, "Invalid panel layout; restored the default dock")); - return false; - } - let mut layout = layout.clone(); - if !app.ui.dock_expanded { - layout.root = None; } + let layout = layout_of(app); let raise = app.ui.docking_raise.take(); let mut blocked = Vec::new(); if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { @@ -507,79 +695,87 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); blocked.push(rect); } - let t = crate::theme::Tokens::get(ui.ctx()); - let language = app.ui.language.clone(); - let mut draw = |ui: &mut egui::Ui| { - let mut style = craft_ui::docking::DockStyle::from_ui(ui); - style.tab_height = 27.0; - style.background = t.panel; - style.tab_background = t.tab_strip; - style.active_background = t.panel; - style.text = t.text_strong; - style.inactive_text = t.text_dim; - style.border = egui::Stroke::new(1.0, t.border); - style.accent = t.accent; - style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned(); - style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); - style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); - style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); - style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); - style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); - let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); - if let Some(panel) = &raise { - area = area.raise_panel(panel.clone()); - } - area.show_customized( - ui, - &layout, - &style, - |id| { - crate::i18n::tr( - &language, - crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), - ) - .to_owned() - }, - // Close comes from the panel's own tab commands, so the surface doesn't add another. - |_| craft_ui::docking::Permissions { closable: false, ..Default::default() }, - |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, - &mut PanelContent(app), - ) - }; - let output = if layout.root.is_some() { - egui::Panel::right("shared-panel-dock") - .default_size(310.0) - .size_range(220.0..=720.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel)) - .show(ui, &mut draw) - .inner - } else { - draw(ui) - }; + let style = dock_style(ui, &app.ui.language); + let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked); + if let Some(panel) = raise { + dock = dock.raise_panel(panel); + } + let mut content = PanelContent(app, Vec::new()); + let output = dock.show_content(ui, &layout, &style, &mut content); + let floats = content.1; if let Some(panel) = output.focused { - app.ui.docking_opened = Some(panel); + if panel != CANVAS { + app.ui.docking_opened = Some(panel.clone()); + } + app.ui.docking_focused = Some(panel); } if let Some(error) = output.error { app.ui.status = error.to_string(); } - if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. } | Action::MoveGroup { .. })) - && cancel_legacy_drag(app, ui.ctx()) - { - return app.ui.docking.is_some(); - } - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); - } - for action in output.actions { + for action in output.actions.into_iter().chain(floats) { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } } - icon_rail(app, ui); - true } +/// What the control channel reports: the layout in use (craft-ui's serialized `Layout`), +/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, and +/// the panels closed with a remembered place. +pub(crate) fn inspect(app: &DesignApp) -> Value { + json!({ + "layout": layout_of(app), + "customized": app.ui.docking.is_some(), + "focused": app.ui.docking_focused, + "rightToLeft": crate::i18n::is_rtl(&app.ui.language), + "hidden": app.ui.docking_hidden.keys().collect::>(), + }) +} + +fn dock_style(ui: &egui::Ui, language: &str) -> craft_ui::docking::DockStyle { + let t = crate::theme::Tokens::get(ui.ctx()); + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.right_to_left = crate::i18n::is_rtl(language); + style.localized(|key| DOCK_LABELS.iter().find(|(k, _)| *k == key).map(|(_, english)| crate::i18n::tr(language, english).to_owned())) +} + +/// DesignCraft's wording of craft-ui's docking labels, translated through `i18n::tr`. +const DOCK_LABELS: &[(&str, &str)] = &[ + ("dock.float", "Float Panel"), + ("dock.close", "Close"), + ("dock.move", "Group with"), + ("dock.panels", "Panels"), + ("dock.resize", "Resize panels"), + ("dock.resizeWindow", "Resize panel window"), + ("dock.moveGroup", "Move Tab Group"), + ("dock.closeGroup", "Close Tab Group"), + ("dock.minimize", "Minimize"), + ("dock.expand", "Expand"), + ("dock.floatGroup", "Float Tab Group"), + ("dock.groupWith", "Group Tab Group with"), + ("dock.moveColumn", "Move Panel Column"), + ("dock.collapseToIcons", "Collapse to Icons"), + ("dock.expandPanels", "Expand Panels"), + ("dock.moveWindow", "Move Panel Window"), + ("dock.closeWindow", "Close Panel Window"), + ("dock.undock", "Undock Panel"), + ("dock.undockGroup", "Undock Panel Group"), + ("dock.closePanelGroup", "Close Panel Group"), + ("dock.closeOthers", "Close Other Panels in Group"), + ("dock.maximize", "Maximize Frame"), + ("dock.restoreFrame", "Restore Frame Size"), + ("dock.newWindow", "Move Panel to New Window"), +]; + +#[allow(dead_code)] // The classic icon rail; removed with the classic dock. fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons") @@ -616,7 +812,7 @@ pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, re if response.drag_started() && let Some(id) = normalize(panel) { - let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut layout = layout_of(app); let bounds = ui.max_rect(); let source = egui::Rect::from_min_size( egui::pos2(bounds.left(), response.rect.top()), @@ -705,9 +901,51 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } -struct PanelContent<'a>(&'a mut DesignApp); +/// The panels' content, and the floats released over the canvas this frame. +struct PanelContent<'a>(&'a mut DesignApp, Vec>); impl craft_ui::docking::DockContent for PanelContent<'_> { + /// A panel or group released over the document area floats there, as in InDesign, instead of + /// splitting the canvas. The window's edge bands still make new docks and root splits. + fn resolve_drag( + &mut self, + _: &egui::Context, + style: &craft_ui::docking::DockStyle, + request: &craft_ui::docking::DropRequest<'_, String>, + ) -> Option { + let pointer = request.pointer; + let over_canvas = request.targets.iter().rev().find(|t| t.rect.contains(pointer)).is_some_and(|t| t.anchor == CANVAS); + let band = style.edge_band.max(style.new_dock_band); + if !over_canvas || !request.workspace.shrink(band).contains(pointer) { + return None; + } + // A floating window moving live has no float rect; it moves as before. + if request.whole_group && request.floating_rect.is_none() { + return None; + } + if request.released + && let Some(r) = request.floating_rect + { + let rect = [r.left(), r.top(), r.width(), r.height()]; + let panel = request.panel.clone(); + self.1.push(if request.whole_group { + Action::MoveGroup { panel, to: Target::Float { rect: Some(rect) } } + } else { + Action::Float { panel, rect } + }); + } + Some(craft_ui::docking::DropFeedback { preview: request.floating_rect, insertion: None }) + } fn body(&mut self, ui: &mut egui::Ui, id: &String) { + if id == CANVAS { + let fill = crate::theme::Tokens::get(ui.ctx()).pasteboard; + ui.painter().rect_filled(ui.max_rect(), 0.0, fill); + // A bare panel has no tab to report focus through, so the canvas reports its own. + if ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer() { + self.0.ui.docking_focused = Some(CANVAS.to_string()); + } + self.0.canvas_area(ui, false); + return; + } egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { if crate::dock::scrolls_itself(id) { crate::dock::panel_body(self.0, ui, id); @@ -719,17 +957,23 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); }); } - fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { - // The docking surface adds Float Panel and Group with after these, so a tab's menu lists - // what the classic dock's does. - if let Some(panel) = normalize(id) { - tab_commands(self.0, ui, panel); - ui.separator(); - } + fn logical_label(&self, id: &String) -> String { + let english = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map_or(id.as_str(), |p| p.1); + crate::i18n::tr(&self.0.ui.language, english).to_owned() + } + fn permissions(&self, id: &String) -> Permissions { + model_permissions(id) + } + fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { + let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(220.0, 80.0) }; + craft_ui::docking::PanelLimits { min, ..Default::default() } } fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } + fn bare(&self, id: &String) -> bool { + id == CANVAS + } fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 { 24.0 } @@ -750,7 +994,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { /// closed with Close. fn close_group(app: &mut DesignApp, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let layout = layout_of(app); let group = tab_group(&layout, panel); for p in &group { change(app, "close", &json!({"panel": p}))?; @@ -758,8 +1002,9 @@ fn close_group(app: &mut DesignApp, params: &Value) -> Result { Ok(json!({"closed": group})) } -/// InDesign's commands on a panel tab (and at the end of its panel menu): Close, Close Tab Group, -/// Minimize (an open accordion entry) and Collapse to Icons. +/// InDesign's panel tab commands at the end of a panel's three-line menu: Close, Close Tab Group, +/// Minimize (an open accordion entry) and Collapse to Icons. A tab's own context menu is +/// craft-ui's, which lists the same commands. fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { let language = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&language, s).to_owned(); @@ -775,26 +1020,24 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { if ui.button(crate::rtl::widget(ui, tr("Close Tab Group"))).clicked() { run(app, ui, "window.panel.closeGroup", json!({"panel": panel})); } - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let layout = layout_of(app); let minimizable = open_in_stack(&layout, panel); if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { run(app, ui, "window.panel.layout", json!({"action": {"SetStackOpen": {"panel": panel, "open": false}}})); } ui.separator(); - if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { + let expanded = dock_columns(&layout).iter().any(|c| !c.iconic); + if ui.add_enabled(expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { run(app, ui, "window.collapseToIcons", json!({})); } } /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { - if app.ui.docking.is_some() { - if let Some(panel) = app.ui.docking_opened.clone() { - change(app, "close", &json!({"panel": panel}))?; - } - } else { - app.ui.open_panel = None; + if let Some(panel) = app.ui.docking_opened.clone() { + change(app, "close", &json!({"panel": panel}))?; } + app.ui.open_panel = None; Ok(()) } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 8d8e34fdd..abbcf1391 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -59,8 +59,7 @@ fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |_| {}); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -188,7 +187,7 @@ fn capture_custom_panel_docking_visual_fixtures() { } #[test] -fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { +fn escape_cancels_a_tab_drag_without_customizing_the_workspace() { let mut h = harness(app(), vec2(1280.0, 800.0)); let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); h.event(Event::PointerMoved(source)); @@ -197,7 +196,6 @@ fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { h.run_steps(1); h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); h.run_steps(2); - assert!(h.state().ui.docking.is_some()); h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(1); assert!(h.state().ui.docking.is_none()); @@ -235,27 +233,26 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti assert_eq!(app.ui.docking, before); assert_eq!(app.ui.docking_hidden, hidden); } + let stack = Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }; app.ui.docking = Some(Layout { - root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::Ratio(0.7), + first: Box::new(Node::Tabs { panels: vec![CANVAS.into()], active: 0 }), + second: Box::new(stack), + }), floating: Vec::new(), }); app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); - let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") }; + let Some(Node::Split { second, .. }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected split") }; + let Node::Stack { entries } = &**second else { panic!("expected stack") }; assert!(!entries[0].open); assert_eq!(entries[0].height, Some(150.0)); } fn group_members_for_test(layout: &Layout, panel: &str) -> Vec { - let mut pending: Vec<_> = layout.root.iter().collect(); - while let Some(node) = pending.pop() { - match node { - Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), - Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]), - _ => {} - } - } - Vec::new() + tab_group(layout, panel) } #[test] @@ -309,7 +306,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); // Floating Layers leaves Pages the active tab beside Properties. app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "stroke")); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { app.logic(ui.ctx()); @@ -326,7 +323,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { h.run_steps(1); } h.run_steps(2); - assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert!(!in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); } @@ -628,7 +625,7 @@ fn capture_legacy_panel_menu_visual_fixture() { } #[test] -fn legacy_dock_menu_button_opens_the_active_panel_menu() { +fn dock_menu_button_opens_the_active_panel_menu() { let mut app = app(); app.run("file.new", json!({})).unwrap(); app.ui.dock_tab = "pages".into(); @@ -636,8 +633,8 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { let mut h = harness(app, vec2(1280.0, 800.0)); assert!(h.state().ui.docking.is_none()); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("dock")).unwrap().outer_rect; - let at = egui::pos2(dock.right() - 15.0, tab.center().y); + // The outer dock column is at the window's right edge; the ≡ ends its tab strip. + let at = egui::pos2(1280.0 - 12.0, tab.center().y); h.event(Event::PointerMoved(at)); h.run_steps(1); for pressed in [true, false] { @@ -697,9 +694,11 @@ fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { h.run_steps(1); h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); + // The column bar's » button is also labelled Collapse to Icons. for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { - assert!(h.query_by_label(item).is_some(), "{item}"); + assert!(h.query_all_by_label(item).next().is_some(), "{item}"); } + assert_eq!(h.query_all_by_label("Minimize").count(), 1, "the tab menu lists Minimize once"); h.get_by_label("Close").click(); h.run_steps(2); assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); @@ -716,7 +715,7 @@ fn paragraph_styles_open_floating_with_character_styles() { assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. - assert!(matches!(&layout.root, Some(craft_ui::docking::Node::Tabs { panels, active }) if panels[*active] == "properties"), "{:?}", layout.root); + assert!(front_tabs(&layout).iter().any(|p| p == "properties"), "{:?}", layout.root); // Other panels from Window leave it open. app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); @@ -768,8 +767,7 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(app.ui.docking.is_some()); let mut h = harness(app, vec2(1280.0, 800.0)); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("shared-panel-dock")).unwrap().outer_rect; - click(&mut h, egui::pos2(dock.right() - 12.0, tab.center().y), PointerButton::Primary); + click(&mut h, egui::pos2(1280.0 - 12.0, tab.center().y), PointerButton::Primary); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); for item in ["Numbering & Section Options…", "Close Tab Group", "Float Panel"] { assert!(h.query_by_label(item).is_some(), "{item}"); @@ -797,6 +795,11 @@ fn group_with<'a>(layout: &'a Layout, panel: &str) -> (bool, &'a [String match node { Node::Split { first, second, .. } => visit.extend([&**first, &**second]), Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels, *active), + Node::Dock { columns, .. } => { + if let Some(g) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (false, &g.panels, g.active); + } + } _ => {} } } @@ -814,7 +817,7 @@ fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { fn dragging_a_docked_group_by_its_bar_moves_every_tab() { let mut app = app(); app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); - let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Split": "Bottom"}}}}}); + let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Group": {"below": true}}}}}}); app.run("window.panel.layout", json!({"action": below})).unwrap(); let styles = ["characterStyles", "paragraphStyles"]; assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, &styles.map(String::from)[..], 1)); @@ -837,3 +840,81 @@ fn dragging_a_docked_group_by_its_bar_moves_every_tab() { assert_eq!(panels[active], "paragraphStyles"); assert!(layout.floating.is_empty(), "{:?}", layout.floating); } + +fn dock_of(layout: &Layout) -> (Side, &[Column]) { + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("root is not a split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &[CANVAS.to_string()]), "{layout:?}"); + let Node::Dock { side, columns } = &**second else { panic!("second is not a dock: {layout:?}") }; + (*side, columns) +} + +#[test] +fn default_workspaces_are_the_canvas_beside_an_end_dock() { + let mut app = app(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (side, columns) = dock_of(&layout); + assert_eq!(side, Side::End); + let [outer, icons] = columns else { panic!("{columns:?}") }; + assert!(!outer.iconic); + assert_eq!(outer.groups.len(), 1); + assert_eq!(outer.groups[0].panels, ["properties", "pages", "layers"]); + assert!(icons.iconic); + assert!(icons.groups.iter().all(|g| g.panels.len() == 1)); + assert!(!layout.contains(&"paragraphStyles".to_string()), "Window › Styles opens the styles floating"); + // Essentials Classic: only its named icon groups, in one iconic column. + app.run("window.workspace", json!({"name": "Essentials Classic"})).unwrap(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (_, columns) = dock_of(&layout); + let [column] = columns else { panic!("{columns:?}") }; + assert!(column.iconic); + let expected: Vec> = app.ui.icon_groups.clone().unwrap(); + let groups: Vec> = column.groups.iter().map(|g| g.panels.clone()).collect(); + assert_eq!(groups.len(), expected.len()); + assert!(groups.iter().flatten().all(|p| expected.iter().flatten().any(|e| e == p))); +} + +#[test] +fn the_dock_is_at_the_right_in_english_and_the_left_in_arabic() { + let tab = |h: &Harness<'static, DesignApp>| { + h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string()))).map(|r| r.rect).unwrap() + }; + let h = harness(app(), vec2(1280.0, 800.0)); + assert!(tab(&h).left() > 640.0, "{:?}", tab(&h)); + let mut arabic = app(); + arabic.ui.language = "ar".into(); + let h = harness(arabic, vec2(1280.0, 800.0)); + assert!(tab(&h).right() < 640.0, "{:?}", tab(&h)); +} + +#[test] +fn ui_docking_reports_the_layout_and_the_focused_panel() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let ctx = h.ctx.clone(); + let docking = |app: &mut DesignApp| { + let (req, _rx) = crate::control::ControlRequest::new("ui.docking", json!({})); + match crate::control::handle(app, &ctx, &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => panic!("ui.docking took a screenshot"), + } + }; + let v = docking(h.state_mut()); + assert_eq!(v["ok"], true, "{v}"); + let result = &v["result"]; + assert_eq!(result["customized"], false); + assert_eq!(result["rightToLeft"], false); + assert!(result["focused"].is_null()); + let layout: Layout = serde_json::from_value(result["layout"].clone()).unwrap(); + assert_eq!(layout, default_layout(&h.state().ui)); + let at = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + click(&mut h, at, PointerButton::Primary); + let v = docking(h.state_mut()); + assert_eq!(v["result"]["focused"], "layers", "{v}"); + // Closing the focused panel releases the focus. + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + let v = docking(h.state_mut()); + assert!(v["result"]["focused"].is_null(), "{v}"); + assert_eq!(v["result"]["customized"], true); + assert_eq!(v["result"]["hidden"], json!(["layers"])); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c40f3b1b..42755d75d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -741,6 +741,15 @@ mod font_menu_ui_tests { for k in 0..count { h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); h.run_steps(4); + // A short Properties group (below it, the workspace's open panel) scrolls to the field. + let mut nodes: Vec<_> = h + .query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(designcraft_fonts::DEFAULT_FAMILY)) + .collect(); + nodes.sort_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y).then(a.rect().min.x.total_cmp(&b.rect().min.x))); + if let Some(node) = nodes.get(k) { + node.scroll_to_me(); + } + h.run_steps(4); let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; search_in_menu(&mut h, at, &format!("{name}, field {k}")); assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fded030b9..79cf2f0e7 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1173,7 +1173,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { ) .clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 12.0; @@ -1191,7 +1191,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("style.paragraph.apply", json!({"name": cur, "clearOverrides": true})); } if icon_toggle(ui, "style-load", false, crate::i18n::tr(&app.ui.language, "Styles panel")).clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } }); }); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1dc0eed49..78ef44fa3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | -| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | +| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf | +| `ui.docking` | — | The panel layout in use and the focused panel (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | @@ -77,18 +78,30 @@ Panel arrangements support tab grouping and reordering, horizontal and vertical and movable, resizable floating groups inside the application window. Drag a panel tab, or use its context menu. Floating groups remain within the application viewport. -The shared layout begins with the existing workspace on the first docking operation. -Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the -positions of hidden panels. Resetting a built-in workspace restores its default dock. +The window is one panel layout: the protected `canvas` panel (the document area) beside a +dock at the logical end edge, the right in left-to-right languages and the left in +right-to-left ones. Each built-in workspace has a default layout. Essentials and most others +have an outer column with the Properties · Pages · Layers group and an iconic column with the +other panels; Essentials Classic and Typography have their named iconic groups only. A panel a +workspace opens (Printing and Proofing: Swatches) is a group below Properties · Pages · Layers. +The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped +over it floats there. Workspace saves retain its groups, active tabs, split sizes, floating +geometry, and the positions of hidden panels. Resetting a built-in workspace restores its +default dock. - `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders - tabs by panel ID. The destination must already be visible. + tabs by panel ID. The destination must already be visible. Beside a panel in a dock column, + `top` and `bottom` add a group above or below its group and `left` and `right` add a column + on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` + are errors. - `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. - `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous dock placement when possible, or to the supplied destination. -- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved - position when possible. `window.panel.close {panel}` hides it. +- `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, + restoring its saved position when possible; a panel in an iconic column expands that + column. `window.panel.close {panel}` hides it. +- `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and invalid destinations return an error without changing the arrangement. @@ -102,8 +115,26 @@ resizes the root divider (`false`/`true` path entries select first/second childr The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale split paths return an error without changing the workspace. -After panel customization, `app.tablePanel` (including Shift+F9) still toggles the -Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; -`dockExpanded:false` hides the root dock while floating groups remain visible. -The icon rail can reopen a collapsed dock through any root panel. Activating a -floating panel also raises its group. These operations change only UI layout. +`app.tablePanel` (including Shift+F9) toggles the Table panel. `ui.set` with `closePanel` +closes the last explicitly opened panel; `dockExpanded:false` collapses every dock column +to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also +raises its group. These operations change only UI layout. + +`ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as +`docking`: + +```json +{"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 297.0}, + "first": {"Tabs": {"panels": ["canvas"], "active": 0}}, + "second": {"Dock": {"side": "End", "columns": [ + {"groups": [{"panels": ["properties", "pages", "layers"], "active": 0}], "width": 260.0, + "iconic": false, "iconic_width": 37.0}, + {"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true, + "iconic_width": 37.0}]}}}}, + "floating": []}, + "customized": false, "focused": "canvas", "rightToLeft": false, "hidden": []} +``` + +`layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's +default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or +null; `hidden` lists closed panels that remember their place. From 84e4032c114d1547d0944de3d5046d475788e1e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:24:39 +0000 Subject: [PATCH 012/315] Docking trial: migrate ui.json, delete the classic dock --- ROADMAP.md | 2 +- apps/designcraft/src/main.rs | 56 +++- crates/ui-egui/src/chrome.rs | 7 +- crates/ui-egui/src/dock.rs | 288 ++-------------- crates/ui-egui/src/lib.rs | 23 +- crates/ui-egui/src/menus.rs | 64 ++-- crates/ui-egui/src/panel_docking.rs | 387 ++++++++++++---------- crates/ui-egui/src/panel_docking_tests.rs | 100 +----- crates/ui-egui/src/panels/properties.rs | 13 +- crates/ui-egui/src/prefs.rs | 201 ++++++++++- docs/control-protocol.md | 18 +- docs/target-app-parity.md | 2 +- 12 files changed, 539 insertions(+), 622 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274ef..815ac6cc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,7 +163,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable, resizable floating window; Dock Panel puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index b56878176..3c5a79f20 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -143,15 +143,47 @@ fn prefs_path() -> Option { profile::config_dir().map(|b| b.join("ui.json")) } +/// Set when `ui.json` could not be read in full and no copy of it could be kept: the file is +/// then left as it is rather than overwritten. +static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy an unreadable (or partly unreadable) `ui.json` beside it, as `ui.unreadable.json` or the +/// first free `ui.unreadable-N.json`, before the next save replaces it. A newer DesignCraft may +/// have written it. +fn keep_unreadable_prefs(path: &std::path::Path, bytes: &[u8]) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { "ui.unreadable.json".to_string() } else { format!("ui.unreadable-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + fn load_prefs(app: &mut DesignApp) { if no_prefs() { return; } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { - app.ui = ui; + match designcraft_ui_egui::UiState::read_preferences(&bytes) { + Ok(read) => { + app.ui = read.ui; + if !read.intact { + keep_unreadable_prefs(&p, &bytes); + } + } + Err(_) => keep_unreadable_prefs(&p, &bytes), + } } // Engine preferences (Preferences dialog, favourites…) live beside the UI state. if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) @@ -168,7 +200,9 @@ fn save_prefs(app: &DesignApp) { } if let Some(p) = prefs_path() { let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) + && let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) + { let _ = std::fs::write(&p, bytes); } if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { @@ -556,6 +590,22 @@ fn main() -> std::process::ExitCode { #[cfg(test)] mod tests { + #[test] + fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_unreadable_prefs(&path, b"{\"docking\": {\"root\": {\"FutureLayout\": {}}}}"); + super::keep_unreadable_prefs(&path, b"not json"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); + } + #[test] fn relink_open_dialog_lists_graphics() { let filters = super::open_filters("relink"); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1f865a3dc..eea906ee6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -735,12 +735,9 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs -/// only when Preflight isn't showing. +/// Show the Preflight panel. fn show_preflight(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() != Some("preflight") - && let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) - { + if let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) { app.status(e); } } diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index a825e3502..0528cfdd6 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,16 +1,16 @@ -//! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon -//! column whose panels open as flyouts beside the dock. +//! The panels: their ids, names, icons and contents, and the windows of the float-only panels. +//! `panel_docking` places the others in the docking layout. -use egui::{Color32, Sense, Stroke, vec2}; +use egui::{Sense, vec2}; use crate::theme::{Tokens, semibold}; -use crate::{DesignApp, icons, panels}; +use crate::{DesignApp, panels}; -/// (id, label, icon) of the tabs in the expanded group. +/// (id, label, icon) of the Properties · Pages · Layers group of the default dock. pub const DOCK_TABS: &[(&str, &str, &str)] = &[("properties", "Properties", "panel-properties"), ("pages", "Pages", "panel-pages"), ("layers", "Layers", "panel-layers")]; -/// Panels in the collapsed icon column. +/// (id, label, icon) of the other dock panels, which start in the iconic column. pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("stroke", "Stroke", "panel-stroke"), ("swatches", "Swatches", "panel-swatches"), @@ -50,7 +50,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("dataMerge", "Data Merge", "panel-datamerge"), ]; -/// Panels that only float (they open from a menu, not from the icon column). +/// Panels that only float, outside the docking layout (they open from a menu). pub const FLOATING_PANELS: &[(&str, &str)] = &[("tabs", "Tabs")]; /// The English name of panel `id`. @@ -58,197 +58,6 @@ pub fn panel_label(id: &str) -> &'static str { DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1) } -/// The classic dock, replaced by the docking layout (`panel_docking::show`) and no longer drawn. -pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. - if app.ui.dock_expanded { - egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show( - ui, - |ui| { - dock_header(ui, &t, "»"); - // Tab strip (27 pt). - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.tab_strip); - let mut x = strip.min.x; - for (id, label, _) in DOCK_TABS { - let active = app.ui.dock_tab == *id; - let g = crate::rtl::plain( - ui.ctx(), - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - if active { t.text_strong } else { t.text_dim }, - ); - let w = g.size().x + 26.0; - let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); - if active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( - ui, - r, - |_, _| {}, - ); - if response.clicked() { - app.ui.dock_tab = id.to_string(); - } - crate::panel_docking::legacy_tab(app, ui, id, &response); - x += w; - } - let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let menu = ui.interact(menu_r, ui.id().with("dock-menu"), Sense::click()); - if menu.hovered() { - ui.painter().rect_filled(menu_r, 3.0, t.hover); - } - icons::paint(ui.painter(), menu_r, "menu", t.icon); - let tab = app.ui.dock_tab.clone(); - egui::Popup::menu(&menu) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| crate::panel_docking::three_line_menu(app, ui, &tab)); - egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { - // The Pages panel scrolls its parts itself and keeps its footer at the bottom. - if app.ui.dock_tab == "pages" { - panels::pages::show(app, ui); - return; - } - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { - "layers" => panels::layers::show(app, ui), - _ => panels::properties::show(app, ui), - }); - }); - }, - ); - } - // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups - // (Essentials Classic) show their names. - let groups = app.ui.icon_groups.clone(); - egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { - dock_header(ui, &t, "«"); - ui.add_space(4.0); - match &groups { - Some(groups) => { - for (i, group) in groups.iter().enumerate() { - if i > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); - } - for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) { - let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id); - let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); - if on { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active); - } else if response.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover); - } - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon); - crate::rtl::paint( - ui.painter(), - r.left_center() + vec2(32.0, 0.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - t.text, - ); - icon_clicked(app, ui, id, &response); - } - } - } - None => { - ui.vertical_centered(|ui| { - for (id, label, icon) in ICON_PANELS { - let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id); - let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); - icon_clicked(app, ui, id, &response); - ui.add_space(1.0); - } - }); - } - } - }); -} - -/// Width of the icon column: icons only, or icons with names for a workspace's groups. -fn icon_column_width(app: &DesignApp) -> f32 { - if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 } -} - -/// (id, label, icon) of any dock panel. -fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { - DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() -} - -/// A click on a panel's icon: raise it when it floats, else open or close its flyout. -fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { - if response.clicked() { - if app.ui.floating.iter().any(|(p, _)| p == id) { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", id)))); - } else { - let open = app.ui.open_panel.as_deref() == Some(id); - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; - } - } - crate::panel_docking::legacy_tab(app, ui, id, response); -} - -/// 11 pt dock header strip with a collapse chevron. -fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover()); - ui.painter().rect_filled(r, 0.0, t.tab_strip); - ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim); - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); -} - -/// A panel opened from the icon column, shown as a floating flyout next to the dock. -pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { - if app.ui.docking.is_some() { - return; - } - let Some(id) = app.ui.open_panel.clone() else { return }; - let t = Tokens::get(ctx); - let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let screen = ctx.content_rect(); - let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::new("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; - let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); - let mut open = true; - egui::Area::new(egui::Id::new("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { - ui.set_width(256.0); - // A previous shorter flyout must not constrain the next panel's scroll viewport. - ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0)); - let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); - let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - if ui.interact(close, ui.id().with("flyclose"), Sense::click()).clicked() { - open = false; - } - ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - // Float: the button beside the close box, or drag the header away from the dock. - let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let fr = ui.interact(float_r, ui.id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel")); - }); - float_glyph(ui.painter(), float_r, t.text_dim); - let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y)); - let hr = ui.interact(head, ui.id().with("flyhead"), Sense::drag()); - let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0); - if fr.clicked() || torn { - let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) }; - float_panel(app, &id, at); - return; - } - egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - scrolled_body(app, ui, &id, screen.height() - 220.0); - }); - }); - }); - if !open { - app.ui.open_panel = None; - } -} - /// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its /// parts itself and keeps its footer at the bottom, so it gets the height instead. fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { @@ -315,41 +124,16 @@ pub(crate) fn float_only(id: &str) -> bool { FLOATING_PANELS.iter().any(|p| p.0 == id) } -/// Tear panel `id` off the dock into a floating window at `at`. -pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if float_only(id) { - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); - return; - } - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { - app.status(error); - } -} - -/// Put a floating panel back in the dock's icon column. -pub fn dock_panel(app: &mut DesignApp, id: &str) { - if !float_only(id) { - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { - app.status(error); - } - return; - } - app.ui.floating.retain(|(p, _)| p != id); -} - -/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. -pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { - // The docking layout draws its own floating groups; float-only panels are drawn here. +/// The float-only panels' windows (the Tabs panel). The docking layout draws the other +/// floating panels. +pub fn float_only_windows(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { if !float_only(&id) { continue; } let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let docks = !float_only(&id); let mut open = true; - let mut dock = false; let mut w = egui::Window::new(label.clone()) .id(egui::Id::new(("floating_panel", &id))) .title_bar(false) @@ -364,7 +148,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { w = w.current_pos(pos).min_width(width).max_width(width); } let r = w.show(ctx, |ui| { - // Header strip (drag it to move the panel): name, Dock, close. + // Header strip (drag it to move the panel): name and close. let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width().max(200.0), 24.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); @@ -379,20 +163,6 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { open = false; } ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0)); - if docks - && ui - .interact(dock_r, ui.id().with("fdock"), Sense::click()) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel")); - }) - .clicked() - { - dock = true; - } - if docks { - dock_glyph(ui.painter(), dock_r, t.text_dim); - } egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { scrolled_body(app, ui, &id, ctx.content_rect().height() - 160.0); }); @@ -403,29 +173,16 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { { f.1 = [r.response.rect.min.x, r.response.rect.min.y]; } - if !open || dock { - dock_panel(app, &id); + if !open { + app.ui.floating.retain(|(p, _)| *p != id); } } } -/// Two overlapping boxes: "float this panel". -fn float_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let s = Stroke::new(1.0, c); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(3.5, 6.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(6.5, 3.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); -} - -/// A box with a bar on its right: "back into the dock". -fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let b = egui::Rect::from_min_size(r.min + vec2(3.5, 4.5), vec2(11.0, 9.0)); - p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); - p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); -} - #[cfg(test)] mod visual_regressions { use super::*; + use egui::Color32; fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; @@ -441,9 +198,7 @@ mod visual_regressions { for brightness in crate::theme::Brightness::ALL { for width in [800.0, 1280.0] { for scale in [1.0, 1.5, 2.0] { - let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); + let app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); let mut ready = false; let mut harness = egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( @@ -454,7 +209,7 @@ mod visual_regressions { ready = true; return; } - show(app, ui); + crate::panel_docking::show(app, ui); }, app, ); @@ -462,10 +217,11 @@ mod visual_regressions { harness.run_steps(4); let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); - let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let tab = harness.ctx.read_response(tab).unwrap().rect; let region = serde_json::json!({ "screen": [width, 640.0], "scale": scale, - "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + "regions": {"dock-tabs": [tab.left(), tab.top(), (width - tab.left()), tab.height()]} }); std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); } @@ -491,9 +247,9 @@ mod visual_regressions { let tokens = Tokens::for_brightness(brightness); crate::theme::apply(&ctx, &tokens); let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); - let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); output.textures_delta.clear(); assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); assert_eq!(ctx.pixels_per_point(), scale); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0a76e123c..370388317 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub use control::{ControlRequest, ControlResponse}; +pub use prefs::ReadPreferences; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; @@ -116,7 +117,7 @@ pub struct SavedWorkspace { pub docking: Option>, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub docking_hidden: std::collections::BTreeMap>, - /// The workspace's own icon column (see [`UiState::icon_groups`]). + /// The workspace's own iconic column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, /// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the @@ -253,11 +254,14 @@ pub struct UiState { pub shortcuts: std::collections::BTreeMap, pub control_bar: bool, pub tools_double_column: bool, - /// Expanded right-dock panel group tab. + /// The front tab of the default layout's Properties · Pages · Layers group. `dock_tab`, + /// `open_panel`, `floating`, `dock_expanded` and `icon_groups` describe a workspace's + /// default layout (`panel_docking::default_layout`). pub dock_tab: String, - /// Panel opened from the collapsed icon column (flyout). + /// The panel it opens below that group (Printing and Proofing: Swatches). pub open_panel: Option, - /// Panels torn off the dock into their own floating windows: (panel id, top-left position). + /// Its floating panels, and the float-only panels' windows (Type › Tabs): (panel id, + /// top-left position). A customized layout holds its own floating panels. pub floating: Vec<(String, [f32; 2])>, /// The panel layout once the user customizes it; `None` is the workspace's default layout /// (`panel_docking::default_layout`). @@ -274,12 +278,13 @@ pub struct UiState { /// One-frame reveal request; z-order is never persisted. #[serde(skip)] pub docking_raise: Option, - /// Last explicitly revealed customized panel, for the legacy closePanel control. + /// The panel last revealed explicitly, which `ui.set closePanel` closes. #[serde(skip)] pub docking_opened: Option, + /// Whether the default layout's outer column is expanded (false: collapsed to icons). pub dock_expanded: bool, - /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon - /// column instead of every panel; `None` is the full icon column. + /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the + /// iconic column instead of every panel; `None` is one icon per panel. #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, pub units: Unit, @@ -1563,7 +1568,7 @@ impl DesignApp { let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; if presenting || self.ui.hidden_panels != 0 { - panel_docking::cancel_gesture(self, &ctx); + panel_docking::cancel_gesture(&ctx); } canvas::begin_frame(&ctx); let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); @@ -1631,7 +1636,7 @@ impl DesignApp { } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { - dock::floating(self, &ctx); + dock::float_only_windows(self, &ctx); } story_editor::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3558be93a..9e4ceda19 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,7 +236,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), - ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own floating window (window.panel.float)"), ( "window.panel.layout", "Change Panel Layout", @@ -255,7 +255,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), - ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), + ("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), @@ -925,7 +925,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ pub const WORKSPACES: &[&str] = &["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"]; -/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom. +/// Essentials Classic's iconic column: InDesign's iconic panel groups, top to bottom. pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["pages", "layers", "links"], &["stroke", "color"], @@ -955,10 +955,10 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it. fn toggle_tabs_panel(app: &mut DesignApp) { if app.ui.floating.iter().any(|(p, _)| p == "tabs") { - crate::dock::dock_panel(app, "tabs"); + app.ui.floating.retain(|(p, _)| p != "tabs"); } else { let at = app.canvas_rect.map_or(egui::pos2(300.0, 140.0), |r| r.min + egui::vec2(80.0, 60.0)); - crate::dock::float_panel(app, "tabs", at); + app.ui.floating.push(("tabs".into(), [at.x, at.y])); app.ui.tabs_panel.snap_request = 2; } } @@ -1342,10 +1342,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) }; - Ok(Value::Null) - } "app.tabsPanel" => { toggle_tabs_panel(app); Ok(Value::Null) @@ -1647,19 +1643,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), + // `panel_docking::command` takes the dock panels (and the Window › Styles group) first. "window.panel" => { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); - // A panel in the workspace's icon groups opens from there (Essentials Classic's Pages). - let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel)); - if panel == "tabs" { - toggle_tabs_panel(app); - } else if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { - app.ui.dock_tab = panel; - app.ui.dock_expanded = true; - app.ui.open_panel = None; - } else if !app.ui.floating.iter().any(|(p, _)| *p == panel) { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) }; + let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties"); + if panel != "tabs" { + return crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": panel})); } + toggle_tabs_panel(app); Ok(Value::Null) } "fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => { @@ -1737,22 +1727,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - if !crate::dock::ICON_PANELS.iter().any(|(id, _, _)| *id == panel) && !crate::dock::FLOATING_PANELS.iter().any(|(id, _)| *id == panel) { - return Some(Err(format!("{id}: unknown panel `{panel}`"))); - } - if id == "window.floatPanel" { - let at = egui::pos2( - p.get("x").and_then(Value::as_f64).unwrap_or(400.0) as f32, - p.get("y").and_then(Value::as_f64).unwrap_or(160.0) as f32, - ); - crate::dock::float_panel(app, panel, at); - } else { - crate::dock::dock_panel(app, panel); - } - Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::>()})) - } "window.controlBar" => flag(&mut app.ui.control_bar), "view.flattenerPreview" => flag(&mut app.ui.flattener_preview), "view.tagMarkers" => { @@ -1904,7 +1878,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(CLASSIC_GROUPS), @@ -4730,13 +4704,21 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "swatches", "x": 50, "y": 60})).unwrap().unwrap(); assert_eq!(r["floating"], json!(["swatches"])); - assert_eq!(app.ui.floating[0].1, [50.0, 60.0]); - // Showing a floating panel doesn't also open its flyout. + let window = &app.ui.docking.as_ref().unwrap().floating[0]; + assert_eq!([window.rect[0], window.rect[1]], [50.0, 60.0]); + // Showing a floating panel leaves it where it is. run_ui(&mut app, "window.panel", &json!({"panel": "swatches"})).unwrap().unwrap(); assert_eq!(app.ui.open_panel, None); - run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); - assert!(app.ui.floating.is_empty()); + let r = run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); + assert_eq!(r["floating"], json!([])); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty()); assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err()); + // Float-only panels (Type › Tabs) have their own windows outside the layout. + let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "tabs", "x": 10, "y": 20})).unwrap().unwrap(); + assert_eq!(r["floating"], json!(["tabs"])); + assert_eq!(app.ui.floating, [("tabs".to_string(), [10.0, 20.0])]); + run_ui(&mut app, "window.dockPanel", &json!({"panel": "tabs"})).unwrap().unwrap(); + assert!(app.ui.floating.is_empty()); } /// One frame as the desktop app runs it: queued synthetic input, then the native menu item diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 393c977e5..c0e0cfd5f 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -9,36 +9,10 @@ use serde_json::{Value, json}; use crate::DesignApp; -#[derive(Clone, Default)] -struct LegacyDrag { - layout: Option>, - open_panel: Option, - floating: Vec<(String, [f32; 2])>, - generation: u64, -} - -pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { - cancel_legacy_drag(app, ctx); +pub(crate) fn cancel_gesture(ctx: &egui::Context) { craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); } -fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { - let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); - let previous = ctx.data_mut(|data| data.remove_temp::(key)); - if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); - if previous.generation != app.ui.docking_generation { - return false; - } - app.ui.open_panel = previous.open_panel; - app.ui.floating = previous.floating; - app.ui.docking = previous.layout; - true - } else { - false - } -} - fn normalize(raw: &str) -> Option<&'static str> { crate::dock::DOCK_TABS .iter() @@ -72,67 +46,53 @@ pub(crate) fn valid(layout: &Layout) -> bool { && Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&CANVAS.to_string()) } -/// The layout of a workspace that has not been customized, from its panel settings: the -/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an -/// open flyout panel stacked below it) and whose inner column is iconic. Essentials Classic -/// and Typography (`icon_groups`) have the iconic column only. -pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { - let mut placed: Vec = Vec::new(); - let take = |id: &str, placed: &mut Vec| -> Option { - let id = normalize(id)?; - (!placed.iter().any(|p| p == id)).then(|| { - placed.push(id.to_string()); - id.to_string() - }) - }; - let mut floating = Vec::new(); - for (id, at) in &ui.floating { - if at.iter().all(|v| v.is_finite()) - && let Some(id) = take(id, &mut placed) - { - floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); +/// Panels already given a place, so each appears once. +#[derive(Default)] +struct Placed(Vec); + +impl Placed { + /// The registered id of `raw` if no other place holds it yet. + fn take(&mut self, raw: &str) -> Option { + let id = normalize(raw)?; + if self.0.iter().any(|p| p == id) { + return None; } + self.0.push(id.to_string()); + Some(id.to_string()) } - let mut columns = Vec::new(); - match &ui.icon_groups { - Some(groups) => { - let groups: Vec> = groups - .iter() - .map(|g| g.iter().filter_map(|id| take(id, &mut placed)).collect::>()) - .filter(|panels| !panels.is_empty()) - .map(|panels| Group::new(panels, 0)) - .collect(); - if !groups.is_empty() { - columns.push(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(NAMED_ICON_WIDTH) }); - } - } - None => { - let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| take(p.0, &mut placed)).collect(); - let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); - let mut groups = Vec::new(); - if !panels.is_empty() { - groups.push(Group::new(panels, active)); - } - // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed - // height, so Properties keeps most of the column. - if let Some(id) = ui.open_panel.as_deref().and_then(|id| take(id, &mut placed)) { - groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); - } - if !groups.is_empty() { - columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); - } - // Window › Styles opens the style panels as a floating group, so they start outside. - let icons: Vec> = crate::dock::ICON_PANELS + + /// A group of the unplaced registered panels of `panels`, with the one at `active` in front + /// when it is among them. + fn group(&mut self, panels: &[String], active: usize) -> Option> { + let front = panels.get(active).and_then(|p| normalize(p)); + let kept: Vec = panels.iter().filter_map(|p| self.take(p)).collect(); + let active = front.and_then(|f| kept.iter().position(|p| p == f)).unwrap_or(0); + (!kept.is_empty()).then(|| Group::new(kept, active)) + } +} + +/// A workspace's iconic column: its named `icon_groups` (Essentials Classic, Typography), or +/// one group per panel not placed yet. Window › Styles opens the style panels as a floating +/// group, so they start outside it. +fn icon_column(icon_groups: Option<&[Vec]>, placed: &mut Placed) -> Option> { + let (groups, width): (Vec>, f32) = match icon_groups { + Some(groups) => (groups.iter().filter_map(|g| placed.group(g, 0)).collect(), NAMED_ICON_WIDTH), + None => ( + crate::dock::DOCK_TABS .iter() + .chain(crate::dock::ICON_PANELS) .filter(|p| !FLOATING_GROUPS.iter().any(|g| g.contains(&p.0))) - .filter_map(|p| take(p.0, &mut placed)) + .filter_map(|p| placed.take(p.0)) .map(|id| Group::new(vec![id], 0)) - .collect(); - if !icons.is_empty() { - columns.push(Column { groups: icons, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(ICON_WIDTH) }); - } - } - } + .collect(), + ICON_WIDTH, + ), + }; + (!groups.is_empty()).then_some(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(width) }) +} + +/// The canvas beside a dock of `columns` (from the window edge inwards) at the logical end edge. +fn with_canvas(columns: Vec>, floating: Vec>) -> Layout { let width: f32 = columns.iter().map(|c| if c.iconic { c.iconic_width.unwrap_or(ICON_WIDTH) } else { c.width }).sum(); Layout { root: Some(Node::Split { @@ -145,6 +105,118 @@ pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { } } +/// The layout of a workspace that has not been customized, from its panel settings: the +/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an +/// the panel the workspace opens stacked below it) and whose inner column is iconic. Essentials Classic +/// and Typography (`icon_groups`) have the iconic column only. +pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { + let mut placed = Placed::default(); + let mut floating = Vec::new(); + for (id, at) in &ui.floating { + if at.iter().all(|v| v.is_finite()) + && let Some(id) = placed.take(id) + { + floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0])); + } + } + let mut columns = Vec::new(); + if ui.icon_groups.is_none() { + let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| placed.take(p.0)).collect(); + let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); + let mut groups = Vec::new(); + if !panels.is_empty() { + groups.push(Group::new(panels, active)); + } + // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed + // height, so Properties keeps most of the column. + if let Some(id) = ui.open_panel.as_deref().and_then(|id| placed.take(id)) { + groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); + } + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); + } + } + columns.extend(icon_column(ui.icon_groups.as_deref(), &mut placed)); + with_canvas(columns, floating) +} + +/// Is `layout` from before the canvas joined the panel layout? Those layouts held only the +/// right dock's panels and floating windows; every later one holds the canvas. +pub(crate) fn dock_only(layout: &Layout) -> bool { + !layout.panels().iter().any(|p| p.as_str() == CANVAS) +} + +/// A [`dock_only`] layout in the current shape: its tab groups, accordions and splits become +/// the dock's expanded columns (collapsed to icons when the dock was), the panels it does not +/// hold form the iconic column, and its floating windows stay. Panels closed with a remembered +/// place (`hidden`) stay closed. None when the layout is invalid. +pub(crate) fn from_dock_only<'a>( + old: &Layout, + hidden: impl IntoIterator, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) -> Option> { + old.validate().ok()?; + let mut placed = Placed::default(); + // Reading order (start to end); a dock lists its columns from the window edge inwards. + let mut reading = old.root.as_ref().map(|root| dock_only_columns(root, 0, &mut placed)).unwrap_or_default(); + reading.reverse(); + let mut columns: Vec> = + reading.into_iter().map(|groups| Column { groups, width: COLUMN_WIDTH, iconic: !dock_expanded, iconic_width: Some(ICON_WIDTH) }).collect(); + let mut floating = Vec::new(); + for window in &old.floating { + let groups: Vec> = window + .groups + .iter() + .filter_map(|g| placed.group(&g.panels, g.active).map(|kept| Group { minimized: g.minimized, height: g.height, ..kept })) + .collect(); + if !groups.is_empty() { + floating.push(Floating { groups, rect: window.rect, native: None }); + } + } + for panel in hidden { + let _ = placed.take(panel); + } + columns.extend(icon_column(icon_groups, &mut placed)); + let layout = with_canvas(columns, floating); + valid(&layout).then_some(layout) +} + +/// The groups of a dock-only layout's node, column by column in reading order. A vertical split +/// stacks its parts in one column; a horizontal split puts them side by side. +fn dock_only_columns(node: &Node, depth: usize, placed: &mut Placed) -> Vec>> { + if depth > craft_ui::docking::MAX_DEPTH { + return Vec::new(); + } + let column = |groups: Vec>| if groups.is_empty() { Vec::new() } else { vec![groups] }; + match node { + Node::Tabs { panels, active } => column(placed.group(panels, *active).into_iter().collect()), + Node::Stack { entries } => column( + entries + .iter() + .filter_map(|e| placed.take(&e.panel).map(|id| Group { minimized: !e.open, height: e.height, ..Group::new(vec![id], 0) })) + .collect(), + ), + Node::Split { axis, first, second, .. } => { + let mut parts = dock_only_columns(first, depth.saturating_add(1), placed); + parts.extend(dock_only_columns(second, depth.saturating_add(1), placed)); + match axis { + craft_ui::layout::SplitAxis::Horizontal => parts, + _ => column(parts.into_iter().flatten().collect()), + } + } + Node::Dock { columns, .. } => columns + .iter() + .rev() + .filter_map(|c| { + let groups: Vec> = c.groups.iter().filter_map(|g| placed.group(&g.panels, g.active)).collect(); + (!groups.is_empty()).then_some(groups) + }) + .collect(), + _ => Vec::new(), + } +} + /// The layout in use: the customized one, or the workspace's default. fn layout_of(app: &DesignApp) -> Layout { app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) @@ -184,6 +256,7 @@ fn dock_columns(layout: &Layout) -> Vec<&Column> { /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. +#[cfg(test)] pub(crate) fn tab_group(layout: &Layout, panel: &str) -> Vec { if let Some(group) = layout.floating.iter().flat_map(|w| &w.groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); @@ -333,7 +406,19 @@ pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option) -> Result) -> Result Result { + let at = if matches!(id, "window.floatPanel" | "window.panel.float") { + Some([number(params, "x", 400.0)?, number(params, "y", 160.0)?]) + } else { + None + }; + app.ui.floating.retain(|(p, _)| p != panel); + if let Some(at) = at { + app.ui.floating.push((panel.to_string(), at)); + } + Ok(json!({"panel": panel, "floating": floating_panels(app)})) +} + +/// Once the layout is customized it holds every floating dock panel, so `UiState::floating` +/// keeps only the float-only panels. +fn keep_float_only(app: &mut DesignApp) { + if app.ui.docking.is_some() { + app.ui.floating.retain(|(id, _)| crate::dock::float_only(id)); + } +} + +/// Every floating panel: the layout's floating windows, then the float-only panels. +pub(crate) fn floating_panels(app: &DesignApp) -> Vec { + let layout = layout_of(app); + let docked = layout.floating.iter().flat_map(|w| w.panels().cloned()); + docked.chain(app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).map(|(id, _)| id.clone())).collect() } /// The panel in front of each tab group in the dock (not the floating groups). @@ -627,14 +742,14 @@ fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result>(), "layout": app.ui.docking})) + Ok(json!({"panel": panel, "floating": floating_panels(app), "layout": app.ui.docking})) } /// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last @@ -679,7 +794,6 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); if changed_workspace { craft_ui::docking::cancel_drag::(ui.ctx(), area); - ui.ctx().data_mut(|data| data.remove::(area.with("legacy-origin"))); } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { app.ui.docking = None; @@ -775,72 +889,9 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.newWindow", "Move Panel to New Window"), ]; -#[allow(dead_code)] // The classic icon rail; removed with the classic dock. -fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = crate::theme::Tokens::get(ui.ctx()); - let column = egui::Panel::right("shared-panel-icons") - .exact_size(38.0) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.panel)) - .show(ui, |ui| { - egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if app.ui.docking.as_ref().is_some_and(|layout| { - layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels().any(|p| p == id))) - }) { - continue; - } - - let response = - crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); - if response.clicked() { - let _ = app.run("window.panel.activate", json!({"panel":id})); - } - legacy_tab(app, ui, id, &response); - } - }); - }) - .response - .rect; - let _ = column; -} - -pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - let Some(panel) = normalize(panel) else { return }; - response.context_menu(|ui| panel_menu(app, ui, panel)); - if response.drag_started() - && let Some(id) = normalize(panel) - { - let mut layout = layout_of(app); - let bounds = ui.max_rect(); - let source = egui::Rect::from_min_size( - egui::pos2(bounds.left(), response.rect.top()), - egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), - ); - if ensure(&mut layout, id).is_ok() - && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("designcraft-panel-docking"), id.to_string(), source) - { - ui.ctx().data_mut(|data| { - data.insert_temp( - egui::Id::new("designcraft-panel-docking").with("legacy-origin"), - LegacyDrag { - layout: app.ui.docking.clone(), - open_panel: app.ui.open_panel.clone(), - floating: app.ui.floating.clone(), - generation: app.ui.docking_generation, - }, - ) - }); - app.ui.docking = Some(layout); - app.ui.open_panel = None; - } - } -} - -/// The menu behind a panel's three-line button, in either dock: the panel's own commands, then -/// its docking commands. -pub(crate) fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { +/// The menu behind a panel's three-line button: the panel's own commands, then its docking +/// commands. +fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { if panel == "pages" { crate::panels::pages::panel_menu(app, ui); ui.separator(); @@ -990,18 +1041,6 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } -/// Close Tab Group: `panel` and the panels tabbed with it, each restorable from Window as one -/// closed with Close. -fn close_group(app: &mut DesignApp, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - let layout = layout_of(app); - let group = tab_group(&layout, panel); - for p in &group { - change(app, "close", &json!({"panel": p}))?; - } - Ok(json!({"closed": group})) -} - /// InDesign's panel tab commands at the end of a panel's three-line menu: Close, Close Tab Group, /// Minimize (an open accordion entry) and Collapse to Icons. A tab's own context menu is /// craft-ui's, which lists the same commands. @@ -1041,20 +1080,6 @@ pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { Ok(()) } -// Legacy inspection fields are derived from the shared layout after customization. -fn sync_legacy(app: &mut DesignApp) { - if let Some(layout) = &app.ui.docking { - // Float-only panels aren't in the layout; they keep their place in the list. - let float_only: Vec<(String, [f32; 2])> = app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).cloned().collect(); - app.ui.floating = layout - .floating - .iter() - .flat_map(|window| window.panels().map(|id| (id.clone(), [window.rect[0], window.rect[1]]))) - .chain(float_only) - .collect(); - } -} - #[cfg(test)] #[path = "panel_docking_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index abbcf1391..b44265e7d 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -136,16 +136,7 @@ fn capture_custom_panel_docking_visual_fixtures() { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Custom panel workspace"); - if app.ui.language == "ar" { - ui.label("Shaped action references:"); - for action in ["Float Panel", "Group with", "Close Panel"] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); - } - } - }); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -174,10 +165,7 @@ fn capture_custom_panel_docking_visual_fixtures() { harness.run_steps(2); assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); for action in ["Float Panel", "Group with", "Close Panel"] { - assert!( - harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, - "both logical reference and actual menu label must exist" - ); + assert!(harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 1, "the menu lists {action}"); } harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); } @@ -352,7 +340,7 @@ fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { } #[test] -fn first_customization_preserves_the_visible_legacy_flyout() { +fn first_customization_keeps_the_panel_the_workspace_opened() { let mut app = app(); app.ui.open_panel = Some("paragraphStyles".into()); app.ui.dock_expanded = false; @@ -383,7 +371,7 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() let floating = h.state().ui.floating.clone(); // The app calls this before dock rendering when panels are hidden or Presentation starts. let ctx = h.ctx.clone(); - cancel_gesture(h.state_mut(), &ctx); + cancel_gesture(&ctx); assert_eq!(h.state().ui.docking, destination); assert_eq!(h.state().ui.open_panel, open_panel); assert_eq!(h.state().ui.floating, floating); @@ -393,7 +381,7 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() } #[test] -fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() { +fn table_and_control_visibility_work_after_customization_without_document_edits() { let mut app = app(); app.run("file.new", json!({})).unwrap(); let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); @@ -548,82 +536,6 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); } -#[test] -fn capture_legacy_panel_menu_visual_fixture() { - let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; - std::fs::create_dir_all(&directory).unwrap(); - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); - app.run("app.language", json!({"lang":"ar"})).unwrap(); - let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let mut ready = false; - let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), &app.ui.language); - crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); - ready = true; - return; - } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Shaped legacy menu references:"); - for label in ["Float Panel", "Layers"] { - crate::rtl::label(ui, crate::i18n::tr("ar", label)); - } - }); - }, - app, - ); - h.input_mut().max_texture_side = Some(8192); - h.run_steps(5); - assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); - let at = h.get_by_label("Properties").rect().center(); - h.input_mut().events.extend([ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, - ]); - h.run_steps(1); - h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(egui::Popup::is_any_open(&h.ctx)); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); - let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); - h.hover_at(group); - h.run_steps(4); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); - assert!(h.state().ui.docking.is_none()); - let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); - let logical = crate::i18n::tr("ar", last_label); - let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); - let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); - let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); - let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); - assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); - h.input_mut().events.extend([ - Event::PointerMoved(scroll_at), - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, - ]); - h.run_steps(12); - let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); - assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); - assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); - h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); - h.run_steps(4); - let layout = h.state().ui.docking.as_ref().unwrap(); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); - let group = group_members_for_test(layout, last); - assert!(group.iter().any(|panel| panel == "properties")); - assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); - assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); - h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); -} - #[test] fn dock_menu_button_opens_the_active_panel_menu() { let mut app = app(); @@ -757,7 +669,7 @@ fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { } /// Window › Paragraph Styles switches to the docking layout. After Window › Pages the Pages panel -/// keeps its three-line panel menu, and its tab menu lists what the classic dock's does. +/// keeps its three-line panel menu, and its tab menu lists InDesign's tab commands. #[test] fn pages_keeps_its_panel_menu_in_the_docking_layout() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 79cf2f0e7..e216df3b3 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,12 +16,9 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; -/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it -/// toggles the flyout, so a flyout that is already open is left as it is. +/// A link to another panel: `window.panel` shows it in the docking layout. fn show_panel(app: &mut DesignApp, panel: &str) { - if app.ui.open_panel.as_deref() != Some(panel) { - let _ = app.run("window.panel", json!({"panel": panel})); - } + let _ = app.run("window.panel", json!({"panel": panel})); } fn units(app: &DesignApp) -> Unit { @@ -1692,12 +1689,8 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a -/// flyout, so it is skipped while the Typography flyout is open. +/// Opens the Typography panel, or brings it to the front when it is already open. pub fn show_typography_panel(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() == Some("typography") { - return; - } let _ = app.run("window.panel", json!({"panel": "typography"})); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index cb5a0b54c..37a70c3d6 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,43 +1,92 @@ //! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. +use std::collections::BTreeMap; + use craft_ui::docking::{Layout, Location}; use serde_json::Value; -use crate::UiState; +use crate::{SavedWorkspace, UiState, panel_docking}; + +/// Saved preferences, and whether reading them discarded anything. +pub struct ReadPreferences { + pub ui: UiState, + /// False when unreadable docking data was dropped. The caller keeps the file it read + /// instead of overwriting it. + pub intact: bool, +} impl UiState { /// Read saved preferences, discarding only unreadable optional docking metadata. /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result { + Self::read_preferences(bytes).map(|read| read.ui) + } + + /// [`Self::from_preferences_json`], also reporting whether anything was discarded. Layouts + /// saved before the canvas joined the panel layout are converted (see + /// [`panel_docking::from_dock_only`]); that is not a loss. + pub fn read_preferences(bytes: &[u8]) -> serde_json::Result { let mut saved: Value = serde_json::from_slice(bytes)?; - recover_docking(&mut saved); + let mut intact = recover_docking(&mut saved); if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { for workspace in workspaces { - recover_docking(workspace); + intact &= recover_docking(workspace); } } - serde_json::from_value(saved) + let mut ui: UiState = serde_json::from_value(saved)?; + migrate(&mut ui.docking, &mut ui.floating, &ui.docking_hidden, ui.dock_expanded, ui.icon_groups.as_deref()); + for workspace in &mut ui.custom_workspaces { + let SavedWorkspace { docking, floating, docking_hidden, dock_expanded, icon_groups, .. } = workspace; + migrate(docking, floating, docking_hidden, *dock_expanded, icon_groups.as_deref()); + } + Ok(ReadPreferences { ui, intact }) } } -fn recover_docking(saved: &mut Value) { - let Some(fields) = saved.as_object_mut() else { return }; +/// Drop docking data this version cannot read. True when nothing was dropped. +fn recover_docking(saved: &mut Value) -> bool { + let Some(fields) = saved.as_object_mut() else { return true }; + let mut intact = true; if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { fields.remove("docking"); + intact = false; } if let Some(hidden) = fields.get_mut("dockingHidden") { if let Some(entries) = hidden.as_object_mut() { + let before = entries.len(); entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); + intact &= entries.len() == before; } else { fields.remove("dockingHidden"); + intact = false; } } + intact +} + +/// Convert a layout saved before the canvas joined the panel layout. One that cannot be +/// converted gives way to the workspace's default layout, which the workspace fields +/// (`dockTab`, `openPanel`, `floating`, `dockExpanded`, `iconGroups`) still describe. +fn migrate( + docking: &mut Option>, + floating: &mut Vec<(String, [f32; 2])>, + hidden: &BTreeMap>, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) { + let Some(old) = docking.as_ref().filter(|layout| panel_docking::dock_only(layout)) else { return }; + *docking = panel_docking::from_dock_only(old, hidden.keys(), dock_expanded, icon_groups); + if docking.is_some() { + // The converted layout holds the floating dock panels. + floating.retain(|(id, _)| crate::dock::float_only(id)); + } } #[cfg(test)] mod tests { use super::*; - use crate::{DesignApp, SavedWorkspace}; + use crate::DesignApp; + use craft_ui::docking::{Column, Node, Side}; use serde_json::json; fn populated() -> UiState { @@ -108,6 +157,144 @@ mod tests { assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); } + #[test] + fn unreadable_docking_is_reported_so_the_file_is_kept() { + let mut saved = serde_json::to_value(populated()).unwrap(); + assert!(UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + saved["customWorkspaces"][1]["docking"] = json!({"root":{"FutureLayout":{}}}); + assert!(!UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + } + + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. + fn dock(layout: &Layout) -> &[Column] { + assert!(crate::panel_docking::valid(layout), "{layout:?}"); + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("expected the canvas split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &["canvas"])); + let Node::Dock { side: Side::End, columns } = &**second else { panic!("expected an end dock: {second:?}") }; + columns + } + + fn panels(column: &Column) -> Vec> { + column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect() + } + + /// Hand-written pre-trial `ui.json` files: each loads through the app's path with nothing lost. + fn load(saved: serde_json::Value) -> UiState { + UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).map(|read| read.ui).unwrap() + } + + #[test] + fn classic_dock_file_loads_as_its_workspace_default_layout() { + let ui = load(json!({ + "workspace": "Printing and Proofing", "dockTab": "layers", "dockExpanded": true, "openPanel": "swatches", + "floating": [["glyphs", [100.0, 120.0]]], + })); + assert!(ui.docking.is_none()); + let layout = crate::panel_docking::default_layout(&ui); + let columns = dock(&layout); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 2, "Layers stays in front"); + assert!(!columns[0].iconic && columns[1].iconic); + assert!(columns[1].groups.iter().all(|g| !g.panels.iter().any(|p| p == "glyphs" || p == "swatches"))); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["glyphs"]); + assert_eq!(&layout.floating[0].rect[..2], &[100.0, 120.0]); + } + + #[test] + fn dock_only_layout_becomes_the_main_column() { + let ui = load(json!({ + "dockExpanded": true, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers", "swatches"], "active": 3}}, "floating": []}, + "customWorkspaces": [{ + "name": "Split", "dockExpanded": true, + "docking": {"root": {"Split": {"axis": "Horizontal", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["links"], "active": 0}}, + "second": {"Split": {"axis": "Vertical", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["properties", "pages"], "active": 1}}, + "second": {"Stack": {"entries": [ + {"panel": "layers", "open": true, "height": null}, + {"panel": "swatches", "open": false, "height": 120.0}]}}}}}}, + "floating": []}, + }], + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers", "swatches"]]); + assert_eq!(columns[0].groups[0].active, 3); + assert!(!columns[0].iconic); + let icons = &columns[1]; + assert!(icons.iconic && icons.groups.iter().any(|g| g.panels == ["stroke"])); + assert!(icons.groups.iter().all(|g| g.panels != ["swatches"]), "each panel appears once"); + + // Splits: a vertical split stacks groups in one column, a horizontal one adds a column + // (the dock lists columns from the window edge inwards). + let columns = dock(ui.custom_workspaces[0].docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages"], vec!["layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 1); + assert!(columns[0].groups[2].minimized && columns[0].groups[2].height == Some(120.0), "a closed accordion entry stays minimized"); + assert_eq!(panels(&columns[1]), [vec!["links"]]); + assert!(columns[2].iconic); + } + + #[test] + fn icon_groups_become_the_iconic_column_in_order() { + let ui = load(json!({ + "dockExpanded": true, + "iconGroups": [["pages", "layers"], ["properties", "swatches", "color"]], + "docking": {"root": {"Tabs": {"panels": ["properties"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(columns.len(), 2); + assert_eq!(panels(&columns[0]), [vec!["properties"]]); + assert!(columns[1].iconic); + assert_eq!(panels(&columns[1]), [vec!["pages", "layers"], vec!["swatches", "color"]]); + } + + #[test] + fn collapsed_dock_makes_the_main_column_iconic() { + let ui = load(json!({ + "dockExpanded": false, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"]]); + assert!(columns.iter().all(|c| c.iconic)); + } + + #[test] + fn floating_windows_and_hidden_places_survive_the_migration() { + let saved = json!({ + "dockExpanded": true, + "floating": [["glyphs", [40.0, 60.0]], ["character", [40.0, 60.0]], ["tabs", [10.0, 20.0]]], + "docking": { + "root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, + "floating": [{"panels": ["glyphs", "character"], "active": 1, "rect": [40.0, 60.0, 300.0, 400.0]}], + }, + "dockingHidden": {"color": {"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, + "size": {"Ratio": 0.5}, "stack": null}}, + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(read.intact, "converting is not a loss"); + let ui = read.ui; + let layout = ui.docking.clone().unwrap(); + let columns = dock(&layout); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["glyphs", "character"]); + assert_eq!(layout.floating[0].groups[0].active, 1); + assert_eq!(layout.floating[0].rect, [40.0, 60.0, 300.0, 400.0]); + assert_eq!(ui.floating, [("tabs".to_string(), [10.0, 20.0])], "the layout holds the floating dock panels"); + assert!(ui.docking_hidden.contains_key("color") && !layout.contains(&"color".to_string()), "a closed panel stays closed"); + assert!(columns[1].groups.iter().all(|g| g.panels != ["glyphs"])); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + // Saved again, the file is in the current shape and loads unchanged. + let again = UiState::from_preferences_json(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(again.docking, app.ui.docking); + } + #[test] fn unrelated_malformed_preferences_are_still_rejected() { assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 78ef44fa3..446460981 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -87,7 +87,12 @@ workspace opens (Printing and Proofing: Swatches) is a group below Properties · The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped over it floats there. Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the positions of hidden panels. Resetting a built-in workspace restores its -default dock. +default dock. Preferences saved before the canvas joined the layout load with nothing lost: +the classic dock becomes the workspace's default layout, and a customized dock-only layout +becomes the dock's expanded columns (iconic if the dock was collapsed), with the remaining +panels (or the workspace's named groups) in the iconic column and its floating windows and +hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to +`ui.unreadable.json` before it is saved again. - `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders @@ -95,12 +100,17 @@ default dock. `top` and `bottom` add a group above or below its group and `left` and `right` add a column on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` are errors. -- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. -- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous - dock placement when possible, or to the supplied destination. +- `window.panel.float {panel, x?, y?, width?, height?}` (and `window.floatPanel {panel, x?, y?}`) + detaches a panel. +- `window.panel.dock {panel, anchor?, zone?, before?}` (and `window.dockPanel {panel}`) + returns a panel to its previous dock placement when possible, or to the supplied + destination. For the float-only Tabs panel (`tabs`), which has its own window outside the + layout, float opens that window and dock closes it. - `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, restoring its saved position when possible; a panel in an iconic column expands that column. `window.panel.close {panel}` hides it. +- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it (craft-ui's + `CloseGroup`); the result lists them in `closed`. - `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4a7e9b99f..8231f8c17 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable, resizable floating window; Dock Panel puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. From b66c3223f1fc0555c09894c3b21f0f885cb57e44 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:57:34 +0000 Subject: [PATCH 013/315] Docking trial: craft-ui f4f9e5b, DockCommand, hit rects, iconic column --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/chrome.rs | 9 +- crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 70 ++- crates/ui-egui/src/menus.rs | 38 +- crates/ui-egui/src/panel_docking.rs | 660 ++++++++++------------ crates/ui-egui/src/panel_docking_tests.rs | 152 ++++- crates/ui-egui/src/panel_icons.rs | 168 ++++++ crates/ui-egui/src/prefs.rs | 43 +- docs/control-protocol.md | 77 ++- 11 files changed, 809 insertions(+), 416 deletions(-) create mode 100644 crates/ui-egui/src/panel_icons.rs diff --git a/Cargo.lock b/Cargo.lock index 1e77d5344..5db9c5acd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" +source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9ecd08b25..63ea8c527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index eea906ee6..6041f8790 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1249,7 +1249,7 @@ mod tests { h.run_steps(3); // Preflight is in the iconic column: `window.panel` shows it. assert!(crate::panel_docking::showing(&h.state().app, "preflight")); - h.get_by_label("Preflight"); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); // With the panel already showing, the entry keeps it open. h.get_by_label("No errors ▾").click(); h.run_steps(3); @@ -1270,9 +1270,12 @@ mod tests { count.click(); h.run_steps(3); assert!(crate::panel_docking::showing(&h.state().app, "preflight")); - h.get_by_label("Preflight"); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); - // A double-click with the panel already showing keeps it open. + // A double-click with the panel already showing keeps it open (the first click closes + // its flyout, the double-click opens it again). Past the double-click time, so the + // clicks are not counted with the ones before. + h.run_steps(30); let count = h.get_by_label("No errors ▾"); count.click(); count.click(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ea81c278e..1ff3218de 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -78,7 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), - "docking": crate::panel_docking::inspect(app), + "docking": crate::panel_docking::inspect(app, ctx), "window": [r.width(), r.height()], "documents": app .session @@ -171,7 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", json!({}))), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => ok(crate::panel_docking::inspect(app)), + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 370388317..de859f4ff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ mod modal_drag; mod new_document; mod object_control; mod panel_docking; +mod panel_icons; pub mod panels; mod prefs; pub mod render_worker; @@ -88,6 +89,59 @@ pub struct Services { pub clipboard_text: Option, } +/// Closed panels, each with where it goes back, in craft-ui's `Workspace::hidden` shape +/// (`[[panel, location], …]`). A floating panel's entry is where Dock Panel returns it. +/// Files from before this shape hold an object keyed by panel; it still loads. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(transparent)] +pub struct HiddenPanels(pub Vec<(String, craft_ui::docking::Location)>); + +impl<'de> Deserialize<'de> for HiddenPanels { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Shape { + List(Vec<(String, craft_ui::docking::Location)>), + Keyed(std::collections::BTreeMap>), + } + let entries = match Shape::deserialize(deserializer)? { + Shape::List(entries) => entries, + Shape::Keyed(entries) => entries.into_iter().collect(), + }; + let mut hidden = Self::default(); + for (panel, location) in entries { + hidden.insert(panel, location); + } + Ok(hidden) + } +} + +impl HiddenPanels { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn contains_key(&self, panel: &str) -> bool { + self.0.iter().any(|(p, _)| p == panel) + } + pub fn get(&self, panel: &str) -> Option<&craft_ui::docking::Location> { + self.0.iter().find(|(p, _)| p == panel).map(|(_, location)| location) + } + /// Record where `panel` goes back, replacing an earlier entry. + pub fn insert(&mut self, panel: String, location: craft_ui::docking::Location) { + self.remove(&panel); + self.0.push((panel, location)); + } + pub fn remove(&mut self, panel: &str) { + self.0.retain(|(p, _)| p != panel); + } + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(p, _)| p) + } + pub fn clear(&mut self) { + self.0.clear(); + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ScreenMode { @@ -115,8 +169,8 @@ pub struct SavedWorkspace { /// Custom panel membership and placement; absent in older workspace files. #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, /// The workspace's own iconic column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, @@ -267,8 +321,8 @@ pub struct UiState { /// (`panel_docking::default_layout`). #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, /// The panel (or the canvas) last clicked or selected in the docking layout. #[serde(skip)] pub docking_focused: Option, @@ -281,6 +335,12 @@ pub struct UiState { /// The panel last revealed explicitly, which `ui.set closePanel` closes. #[serde(skip)] pub docking_opened: Option, + /// The panel whose iconic-column flyout is open, as of the last frame or a command that + /// opened it. + #[serde(skip)] + pub docking_flyout: Option, + /// Auto-Collapse Iconic Panels: a flyout closes on Escape or a click outside it. + pub dock_auto_collapse: bool, /// Whether the default layout's outer column is expanded (false: collapsed to icons). pub dock_expanded: bool, /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the @@ -413,6 +473,8 @@ impl Default for UiState { docking_generation: 0, docking_raise: None, docking_opened: None, + docking_flyout: None, + dock_auto_collapse: true, docking_focused: None, dock_expanded: true, icon_groups: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e4ceda19..abed8cea7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -236,7 +236,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), - ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own floating window (window.panel.float)"), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?, width?, height?} — the panel in its own floating window (window.panel.float)"), ( "window.panel.layout", "Change Panel Layout", @@ -247,13 +247,38 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.panel.move", "Move Panel", None, - "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel|null, group?, placement?} — group, split or reorder a panel (group: its whole tab group)", + ), + ( + "window.panel.float", + "Float Panel", + None, + "{panel, x?, y?, width?, height?, group?, column?} — detach a panel (or its group, or its dock column) inside the application window", + ), + ( + "window.panel.dock", + "Dock Panel", + None, + "{panel, anchor?, zone?, before?, group?, column?, side?: Start|End} — dock a floating panel: beside anchor, in a new column at side, or back where it floated from", ), - ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), - ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), + ("window.panel.closeOthers", "Close Other Panels in Group", None, "{panel} — hide the panels tabbed with it"), + ("window.panel.minimize", "Minimize", None, "{panel, minimized?} — minimize the panel's group to its tab strip (toggles without minimized)"), + ("window.panel.iconic", "Collapse to Icons", None, "{panel, iconic?} — collapse the panel's dock column to icons (toggles without iconic)"), + ("window.panel.window", "Move Panel to New Window", None, "{panel, native?} — not available: DesignCraft keeps panels in its window"), + ("window.workspace.new", "New Workspace", None, "{name} — save the current workspace (window.newWorkspace)"), + ("window.workspace.delete", "Delete Workspace", None, "{name} — delete a saved workspace (window.deleteWorkspace)"), + ("window.workspace.reset", "Reset Workspace", None, "{} — back to the current workspace as saved (window.resetWorkspace)"), + ("window.workspace.lock", "Lock Workspace", None, "{locked?} — not available in DesignCraft"), + ( + "ui.dock", + "Change Panel Layout", + None, + "{action} | {operation: open|close|activate|float|moveFloating|move|setStackOpen|resizeStack|resizeSplit, …} — craft-ui's shared ui.dock shape", + ), + ("ui.dock.reset", "Reset Workspace", None, "{} — window.workspace.reset"), ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), ("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), @@ -1848,6 +1873,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option bool { if is_window_control(id) { return window_control_available(app, id); } + // Shared docking commands DesignCraft does not provide (native panel windows, Lock Workspace). + if matches!(id, "window.panel.window" | "window.workspace.lock") { + return false; + } match designcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), None => true, diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index c0e0cfd5f..44b327196 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -4,7 +4,9 @@ //! protected `canvas` panel and a dock at the logical end edge (the right in left-to-right //! languages, the left in right-to-left ones). -use craft_ui::docking::{Action, Column, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, Zone}; +use craft_ui::docking::{ + Action, Column, DockCommand, DockOperation, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, +}; use serde_json::{Value, json}; use crate::DesignApp; @@ -222,9 +224,12 @@ fn layout_of(app: &DesignApp) -> Layout { app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) } -/// Is `panel` showing: in front of its group, and not in a column collapsed to icons? +/// Is `panel` showing: in front of its group and not in a column collapsed to icons, or in +/// the open flyout of an iconic column? pub(crate) fn showing(app: &DesignApp, panel: &str) -> bool { - layout_of(app).panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front + let layout = layout_of(app); + layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front + || (app.ui.docking_flyout.as_deref() == Some(panel) && in_iconic_column(&layout, panel)) } /// Is `panel` in a dock column collapsed to icons? @@ -323,12 +328,7 @@ fn first_dock_panel(layout: &Layout, except: &str) -> Option { }) } -fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { - let anchor = first_dock_panel(layout, panel); - apply(layout, Action::Open { panel: panel.into(), anchor }) -} - -fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { +fn apply(layout: &mut Layout, action: Action) -> Result, String> { layout.apply_with_permissions(action, model_permissions).map_err(|e| e.to_string()) } @@ -343,122 +343,253 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } -/// The side of the dock holding `panel`, if a dock column holds it. -fn dock_side(layout: &Layout, panel: &str) -> Option { - let mut visit: Vec<&Node> = layout.root.iter().collect(); - let mut seen = 0usize; - while let Some(node) = visit.pop() { - seen = seen.saturating_add(1); - if seen > 2 * craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Dock { side, columns } if columns.iter().flat_map(|c| &c.groups).any(|g| g.panels.iter().any(|p| p == panel)) => { - return Some(*side); - } - _ => {} - } +/// The shared docking command a DesignCraft command id names. `window.floatPanel` and +/// `window.dockPanel` are DesignCraft's names for Float Panel and Dock Panel. `window.workspace` +/// stays DesignCraft's own command (same id and params), with the other workspace commands. +fn shared_id(id: &str) -> Option<&'static str> { + match id { + "window.floatPanel" => Some("window.panel.float"), + "window.dockPanel" => Some("window.panel.dock"), + "window.workspace" => None, + _ => DockCommand::::IDS.iter().copied().find(|known| *known == id), } - None -} - -/// The placement a `zone` names beside `anchor`. In a dock column or a floating window, top and -/// bottom add a group above or below the anchor's group; in a dock column, left and right add a -/// column on that side of the anchor's. Elsewhere the sides split the anchor's group. -fn placement(params: &Value, layout: &Layout, anchor: &str, rtl: bool) -> Result, String> { - if let Some(before) = params.get("before") { - let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; - return Ok(Placement::Tab { before: Some(id.into()) }); - } - let zone = match params.get("zone") { - None => "tab", - Some(value) => value.as_str().ok_or("zone must be a string")?, - }; - let side = dock_side(layout, anchor); - let floating = layout.floating.iter().any(|w| w.contains(anchor)); - Ok(match zone { - "tab" => Placement::Tab { before: None }, - "center" => Placement::Split(Zone::Center), - "top" | "bottom" if side.is_some() || floating => Placement::Group { below: zone == "bottom" }, - "left" | "right" if floating => return Err("a floating window has no columns; use top, bottom or tab".into()), - "left" | "right" if let Some(side) = side => { - // Columns count from the window edge inwards; `after` is away from the edge. - let edge_is_right = (side == Side::End) != rtl; - Placement::Column { after: (zone == "right") != edge_is_right } - } - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be tab, center, left, right, top or bottom".into()), - }) } pub(crate) fn command(app: &mut DesignApp, id: &str, params: &Value) -> Option> { if id == "app.tablePanel" { - return Some(change(app, if showing(app, "table") { "close" } else { "activate" }, &json!({"panel": "table"}))); + let id = if showing(app, "table") { "window.panel.close" } else { "window.panel.activate" }; + return Some(shared_command(app, id, &json!({"panel": "table"}))); } if id == "window.collapseToIcons" { return Some(set_iconic(app, true)); } - if id == "window.panel.closeGroup" { - return Some( - params - .get("panel") - .and_then(Value::as_str) - .and_then(normalize) - .ok_or_else(|| "panel must name a registered panel".to_string()) - .and_then(|panel| apply_action(app, Action::CloseGroup { panel: panel.into() })), - ); - } if matches!(id, "window.floatPanel" | "window.dockPanel" | "window.panel.float" | "window.panel.dock") && let Some(panel) = params.get("panel").and_then(Value::as_str).filter(|p| crate::dock::float_only(p)) { return Some(float_only_panel(app, id, panel, params)); } - if id == "window.panel" - && let Some(result) = open_floating_group(app, params) - { - return Some(result); - } - if id == "window.panel.layout" { - return Some( - params - .get("action") - .ok_or_else(|| "action is required".to_string()) - .and_then(|value| serde_json::from_value::>(value.clone()).map_err(|error| error.to_string())) - .and_then(|action| apply_action(app, action)), - ); - } - let operation = match id { - "window.panel.move" => "move", - "window.panel.float" | "window.floatPanel" => "float", - "window.panel.dock" | "window.dockPanel" => "dock", - "window.panel.activate" => "activate", - "window.panel.close" => "close", + if id == "window.panel" { + if let Some(result) = open_floating_group(app, params) { + return Some(result); + } // Float-only panels (Type › Tabs) are not in the layout. - "window.panel" if params.get("panel").and_then(Value::as_str).and_then(normalize).is_some() => "activate", - _ => return None, - }; - Some(change(app, operation, params)) + params.get("panel").and_then(Value::as_str).and_then(normalize)?; + return Some(shared_command(app, "window.panel.activate", params)); + } + let id = shared_id(id)?; + Some(shared_command(app, id, params)) } -// UI output and automation share this atomic dispatcher. The application owns the registry -// and saved return locations; craft-ui owns tree validation and structural changes. -fn apply_action(app: &mut DesignApp, action: Action) -> Result { +/// Command params with DesignCraft's panel names resolved (a panel id or English name, in any +/// case) and `onto` read as `anchor`. +fn resolve_panels(params: &Value) -> Result { + let mut params = if params.is_null() { json!({}) } else { params.clone() }; + let fields = params.as_object_mut().ok_or("params must be an object")?; + if let Some(onto) = fields.remove("onto") { + fields.entry("anchor").or_insert(onto); + } + for key in ["panel", "anchor", "target", "before"] { + let Some(value) = fields.get_mut(key) else { continue }; + if key == "before" && value.is_null() { + continue; + } + let id = value.as_str().and_then(normalize).ok_or_else(|| format!("{key} must name a registered panel"))?; + *value = Value::String(id.to_owned()); + } + Ok(params) +} + +/// The panel `params` names, resolved, if it names one. +fn named_panel(params: &Value) -> Option { + params.get("panel").and_then(Value::as_str).map(str::to_owned) +} + +/// Is `panel` alone in a floating window? +fn floats_alone(layout: &Layout, panel: &str) -> bool { + layout.floating.iter().any(|w| w.contains(&panel.to_string()) && w.panels().count() == 1) +} + +/// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in +/// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; +/// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking +/// it, floats a panel at its own size, and returns a floated panel to its dock place on Dock +/// Panel. +fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { + let mut params = resolve_panels(params)?; + if id == "window.panel.float" + && let Some(panel) = named_panel(¶ms) + && !floats_alone(&layout_of(app), &panel) + && let Some(fields) = params.as_object_mut() + { + let size = floating_size(&panel); + for (key, default) in [("x", 400.0), ("y", 140.0), ("width", size.x), ("height", size.y)] { + fields.entry(key).or_insert(json!(default)); + } + } + if let Some(fields) = params.as_object_mut() { + fields.insert("command".into(), Value::String(id.to_owned())); + } + let command: DockCommand = serde_json::from_value(params).map_err(|e| e.to_string())?; + match &command { + DockCommand::Workspace { name } => return app.run("window.workspace", json!({"name": name})), + DockCommand::NewWorkspace { name } => return app.run("window.newWorkspace", json!({"name": name})), + DockCommand::DeleteWorkspace { name } => return app.run("window.deleteWorkspace", json!({"name": name})), + DockCommand::ResetWorkspace {} => return app.run("window.resetWorkspace", json!({})), + DockCommand::Lock { .. } => return Err("Lock Workspace is not available in DesignCraft".into()), + DockCommand::Window { .. } => return Err("DesignCraft does not move panels to their own windows".into()), + _ => {} + } + let rtl = crate::i18n::is_rtl(&app.ui.language); + let mut txn = Transaction::begin(app)?; + let shows = match &command { + DockCommand::Activate { panel } + | DockCommand::Move { panel, .. } + | DockCommand::Float { panel, .. } + | DockCommand::Dock { panel, .. } + | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel.clone()), + _ => None, + }; + let mut shown = false; + if let Some(panel) = shows.as_ref().filter(|p| !txn.workspace.layout.contains(p)) { + let fallback = first_dock_panel(&txn.workspace.layout, panel); + txn.workspace.set_visible(panel.clone(), true, fallback).map_err(|e| e.to_string())?; + shown = true; + } + let returned = match &command { + DockCommand::Dock { panel, anchor: None, side: None, group: false, column: false, .. } if !shown => txn.dock_back(panel)?, + _ => false, + }; + let actions = match &command { + // Showing it was the whole command (craft-ui's `DockCommand::apply` does the same). + DockCommand::Dock { anchor: None, side: None, .. } if shown || returned => Vec::new(), + _ => command.to_actions(&txn.workspace, rtl).map_err(|e| e.to_string())?, + }; + let reveal = match &command { + DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }, + DockCommand::UiDock(UiDock::Operation( + DockOperation::Float { panel, .. } | DockOperation::MoveFloating { panel, .. } | DockOperation::Move { panel, .. }, + )) => Some(panel.clone()), + _ => shows, + }; + let mut closed = Vec::new(); + for action in actions { + closed.extend(txn.apply(action)?); + } + Ok(txn.commit(app, closed, reveal)) +} + +/// A change to the layout in use, committed only when every step succeeded. +struct Transaction { + /// The layout, and the closed panels with where each one goes back. + workspace: Workspace, + /// Where each floating panel was docked before it floated, for Dock Panel. + returns: crate::HiddenPanels, +} + +impl Transaction { + fn begin(app: &DesignApp) -> Result { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let layout = layout_of(app); + let mut workspace = Workspace::new(layout); + let mut returns = crate::HiddenPanels::default(); + for (panel, location) in + app.ui.docking_hidden.0.iter().filter(|(id, _)| normalize(id) == Some(id.as_str())).take(craft_ui::docking::MAX_PANELS) + { + if !workspace.layout.contains(panel) { + workspace.hidden.push((panel.clone(), location.clone())); + } else if workspace.layout.floating.iter().any(|w| w.contains(panel)) { + returns.insert(panel.clone(), location.clone()); + } + } + Ok(Self { workspace, returns }) + } + + /// Dock Panel for a floating panel that floated from the dock: back to that place, while + /// the panel it was beside is still docked. True when it went back. + fn dock_back(&mut self, panel: &String) -> Result { + let layout = &self.workspace.layout; + if !layout.floating.iter().any(|w| w.contains(panel)) { + return Ok(false); + } + let docked = Layout { root: layout.root.clone(), floating: Vec::new() }; + let Some(location) = self + .returns + .get(panel) + .filter(|location| location.floating.is_none() && location.anchor.as_ref().is_none_or(|anchor| docked.contains(anchor))) + else { + return Ok(false); + }; + let mut candidate = layout.clone(); + apply(&mut candidate, Action::Close { panel: panel.clone() })?; + if candidate.restore(panel.clone(), location).is_err() { + return Ok(false); + } + self.workspace.layout = candidate; + self.returns.remove(panel); + Ok(true) + } + + /// One action from a command or the renderer, for registered panels only. Floating a + /// docked panel remembers its place for Dock Panel. + fn apply(&mut self, action: Action) -> Result, String> { + if !known(&action) { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if let Action::Float { panel, .. } = &action + && let Ok(location) = self.workspace.layout.location(panel) + && location.floating.is_none() + { + self.returns.insert(panel.clone(), location); + } + self.workspace.apply(action, model_permissions).map_err(|e| e.to_string()) + } + + /// Store the result. `reveal` is the panel the command showed, raised (or its flyout opened) + /// on the next frame. + fn commit(self, app: &mut DesignApp, closed: Vec, reveal: Option) -> Value { + let Self { workspace, returns } = self; + let layout = workspace.layout; + let floating = |p: &String| layout.floating.iter().any(|w| w.contains(p)); + let mut hidden = crate::HiddenPanels(workspace.hidden); + for (panel, location) in returns.0 { + if floating(&panel) && !hidden.contains_key(&panel) { + hidden.0.push((panel, location)); + } + } + let reveal = reveal.filter(|panel| layout.contains(panel)); + let flyout = reveal.as_ref().filter(|panel| in_iconic_column(&layout, panel)).cloned(); + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + release_closed(app, &closed); + keep_float_only(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.docking_flyout = flyout; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + app.ui.open_panel = None; + json!({"layout": app.ui.docking, "closed": closed, "floating": floating_panels(app)}) + } +} + +/// Whether every panel an action names is registered (and a split path is not excessive). +/// The application owns the registry; craft-ui owns tree validation and structural changes. +fn known(action: &Action) -> bool { let registered = |id: &String| self::registered(id); - let known = match &action { + let tab = |placement: &Placement| match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + }; + match action { Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { - registered(panel) - && registered(anchor) - && match placement { - Placement::Tab { before: Some(id) } => registered(id), - _ => true, - } + registered(panel) && registered(anchor) && tab(placement) } Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), Action::Float { panel, .. } @@ -471,13 +602,7 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result { registered(panel) && match to { - Target::At { anchor, placement } => { - registered(anchor) - && match placement { - Placement::Tab { before: Some(id) } => registered(id), - _ => true, - } - } + Target::At { anchor, placement } => registered(anchor) && tab(placement), _ => true, } } @@ -491,42 +616,7 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result registered(panel), // Native OS windows are not enabled in DesignCraft; newer actions are rejected. _ => false, - }; - if !known { - return Err("docking action contains an unknown panel or an excessive split path".into()); } - if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { - return Err("saved panel layout is invalid; reset the workspace first".into()); - } - let mut layout = layout_of(app); - let closed = layout.closed_by(&action).map_err(|e| e.to_string())?; - // Window › a closed panel, and Dock Panel after a float, bring it back where it was. - let returning: Vec<_> = match &action { - Action::Float { panel, .. } => { - layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)).into_iter().collect() - } - _ => closed.iter().filter_map(|panel| layout.location(panel).ok().map(|location| (panel.clone(), location))).collect(), - }; - let reveal = match &action { - Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), - _ => None, - }; - apply(&mut layout, action)?; - for (panel, location) in returning { - app.ui.docking_hidden.insert(panel, location); - } - app.ui.docking = Some(layout); - release_closed(app, &closed); - keep_float_only(app); - if let Some(panel) = reveal { - app.ui.docking_opened = Some(panel.clone()); - app.ui.docking_raise = Some(panel); - app.ui.dock_expanded = true; - } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { - app.ui.docking_opened = None; - } - app.ui.open_panel = None; - Ok(json!({"layout": app.ui.docking, "closed": closed})) } /// Type › Tabs and the other float-only panels: their own windows outside the docking layout, @@ -559,65 +649,13 @@ pub(crate) fn floating_panels(app: &DesignApp) -> Vec { docked.chain(app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).map(|(id, _)| id.clone())).collect() } -/// The panel in front of each tab group in the dock (not the floating groups). -fn front_tabs(layout: &Layout) -> Vec { - let mut out = Vec::new(); - let mut visit: Vec<&Node> = layout.root.iter().collect(); - while let Some(node) = visit.pop() { - if out.len() > craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), - Node::Dock { columns, .. } => out.extend(columns.iter().flat_map(|c| &c.groups).filter_map(|g| g.active_panel().cloned())), - _ => {} - } - } - out -} - -/// Bring each of `front` back in front of the dock tab group that holds it. -fn restore_front_tabs(layout: &mut Layout, front: &[String]) { - let mut visit: Vec<&mut Node> = layout.root.iter_mut().collect(); - let mut seen = 0usize; - while let Some(node) = visit.pop() { - seen = seen.saturating_add(1); - if seen > 2 * craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|p| front.contains(p)) { - *active = i; - } - } - Node::Dock { columns, .. } => { - for group in columns.iter_mut().flat_map(|c| &mut c.groups) { - if let Some(i) = group.panels.iter().position(|p| front.contains(p)) { - group.active = i; - } - } - } - _ => {} - } - } -} - /// Panels InDesign opens together as one floating group from the Window menu, in tab order: /// Window › Styles › Paragraph Styles (or Character Styles) brings both. const FLOATING_GROUPS: &[&[&str]] = &[&["characterStyles", "paragraphStyles"]]; /// Window › a panel of a [`FLOATING_GROUPS`] group none of whose panels is showing: the group -/// opens floating over the right of the canvas, with that panel in front. None otherwise (the -/// panel opens as before). +/// opens as one floating window over the right of the canvas, with that panel in front. None +/// otherwise (the panel opens as before). fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option> { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; let group = FLOATING_GROUPS.iter().find(|g| g.contains(&panel))?; @@ -626,7 +664,7 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Option Result { - let first = group.first().ok_or("empty panel group")?; - change(app, "float", &json!({"panel": first, "x": x, "y": y, "width": w, "height": h}))?; - for p in group.get(1..).unwrap_or_default() { - change(app, "move", &json!({"panel": p, "anchor": first}))?; - } - change(app, "activate", &json!({"panel": panel})) + let mut txn = Transaction::begin(app)?; + let panels: Vec = group.iter().map(|p| (*p).to_string()).collect(); + let active = group.iter().position(|p| *p == panel).unwrap_or(0); + let mut layout = txn.workspace.layout.clone(); + layout.floating.push(Floating::single(panels, active, [x, y, w, h])); + layout.validate().map_err(|e| e.to_string())?; + txn.workspace.layout = layout; + txn.workspace.hidden.retain(|(p, _)| !group.contains(&p.as_str())); + Ok(txn.commit(app, Vec::new(), Some(panel.to_string()))) }; - let result = open(app); - if result.is_ok() - && let Some(layout) = app.ui.docking.as_mut() - { - restore_front_tabs(layout, &front); - } - if result.is_err() { - // All or nothing. - (app.ui.docking, app.ui.docking_hidden, app.ui.floating, app.ui.open_panel) = before; - } - Some(result) -} - -fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { - return Err("saved panel layout is invalid; reset the workspace first".into()); - } - let mut layout = layout_of(app); - let mut hidden: std::collections::BTreeMap<_, _> = - app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); - let mut restored = false; - if operation == "dock" - && params.get("anchor").or_else(|| params.get("onto")).is_none() - && let Some(location) = hidden.get(panel).filter(|location| { - location.floating.is_none() - && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) - }) - { - let mut candidate = layout.clone(); - if candidate.contains(&panel.to_string()) { - apply(&mut candidate, Action::Close { panel: panel.into() })?; - } - if candidate.restore(panel.into(), location).is_ok() { - layout = candidate; - restored = true; - hidden.remove(panel); - } - } - match operation { - "dock" if restored => {} - "float" => { - let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; - ensure(&mut layout, panel)?; - if let Ok(location) = layout.location(&panel.to_string()) - && location.floating.is_none() - { - hidden.insert(panel.into(), location); - } - apply(&mut layout, Action::Float { panel: panel.into(), rect })?; - } - "move" | "dock" => { - if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { - return Err("move requires a destination panel".into()); - } - let anchor = match params.get("anchor").or_else(|| params.get("onto")) { - Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), - None => first_dock_panel(&layout, panel), - }; - if let Some(anchor) = anchor { - let placement = placement(params, &layout, &anchor, crate::i18n::is_rtl(&app.ui.language))?; - ensure(&mut layout, panel)?; - apply(&mut layout, Action::Move { panel: panel.into(), anchor, placement })?; - } else if operation == "dock" { - if layout.contains(&panel.to_string()) { - apply(&mut layout, Action::Close { panel: panel.into() })?; - } - apply(&mut layout, Action::Open { panel: panel.into(), anchor: None })?; - } else { - return Err("move requires an existing destination panel".into()); - } - } - "close" => { - hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); - apply(&mut layout, Action::Close { panel: panel.into() })?; - } - _ => { - if !layout.contains(&panel.to_string()) - && let Some(location) = hidden.get(panel) - { - // A missing neighbor is allowed: the normal visible dock becomes the fallback. - let _ = layout.restore(panel.into(), location); - } - ensure(&mut layout, panel)?; - hidden.remove(panel); - // Iconic columns open no flyouts yet, so showing a panel there expands its column. - if in_iconic_column(&layout, panel) { - apply(&mut layout, Action::SetIconic { panel: panel.into(), iconic: false })?; - } - } - } - app.ui.docking = Some(layout); - app.ui.docking_hidden = hidden; - if operation == "close" { - release_closed(app, &[panel.to_owned()]); - } - keep_float_only(app); - app.ui.open_panel = None; - app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; - app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; - if operation != "close" { - app.ui.dock_expanded = true; - } - Ok(json!({"panel": panel, "floating": floating_panels(app), "layout": app.ui.docking})) + Some(open(app)) } /// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last /// opened (for `ui.set closePanel`) and a pending raise. fn release_closed(app: &mut DesignApp, closed: &[String]) { - for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise] { + for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise, &mut app.ui.docking_flyout] { if slot.as_ref().is_some_and(|panel| closed.contains(panel)) { *slot = None; } @@ -810,13 +745,18 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { blocked.push(rect); } let style = dock_style(ui, &app.ui.language); - let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked); + let mut dock = craft_ui::docking::DockArea::new(area).blocked_rects(&blocked).auto_collapse_iconic(app.ui.dock_auto_collapse); if let Some(panel) = raise { dock = dock.raise_panel(panel); } - let mut content = PanelContent(app, Vec::new()); - let output = dock.show_content(ui, &layout, &style, &mut content); - let floats = content.1; + let icons = crate::panel_icons::panel_icons(ui.ctx(), style.icon_size); + let output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, icons }); + app.ui.docking_flyout = craft_ui::docking::flyout::(ui.ctx(), area); + for event in &output.events { + if *event == craft_ui::docking::DockEvent::ToggleAutoCollapse { + app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse; + } + } if let Some(panel) = output.focused { if panel != CANVAS { app.ui.docking_opened = Some(panel.clone()); @@ -826,7 +766,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(error) = output.error { app.ui.status = error.to_string(); } - for action in output.actions.into_iter().chain(floats) { + for action in output.actions { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } @@ -834,15 +774,21 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } /// What the control channel reports: the layout in use (craft-ui's serialized `Layout`), -/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, and -/// the panels closed with a remembered place. -pub(crate) fn inspect(app: &DesignApp) -> Value { +/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, the +/// panels closed with a remembered place, the panel whose flyout is open, and where each +/// docking gesture starts in the last frame (`hitRects`). +pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, egui::Id::new("designcraft-panel-docking")); + let hit_rects: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); json!({ "layout": layout_of(app), "customized": app.ui.docking.is_some(), "focused": app.ui.docking_focused, + "flyout": app.ui.docking_flyout, + "autoCollapse": app.ui.dock_auto_collapse, "rightToLeft": crate::i18n::is_rtl(&app.ui.language), "hidden": app.ui.docking_hidden.keys().collect::>(), + "hitRects": hit_rects, }) } @@ -865,7 +811,7 @@ fn dock_style(ui: &egui::Ui, language: &str) -> craft_ui::docking::DockStyle { const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.float", "Float Panel"), ("dock.close", "Close"), - ("dock.move", "Group with"), + ("dock.movePanelTo", "Group with"), ("dock.panels", "Panels"), ("dock.resize", "Resize panels"), ("dock.resizeWindow", "Resize panel window"), @@ -874,10 +820,12 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.minimize", "Minimize"), ("dock.expand", "Expand"), ("dock.floatGroup", "Float Tab Group"), - ("dock.groupWith", "Group Tab Group with"), + ("dock.moveGroupTo", "Group Tab Group with"), ("dock.moveColumn", "Move Panel Column"), ("dock.collapseToIcons", "Collapse to Icons"), ("dock.expandPanels", "Expand Panels"), + ("dock.collapseColumn", "Collapse to Icons"), + ("dock.expandColumn", "Expand Panels"), ("dock.moveWindow", "Move Panel Window"), ("dock.closeWindow", "Close Panel Window"), ("dock.undock", "Undock Panel"), @@ -887,6 +835,7 @@ const DOCK_LABELS: &[(&str, &str)] = &[ ("dock.maximize", "Maximize Frame"), ("dock.restoreFrame", "Restore Frame Size"), ("dock.newWindow", "Move Panel to New Window"), + ("dock.autoCollapse", "Auto-Collapse Iconic Panels"), ]; /// The menu behind a panel's three-line button: the panel's own commands, then its docking @@ -952,65 +901,33 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } -/// The panels' content, and the floats released over the canvas this frame. -struct PanelContent<'a>(&'a mut DesignApp, Vec>); +/// The panels' content and icons. +struct PanelContent<'a> { + app: &'a mut DesignApp, + icons: std::collections::HashMap>, +} impl craft_ui::docking::DockContent for PanelContent<'_> { - /// A panel or group released over the document area floats there, as in InDesign, instead of - /// splitting the canvas. The window's edge bands still make new docks and root splits. - fn resolve_drag( - &mut self, - _: &egui::Context, - style: &craft_ui::docking::DockStyle, - request: &craft_ui::docking::DropRequest<'_, String>, - ) -> Option { - let pointer = request.pointer; - let over_canvas = request.targets.iter().rev().find(|t| t.rect.contains(pointer)).is_some_and(|t| t.anchor == CANVAS); - let band = style.edge_band.max(style.new_dock_band); - if !over_canvas || !request.workspace.shrink(band).contains(pointer) { - return None; - } - // A floating window moving live has no float rect; it moves as before. - if request.whole_group && request.floating_rect.is_none() { - return None; - } - if request.released - && let Some(r) = request.floating_rect - { - let rect = [r.left(), r.top(), r.width(), r.height()]; - let panel = request.panel.clone(); - self.1.push(if request.whole_group { - Action::MoveGroup { panel, to: Target::Float { rect: Some(rect) } } - } else { - Action::Float { panel, rect } - }); - } - Some(craft_ui::docking::DropFeedback { preview: request.floating_rect, insertion: None }) - } fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { let fill = crate::theme::Tokens::get(ui.ctx()).pasteboard; ui.painter().rect_filled(ui.max_rect(), 0.0, fill); - // A bare panel has no tab to report focus through, so the canvas reports its own. - if ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer() { - self.0.ui.docking_focused = Some(CANVAS.to_string()); - } - self.0.canvas_area(ui, false); + self.app.canvas_area(ui, false); return; } egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { if crate::dock::scrolls_itself(id) { - crate::dock::panel_body(self.0, ui, id); + crate::dock::panel_body(self.app, ui, id); return; } egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) .auto_shrink([false, false]) - .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + .show(ui, |ui| crate::dock::panel_body(self.app, ui, id)); }); } fn logical_label(&self, id: &String) -> String { let english = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map_or(id.as_str(), |p| p.1); - crate::i18n::tr(&self.0.ui.language, english).to_owned() + crate::i18n::tr(&self.app.ui.language, english).to_owned() } fn permissions(&self, id: &String) -> Permissions { model_permissions(id) @@ -1022,6 +939,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } + fn icon(&self, id: &String) -> Option> { + self.icons.get(id).cloned() + } fn bare(&self, id: &String) -> bool { id == CANVAS } @@ -1030,7 +950,7 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } fn group_header(&mut self, ui: &mut egui::Ui, _: &[String], active: &String, _: bool) { if let Some(panel) = normalize(active) { - three_line_button(self.0, ui, panel); + three_line_button(self.app, ui, panel); } } fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { @@ -1074,7 +994,7 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { if let Some(panel) = app.ui.docking_opened.clone() { - change(app, "close", &json!({"panel": panel}))?; + shared_command(app, "window.panel.close", &json!({"panel": panel}))?; } app.ui.open_panel = None; Ok(()) diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index b44265e7d..f182bf644 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -311,7 +311,10 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { h.run_steps(1); } h.run_steps(2); - assert!(!in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); + // Stroke stays in the iconic column and opens as its flyout. + assert!(in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("stroke")); + assert!(showing(h.state(), "stroke")); assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); } @@ -399,7 +402,7 @@ fn table_and_control_visibility_work_after_customization_without_document_edits( assert!(!app.ui.dock_expanded); assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("properties"), "it opens as a flyout from the collapsed dock"); assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); assert_eq!(app.session.active().unwrap().history.undo.len(), undo); @@ -591,9 +594,12 @@ fn collapse_to_icons_shrinks_the_dock_to_its_icons() { assert!(app.ui.dock_expanded); app.run("window.collapseToIcons", json!({})).unwrap(); assert!(!app.ui.dock_expanded); - // A panel chosen from Window opens the dock again. + // A panel chosen from Window opens as a flyout beside its icon; the dock stays collapsed. app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); + assert_eq!(app.ui.docking_raise.as_deref(), Some("pages")); + assert!(showing(&app, "pages")); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "pages")); } #[test] @@ -627,7 +633,8 @@ fn paragraph_styles_open_floating_with_character_styles() { assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. - assert!(front_tabs(&layout).iter().any(|p| p == "properties"), "{:?}", layout.root); + assert_eq!(layout.panel_state(&"properties".to_string()), craft_ui::docking::PanelState::Front, "{:?}", layout.root); + assert_eq!(layout.floating.len(), 1, "one floating window holds both tabs"); // Other panels from Window leave it open. app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); @@ -830,3 +837,138 @@ fn ui_docking_reports_the_layout_and_the_focused_panel() { assert_eq!(v["result"]["customized"], true); assert_eq!(v["result"]["hidden"], json!(["layers"])); } + +fn hits(h: &Harness<'static, DesignApp>) -> Vec<(craft_ui::docking::Hit, egui::Rect)> { + craft_ui::docking::DockArea::::hit_rects(&h.ctx, egui::Id::new("designcraft-panel-docking")) +} + +/// `window.panel.*` ids and DesignCraft's aliases go through craft-ui's `DockCommand`. +#[test] +fn shared_panel_commands_float_dock_and_reject_atomically() { + let mut app = app(); + let docked = default_layout(&app.ui); + app.run("window.floatPanel", json!({"panel": "Layers", "x": 60, "y": 90})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter().find(|w| w.contains(&"layers".to_string())).expect("Layers floats"); + assert_eq!(window.rect, [60.0, 90.0, 300.0, 420.0], "at the given place, at its own size"); + app.run("window.dockPanel", json!({"panel": "layers"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!( + layout.root, + docked.root.map(|mut root| { + // Back in its place, in front of its group. + if let Node::Split { second, .. } = &mut root + && let Node::Dock { columns, .. } = &mut **second + && let Some(group) = columns.first_mut().and_then(|c| c.groups.first_mut()) + { + group.active = 2; + } + root + }), + "Dock Panel returns it where it floated from" + ); + // A whole group, by any of its panels, then into a new column of the end dock. + app.run("window.panel.float", json!({"panel": "pages", "group": true, "x": 80, "y": 80, "width": 300, "height": 400})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert_eq!(layout.floating.len(), 1); + assert_eq!(layout.floating[0].groups[0].panels, ["properties", "pages", "layers"]); + app.run("window.panel.dock", json!({"panel": "pages", "group": true, "side": "End"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(tab_group(&layout, "pages"), ["properties", "pages", "layers"]); + // A lone panel to a new dock at the start edge. + app.run("window.panel.float", json!({"panel": "stroke"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); + assert!( + matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. })) + ); + // Close Others and Minimize are shared commands too. + app.run("window.panel.closeOthers", json!({"panel": "pages"})).unwrap(); + assert_eq!(tab_group(app.ui.docking.as_ref().unwrap(), "pages"), ["pages"]); + assert!(app.ui.docking_hidden.contains_key("properties") && app.ui.docking_hidden.contains_key("layers")); + app.run("window.panel.minimize", json!({"panel": "pages"})).unwrap(); + let location = app.ui.docking.as_ref().unwrap().location(&"pages".to_string()).unwrap(); + assert!(location.group.is_some_and(|g| g.minimized)); + // Rejected commands change nothing. + let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone()); + for (id, params) in [ + ("window.workspace.lock", json!({})), + ("window.panel.window", json!({"panel": "pages"})), + ("window.panel.move", json!({"panel": "stroke", "anchor": "pages", "zone": "left", "before": "pages"})), + ("window.panel.dock", json!({"panel": "stroke", "side": "Middle"})), + ] { + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!((app.ui.docking.clone(), app.ui.docking_hidden.clone()), before, "{id}"); + } + // Every shared id is registered for the command list and MCP. + for id in craft_ui::docking::DockCommand::::IDS { + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == *id), "{id} is not registered"); + } +} + +/// A tab released over the canvas floats there (craft-ui: the protected canvas takes no tabs or +/// splits); a lone panel released in the window's left edge band makes a new dock there. +#[test] +fn tabs_dropped_on_the_canvas_float_and_at_the_far_edge_make_a_dock() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let tab = |h: &Harness<'static, DesignApp>, panel: &str| { + hits(h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab(panel.into())).map(|(_, r)| r) + }; + let layers = tab(&h, "layers").expect("the Layers tab"); + drag(&mut h, layers.center(), egui::pos2(500.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(layout.floating.iter().any(|w| w.contains(&"layers".to_string())), "{layout:?}"); + // A new dock takes a panel that is alone in its group: the floating Layers, by its title bar. + // The harness's area starts 8 pt in from the window edge. + let title = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Title("layers".into())).expect("the title bar").1; + drag(&mut h, title.center(), egui::pos2(12.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(valid(&layout)); + let mut docks = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side, columns } => { + docks.push((*side, columns.iter().flat_map(|c| &c.groups).flat_map(|g| g.panels.clone()).collect::>())) + } + _ => {} + } + } + assert!(docks.iter().any(|(side, panels)| *side == Side::Start && panels == &["layers"]), "{docks:?} {:?}", layout.floating); +} + +/// `ui.docking` lists where each gesture starts: tabs, drag bars and the iconic column's icons. +#[test] +fn ui_docking_reports_hit_rects() { + let h = harness(app(), vec2(1280.0, 800.0)); + let v = crate::panel_docking::inspect(h.state(), &h.ctx); + let rects = v["hitRects"].as_array().expect("hitRects"); + let rect_of = |hit: Value| { + let entry = rects.iter().find(|r| r["hit"] == hit).unwrap_or_else(|| panic!("no {hit} in {rects:?}")); + let r: Vec = serde_json::from_value(entry["rect"].clone()).unwrap(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), vec2(r[2], r[3])) + }; + let tab = rect_of(json!({"Tab": "pages"})); + let bar = rect_of(json!({"DragBar": "properties"})); + let icon = rect_of(json!({"Icon": "stroke"})); + assert!(tab.width() > 20.0 && bar.width() >= 24.0 && icon.width() > 20.0, "{tab:?} {bar:?} {icon:?}"); + assert!((tab.center().y - bar.center().y).abs() < 1.0, "the bar is in the tab strip"); + assert!(icon.left() > 640.0, "the iconic column is at the end edge"); +} + +/// Clicking an icon in the iconic column opens its panel as a flyout on the canvas side; a +/// second click closes it. +#[test] +fn clicking_an_iconic_column_icon_opens_its_flyout() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let icon = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Icon("swatches".into())).expect("the Swatches icon").1; + click(&mut h, icon.center(), PointerButton::Primary); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("swatches")); + let tab = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab("swatches".into())).expect("the flyout's tab").1; + assert!(tab.right() <= icon.left() + 1.0, "the flyout is beside the column, on the canvas side: {tab:?} {icon:?}"); + assert!(showing(h.state(), "swatches")); + click(&mut h, icon.center(), PointerButton::Primary); + assert!(h.state().ui.docking_flyout.is_none()); +} diff --git a/crates/ui-egui/src/panel_icons.rs b/crates/ui-egui/src/panel_icons.rs new file mode 100644 index 000000000..5093d5ae1 --- /dev/null +++ b/crates/ui-egui/src/panel_icons.rs @@ -0,0 +1,168 @@ +//! Panel icons as textures, for craft-ui's iconic columns, which draw an `ImageSource`. +//! +//! DesignCraft draws its icons in code ([`crate::icons::paint`]). Each panel's icon is painted +//! once, white, into a scratch egui context; the shapes are tessellated there and the triangles +//! filled on the CPU. The column tints the texture with its text colour. + +use std::collections::HashMap; + +use egui::{ + Color32, ColorImage, Context, Id, ImageSource, LayerId, Pos2, RawInput, Rect, TextureHandle, TextureId, TextureOptions, + epaint::{ImageData, Mesh, Primitive}, + load::SizedTexture, + pos2, vec2, +}; + +/// Icons per row of the scratch canvas. +const COLUMNS: usize = 8; + +#[derive(Clone, Default)] +struct Cache { + /// Side of each texture in pixels. + px: usize, + textures: Vec<(String, TextureHandle)>, +} + +fn cache_id() -> Id { + Id::new("designcraft-panel-icon-textures") +} + +/// Every dock panel's icon at `size` points, keyed by panel id. Textures are made once per +/// pixel size and kept in the context. +pub(crate) fn panel_icons(ctx: &Context, size: f32) -> HashMap> { + let scale = size * ctx.pixels_per_point(); + let px = if scale.is_finite() { scale.round().clamp(8.0, 128.0) as usize } else { 20 }; + let cached = ctx.data(|d| d.get_temp::(cache_id())).filter(|cache| cache.px == px); + let cache = cached.unwrap_or_else(|| { + let panels: Vec<(&str, &str)> = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).map(|(id, _, icon)| (*id, *icon)).collect(); + let images = render(px, &panels); + let textures = panels + .iter() + .zip(images) + .map(|((id, _), image)| ((*id).to_string(), ctx.load_texture(format!("designcraft-panel-icon-{id}"), image, TextureOptions::LINEAR))) + .collect(); + let cache = Cache { px, textures }; + ctx.data_mut(|d| d.insert_temp(cache_id(), cache.clone())); + cache + }); + cache + .textures + .iter() + .map(|(id, texture)| (id.clone(), ImageSource::Texture(SizedTexture::new(texture.id(), vec2(px as f32, px as f32))))) + .collect() +} + +/// The icons of `panels` (id, icon name), white on transparent, `px` pixels square. +fn render(px: usize, panels: &[(&str, &str)]) -> Vec { + let cell = px as f32; + let rows = panels.len().div_ceil(COLUMNS).max(1); + let (width, height) = (COLUMNS * px, rows * px); + let cell_rect = |i: usize| Rect::from_min_size(pos2((i % COLUMNS) as f32 * cell, (i / COLUMNS) as f32 * cell), vec2(cell, cell)); + let scratch = Context::default(); + scratch.set_fonts(crate::theme::font_definitions(&[], "en")); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))), ..Default::default() }; + let mut output = scratch.run_ui(input, |ui| { + let painter = ui.ctx().layer_painter(LayerId::background()); + for (i, (_, icon)) in panels.iter().enumerate() { + let rect = cell_rect(i); + crate::icons::paint(&painter.with_clip_rect(rect), rect, icon, Color32::WHITE); + } + }); + let atlas = + output.textures_delta.set.get(&TextureId::default()).and_then(|deltas| deltas.iter().find(|delta| delta.pos.is_none())).map(|delta| { + match &delta.image { + ImageData::Color(image) => image.clone(), + } + }); + let shapes = std::mem::take(&mut output.shapes); + let primitives = scratch.tessellate(shapes, output.pixels_per_point); + output.textures_delta.clear(); + let mut canvas = vec![[0.0f32; 4]; width * height]; + for primitive in &primitives { + if let Primitive::Mesh(mesh) = &primitive.primitive { + fill(&mut canvas, width, height, mesh, primitive.clip_rect, atlas.as_deref()); + } + } + (0..panels.len()) + .map(|i| { + let origin = cell_rect(i).min; + let (x0, y0) = (origin.x as usize, origin.y as usize); + let mut pixels = Vec::with_capacity(px * px * 4); + for y in y0..y0 + px { + for x in x0..x0 + px { + let [r, g, b, a] = canvas.get(y * width + x).copied().unwrap_or_default(); + pixels.extend([r, g, b, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } + } + ColorImage::from_rgba_premultiplied([px, px], &pixels) + }) + .collect() +} + +/// Fill `mesh`'s triangles into a premultiplied RGBA canvas, sampling the font atlas for text. +/// Pixel centres inside a triangle take its interpolated colour; egui's feathered edges give +/// the anti-aliasing. +fn fill(canvas: &mut [[f32; 4]], width: usize, height: usize, mesh: &Mesh, clip: Rect, atlas: Option<&ColorImage>) { + let bounds = clip.intersect(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))); + if !bounds.is_positive() { + return; + } + let texel = |u: f32, v: f32| -> [f32; 4] { + if mesh.texture_id != TextureId::default() { + return [1.0; 4]; + } + let Some(atlas) = atlas else { return [1.0; 4] }; + let [w, h] = atlas.size; + let x = ((u * w as f32) as usize).min(w.saturating_sub(1)); + let y = ((v * h as f32) as usize).min(h.saturating_sub(1)); + atlas.pixels.get(y * w + x).map_or([1.0; 4], |c| c.to_array().map(|v| f32::from(v) / 255.0)) + }; + let edge = |a: Pos2, b: Pos2, p: Pos2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); + for triangle in mesh.indices.as_chunks::<3>().0 { + let vertex = |i: usize| triangle.get(i).and_then(|&index| mesh.vertices.get(index as usize)); + let (Some(a), Some(b), Some(c)) = (vertex(0), vertex(1), vertex(2)) else { continue }; + let area = edge(a.pos, b.pos, c.pos); + if !area.is_finite() || area.abs() < 1e-6 { + continue; + } + let min = a.pos.min(b.pos).min(c.pos).max(bounds.min); + let max = a.pos.max(b.pos).max(c.pos).min(bounds.max); + if !(min.x < max.x && min.y < max.y) { + continue; + } + let colors = [a.color, b.color, c.color].map(|c| c.to_array().map(|v| f32::from(v) / 255.0)); + for y in min.y.floor() as usize..(max.y.ceil() as usize).min(height) { + for x in min.x.floor() as usize..(max.x.ceil() as usize).min(width) { + let p = pos2(x as f32 + 0.5, y as f32 + 0.5); + let wa = edge(b.pos, c.pos, p) / area; + let wb = edge(c.pos, a.pos, p) / area; + let wc = 1.0 - wa - wb; + if wa < 0.0 || wb < 0.0 || wc < 0.0 { + continue; + } + let u = wa * a.uv.x + wb * b.uv.x + wc * c.uv.x; + let v = wa * a.uv.y + wb * b.uv.y + wc * c.uv.y; + let t = texel(u, v); + let src: [f32; 4] = std::array::from_fn(|k| (wa * colors[0][k] + wb * colors[1][k] + wc * colors[2][k]) * t[k]); + if let Some(dst) = canvas.get_mut(y * width + x) { + let keep = 1.0 - src[3]; + *dst = std::array::from_fn(|k| src[k] + dst[k] * keep); + } + } + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn panel_icons_are_painted_into_their_textures() { + let images = super::render(20, &[("pages", "panel-pages"), ("character", "panel-character")]); + assert_eq!(images.len(), 2); + for image in &images { + assert_eq!(image.size, [20, 20]); + let inked = image.pixels.iter().filter(|p| p.a() > 128).count(); + assert!(inked > 10 && inked < 300, "{inked} inked pixels"); + } + } +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 37a70c3d6..6e5f599b0 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1,11 +1,9 @@ //! Compatibility recovery at the saved-preferences boundary. Command deserialization stays strict. -use std::collections::BTreeMap; - use craft_ui::docking::{Layout, Location}; use serde_json::Value; -use crate::{SavedWorkspace, UiState, panel_docking}; +use crate::{HiddenPanels, SavedWorkspace, UiState, panel_docking}; /// Saved preferences, and whether reading them discarded anything. pub struct ReadPreferences { @@ -52,7 +50,12 @@ fn recover_docking(saved: &mut Value) -> bool { intact = false; } if let Some(hidden) = fields.get_mut("dockingHidden") { - if let Some(entries) = hidden.as_object_mut() { + // `[[panel, location], …]`, or an object keyed by panel from older files. + if let Some(entries) = hidden.as_array_mut() { + let before = entries.len(); + entries.retain(|entry| serde_json::from_value::<(String, Location)>(entry.clone()).is_ok()); + intact &= entries.len() == before; + } else if let Some(entries) = hidden.as_object_mut() { let before = entries.len(); entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); intact &= entries.len() == before; @@ -70,7 +73,7 @@ fn recover_docking(saved: &mut Value) -> bool { fn migrate( docking: &mut Option>, floating: &mut Vec<(String, [f32; 2])>, - hidden: &BTreeMap>, + hidden: &HiddenPanels, dock_expanded: bool, icon_groups: Option<&[Vec]>, ) { @@ -149,8 +152,8 @@ mod tests { fn unreadable_hidden_origin_does_not_discard_other_origins() { let original = populated(); let mut saved = serde_json::to_value(&original).unwrap(); - saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); - saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + saved["dockingHidden"].as_array_mut().unwrap().push(json!(["futurePanel", {"FutureLocation":{}}])); + saved["customWorkspaces"][0]["dockingHidden"].as_array_mut().unwrap().push(json!(false)); let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); assert!(!original.docking_hidden.is_empty()); assert_eq!(loaded.docking_hidden, original.docking_hidden); @@ -295,6 +298,32 @@ mod tests { assert_eq!(again.docking, app.ui.docking); } + /// `dockingHidden` as an object keyed by panel (files before craft-ui's `Workspace::hidden` + /// list) loads as the list, drops only its unreadable entries and saves as the list. + #[test] + fn hidden_panels_keyed_by_panel_load_as_the_workspace_list() { + let place = json!({"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, "size": {"Ratio": 0.5}, "stack": null}); + let saved = json!({ + "dockingHidden": {"color": place, "swatches": place, "futurePanel": {"FutureLocation": {}}}, + "customWorkspaces": [{"name": "Old", "dockingHidden": {"stroke": place}}], + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!read.intact, "the unreadable entry is reported so the file is kept"); + let ui = read.ui; + assert_eq!(ui.docking_hidden.keys().collect::>(), ["color", "swatches"]); + assert_eq!(ui.custom_workspaces[0].docking_hidden.keys().collect::>(), ["stroke"]); + let written = serde_json::to_value(&ui).unwrap(); + assert_eq!(written["dockingHidden"][0][0], "color"); + let workspace: craft_ui::docking::Workspace = + serde_json::from_value(json!({"layout": {"root": null, "floating": []}, "hidden": written["dockingHidden"]})).unwrap(); + assert_eq!(workspace.hidden, ui.docking_hidden.0, "the same shape as craft-ui's Workspace::hidden"); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + } + #[test] fn unrelated_malformed_preferences_are_still_rejected() { assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 446460981..41dc02a01 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,7 +36,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | | `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf | -| `ui.docking` | — | The panel layout in use and the focused panel (see Shared panel docking) | +| `ui.docking` | — | The panel layout in use, the focused panel and the docking hit rects (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | @@ -94,23 +94,47 @@ panels (or the workspace's named groups) in the iconic column and its floating w hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to `ui.unreadable.json` before it is saved again. -- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. - `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders - tabs by panel ID. The destination must already be visible. Beside a panel in a dock column, - `top` and `bottom` add a group above or below its group and `left` and `right` add a column - on that side; in a floating window, `top` and `bottom` add a group and `left` and `right` - are errors. -- `window.panel.float {panel, x?, y?, width?, height?}` (and `window.floatPanel {panel, x?, y?}`) - detaches a panel. -- `window.panel.dock {panel, anchor?, zone?, before?}` (and `window.dockPanel {panel}`) - returns a panel to its previous dock placement when possible, or to the supplied - destination. For the float-only Tabs panel (`tabs`), which has its own window outside the - layout, float opens that window and dock closes it. +The `window.panel.*`, `window.workspace.*` and `ui.dock` commands are craft-ui's shared +docking commands (`DockCommand`), with the same ids and params in every craft app. `panel`, +`anchor`, `target` and `before` take a panel ID or its English name in any case; `onto` is +read as `anchor`. + +- `window.panel.move {panel, anchor, zone?, before?, group?, placement?}` moves a panel into + the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, `bottom`, + `above` or `below`; `before` reorders tabs by panel ID (`null` appends). Beside a panel in a + dock column, `top` and `bottom` add a group above or below its group and `left` and `right` + add a column on that side; in a floating window, `top` and `bottom` add a group and `left` + and `right` are errors (craft-ui's `Placement::for_zone`). `group: true` moves the panel's + whole tab group; `placement` is a serialized craft-ui `Placement` and takes precedence over + `zone` and `before`. +- `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and + `window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column + (`column`). A panel floats at 400, 140 at its own size unless the params say otherwise; a + panel already alone in a floating window moves or resizes. +- `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and + `window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side` + (`Start` or `End`; a new dock when there is none). Without either, a panel that floated from + the dock goes back to that place, and another floating panel joins the first dock group. For + the float-only Tabs panel (`tabs`), which has its own window outside the layout, float opens + that window and dock closes it. - `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, - restoring its saved position when possible; a panel in an iconic column expands that - column. `window.panel.close {panel}` hides it. -- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it (craft-ui's - `CloseGroup`); the result lists them in `closed`. + restoring its saved position when possible; a panel in an iconic column opens as a flyout + beside its icon. `window.panel.close {panel}` hides it. +- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it, and + `window.panel.closeOthers {panel}` hides the panels tabbed with it; the result lists them in + `closed`. +- `window.panel.minimize {panel, minimized?}` minimizes the panel's group to its tab strip and + `window.panel.iconic {panel, iconic?}` collapses its dock column to icons; without the flag + they toggle. +- `window.workspace.new {name}`, `window.workspace.delete {name}` and `window.workspace.reset` + (also `ui.dock.reset`) are `window.newWorkspace`, `window.deleteWorkspace` and + `window.resetWorkspace`. `window.workspace {name}` switches workspaces as before. +- `ui.dock {action}` or `ui.dock {operation, …}` is the shape LightCraft, PDFCraft, CADCraft + and SoundCraft use: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`, + `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}` and + `resizeSplit {path, size}`, with `rect` as `[x, y, width, height]`. +- `window.panel.window` (native panel windows) and `window.workspace.lock` are not available + in DesignCraft and return an error. - `window.collapseToIcons` collapses every dock column to icons. Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and @@ -130,6 +154,10 @@ closes the last explicitly opened panel; `dockExpanded:false` collapses every do to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also raises its group. These operations change only UI layout. +Clicking an icon in an iconic column opens its group as a flyout beside the column, on the +canvas side; clicking the icon again closes it. With Auto-Collapse Iconic Panels on (the +default; a column bar's menu toggles it), Escape or a click outside the flyout closes it too. + `ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as `docking`: @@ -142,9 +170,20 @@ raises its group. These operations change only UI layout. {"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true, "iconic_width": 37.0}]}}}}, "floating": []}, - "customized": false, "focused": "canvas", "rightToLeft": false, "hidden": []} + "customized": false, "focused": "canvas", "flyout": null, "autoCollapse": true, + "rightToLeft": false, "hidden": [], + "hitRects": [{"hit": {"Tab": "properties"}, "rect": [983.0, 0.0, 74.0, 27.0]}, + {"hit": {"DragBar": "properties"}, "rect": [1190.0, 0.0, 56.0, 27.0]}, + {"hit": {"Icon": "stroke"}, "rect": [1243.0, 14.0, 37.0, 30.0]}, …]} ``` `layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or -null; `hidden` lists closed panels that remember their place. +null; `flyout` is the panel whose iconic-column flyout is open; `autoCollapse` is the +Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place. +`hitRects` lists where each docking gesture starts in the last frame, from craft-ui's +`DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`, +`Overflow`, `ColumnBar`, `Collapse`, `StackToggle`, `Title`, `Close`, `Icon`, `IconGrip`, +`Resize` with its edge, or `Divider`), naming groups, columns and windows by their first panel; +`rect` is `[x, y, width, height]` in points. Drag from a rect's centre with `ui.drag`, or click +it with `ui.click`, to script a gesture. From e6f125437ac821723e25033e19b263863190874c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:18:20 +0000 Subject: [PATCH 014/315] Docking trial: craft-ui 6261d9d, paint_icon, float space, flyout event --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/lib.rs | 1 - crates/ui-egui/src/panel_docking.rs | 60 +++++--- crates/ui-egui/src/panel_docking_tests.rs | 22 +++ crates/ui-egui/src/panel_icons.rs | 168 ---------------------- docs/control-protocol.md | 10 +- 7 files changed, 74 insertions(+), 191 deletions(-) delete mode 100644 crates/ui-egui/src/panel_icons.rs diff --git a/Cargo.lock b/Cargo.lock index 5db9c5acd..32b91c425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" +source = "git+https://github.com/aiongg/craft-libs?rev=6261d9d39a0a109a832685f37af6ce75321caea1#6261d9d39a0a109a832685f37af6ce75321caea1" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index 63ea8c527..f5c0281d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "6261d9d39a0a109a832685f37af6ce75321caea1" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de859f4ff..5fd50284e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,7 +22,6 @@ mod modal_drag; mod new_document; mod object_control; mod panel_docking; -mod panel_icons; pub mod panels; mod prefs; pub mod render_worker; diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 44b327196..a55f059d7 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -5,14 +5,22 @@ //! languages, the left in right-to-left ones). use craft_ui::docking::{ - Action, Column, DockCommand, DockOperation, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, + Action, Column, DockCommand, DockOperation, FloatSpace, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, }; use serde_json::{Value, json}; use crate::DesignApp; +/// The id of the dock area that draws the panels. +fn area_id() -> egui::Id { + egui::Id::new("designcraft-panel-docking") +} + +/// The dock area a headless app floats into, before it ever drew a frame. +const HEADLESS_AREA: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 900.0)); + pub(crate) fn cancel_gesture(ctx: &egui::Context) { - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, area_id()); } fn normalize(raw: &str) -> Option<&'static str> { @@ -412,17 +420,20 @@ fn floats_alone(layout: &Layout, panel: &str) -> bool { /// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in /// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; /// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking -/// it, floats a panel at its own size, and returns a floated panel to its dock place on Dock -/// Panel. +/// it, floats a panel at its own size (also when it was hidden, which craft-ui never drew), +/// and returns a floated panel to its dock place on Dock Panel. fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { let mut params = resolve_panels(params)?; + let whole = |key: &str| params.get(key).and_then(Value::as_bool).unwrap_or(false); if id == "window.panel.float" + && !whole("group") + && !whole("column") && let Some(panel) = named_panel(¶ms) && !floats_alone(&layout_of(app), &panel) && let Some(fields) = params.as_object_mut() { let size = floating_size(&panel); - for (key, default) in [("x", 400.0), ("y", 140.0), ("width", size.x), ("height", size.y)] { + for (key, default) in [("width", size.x), ("height", size.y)] { fields.entry(key).or_insert(json!(default)); } } @@ -440,6 +451,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result {} } let rtl = crate::i18n::is_rtl(&app.ui.language); + let space = float_space(app); let mut txn = Transaction::begin(app)?; let shows = match &command { DockCommand::Activate { panel } @@ -462,7 +474,7 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result Vec::new(), - _ => command.to_actions(&txn.workspace, rtl).map_err(|e| e.to_string())?, + _ => command.to_actions(&txn.workspace, rtl, &space).map_err(|e| e.to_string())?, }; let reveal = match &command { DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action { @@ -481,6 +493,14 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result FloatSpace { + let Some(ctx) = app.egui_ctx.as_ref() else { return FloatSpace::new(HEADLESS_AREA) }; + craft_ui::docking::float_space::(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(ctx.content_rect())) +} + /// A change to the layout in use, committed only when every step succeeded. struct Transaction { /// The layout, and the closed panels with where each one goes back. @@ -721,7 +741,7 @@ pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); @@ -749,12 +769,12 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if let Some(panel) = raise { dock = dock.raise_panel(panel); } - let icons = crate::panel_icons::panel_icons(ui.ctx(), style.icon_size); - let output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, icons }); - app.ui.docking_flyout = craft_ui::docking::flyout::(ui.ctx(), area); - for event in &output.events { - if *event == craft_ui::docking::DockEvent::ToggleAutoCollapse { - app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse; + let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app }); + for event in std::mem::take(&mut output.events) { + match event { + craft_ui::docking::DockEvent::ToggleAutoCollapse => app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse, + craft_ui::docking::DockEvent::FlyoutChanged { panel } => app.ui.docking_flyout = panel, + _ => {} } } if let Some(panel) = output.focused { @@ -778,7 +798,7 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { /// panels closed with a remembered place, the panel whose flyout is open, and where each /// docking gesture starts in the last frame (`hitRects`). pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { - let hits = craft_ui::docking::DockArea::::hit_rects(ctx, egui::Id::new("designcraft-panel-docking")); + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, area_id()); let hit_rects: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); json!({ "layout": layout_of(app), @@ -901,10 +921,14 @@ fn floating_size(panel: &str) -> egui::Vec2 { } } +/// The name of a dock panel's icon in [`crate::icons`]. +fn panel_icon(panel: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|(id, _, _)| *id == panel).map(|(_, _, icon)| *icon) +} + /// The panels' content and icons. struct PanelContent<'a> { app: &'a mut DesignApp, - icons: std::collections::HashMap>, } impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { @@ -939,8 +963,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { fn floating_size(&self, id: &String) -> Option { Some(floating_size(id)) } - fn icon(&self, id: &String) -> Option> { - self.icons.get(id).cloned() + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, id: &String, color: egui::Color32) -> bool { + let Some(icon) = panel_icon(id) else { return false }; + crate::icons::paint(painter, rect, icon, color); + true } fn bare(&self, id: &String) -> bool { id == CANVAS diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f182bf644..b410f76ac 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -972,3 +972,25 @@ fn clicking_an_iconic_column_icon_opens_its_flyout() { click(&mut h, icon.center(), PointerButton::Primary); assert!(h.state().ui.docking_flyout.is_none()); } + +/// Iconic columns paint DesignCraft's own panel icons, in the colour craft-ui passes; a panel +/// without an icon paints nothing. +#[test] +fn panel_icons_are_painted_in_code() { + let mut app = app(); + let mut content = PanelContent { app: &mut app }; + let ctx = egui::Context::default(); + let mut paint = |panel: &str| { + let mut painted = false; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel.to_string(), egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + let (painted, shapes) = paint("pages"); + assert!(painted && shapes > 0, "{painted} {shapes}"); + assert_eq!(paint(CANVAS), (false, 0)); +} diff --git a/crates/ui-egui/src/panel_icons.rs b/crates/ui-egui/src/panel_icons.rs deleted file mode 100644 index 5093d5ae1..000000000 --- a/crates/ui-egui/src/panel_icons.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Panel icons as textures, for craft-ui's iconic columns, which draw an `ImageSource`. -//! -//! DesignCraft draws its icons in code ([`crate::icons::paint`]). Each panel's icon is painted -//! once, white, into a scratch egui context; the shapes are tessellated there and the triangles -//! filled on the CPU. The column tints the texture with its text colour. - -use std::collections::HashMap; - -use egui::{ - Color32, ColorImage, Context, Id, ImageSource, LayerId, Pos2, RawInput, Rect, TextureHandle, TextureId, TextureOptions, - epaint::{ImageData, Mesh, Primitive}, - load::SizedTexture, - pos2, vec2, -}; - -/// Icons per row of the scratch canvas. -const COLUMNS: usize = 8; - -#[derive(Clone, Default)] -struct Cache { - /// Side of each texture in pixels. - px: usize, - textures: Vec<(String, TextureHandle)>, -} - -fn cache_id() -> Id { - Id::new("designcraft-panel-icon-textures") -} - -/// Every dock panel's icon at `size` points, keyed by panel id. Textures are made once per -/// pixel size and kept in the context. -pub(crate) fn panel_icons(ctx: &Context, size: f32) -> HashMap> { - let scale = size * ctx.pixels_per_point(); - let px = if scale.is_finite() { scale.round().clamp(8.0, 128.0) as usize } else { 20 }; - let cached = ctx.data(|d| d.get_temp::(cache_id())).filter(|cache| cache.px == px); - let cache = cached.unwrap_or_else(|| { - let panels: Vec<(&str, &str)> = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).map(|(id, _, icon)| (*id, *icon)).collect(); - let images = render(px, &panels); - let textures = panels - .iter() - .zip(images) - .map(|((id, _), image)| ((*id).to_string(), ctx.load_texture(format!("designcraft-panel-icon-{id}"), image, TextureOptions::LINEAR))) - .collect(); - let cache = Cache { px, textures }; - ctx.data_mut(|d| d.insert_temp(cache_id(), cache.clone())); - cache - }); - cache - .textures - .iter() - .map(|(id, texture)| (id.clone(), ImageSource::Texture(SizedTexture::new(texture.id(), vec2(px as f32, px as f32))))) - .collect() -} - -/// The icons of `panels` (id, icon name), white on transparent, `px` pixels square. -fn render(px: usize, panels: &[(&str, &str)]) -> Vec { - let cell = px as f32; - let rows = panels.len().div_ceil(COLUMNS).max(1); - let (width, height) = (COLUMNS * px, rows * px); - let cell_rect = |i: usize| Rect::from_min_size(pos2((i % COLUMNS) as f32 * cell, (i / COLUMNS) as f32 * cell), vec2(cell, cell)); - let scratch = Context::default(); - scratch.set_fonts(crate::theme::font_definitions(&[], "en")); - let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))), ..Default::default() }; - let mut output = scratch.run_ui(input, |ui| { - let painter = ui.ctx().layer_painter(LayerId::background()); - for (i, (_, icon)) in panels.iter().enumerate() { - let rect = cell_rect(i); - crate::icons::paint(&painter.with_clip_rect(rect), rect, icon, Color32::WHITE); - } - }); - let atlas = - output.textures_delta.set.get(&TextureId::default()).and_then(|deltas| deltas.iter().find(|delta| delta.pos.is_none())).map(|delta| { - match &delta.image { - ImageData::Color(image) => image.clone(), - } - }); - let shapes = std::mem::take(&mut output.shapes); - let primitives = scratch.tessellate(shapes, output.pixels_per_point); - output.textures_delta.clear(); - let mut canvas = vec![[0.0f32; 4]; width * height]; - for primitive in &primitives { - if let Primitive::Mesh(mesh) = &primitive.primitive { - fill(&mut canvas, width, height, mesh, primitive.clip_rect, atlas.as_deref()); - } - } - (0..panels.len()) - .map(|i| { - let origin = cell_rect(i).min; - let (x0, y0) = (origin.x as usize, origin.y as usize); - let mut pixels = Vec::with_capacity(px * px * 4); - for y in y0..y0 + px { - for x in x0..x0 + px { - let [r, g, b, a] = canvas.get(y * width + x).copied().unwrap_or_default(); - pixels.extend([r, g, b, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); - } - } - ColorImage::from_rgba_premultiplied([px, px], &pixels) - }) - .collect() -} - -/// Fill `mesh`'s triangles into a premultiplied RGBA canvas, sampling the font atlas for text. -/// Pixel centres inside a triangle take its interpolated colour; egui's feathered edges give -/// the anti-aliasing. -fn fill(canvas: &mut [[f32; 4]], width: usize, height: usize, mesh: &Mesh, clip: Rect, atlas: Option<&ColorImage>) { - let bounds = clip.intersect(Rect::from_min_size(Pos2::ZERO, vec2(width as f32, height as f32))); - if !bounds.is_positive() { - return; - } - let texel = |u: f32, v: f32| -> [f32; 4] { - if mesh.texture_id != TextureId::default() { - return [1.0; 4]; - } - let Some(atlas) = atlas else { return [1.0; 4] }; - let [w, h] = atlas.size; - let x = ((u * w as f32) as usize).min(w.saturating_sub(1)); - let y = ((v * h as f32) as usize).min(h.saturating_sub(1)); - atlas.pixels.get(y * w + x).map_or([1.0; 4], |c| c.to_array().map(|v| f32::from(v) / 255.0)) - }; - let edge = |a: Pos2, b: Pos2, p: Pos2| (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); - for triangle in mesh.indices.as_chunks::<3>().0 { - let vertex = |i: usize| triangle.get(i).and_then(|&index| mesh.vertices.get(index as usize)); - let (Some(a), Some(b), Some(c)) = (vertex(0), vertex(1), vertex(2)) else { continue }; - let area = edge(a.pos, b.pos, c.pos); - if !area.is_finite() || area.abs() < 1e-6 { - continue; - } - let min = a.pos.min(b.pos).min(c.pos).max(bounds.min); - let max = a.pos.max(b.pos).max(c.pos).min(bounds.max); - if !(min.x < max.x && min.y < max.y) { - continue; - } - let colors = [a.color, b.color, c.color].map(|c| c.to_array().map(|v| f32::from(v) / 255.0)); - for y in min.y.floor() as usize..(max.y.ceil() as usize).min(height) { - for x in min.x.floor() as usize..(max.x.ceil() as usize).min(width) { - let p = pos2(x as f32 + 0.5, y as f32 + 0.5); - let wa = edge(b.pos, c.pos, p) / area; - let wb = edge(c.pos, a.pos, p) / area; - let wc = 1.0 - wa - wb; - if wa < 0.0 || wb < 0.0 || wc < 0.0 { - continue; - } - let u = wa * a.uv.x + wb * b.uv.x + wc * c.uv.x; - let v = wa * a.uv.y + wb * b.uv.y + wc * c.uv.y; - let t = texel(u, v); - let src: [f32; 4] = std::array::from_fn(|k| (wa * colors[0][k] + wb * colors[1][k] + wc * colors[2][k]) * t[k]); - if let Some(dst) = canvas.get_mut(y * width + x) { - let keep = 1.0 - src[3]; - *dst = std::array::from_fn(|k| src[k] + dst[k] * keep); - } - } - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn panel_icons_are_painted_into_their_textures() { - let images = super::render(20, &[("pages", "panel-pages"), ("character", "panel-character")]); - assert_eq!(images.len(), 2); - for image in &images { - assert_eq!(image.size, [20, 20]); - let inked = image.pixels.iter().filter(|p| p.a() > 128).count(); - assert!(inked > 10 && inked < 300, "{inked} inked pixels"); - } - } -} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 41dc02a01..7c1f946cd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -109,8 +109,11 @@ read as `anchor`. `zone` and `before`. - `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and `window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column - (`column`). A panel floats at 400, 140 at its own size unless the params say otherwise; a - panel already alone in a floating window moves or resizes. + (`column`). Without a position it floats 24 points inside the dock area's top-left corner, + clear of the menu bar; a panel floats at its own size and a tab group or column at the size + a drag would float it at. Given values replace these. A panel already alone in a floating + window moves or resizes, and a tab group that is already a whole floating window keeps its + rect unless the params change it. - `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and `window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side` (`Start` or `End`; a new dock when there is none). Without either, a panel that floated from @@ -179,7 +182,8 @@ default; a column bar's menu toggles it), Escape or a click outside the flyout c `layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or -null; `flyout` is the panel whose iconic-column flyout is open; `autoCollapse` is the +null; `flyout` is the panel whose iconic-column flyout is open (as drawn in the last frame, +or as a command that opens one sets it); `autoCollapse` is the Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place. `hitRects` lists where each docking gesture starts in the last frame, from craft-ui's `DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`, From 8be489eaecdd9458730d0be376d43e42d823fa54 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 13:25:02 +0000 Subject: [PATCH 015/315] Docking trial: craft-ui c715d3f, seeded float space --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ui-egui/src/panel_docking.rs | 38 +++++++++-------------------- 3 files changed, 13 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32b91c425..85374c054 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=6261d9d39a0a109a832685f37af6ce75321caea1#6261d9d39a0a109a832685f37af6ce75321caea1" +source = "git+https://github.com/aiongg/craft-libs?rev=c715d3f25fdfd615ae1685daa328776e9d7ad9a8#c715d3f25fdfd615ae1685daa328776e9d7ad9a8" dependencies = [ "egui", "serde", diff --git a/Cargo.toml b/Cargo.toml index f5c0281d2..52f732973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,4 +126,4 @@ hayro-jbig2 = { git = "https://github.com/storytold/pdfcraft", rev = "b5467d93a0 # Docking trial: craft-ui from the craft-libs `docking` branch. [patch."https://github.com/storytold/craft-libs"] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "6261d9d39a0a109a832685f37af6ce75321caea1" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c715d3f25fdfd615ae1685daa328776e9d7ad9a8" } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a55f059d7..7edc2705d 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -407,36 +407,12 @@ fn resolve_panels(params: &Value) -> Result { Ok(params) } -/// The panel `params` names, resolved, if it names one. -fn named_panel(params: &Value) -> Option { - params.get("panel").and_then(Value::as_str).map(str::to_owned) -} - -/// Is `panel` alone in a floating window? -fn floats_alone(layout: &Layout, panel: &str) -> bool { - layout.floating.iter().any(|w| w.contains(&panel.to_string()) && w.panels().count() == 1) -} - /// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in /// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; /// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking -/// it, floats a panel at its own size (also when it was hidden, which craft-ui never drew), -/// and returns a floated panel to its dock place on Dock Panel. +/// it, and returns a floated panel to its dock place on Dock Panel. fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { let mut params = resolve_panels(params)?; - let whole = |key: &str| params.get(key).and_then(Value::as_bool).unwrap_or(false); - if id == "window.panel.float" - && !whole("group") - && !whole("column") - && let Some(panel) = named_panel(¶ms) - && !floats_alone(&layout_of(app), &panel) - && let Some(fields) = params.as_object_mut() - { - let size = floating_size(&panel); - for (key, default) in [("width", size.x), ("height", size.y)] { - fields.entry(key).or_insert(json!(default)); - } - } if let Some(fields) = params.as_object_mut() { fields.insert("command".into(), Value::String(id.to_owned())); } @@ -496,9 +472,17 @@ fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result