From 5b2cf6ec76d74183329dfd915f37ceb5964a1d03 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:05:20 -0400 Subject: [PATCH] =?UTF-8?q?V:=20Split=20Window=20and=20New=20Window=20?= =?UTF-8?q?=E2=80=94=20a=20second=20view=20of=20the=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/lib.rs | 113 ++++++++++++++++++++++++++++------- crates/ui-egui/src/menus.rs | 52 ++++++++++++++++ docs/parity.md | 12 ++-- 5 files changed, 152 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 252b0a59..01793f7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,6 +90,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. - Live Distribute (Space while dragging a selection handle; `transform.resize {distribute}`): objects keep their size and spread with the bounds. - Liquid Layout (`liquid.pageRule/object`, `guide.liquid`, `layout.createAlternate`, Liquid Layout panel): scale / re-center / guide-based / object-based page rules applied on page resize and Document Setup; dashed liquid guides; alternate layouts as resized page copies in a named section. Alternate layouts aren't linked to their source. +- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window opens that second view rather than a separate OS window. - 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/canvas.rs b/crates/ui-egui/src/canvas.rs index df5221b6..d0a2bd93 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1262,7 +1262,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live // Distribute while resizing). - let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new("canvas_pointer_down"))).unwrap_or(false); + let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); if space && !tool_drag && resp.dragged() { let d = resp.drag_delta(); if let Some(v) = app.view_mut() { @@ -1277,7 +1277,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); let mut events: Vec = Vec::new(); - let down_id = egui::Id::new("canvas_pointer_down"); + let down_id = egui::Id::new(("canvas_pointer_down", app.pane)); let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false); let (pressed, released, origin, latest, dbl) = ui.input(|i| { ( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb60672..11062b92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -227,6 +227,34 @@ pub struct Shown { pub size: (f32, f32), } +impl CanvasCache { + pub fn new() -> Self { + let mut renderer = designcraft_render::Renderer::new(); + renderer.threads = designcraft_render::default_threads(); + CanvasCache { + renderer, + texture: None, + shown: None, + pending: None, + token: 0, + #[cfg(not(target_arch = "wasm32"))] + worker: None, + worker_started: false, + last_ms: 0.0, + shown_doc: None, + pending_doc: None, + patcher: designcraft_render::Renderer::new(), + patches: 0, + } + } +} + +impl Default for CanvasCache { + fn default() -> Self { + Self::new() + } +} + pub struct CanvasCache { pub renderer: designcraft_render::Renderer, pub texture: Option, @@ -260,8 +288,16 @@ pub struct DesignApp { pub ui: UiState, pub services: Services, pub views: HashMap, + /// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`). pub canvas: CanvasCache, pub canvas_rect: Option, + /// Window › Arrange › Split Window: two views of the document side by side. + pub split: bool, + /// The current pane (0 or 1): views, cache and rect refer to it. + pub pane: u8, + /// The pane last clicked (menu zoom and scroll go there). + pub focus_pane: u8, + pub other_pane: Option<(CanvasCache, Option)>, /// Power Zoom in progress: the zoom to return to and the canvas point it will centre on. pub power_zoom: Option<(f64, designcraft_geom::Point)>, pub perf: Perf, @@ -287,29 +323,17 @@ pub struct DesignApp { impl DesignApp { pub fn new(session: Session, services: Services) -> Self { - let mut renderer = designcraft_render::Renderer::new(); - renderer.threads = designcraft_render::default_threads(); DesignApp { session, ui: UiState::default(), services, views: HashMap::new(), - canvas: CanvasCache { - renderer, - texture: None, - shown: None, - pending: None, - token: 0, - #[cfg(not(target_arch = "wasm32"))] - worker: None, - worker_started: false, - last_ms: 0.0, - shown_doc: None, - pending_doc: None, - patcher: designcraft_render::Renderer::new(), - patches: 0, - }, + canvas: CanvasCache::new(), canvas_rect: None, + split: false, + pane: 0, + focus_pane: 0, + other_pane: None, power_zoom: None, perf: Perf::default(), synthetic: vec![], @@ -334,13 +358,27 @@ impl DesignApp { self } - pub fn view(&self) -> Option<&View> { + /// The view key of the active document in the current pane. + fn view_key(&self) -> Option { let uid = self.session.active()?.uid; - self.views.get(&uid) + Some(if self.pane == 1 { uid ^ (1 << 63) } else { uid }) + } + pub fn view(&self) -> Option<&View> { + self.views.get(&self.view_key()?) } pub fn view_mut(&mut self) -> Option<&mut View> { - let uid = self.session.active()?.uid; - Some(self.views.entry(uid).or_default()) + let k = self.view_key()?; + Some(self.views.entry(k).or_default()) + } + /// Make `pane` current: its canvas cache and rect move in. + pub fn switch_pane(&mut self, pane: u8) { + if pane == self.pane { + return; + } + let (c, r) = self.other_pane.take().unwrap_or_default(); + let old = (std::mem::replace(&mut self.canvas, c), std::mem::replace(&mut self.canvas_rect, r)); + self.other_pane = Some(old); + self.pane = pane; } pub fn view_info(&self) -> ViewInfo { ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0) } @@ -547,7 +585,38 @@ impl DesignApp { if !presenting { chrome::doc_tabs(self, ui); } - canvas::show(self, ui); + if self.split && !presenting { + // Two panes with a divider; each has its own view (zoom, scroll) and render cache. + let r = ui.available_rect_before_wrap(); + let mid = r.center().x; + let halves = [ + egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)), + egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max), + ]; + ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border); + for (k, half) in halves.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)); + if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) { + self.focus_pane = k as u8; + } + } + self.switch_pane(self.focus_pane); + } else { + self.switch_pane(0); + self.focus_pane = 0; + canvas::show(self, ui); + } }); dock::flyout(self, &ctx); dock::floating(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 39dff446..2c9951da 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -80,6 +80,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), + ("window.newWindow", "New Window", None, "{} — another view of the active document (shown beside the first)"), ("window.taskBar", "Contextual Task Bar", None, "{}"), ("help.discord", "Join the ArtCraft Discord…", None, "{} — opens https://discord.gg/artcraft in the browser"), ("help.appPage", "DesignCraft Website…", None, "{} — opens https://getartcraft.com/apps/designcraft"), @@ -508,6 +510,11 @@ pub const MENUS: &[(&str, &[&str])] = &[ ( "Window", &[ + ">Arrange", + "ui:window.newWindow", + "ui:window.split", + "<", + "-", "ui:window.controlBar", "ui:window.taskBar", "ui:window.toolsDoubleColumn", @@ -994,6 +1001,15 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option>()})) } "window.controlBar" => flag(&mut app.ui.control_bar), + "window.split" | "window.newWindow" => { + let on = if id == "window.newWindow" { true } else { p.get("on").and_then(Value::as_bool).unwrap_or(!app.split) }; + app.split = on; + if !on { + app.switch_pane(0); + app.focus_pane = 0; + } + Ok(json!(on)) + } "window.taskBar" => flag(&mut app.ui.task_bar), "help.about" => { app.ui.about = p.get("open").and_then(Value::as_bool).unwrap_or(true); @@ -1372,6 +1388,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.taggedFrames" => app.ui.tagged_frames, "changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes), "window.controlBar" => app.ui.control_bar, + "window.split" => app.split, "window.taskBar" => app.ui.task_bar, "window.toolsDoubleColumn" => app.ui.tools_double_column, "view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview, @@ -1700,6 +1717,41 @@ mod tests { } } + #[test] + fn split_window_panes_have_their_own_views() { + 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 frame = |app: &mut crate::DesignApp| { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app); + assert_eq!(run_ui(&mut app, "window.split", &json!({})).unwrap().unwrap(), json!(true)); + frame(&mut app); + frame(&mut app); + // Both panes have a canvas, each half as wide. + let r0 = app.canvas_rect.unwrap(); + let r1 = app.other_pane.as_ref().and_then(|o| o.1).unwrap(); + assert!(r0.width() < 800.0 && r1.width() < 800.0 && (r0.center().x - r1.center().x).abs() > 300.0, "{r0:?} {r1:?}"); + // Zooming the right pane leaves the left alone. + app.switch_pane(1); + let z0 = app.view().unwrap().zoom; + crate::canvas::set_zoom(&mut app, z0 * 3.0); + app.switch_pane(0); + assert!((app.view().unwrap().zoom - z0).abs() < 1e-9); + app.switch_pane(1); + assert!((app.view().unwrap().zoom - z0 * 3.0).abs() < 1e-6); + assert_eq!(run_ui(&mut app, "window.split", &json!({})).unwrap().unwrap(), json!(false)); + assert_eq!(app.pane, 0); + frame(&mut app); + } + #[test] fn panels_float_and_dock() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/parity.md b/docs/parity.md index ee5bac6c..f7f90216 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 | 92 | 4 | 1 | 97% | -| P2 | 76 | 39 | 25 | 12 | 68% | +| P1 | 97 | 92 | 5 | 0 | 97% | +| P2 | 76 | 40 | 25 | 11 | 69% | | Area | Parity | |---|---| -| Application shell & workspace | 88% | +| Application shell & workspace | 91% | | Documents, pages, spreads | 94% | | Layers | 95% | | Frames, shapes & paths | 100% | @@ -31,7 +31,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Interactivity & digital | 90% | | Output & production | 80% | | XML & automation | 83% | -| View & navigation | 89% | +| View & navigation | 93% | | Undo, history, saving | 100% | | Accessibility | 70% | @@ -43,7 +43,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t |---|---|---|---| | Application shell & workspace | Start/Home screen | P0 | D | | Application shell & workspace | Multiple documents in tabs | P0 | D | -| Application shell & workspace | New Window for same document | P1 | M | +| Application shell & workspace | New Window for same document | P1 | P | | Application shell & workspace | Dockable panels | P0 | D | | Application shell & workspace | Workspaces | P1 | D | | Application shell & workspace | Control panel | P0 | D | @@ -306,7 +306,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | View & navigation | Structure / tag markers | P2 | P | | View & navigation | Display performance | P0 | D | | View & navigation | Navigate pages | P0 | D | -| View & navigation | Split window | P2 | M | +| View & navigation | Split window | P2 | D | | View & navigation | Rotate spread view | P2 | M | | View & navigation | Find/zoom to object | P1 | D | | Undo, history, saving | Unlimited undo/redo | P0 | D |