Merge pull request #1331

This commit is contained in:
Brandon Thomas
2026-10-25 13:42:05 -04:00
3 changed files with 184 additions and 2 deletions
+3
View File
@@ -3,6 +3,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
| 2026-10-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) |
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
@@ -287,6 +289,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---|---|
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-24 | trivial | Progress log: Ctrl+wheel zoom during the slide show (#1286). Scores and milestone names unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
+178
View File
@@ -957,6 +957,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm {
actions.push(show.state.prev(&doc));
}
// Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui
// keeps it out of `smooth_scroll_delta`, so it does not also turn the page below.
let line_points = ctx.options(|o| o.input_options.line_scroll_speed);
if !show.grid
&& !show.ended
&& show.blank.is_none()
&& let Some(p) = pointer
&& let Some(lines) = ctx.input(|i| wheel_zoom_lines(&i.events, line_points))
{
show.wheel_zoom(srect, p, lines);
}
// A trackpad swipe (and the mouse wheel) turns the page. The pen is drawing, so a flick
// there stays on the slide. Pinch zoom is not a show gesture.
if !show.pen
@@ -1702,6 +1713,30 @@ impl Show {
}
}
/// Ctrl/Cmd+wheel: `lines` notches in (positive) or out around the slide point under `p`.
/// Back to the whole slide leaves the zoom. A pointer off the slide does nothing.
fn wheel_zoom(&mut self, srect: Rect, p: Pos2, lines: f32) {
let uv = self.zoom_uv();
let Some((nx, ny)) = norm_on_slide(srect, uv, p) else { return };
let from = self.zoom.map_or(1.0, |z| z.scale);
let scale = (from * 2f32.powf(lines / WHEEL_NOTCHES_PER_DOUBLING)).min(WHEEL_ZOOM_MAX);
if !scale.is_finite() {
return;
}
self.zoom_arm = false;
if scale <= 1.0 + 1e-3 {
self.zoom = None;
return;
}
// The new window puts (nx, ny) at the same place on screen as before.
let w = 1.0 / scale;
let fx = (p.x - srect.min.x) / srect.width();
let fy = (p.y - srect.min.y) / srect.height();
let x = (nx - fx * w).clamp(0.0, 1.0 - w);
let y = (ny - fy * w).clamp(0.0, 1.0 - w);
self.zoom = Some(Zoom { nx: x + w * 0.5, ny: y + w * 0.5, scale });
}
/// Esc closes the See All Slides of the window it was pressed in, then a zoom, and only then leaves the show.
fn back_out(&mut self, presenter: bool) -> bool {
if presenter && self.presenter_grid {
@@ -1815,6 +1850,10 @@ impl Show {
const GOTO_HOLD: f64 = 3.0;
const GOTO_DIGITS: usize = 4;
const ZOOM_SCALE: f32 = 2.0;
/// Four notches double the zoom, so the wheel passes through the click-zoom's 2× on its way to 4×.
const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
/// PowerPoint's slide show magnifier goes up to 400%.
const WHEEL_ZOOM_MAX: f32 = 4.0;
const GRID_CAP: usize = 48;
const TOOL_BAR_W: f32 = 520.0;
const NOTES_DEFAULT: f32 = 18.0;
@@ -1932,6 +1971,26 @@ fn zoom_uv(nx: f32, ny: f32, scale: f32) -> Rect {
Rect::from_min_size(pos2(x, y), vec2(w, w))
}
/// The Ctrl/Cmd+wheel movement this frame in wheel lines (up is positive), `None` when there was
/// none. Read from the raw events: egui's `zoom_delta` also carries the trackpad pinch, which is
/// not a show gesture.
fn wheel_zoom_lines(events: &[egui::Event], line_points: f32) -> Option<f32> {
let mut lines = None;
for event in events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event
&& (modifiers.ctrl || modifiers.command)
{
let along = if delta.x.abs() > delta.y.abs() { delta.x } else { delta.y };
let n = match unit {
egui::MouseWheelUnit::Point => along / line_points.max(1.0),
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => along,
};
*lines.get_or_insert(0.0) += if n.is_finite() { n } else { 0.0 };
}
}
lines
}
fn zoom_frame(slide: Rect, uv: Rect) -> Rect {
Rect::from_min_size(
pos2(slide.min.x + uv.min.x * slide.width(), slide.min.y + uv.min.y * slide.height()),
@@ -2861,6 +2920,125 @@ mod tests {
assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3);
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-3
}
/// #1286: Ctrl+wheel zooms around the pointer, four notches per doubling, up to 4× (the most
/// PowerPoint's magnifier goes); zooming back to the whole slide leaves the zoom.
#[test]
fn wheel_zoom_keeps_the_point_under_the_pointer_and_stops_at_4x() {
let doc = deckcraft_model::Presentation::default();
let mut show = Show::new(&doc, 0, false, false);
// A 16:9 slide, not at the origin, and a pointer off its centre.
let srect = Rect::from_min_size(pos2(40.0, 30.0), vec2(320.0, 180.0));
let p = pos2(40.0 + 320.0 * 0.2, 30.0 + 180.0 * 0.3);
show.wheel_zoom(srect, p, 4.0);
let (_, _, scale) = show.zoom_view().expect("zoomed in");
assert!(near(scale, 2.0), "four notches double, got {scale}");
// The window keeps the slide's shape: half the width and half the height of the slide.
let uv = show.zoom_uv();
assert!(near(uv.width(), 0.5) && near(uv.height(), 0.5), "{uv:?}");
assert!(near(uv.min.x, 0.2 - 0.5 * 0.2) && near(uv.min.y, 0.3 - 0.5 * 0.3), "{uv:?}");
let under = norm_on_slide(srect, uv, p).expect("on the slide");
assert!(near(under.0, 0.2) && near(under.1, 0.3), "the point under the pointer moved to {under:?}");
// Again from the zoomed view: the same slide point stays under the pointer.
show.wheel_zoom(srect, p, 2.0);
let (_, _, scale) = show.zoom_view().expect("zoomed in");
assert!(near(scale, 2f32.powf(1.5)), "{scale}");
let under = norm_on_slide(srect, show.zoom_uv(), p).expect("on the slide");
assert!(near(under.0, 0.2) && near(under.1, 0.3), "{under:?}");
show.wheel_zoom(srect, p, 40.0);
let (_, _, scale) = show.zoom_view().expect("zoomed in");
assert!(near(scale, 4.0), "capped at 4×, got {scale}");
assert!(near(show.zoom_uv().width(), 0.25));
show.wheel_zoom(srect, p, -7.0);
assert!(show.zoom_view().is_some(), "one notch short of the whole slide is still zoomed");
show.wheel_zoom(srect, p, -1.0);
assert!(show.zoom_view().is_none(), "back to the whole slide leaves the zoom");
assert_eq!(show.zoom_uv(), unit_uv());
show.wheel_zoom(srect, p, -3.0);
assert!(show.zoom_view().is_none(), "zooming out of the whole slide does nothing");
// Off the slide (the letterbox) the wheel does not zoom.
show.wheel_zoom(srect, pos2(10.0, 10.0), 4.0);
assert!(show.zoom_view().is_none());
// An armed click-zoom gives way to the wheel.
show.zoom_arm = true;
show.wheel_zoom(srect, p, 4.0);
assert!(!show.zoom_arm && show.zoom_view().is_some());
}
#[test]
fn wheel_zoom_reads_only_the_wheel_with_ctrl_or_cmd() {
let wheel = |unit, y: f32, modifiers| egui::Event::MouseWheel { unit, delta: vec2(0.0, y), phase: egui::TouchPhase::Move, modifiers };
use egui::MouseWheelUnit::{Line, Point};
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::CTRL)], 40.0), Some(1.0));
assert_eq!(wheel_zoom_lines(&[wheel(Line, -2.0, egui::Modifiers::COMMAND)], 40.0), Some(-2.0));
assert_eq!(wheel_zoom_lines(&[wheel(Point, 20.0, egui::Modifiers::CTRL), wheel(Point, 20.0, egui::Modifiers::CTRL)], 40.0), Some(1.0));
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::NONE)], 40.0), None, "a plain wheel turns the page");
assert_eq!(wheel_zoom_lines(&[wheel(Line, 1.0, egui::Modifiers::SHIFT)], 40.0), None);
assert_eq!(wheel_zoom_lines(&[egui::Event::Zoom(2.0)], 40.0), None, "a pinch is not a show gesture");
assert_eq!(wheel_zoom_lines(&[wheel(Line, f32::NAN, egui::Modifiers::CTRL)], 40.0), Some(0.0));
}
/// A running show on the first of three slides, with presenter view off.
fn show_of_three() -> egui_kittest::Harness<'static, SlideApp> {
let mut session = deckcraft_engine::Session::with_new();
session.execute("slide.new", &json!({})).unwrap();
session.execute("slide.new", &json!({})).unwrap();
let mut app = SlideApp::new(session, crate::Services::default());
app.ui.presenter_view = false;
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.run_steps(3);
harness.state_mut().start_show(0, false);
harness.run_steps(2);
harness
}
fn show_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, at: Pos2, y: f32, modifiers: egui::Modifiers) {
harness.input_mut().events.push(egui::Event::PointerMoved(at));
harness.input_mut().events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, y),
phase: egui::TouchPhase::Move,
modifiers,
});
harness.run_steps(40);
}
/// #1286: in the show, Ctrl+wheel (Cmd+wheel on a Mac) zooms the slide and does not turn the
/// page; the plain wheel still turns it.
#[test]
fn ctrl_wheel_zooms_the_show_and_the_plain_wheel_turns_the_page() {
for held in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] {
let mut h = show_of_three();
let centre = pos2(500.0, 350.0);
show_wheel(&mut h, centre, 4.0, held);
let show = h.state().show.as_ref().expect("still showing");
assert_eq!(show.state.slide, 0, "{held:?}: the zoom wheel does not turn the page");
let (_, _, scale) = show.zoom_view().expect("zoomed in");
assert!(near(scale, 2.0), "{held:?}: {scale}");
show_wheel(&mut h, centre, -4.0, held);
let show = h.state().show.as_ref().expect("still showing");
assert_eq!(show.state.slide, 0, "{held:?}: zooming out does not go back a page either");
assert!(show.zoom_view().is_none(), "{held:?}: back to the whole slide");
show_wheel(&mut h, centre, -3.0, egui::Modifiers::NONE);
assert_eq!(h.state().show.as_ref().expect("still showing").state.slide, 1, "the plain wheel turns the page");
}
}
#[test]
fn escape_only_closes_the_grid_of_its_own_window() {
let mut s = deckcraft_engine::Session::with_new();
+3 -2
View File
@@ -1,6 +1,6 @@
# Slide show and presenter view: parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
rehearsal and recording. Transition and animation playback are in
@@ -29,7 +29,7 @@ ready** (estimated), **10–16 h** to parity.
| Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable. Click sequence since #116. Slide Show ▸ Show Media Controls (on by default) hides the playback bar. Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media` |
| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls |
| See All Slides grid during the show | yes | yes: G, or All on the show bar; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown |
| Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part |
| Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it. Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4× (#1286) | The presenter keeps the whole slide and draws a frame on the zoomed part. The wheel zooms in the audience window only; no panning while zoomed |
| Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt |
| Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot |
| Subtitles and live captions | yes (cloud speech) | no | AI dimension |
@@ -91,5 +91,6 @@ Reading View in a window with navigation: present (checklist row done).
| 2026-10-14 | minor | A custom show plays only its slides, from the Custom Shows dialog, from Set Up Slide Show, or from a click action. Checklist breadth stays 66% |
| 2026-10-14 | minor | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement and swap stay with #433. Checklist breadth stays 66% |
| 2026-10-14 | minor | See All Slides (G) and click-to-zoom (Z) during the show. Presenter view opens that grid and sets black, white and zoom. Checklist breadth stays 66% |
| 2026-10-24 | minor | Ctrl+wheel (Cmd+wheel on a Mac) zooms the show in and out around the pointer, up to 4×; the plain wheel still turns the page (#1286) |
| 2026-10-23 | trivial | Presenter view draws the notes with their formatting (bullets, numbers, indents, bold/italic) instead of plain text (#1128) |
| 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |