V: Split Window and New Window — a second view of the document

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 05:05:20 -04:00
co-authored by Claude Opus 5.5
parent 340f84ec0c
commit 5b2cf6ec76
5 changed files with 152 additions and 30 deletions
+1
View File
@@ -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.
+2 -2
View File
@@ -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<PointerEvent> = 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| {
(
+91 -22
View File
@@ -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<egui::TextureHandle>,
@@ -260,8 +288,16 @@ pub struct DesignApp {
pub ui: UiState,
pub services: Services,
pub views: HashMap<u64, View>,
/// 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<egui::Rect>,
/// 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<egui::Rect>)>,
/// 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<u64> {
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);
+52
View File
@@ -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<Result<Value,
Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>()}))
}
"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<bool> {
"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());
+6 -6
View File
@@ -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 |