diff --git a/ROADMAP.md b/ROADMAP.md index 93bc931e..01fb5b60 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | | 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | +| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..a7dd9b8d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -244,6 +244,10 @@ pub struct UiState { pub notes: bool, pub notes_height: f32, pub thumbs_width: f32, + /// Vertical scroll offset of the thumbnail pane, recorded each frame for the control + /// channel and tests; not persisted. + #[serde(skip)] + pub thumbs_scroll: f32, /// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`, /// `smartart` (Text Pane). pub pane: Option, @@ -286,6 +290,7 @@ impl Default for UiState { notes: true, notes_height: 52.0, thumbs_width: 236.0, + thumbs_scroll: 0.0, pane: None, pane_width: 300.0, ruler: false, diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index d6c9c02c..694f1b7b 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -26,11 +26,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { master_list(app, ui); return; } - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + let out = egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.add_space(8.0); list(app, ui); ui.add_space(20.0); }); + // Where the pane is scrolled to, for the control channel and tests (#917). + app.ui.thumbs_scroll = out.state.offset.y; }); // Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it. if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) { @@ -47,6 +49,16 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { let width = ui.available_width(); let tw = (width - 40.0).max(60.0); let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32; + // Scroll the current slide into view when it changes, so arrow-key navigation follows + // along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves + // the view, as in PowerPoint, so the user's own scrolling stands; the first frame only + // records the slide. + let scroll_key = ui.id().with("thumb_scrolled_to"); + let slide_changed = ui.data_mut(|d| { + let last: Option = d.get_temp(scroll_key); + d.insert_temp(scroll_key, cur); + last.is_some_and(|last| last != cur) + }); let ppp = ui.ctx().pixels_per_point(); let mut budget = 3u32; let mut rects = vec![]; @@ -166,6 +178,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { if need_more { ui.ctx().request_repaint(); } + // Scroll the current slide's thumbnail into view when the slide changes (#917). + if slide_changed && let Some((_, row_rect)) = rects.iter().find(|(i, _)| *i == cur) { + ui.scroll_to_rect(*row_rect, None); + } // Drag reorder: insertion line. if let Some((from, _)) = app.thumb_drag { if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) { @@ -323,3 +339,56 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { } }); } + +#[cfg(test)] +mod tests { + use egui_kittest::Harness; + + use super::*; + use crate::Services; + use deckcraft_engine::Session; + + fn harness() -> Harness<'static, SlideApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + h + } + + /// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`. + fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 { + h.state().ui.thumbs_scroll + } + + /// #917: navigating to a slide below the fold scrolls the pane to reveal it; a neighbour + /// that is already on screen does not move the pane. + #[test] + fn navigating_slides_keeps_the_current_thumbnail_visible() { + let mut h = harness(); + for _ in 0..30 { + h.state_mut().session.execute("slide.new", &json!({})).unwrap(); + } + // slide.new selects each new slide; back to the first so the pane starts at the top. + h.state_mut().session.execute("slide.go", &json!({"index": 0})).unwrap(); + h.run_steps(3); + let last = h.state().session.active().unwrap().doc.slides.len() - 1; + assert_eq!(thumbs_scroll(&h), 0.0, "the pane starts at the top"); + + // Jump to the last slide: the pane follows and reveals it. + h.state_mut().session.execute("slide.go", &json!({"index": last})).unwrap(); + h.run_steps(120); // the scroll eases in over several frames + let jumped = thumbs_scroll(&h); + assert!(jumped > 100.0, "the pane scrolled to the last slide, offset {jumped}"); + + // One slide up was already on screen after that scroll: the pane holds still. + h.state_mut().session.execute("slide.go", &json!({"index": last - 1})).unwrap(); + h.run_steps(120); + let held = thumbs_scroll(&h); + assert!((held - jumped).abs() < 1.0, "no re-scroll for a visible slide: {jumped} -> {held}"); + } +}