Merge PR #945: Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)

ROADMAP.md: kept all table lines.
This commit is contained in:
aiongg
2026-10-24 06:15:56 +00:00
3 changed files with 76 additions and 1 deletions
+1
View File
@@ -160,6 +160,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 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-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
| 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 |
+5
View File
@@ -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<String>,
@@ -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,
+70 -1
View File
@@ -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<usize> = 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![];
@@ -170,6 +182,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()) {
@@ -327,3 +343,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}");
}
}