UI: View › New Window opens a second window

The editor had one window, so looking at another slide meant leaving
the one you were editing. View › New Window opens one more window on
the same presentation. That window keeps its own slide and shows edits
from the first window. Closing it leaves the presentation open.
Arrange All is not included.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-13 23:52:40 +03:00
co-authored by Cursor
parent 457406a7a5
commit 4dbea3256f
9 changed files with 257 additions and 9 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (View › New Window; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | View › New Window opens a second window on the same presentation. The window has its own slide and shows edits made in the first window. Arrange All is not included |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: View › New Window |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+191
View File
@@ -0,0 +1,191 @@
//! View › New Window: one more window on the same presentation.
//!
//! The window shows that document live, on its own slide, and does not move the editor's
//! selection. It does not edit, and Arrange All is not here. Closing the window leaves the
//! presentation open.
use deckcraft_render::RenderOpts;
use egui::{Color32, CornerRadius, Rect, RichText, pos2, vec2};
use serde_json::{Value, json};
use crate::SlideApp;
/// The extra window. `uid` is the presentation it was opened on.
pub struct DocWindow {
pub uid: u64,
pub slide: usize,
key: Option<(u64, u64, usize, u32, u32, bool)>,
tex: Option<egui::TextureHandle>,
}
/// `view.newWindow`. `{on: false}` closes it. `{slide}` or `{delta}` moves only this window.
pub fn command(app: &mut SlideApp, p: &Value) -> Result<Value, String> {
if p.get("on").and_then(Value::as_bool) == Some(false) {
app.doc_window = None;
return Ok(json!({"open": false}));
}
let (uid, n, title, editor_slide) = {
let active = app.session.active().ok_or("New Window needs an open presentation")?;
let n = active.doc.slides.len();
if n == 0 {
return Err("New Window needs a slide".into());
}
(active.uid, n, window_title(&active.title()), active.selection.slide.min(n - 1))
};
let mut slide = app.doc_window.as_ref().filter(|w| w.uid == uid).map(|w| w.slide).unwrap_or(editor_slide);
if let Some(i) = p.get("slide").and_then(Value::as_u64) {
slide = i as usize;
}
if let Some(delta) = p.get("delta").and_then(Value::as_i64) {
slide = shift(slide, delta, n);
}
if slide >= n {
slide = n - 1;
}
match app.doc_window.as_mut().filter(|w| w.uid == uid) {
Some(w) => w.slide = slide,
None => {
app.doc_window = Some(DocWindow { uid, slide, key: None, tex: None });
}
}
Ok(json!({"open": true, "window": 2, "slide": slide, "slides": n, "title": title}))
}
pub fn window_title(name: &str) -> String {
format!("{name}:2")
}
fn shift(slide: usize, delta: i64, n: usize) -> usize {
if n == 0 {
return 0;
}
let last = (n as i64) - 1;
(slide as i64).saturating_add(delta).clamp(0, last) as usize
}
/// Draw the extra window, or drop it when its presentation has been closed.
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let Some(uid) = app.doc_window.as_ref().map(|w| w.uid) else { return };
let found =
app.session.documents().iter().find(|d| d.uid == uid).map(|d| (d.doc.slides.len(), window_title(&d.title()), d.revision, d.doc.clone()));
let Some((n, title, revision, doc)) = found else {
app.doc_window = None;
return;
};
if n == 0 {
app.doc_window = None;
return;
}
let slide = app.doc_window.as_ref().map(|w| w.slide.min(n - 1)).unwrap_or(0);
if let Some(w) = app.doc_window.as_mut() {
w.slide = slide;
}
let grayscale = app.ui.grayscale;
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("deckcraft_doc_window"),
egui::ViewportBuilder::default().with_title(title.clone()).with_inner_size([960.0, 640.0]).with_min_inner_size([480.0, 360.0]),
|ui, _| {
let t = crate::theme::Tokens::get(ui);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
egui::Panel::top("doc_window_bar")
.exact_size(40.0)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(12, 8)))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.label(RichText::new(title.clone()).font(crate::theme::bold(13.0)).color(t.text));
ui.add_space(16.0);
ui.label(RichText::new(format!("Slide {} of {n}", slide + 1)).color(t.text_dim).font(crate::theme::font(13.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add_enabled(slide + 1 < n, egui::Button::new("Next")).clicked() {
let _ = app.run("view.newWindow", json!({"delta": 1}));
}
if ui.add_enabled(slide > 0, egui::Button::new("Previous")).clicked() {
let _ = app.run("view.newWindow", json!({"delta": -1}));
}
});
});
});
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::same(16))).show(ui, |ui| {
let avail = ui.available_rect_before_wrap();
let (w, h) = pixel_size(&doc, avail.width());
let tex = ensure_texture(app, ui.ctx(), &doc, uid, revision, slide, w, h, grayscale);
if let Some(tex) = tex {
let fitted = fit_rect(avail, w, h);
ui.painter().rect_filled(fitted, CornerRadius::ZERO, Color32::WHITE);
ui.painter().image(tex.id(), fitted, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
});
});
if ui.input(|i| i.viewport().close_requested()) {
app.doc_window = None;
}
},
);
}
fn pixel_size(doc: &deckcraft_model::Presentation, width_pts: f32) -> (u32, u32) {
let w = (width_pts.round() as u32).clamp(64, 1400);
let aspect = doc.slide_size.height / doc.slide_size.width.max(1.0);
let h = ((w as f64) * aspect).round().clamp(1.0, 1400.0) as u32;
(w, h)
}
fn fit_rect(avail: Rect, w: u32, h: u32) -> Rect {
let aspect = h as f32 / (w as f32).max(1.0);
let mut width = avail.width();
let mut height = width * aspect;
if height > avail.height() {
height = avail.height();
width = height / aspect.max(0.01);
}
Rect::from_center_size(avail.center(), vec2(width.max(1.0), height.max(1.0)))
}
fn ensure_texture(
app: &mut SlideApp,
ctx: &egui::Context,
doc: &deckcraft_model::Presentation,
uid: u64,
revision: u64,
slide: usize,
w: u32,
h: u32,
grayscale: bool,
) -> Option<egui::TextureHandle> {
let key = (uid, revision, slide, w, h, grayscale);
if let Some(win) = app.doc_window.as_ref()
&& win.key == Some(key)
&& let Some(tex) = win.tex.clone()
{
return Some(tex);
}
let img = deckcraft_render::render_slide(
doc,
slide,
&RenderOpts { scale: w as f64 / doc.slide_size.width.max(1.0), size: Some((w, h)), edit: true, threads: 0, ..Default::default() },
);
let tex = ctx.load_texture("doc-window-slide", crate::textures::to_color_image(&img, grayscale), egui::TextureOptions::LINEAR);
if let Some(win) = app.doc_window.as_mut() {
win.key = Some(key);
win.tex = Some(tex.clone());
}
Some(tex)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_window_title_marks_the_second_window() {
assert_eq!(window_title("Quarterly review"), "Quarterly review:2");
}
#[test]
fn shifting_stops_at_the_ends() {
assert_eq!(shift(0, -1, 3), 0);
assert_eq!(shift(1, 1, 3), 2);
assert_eq!(shift(2, 5, 3), 2);
assert_eq!(shift(0, 1, 0), 0);
}
}
+44
View File
@@ -11,6 +11,7 @@ pub mod canvas;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod doc_window;
pub mod fillui;
pub mod icons;
pub mod media;
@@ -151,6 +152,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"),
("view.dark", "Dark Mode", None, "{on?: bool}"),
("view.grayscale", "Grayscale", None, "{on?: bool}"),
("view.newWindow", "New Window", None, "{on?: bool, slide?: index, delta?: int}"),
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
@@ -227,6 +229,8 @@ pub struct SlideApp {
pub(crate) thumb_drag: Option<(usize, usize)>,
/// Selection pane: folded groups, renaming, drag and drop.
pub selpane: selpane::State,
/// View › New Window: a second window on one presentation. `None` when it is closed.
pub doc_window: Option<doc_window::DocWindow>,
}
impl SlideApp {
@@ -267,6 +271,7 @@ impl SlideApp {
ui_fonts: uifonts::UiFonts::default(),
thumb_drag: None,
selpane: selpane::State::default(),
doc_window: None,
}
}
@@ -493,6 +498,7 @@ impl SlideApp {
self.restyle = true;
}
"view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale),
"view.newWindow" => return doc_window::command(self, p),
"show.start" => {
let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0);
self.start_show(from, false);
@@ -937,6 +943,7 @@ impl SlideApp {
self.tick_media(&ctx);
if self.show.is_some() {
show::ui(self, ui);
doc_window::show(self, &ctx);
self.perf.frame_ms = now_ms() - t0;
return;
}
@@ -986,6 +993,7 @@ impl SlideApp {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
self.drain_requests();
doc_window::show(self, &ctx);
self.perf.frame_ms = now_ms() - t0;
}
@@ -1305,6 +1313,42 @@ mod tests {
app.run("app.home", json!({})).unwrap();
assert!(!app.backstage_open(), "with nothing open the start screen shows anyway");
}
/// View › New Window is a second window on the open presentation. Its slide is its own;
/// the editor's selection stays where it was. Closing the window leaves the document open.
#[test]
fn new_window_shows_the_same_document_on_its_own_slide() {
let mut app = SlideApp::new(Session::with_new(), Services::default());
app.session.execute("slide.new", &json!({})).unwrap();
app.session.execute("slide.go", &json!({"index": 1})).unwrap();
let err = {
let mut empty = SlideApp::new(Session::new(), Services::default());
empty.run("view.newWindow", json!({})).unwrap_err()
};
assert!(err.contains("presentation"), "{err}");
let name = app.session.active().unwrap().title();
let opened = app.run("view.newWindow", json!({})).unwrap();
assert_eq!(opened["open"], true);
assert_eq!(opened["window"], 2);
assert_eq!(opened["slide"], 1);
assert_eq!(opened["title"], format!("{name}:2"));
assert_eq!(app.session.active().unwrap().selection.slide, 1);
let moved = app.run("view.newWindow", json!({"delta": -1})).unwrap();
assert_eq!(moved["slide"], 0);
assert_eq!(app.doc_window.as_ref().unwrap().slide, 0);
assert_eq!(app.session.active().unwrap().selection.slide, 1, "the editor stays on its slide");
let again = app.run("view.newWindow", json!({})).unwrap();
assert_eq!(again["window"], 2);
assert_eq!(again["slide"], 0, "opening again keeps the window's slide");
app.session.execute("file.close", &json!({})).unwrap();
doc_window::show(&mut app, &egui::Context::default());
assert!(app.doc_window.is_none(), "closing the presentation closes the extra window");
assert!(app.session.active().is_none());
}
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
+2
View File
@@ -198,6 +198,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
("Zoom In", "view.zoomIn"),
("Zoom Out", "view.zoomOut"),
("Fit to Window", "view.fit"),
("-", ""),
("New Window", "view.newWindow"),
],
),
(
+8
View File
@@ -1860,6 +1860,14 @@ fn view(app: &mut SlideApp, ui: &mut Ui) {
app.ui.zoom = None;
}
});
group(ui, |ui| {
let has = app.session.active().is_some();
let on = app.doc_window.is_some();
let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0;
if resp.on_hover_text("Open another window on this presentation").clicked() {
let _ = app.run("view.newWindow", json!({}));
}
});
group(ui, |ui| {
let gs = app.ui.grayscale;
if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() {
+3 -3
View File
@@ -9,14 +9,14 @@ The summary between `<!-- SUMMARY -->
|---|---|---|---|---|---|
| P0 | 72 | 61 | 11 | 0 | 92% |
| P1 | 65 | 38 | 21 | 6 | 75% |
| P2 | 54 | 13 | 15 | 26 | 38% |
| P2 | 54 | 13 | 16 | 25 | 39% |
| Area | Parity |
|---|---|
| Application shell | 77% |
| Files | 71% |
| Slides | 90% |
| Views | 90% |
| Views | 93% |
| Masters & themes | 85% |
| Shapes | 88% |
| Format | 83% |
@@ -89,7 +89,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Views | Rulers, gridlines, guides | P1 | P |
| Views | Smart guides | P1 | D |
| Views | Grayscale / black and white view | P2 | D |
| Views | New window / arrange windows | P2 | M |
| Views | New window / arrange windows | P2 | P |
| Masters & themes | Slide Master view (edit master and layouts) | P0 | D |
| Masters & themes | Insert/rename/delete layouts, insert placeholders | P1 | D |
| Masters & themes | Handout and Notes masters | P2 | M |
+1 -1
View File
@@ -80,7 +80,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as
| M7 | Slide show | mostly | second-display placement, laser, record |
| M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table |
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps |
| M10 | Views | done | grid options, New Window |
| M10 | Views | done | grid options, Arrange All |
| M11 | Output | partly | vector PDF, SVG, GIF, MP4, print |
| M12 | Review | partly | dictionaries, AutoCorrect, find/replace dialog, compare |
| M13 | Automation and CLI parity | done | |
+1 -1
View File
@@ -182,7 +182,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor
| Application shell | 5 | 71% | ~55% | 8–14 | No native macOS menu bar, thin start screen (templates, recent), one window per presentation missing | [ui-parity.md](ui-parity.md) |
| Files (commands) | 4 | 69% | ~50% | see file formats | Print, reuse slides, passwords, legacy and macro formats | [file-format-parity.md](file-format-parity.md) |
| Slides and sections | 6 | 90% | ~85% | 3–5 | Zoom (summary/section/slide), copying slides between decks keeps formatting only partly | [parity-checklist.md](parity-checklist.md) |
| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) |
| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, Arrange All | [ui-parity.md](ui-parity.md) |
| Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx`, effect schemes | [parity-checklist.md](parity-checklist.md) |
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) |
| Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
+4 -3
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (View › New Window opens a second window on the same presentation; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -23,7 +23,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
| One window per presentation, New Window, Arrange All | yes | New Window opens one more window on the same presentation (its own slide, live with edits); tabs stay in the first window; no Arrange All | Arrange All |
| Quick Access Toolbar customization | yes | no | P1 partial |
## File tab (Backstage)
@@ -140,7 +140,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
| Grid Options, edit points UI, crop depth | 4–7 |
| Multiple windows | 3–5 |
| Arrange All (New Window is one extra window) | 2–3 |
| A screenshot comparison pass on the most-used tabs | 2–4 |
| **Total** | **~30–52** |
@@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | View › New Window: a second window on the same presentation, with its own slide; Arrange All is still missing |
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |