From e5ec65bed906c9e487655b1c000ce1a3717bd8ea Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 00:39:40 -0400 Subject: [PATCH] U: custom workspaces (new, delete, reset) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 28 +++++++++++++- crates/ui-egui/src/dialogs.rs | 9 +++++ crates/ui-egui/src/lib.rs | 17 +++++++++ crates/ui-egui/src/menus.rs | 69 +++++++++++++++++++++++++++++++++++ docs/parity.md | 6 +-- 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 305ceaa..330d2f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,6 +63,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Font menu: search, favourites (stars, Show Favorites Only; `favoriteFonts` preference) and each family previewed in its own face. - Application preferences (engine `Prefs`) now persist across launches (desktop: prefs.json beside ui.json). - View ▸ Fit Selection in Window (⌥⌘=) and Find/Change ▸ Object (`find.objects`, `find.changeObjects`: by fill, stroke, weight, opacity, kind, layer, label). +- Custom workspaces (`window.newWorkspace/deleteWorkspace/resetWorkspace`; workspace switcher shows the current one): bars and panel arrangement saved by name. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 343f51d..a5b528b 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -42,15 +42,39 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon); ui.painter().text(r.min + vec2(20.0, 9.0), egui::Align2::LEFT_CENTER, "Search", egui::FontId::proportional(11.0), t.text_dim); ui.add_space(8.0); - ui.menu_button(egui::RichText::new("Essentials ▾").font(semibold(11.5)).color(t.text), |ui| { + let current = app.ui.workspace.clone(); + ui.menu_button(egui::RichText::new(format!("{current} ▾")).font(semibold(11.5)).color(t.text), |ui| { + let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); for w in ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) { - if ui.button(w).clicked() { + if ui.selectable_label(w == current, &w).clicked() { let _ = app.run("window.workspace", json!({"name": w})); ui.close(); } } + ui.separator(); + if ui.button(format!("Reset {current}")).clicked() { + let _ = app.run("window.resetWorkspace", json!({})); + ui.close(); + } + if ui.button("New Workspace…").clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + ui.menu_button("Delete Workspace", |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } + } + }); + } }); ui.add_space(6.0); if icons::button(ui, "share", 22.0, false, "Share").clicked() { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f904ab7..dceae1c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -641,6 +641,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findFont" => "Find/Replace Font", "polygonSettings" => "Polygon Settings", "userDictionary" => "User Dictionary", + "newWorkspace" => "New Workspace", "fittingOptions" => "Frame Fitting Options", "qrCode" => "Generate QR Code", "preferences" => "Preferences", @@ -850,6 +851,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "keyboardShortcuts" => keyboard_shortcuts(app, ui, &mut d), "userDictionary" => user_dictionary(app, ui, &mut d), + "newWorkspace" => { + ui.horizontal(|ui| { + ui.label("Name:"); + text_field(ui, &mut d, "name", 220.0); + }); + ui.label(egui::RichText::new("Saves which bars show and where the panels are.").size(10.5)); + } "fittingOptions" => { check(ui, &mut d, "autoFit", "Auto-Fit"); ui.add_space(4.0); @@ -1191,6 +1199,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { doc["overprintBlack"] = json!(d.b("overprintBlack")); app.run("document.preferences", doc) } + "newWorkspace" => app.run("window.newWorkspace", json!({"name": d.s("name")})), "fittingOptions" => { let m = |k: &str| d.fields.get(k).and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()).unwrap_or(0.0); app.run( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 281cda0..d235a24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,20 @@ pub enum ScreenMode { Presentation, } +/// A saved workspace: which bars show and where the panels are. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SavedWorkspace { + pub name: String, + pub control_bar: bool, + pub task_bar: bool, + pub tools_double_column: bool, + pub dock_tab: String, + pub dock_expanded: bool, + pub open_panel: Option, + pub floating: Vec<(String, [f32; 2])>, +} + /// Persisted UI state. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -104,6 +118,8 @@ pub struct UiState { pub dock_expanded: bool, pub units: Unit, pub workspace: String, + /// Window › Workspace › New Workspace: saved panel arrangements. + pub custom_workspaces: Vec, /// Transform reference point (0..8, row-major; 0 = top-left). pub ref_point: u8, /// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`). @@ -153,6 +169,7 @@ impl Default for UiState { dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), + custom_workspaces: Vec::new(), ref_point: 0, align_to: "selection".into(), text_style_tab: 0, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0ed8443..a2bbbcc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -87,6 +87,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{name: Essentials|Advanced|Book|Digital Publishing|Interactive for PDF|Printing and Proofing|Typography}", ), + ("window.newWorkspace", "New Workspace…", None, "{name} — saves the current bars and panel arrangement"), + ("window.deleteWorkspace", "Delete Workspace…", None, "{name}"), + ("window.resetWorkspace", "Reset Workspace", None, "{} — back to the current workspace as saved (or its defaults)"), ("window.brightness", "Interface Color Theme", None, "{brightness: dark|mediumDark|mediumLight|light|highContrast}"), ]; @@ -907,8 +910,56 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), + "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 = Some(crate::dialogs::Dialog::new("newWorkspace", json!({"name": ""}))); + return Some(Ok(Value::Null)); + }; + let u = &app.ui; + let w = crate::SavedWorkspace { + name: name.to_string(), + control_bar: u.control_bar, + task_bar: u.task_bar, + tools_double_column: u.tools_double_column, + dock_tab: u.dock_tab.clone(), + dock_expanded: u.dock_expanded, + open_panel: u.open_panel.clone(), + floating: u.floating.clone(), + }; + app.ui.custom_workspaces.retain(|x| x.name != name); + app.ui.custom_workspaces.push(w); + app.ui.workspace = name.to_string(); + Ok(Value::Null) + } + "window.deleteWorkspace" => { + let name = p.get("name").and_then(Value::as_str).unwrap_or(""); + let n = app.ui.custom_workspaces.len(); + app.ui.custom_workspaces.retain(|x| x.name != name); + if app.ui.custom_workspaces.len() == n { + return Some(Err(format!("no saved workspace `{name}`"))); + } + if app.ui.workspace == name { + app.ui.workspace = "Essentials".into(); + } + Ok(Value::Null) + } + "window.resetWorkspace" => { + let name = app.ui.workspace.clone(); + return run_ui(app, "window.workspace", &json!({"name": name})); + } "window.workspace" => { let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); + if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() { + app.ui.control_bar = w.control_bar; + app.ui.task_bar = w.task_bar; + app.ui.tools_double_column = w.tools_double_column; + app.ui.dock_tab = w.dock_tab; + app.ui.dock_expanded = w.dock_expanded; + app.ui.open_panel = w.open_panel; + app.ui.floating = w.floating; + app.ui.workspace = w.name; + return Some(Ok(Value::Null)); + } // Workspaces choose which bars and panels are visible. app.ui.control_bar = matches!(name, "Advanced" | "Typography" | "Printing and Proofing" | "Book"); app.ui.dock_tab = if name == "Typography" { "properties".into() } else { app.ui.dock_tab.clone() }; @@ -1559,4 +1610,22 @@ mod tests { // Fixed-parameter variants keep their own labels. assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally")); } + + #[test] + fn custom_workspaces_save_apply_and_delete() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.dock_tab = "swatches".into(); + app.ui.control_bar = true; + run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); + app.ui.dock_tab = "properties".into(); + run_ui(&mut app, "window.workspace", &json!({"name": "Mine"})).unwrap().unwrap(); + assert_eq!((app.ui.dock_tab.as_str(), app.ui.control_bar, app.ui.workspace.as_str()), ("swatches", true, "Mine")); + app.ui.dock_tab = "layers".into(); + run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); + assert_eq!(app.ui.dock_tab, "swatches", "reset to the saved arrangement"); + run_ui(&mut app, "window.deleteWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); + assert!(app.ui.custom_workspaces.is_empty()); + assert_eq!(app.ui.workspace, "Essentials"); + } } diff --git a/docs/parity.md b/docs/parity.md index d42e096..5a2d1a8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,12 +10,12 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 85 | 11 | 1 | 93% | +| P1 | 97 | 86 | 10 | 1 | 94% | | P2 | 76 | 23 | 13 | 40 | 39% | | Area | Parity | |---|---| -| Application shell & workspace | 80% | +| Application shell & workspace | 82% | | Documents, pages, spreads | 85% | | Layers | 95% | | Frames, shapes & paths | 95% | @@ -45,7 +45,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Application shell & workspace | Multiple documents in tabs | P0 | D | | Application shell & workspace | New Window for same document | P1 | M | | Application shell & workspace | Dockable panels | P0 | D | -| Application shell & workspace | Workspaces | P1 | P | +| Application shell & workspace | Workspaces | P1 | D | | Application shell & workspace | Control panel | P0 | D | | Application shell & workspace | Properties panel | P0 | D | | Application shell & workspace | Contextual task bar | P2 | D |