mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Thumbnails: navigating slides scrolls the pane to keep the current slide visible (#917)
The pane scrolled only by hand: past a screenful of slides, arrow-key navigation left the current thumbnail out of view. When the slide changes, the pane now scrolls it into view - minimally, only once the thumbnail has left the viewport, as in PowerPoint, so a visible thumbnail never moves the pane and the user's own scrolling stands. The pane's scroll offset is recorded in UiState (skipped by serde) for the control channel and tests.
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![];
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user