Merge pull request #1239 from marcoarnulfo/feat/canvas-scrollbars

This commit is contained in:
Brandon Thomas
2026-10-23 17:10:40 -04:00
3 changed files with 209 additions and 3 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** 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-23 · **Change:** 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)
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
@@ -138,6 +138,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-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-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
@@ -253,6 +254,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
+203 -1
View File
@@ -182,7 +182,49 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
offset = content_rect.min + at * scale - anchor;
}
let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0));
if resp.hovered() && !zooming {
// Scroll bars on the axes that overflow (#1157). They sit over the canvas edge and take their
// presses before the slide does; the rulers keep their strips.
let inset = if app.ui.ruler { 16.0 } else { 0.0 };
let mut on_bar = false;
let mut bar_active = [false; 2];
for axis in 0..2 {
let Some((track, thumb, travel)) = scroll_bar(avail, inset, overflow, offset, axis) else { continue };
let bar_id = scroll_id.with(("bar", axis));
let bar = ui.interact(track, bar_id, Sense::click_and_drag());
on_bar |= bar.hovered();
bar_active[axis] = bar.hovered() || bar.dragged();
// Where the button went down and the offset then: egui calls a press a click until it
// moves 6 pt, so the release point can't tell the thumb from the track, and drag_delta
// misses those first points.
let grab_id = bar_id.with("grab");
if bar.is_pointer_button_down_on()
&& let Some(origin) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.press_origin()).flatten())
{
ui.data_mut(|d| d.insert_temp(grab_id, (origin, offset[axis])));
}
let grab: Option<(Pos2, f32)> = ui.data_mut(|d| d.get_temp(grab_id));
if let Some((origin, start)) = grab {
if bar.dragged_by(egui::PointerButton::Primary)
&& travel > 0.0
&& let Some(p) = bar.interact_pointer_pos()
{
if last.is_some_and(|(_, s)| (scale - s).abs() > 1e-6) {
// A zoom during the drag moved the slide about its anchor: carry on from there.
ui.data_mut(|d| d.insert_temp(grab_id, (p, offset[axis])));
} else {
offset[axis] = start + (p[axis] - origin[axis]) * overflow[axis] / travel;
}
} else if bar.clicked() && !thumb.contains(origin) {
// A click on the track beside the thumb moves one window's length toward it.
let page = avail.size()[axis] * 0.9;
offset[axis] += if origin[axis] < thumb.min[axis] { -page } else { page };
}
if bar.drag_stopped() || bar.clicked() {
ui.data_mut(|d| d.remove_temp::<(Pos2, f32)>(grab_id));
}
}
}
if (resp.hovered() || on_bar) && !zooming {
if overflow != egui::Vec2::ZERO {
offset -= wheel;
} else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() {
@@ -330,6 +372,42 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
context_menu(app, &resp);
}
for (axis, active) in bar_active.into_iter().enumerate() {
if let Some((track, thumb, _)) = scroll_bar(avail, inset, overflow, offset, axis) {
painter.rect_filled(track, CornerRadius::ZERO, t.chrome);
let fill = if active { t.text_dim } else { t.text_faint };
painter.rect_filled(thumb.shrink(2.0), CornerRadius::same(3), fill);
}
}
}
/// Thickness of the canvas scroll bars, in points.
const SCROLL_BAR: f32 = 10.0;
/// The canvas scroll bar along `axis` (0 across, 1 down): its track, its thumb for `offset`, and
/// how far the thumb can travel. `None` when the slide fits that way. `inset` keeps the rulers'
/// strips clear.
fn scroll_bar(avail: Rect, inset: f32, overflow: egui::Vec2, offset: egui::Vec2, axis: usize) -> Option<(Rect, Rect, f32)> {
let over = overflow[axis];
if !over.is_finite() || over <= 0.0 {
return None;
}
// Both bars showing leave the corner between them empty.
let corner = if overflow[1 - axis] > 0.0 { SCROLL_BAR } else { 0.0 };
let track = if axis == 0 {
Rect::from_min_max(pos2(avail.min.x + inset, avail.max.y - SCROLL_BAR), pos2(avail.max.x - corner, avail.max.y))
} else {
Rect::from_min_max(pos2(avail.max.x - SCROLL_BAR, avail.min.y + inset), pos2(avail.max.x, avail.max.y - corner))
};
let len = track.size()[axis].max(0.0);
let view = avail.size()[axis];
let thumb_len = (len * view / (view + over)).max(SCROLL_BAR * 2.0).min(len);
let travel = (len - thumb_len).max(0.0);
let at = travel * (offset[axis] / over).clamp(0.0, 1.0);
let mut thumb = track;
thumb.min[axis] = track.min[axis] + at;
thumb.max[axis] = thumb.min[axis] + thumb_len;
Some((track, thumb, travel))
}
/// The canvas `avail` in texture pixels (`pixel`: one pixel's size in points) from the slide's
@@ -1500,6 +1578,130 @@ mod tests {
assert_near(mid, slide_point(&h, c), "zoom out");
}
/// Drags with the primary button from `a` to `b`; `during` runs after every move.
fn drag_with(h: &mut Harness<'_, SlideApp>, a: Pos2, b: Pos2, mut during: impl FnMut(&SlideApp)) {
h.hover_at(a);
h.run_steps(1);
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=8 {
h.event(Event::PointerMoved(a + (b - a) * (i as f32 / 8.0)));
h.run_steps(1);
during(h.state());
}
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
/// #1157: a slide zoomed past the window has a scroll bar on each axis that overflows, and
/// dragging its thumb pans the slide, not a marquee on the canvas.
#[test]
fn scroll_bars_pan_a_zoomed_slide() {
let mut h = harness();
let fit = h.state().canvas_scale;
h.state_mut().ui.zoom = Some(fit * 3.0);
h.run_steps(3);
let canvas = h.state().canvas_rect.unwrap();
let before = h.state().slide_rect.unwrap();
let no_marquee =
|app: &SlideApp| assert!(app.session.tool.marquee.is_none() && !app.session.tool.dragging(), "the bar drag reached the slide");
// Zoomed about the middle, so each thumb sits in the middle of its track.
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
drag_with(&mut h, bottom, bottom + vec2(150.0, 0.0), no_marquee);
let after = h.state().slide_rect.unwrap();
assert!(before.min.x - after.min.x > 150.0, "the horizontal thumb pans right: {before:?} -> {after:?}");
assert!((before.min.y - after.min.y).abs() < 1.0, "only across: {before:?} -> {after:?}");
let right = pos2(canvas.max.x - 4.0, canvas.center().y);
drag_with(&mut h, right, right + vec2(0.0, 100.0), no_marquee);
let down = h.state().slide_rect.unwrap();
assert!(after.min.y - down.min.y > 100.0, "the vertical thumb pans down: {after:?} -> {down:?}");
assert!((after.min.x - down.min.x).abs() < 1.0, "only down: {after:?} -> {down:?}");
// A click on the track beside the thumb pages back toward it.
let left = pos2(canvas.min.x + 20.0, canvas.max.y - 4.0);
h.hover_at(left);
h.run_steps(1);
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: left, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let paged = h.state().slide_rect.unwrap();
assert!(paged.min.x - down.min.x > 100.0, "a track click pages left: {down:?} -> {paged:?}");
assert!(h.state().session.tool.marquee.is_none());
// The ends of the tracks reach the slide's far edges.
drag_with(&mut h, right, right + vec2(0.0, 2000.0), |_| {});
let end = h.state().slide_rect.unwrap();
assert!((end.max.y - (canvas.max.y - 28.0)).abs() < 2.0, "the bottom of the slide comes into view: {end:?} in {canvas:?}");
}
/// A press on the thumb that ends just past its end is a nudge, not a page: the track click
/// is decided where the button went down, not where it came up.
#[test]
fn a_short_press_on_the_thumb_does_not_page() {
let mut h = harness();
let fit = h.state().canvas_scale;
h.state_mut().ui.zoom = Some(fit * 3.0);
h.run_steps(3);
let (canvas, before) = (h.state().canvas_rect.unwrap(), h.state().slide_rect.unwrap());
let overflow = vec2((before.width() + 56.0 - canvas.width()).max(0.0), (before.height() + 56.0 - canvas.height()).max(0.0));
let offset = canvas.min + vec2(28.0, 28.0) - before.min;
let (_, thumb, _) = scroll_bar(canvas, 0.0, overflow, offset, 0).unwrap();
let (a, b) = (pos2(thumb.max.x - 3.0, canvas.max.y - 4.0), pos2(thumb.max.x + 3.0, canvas.max.y - 4.0));
h.hover_at(a);
h.run_steps(1);
h.event(Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::PointerMoved(b));
h.run_steps(1);
h.event(Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let after = h.state().slide_rect.unwrap();
let moved = before.min.x - after.min.x;
assert!((0.0..40.0).contains(&moved), "a 6 pt nudge of the thumb, not a page: {before:?} -> {after:?}");
}
/// Only the primary button drags a thumb: a right or middle drag after a left one leaves the
/// slide where it is.
#[test]
fn only_the_primary_button_drags_a_thumb() {
let mut h = harness();
let fit = h.state().canvas_scale;
h.state_mut().ui.zoom = Some(fit * 3.0);
h.run_steps(3);
let canvas = h.state().canvas_rect.unwrap();
let bottom = pos2(canvas.center().x, canvas.max.y - 4.0);
drag_with(&mut h, bottom, bottom + vec2(40.0, 0.0), |_| {});
let before = h.state().slide_rect.unwrap();
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
let a = pos2(canvas.center().x + 40.0, canvas.max.y - 4.0);
h.hover_at(a);
h.run_steps(1);
h.event(Event::PointerButton { pos: a, button, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for i in 1..=4 {
h.event(Event::PointerMoved(a + vec2(30.0 * i as f32, 0.0)));
h.run_steps(1);
}
h.event(Event::PointerButton { pos: a + vec2(120.0, 0.0), button, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let after = h.state().slide_rect.unwrap();
assert!((before.min - after.min).length() < 1.0, "{button:?} drag moved the slide: {before:?} -> {after:?}");
}
}
/// A slide that fits has no scroll bars: a drag along the canvas edge is a marquee.
#[test]
fn a_fitted_slide_has_no_scroll_bars() {
let mut h = harness();
let canvas = h.state().canvas_rect.unwrap();
let a = pos2(canvas.center().x, canvas.max.y - 4.0);
let mut marquee = false;
drag_with(&mut h, a, a + vec2(150.0, 0.0), |app| marquee |= app.session.tool.marquee.is_some());
assert!(marquee, "the drag selects on the canvas");
}
fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) {
let mut s = Session::with_new();
let data = json!([["Hello world", "b"], ["c", ""]]);
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
@@ -81,6 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
| Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) |
| Smart guides (edges, centres, equal spacing) | yes | yes |
| Snap to grid, grid options, drawing guides | yes | Grid Options (`view.gridOptions`): spacing, snap to grid, snap to other objects, display grid and guides; the on-screen grid uses that spacing |
| Nudge (arrows), fine nudge (Option+arrows) | yes | yes |
@@ -165,6 +166,7 @@ consistency is open (#73, #56).
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | Scroll bars on the zoomed slide pane (#1157) |
| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |