mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 21:16:57 -06:00
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:
@@ -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 |
|
||||
|
||||
@@ -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![];
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user