From f49ff09b8ac7282598f4c0cfe8b75329b59304ce Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:25:54 +0100 Subject: [PATCH 1/5] Show: All slides opens in the web Presenter View, as on desktop (#1866) --- apps/deckcraft-web/src/presenter.rs | 3 + crates/ui-egui/src/presenter.rs | 100 ++++++++++++++++++++++++++-- crates/ui-egui/src/show.rs | 3 + crates/ui-egui/src/show/tests.rs | 22 +++++- crates/ui-egui/src/show/ui.rs | 29 +++++++- 5 files changed, 149 insertions(+), 8 deletions(-) diff --git a/apps/deckcraft-web/src/presenter.rs b/apps/deckcraft-web/src/presenter.rs index 1ec9a298c..40cafe849 100644 --- a/apps/deckcraft-web/src/presenter.rs +++ b/apps/deckcraft-web/src/presenter.rs @@ -251,6 +251,9 @@ impl Popup { if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) { data.next = previous.next.clone(); } + if data.slides.is_none() { + data.slides = previous.slides.clone(); + } } *target.borrow_mut() = Some(data); *received.borrow_mut() = js_sys::Date::now(); diff --git a/crates/ui-egui/src/presenter.rs b/crates/ui-egui/src/presenter.rs index 95569778d..0ad7929bc 100644 --- a/crates/ui-egui/src/presenter.rs +++ b/crates/ui-egui/src/presenter.rs @@ -49,10 +49,15 @@ pub struct Snapshot { pub paused: bool, pub notes_px: f32, pub blank: Option, + /// All slides is open in the presenter window (#1866); the audience keeps the current slide. pub grid: bool, pub zoom_armed: bool, pub current: Option, pub next: Option, + /// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta + /// means "unchanged since the last snapshot the window received". + #[serde(default)] + pub slides: Option>, } /// Images acknowledged by a successful transport send, never merely attempted images. @@ -60,6 +65,7 @@ pub struct Snapshot { pub struct DeltaCache { current: Option, next: Option, + slides: Option>, token: String, } impl DeltaCache { @@ -75,6 +81,12 @@ impl DeltaCache { let next_changed = fresh || self.next != snapshot.next; let current = snapshot.current.clone(); let next = snapshot.next.clone(); + let slides = snapshot.slides.clone(); + if !fresh && self.slides == slides { + snapshot.slides = None; + } else if snapshot.slides.is_none() { + snapshot.slides = Some(Vec::new()); + } if !current_changed { snapshot.current = None; } @@ -84,14 +96,28 @@ impl DeltaCache { send(snapshot, current_changed, next_changed)?; self.current = current; self.next = next; + self.slides = slides; self.token = token.to_owned(); Ok(()) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Command { - Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool }, - Zoom { x: f32, y: f32 }, + Key { + name: String, + command: bool, + shift: bool, + alt: bool, + ctrl: bool, + }, + Zoom { + x: f32, + y: f32, + }, + /// A slide chosen in the presenter window's All slides grid (#1866). + Goto { + slide: usize, + }, End, } /// Implemented by the browser host. Native hosts retain the existing egui viewport. @@ -112,6 +138,8 @@ pub struct View { next: Option, previous_current: Option, previous_next: Option, + grid_source: Option>, + grid: Vec>, } impl View { /// Install the same interface and dynamic fallback fonts as SlideApp before layout. @@ -200,6 +228,11 @@ impl View { } }); ui.separator(); + if snapshot.grid && !snapshot.ended { + // All slides opens here, as in the desktop Presenter View; the audience keeps the current slide. + self.grid_ui(ui, snapshot, &mut commands); + return commands; + } ui.columns(2, |columns| { let left = &mut columns[0]; if snapshot.ended { @@ -227,9 +260,6 @@ impl View { if snapshot.zoom_armed { left.label("Click the preview to zoom."); } - if snapshot.grid { - left.label("Choose a slide in the audience window, or press G to return."); - } let right = &mut columns[1]; right.label("Next"); if let Some(texture) = &self.next { @@ -260,6 +290,43 @@ impl View { commands } } +impl View { + fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec) { + let slides = snapshot.slides.as_deref().unwrap_or_default(); + if self.grid_source.as_deref() != Some(slides) { + self.grid_source = Some(slides.to_vec()); + self.grid = slides + .iter() + .enumerate() + .map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR))) + .collect(); + } + ui.heading("All slides"); + ui.label("Choose a slide to show it, or press G to return."); + if snapshot.total > slides.len() && !slides.is_empty() { + ui.label(format!("First {} slides", slides.len())); + } + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (i, texture) in self.grid.iter().enumerate() { + let size = egui::vec2(160.0, 90.0); + let label = format!("{}", i + 1); + let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) }; + let response = match texture { + Some(t) => ui.add(egui::Button::image_and_text( + egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)), + label, + )), + None => ui.add_sized(size, egui::Button::new(label)), + }; + if response.clicked() { + commands.push(Command::Goto { slide: i }); + } + } + }); + }); + } +} fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 { size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0)) } @@ -282,6 +349,7 @@ mod tests { zoom_armed: false, current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }), next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }), + slides: None, } } @@ -418,6 +486,7 @@ mod tests { zoom_armed: false, current: None, next: None, + slides: None, }; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, (view, snapshot, sent): &mut (View, Snapshot, Vec)| { @@ -437,6 +506,27 @@ mod tests { assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name())); } + #[test] + fn all_slides_opens_in_the_popup_and_picks_a_slide() { + use egui_kittest::kittest::Queryable; + let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] }; + let mut snapshot = snapshot(); + snapshot.total = 3; + snapshot.grid = true; + snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, (view, snapshot, sent): &mut (View, Snapshot, Vec)| { + sent.extend(view.ui(ui, snapshot)); + }, + (View::default(), snapshot, Vec::new()), + ); + h.run_steps(2); + assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)"); + h.get_by_label("3").click(); + h.run_steps(2); + assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]); + } + #[test] fn previews_are_bounded_and_bad_dimensions_are_rejected() { let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 0cb2b458b..c1e3f4ab2 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -65,6 +65,8 @@ pub struct Show { preview_spawns: usize, remote_preview: Option, remote_next: Option<(usize, crate::presenter::Preview)>, + /// All slides thumbnails sent to the web presenter window while its grid is open (#1866). + remote_grid: Vec, remote_sent: f64, remote_opened: bool, /// The next slide's first frame, rendered in the background while this one is shown so @@ -237,6 +239,7 @@ impl Show { preview_spawns: 0, remote_preview: None, remote_next: None, + remote_grid: vec![], remote_sent: f64::NEG_INFINITY, remote_opened: false, prefetch: None, diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index b948efde2..01b30ff01 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1132,6 +1132,7 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { opens: usize, closes: usize, commands: Vec, + last: Option, } struct Mock(Rc>); impl Host for Mock { @@ -1145,7 +1146,9 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { fn commands(&mut self) -> Vec { std::mem::take(&mut self.0.borrow_mut().commands) } - fn update(&mut self, _: Snapshot) {} + fn update(&mut self, snapshot: Snapshot) { + self.0.borrow_mut().last = Some(snapshot); + } fn close(&mut self) { self.0.borrow_mut().closes += 1; } @@ -1179,6 +1182,23 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { h.run_steps(2); assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}"); } + // #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide. + bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + let show = h.state().show.as_ref().unwrap(); + assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's"); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + } + let last = bridge.borrow().last.clone().unwrap(); + assert!(last.grid); + assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide"); + bridge.borrow_mut().commands.push(Command::Goto { slide: 2 }); + h.run_steps(2); + let show = h.state().show.as_ref().unwrap(); + assert!(!show.presenter_grid && !show.grid); + assert_eq!(show.state.slide, 2); bridge.borrow_mut().commands.push(Command::End); h.run_steps(2); assert!(h.state().show.is_none()); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 226459755..688d2bb6e 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -96,9 +96,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { crate::presenter::Command::Key { name, command, shift, alt, ctrl } => { if let Some(key) = egui::Key::from_name(&name) { let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl }; - out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0; + // The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866). + out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0; } } + crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => { + show.presenter_grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + let action = show.state.goto(&doc, slide); + out.keep &= show.apply(action, &doc, now); + } _ => {} } } @@ -143,6 +152,21 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ); show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image)); } + if !show.presenter_grid { + show.remote_grid.clear(); + } else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() { + // A few thumbnails per update keep the audience window responsive on the web. + for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) { + let size = (160, (160.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 640.0) as u32); + let image = deckcraft_render::render_slide( + &doc, + i, + &RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() }, + ); + let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)); + show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] })); + } + } host.update(crate::presenter::Snapshot { slide: show.state.slide, total: doc.slides.len(), @@ -152,10 +176,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { paused: show.timer.paused(), notes_px: show.notes_px, blank: show.blank_name().map(str::to_owned), - grid: show.grid, + grid: show.presenter_grid, zoom_armed: show.zoom_arm, current: show.remote_preview.clone(), next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()), + slides: show.presenter_grid.then(|| show.remote_grid.clone()), }); show.remote_sent = now; } From 884e3d3353649870127280eb8bf0bd9c40c5cf48 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:37:37 +0100 Subject: [PATCH 2/5] Show: keep web All slides messages small, clear the grid when the popup closes, show it on the end screen (#1866) --- crates/ui-egui/src/presenter.rs | 6 +++--- crates/ui-egui/src/show.rs | 1 + crates/ui-egui/src/show/tests.rs | 13 ++++++++++++- crates/ui-egui/src/show/ui.rs | 23 +++++++++++++++-------- crates/ui-egui/src/show/zoom.rs | 2 +- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/presenter.rs b/crates/ui-egui/src/presenter.rs index 0ad7929bc..5d99e63c8 100644 --- a/crates/ui-egui/src/presenter.rs +++ b/crates/ui-egui/src/presenter.rs @@ -228,7 +228,7 @@ impl View { } }); ui.separator(); - if snapshot.grid && !snapshot.ended { + if snapshot.grid { // All slides opens here, as in the desktop Presenter View; the audience keeps the current slide. self.grid_ui(ui, snapshot, &mut commands); return commands; @@ -303,8 +303,8 @@ impl View { } ui.heading("All slides"); ui.label("Choose a slide to show it, or press G to return."); - if snapshot.total > slides.len() && !slides.is_empty() { - ui.label(format!("First {} slides", slides.len())); + if snapshot.total > crate::show::GRID_CAP { + ui.label(format!("First {} slides", crate::show::GRID_CAP)); } egui::ScrollArea::vertical().show(ui, |ui| { ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index c1e3f4ab2..db7983c2b 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -18,6 +18,7 @@ mod presenter; mod render; mod ui; mod zoom; +pub(crate) use zoom::GRID_CAP; #[cfg(test)] use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars}; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 01b30ff01..a01989ac0 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1133,6 +1133,7 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { closes: usize, commands: Vec, last: Option, + gone: bool, } struct Mock(Rc>); impl Host for Mock { @@ -1141,7 +1142,7 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { Ok(()) } fn is_open(&self) -> bool { - true + !self.0.borrow().gone } fn commands(&mut self) -> Vec { std::mem::take(&mut self.0.borrow_mut().commands) @@ -1194,11 +1195,21 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { let last = bridge.borrow().last.clone().unwrap(); assert!(last.grid); assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide"); + assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message"); + assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews"); bridge.borrow_mut().commands.push(Command::Goto { slide: 2 }); h.run_steps(2); let show = h.state().show.as_ref().unwrap(); assert!(!show.presenter_grid && !show.grid); assert_eq!(show.state.slide, 2); + // Closing the popup with its grid open must not hold auto-advance for the rest of the show. + bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + assert!(h.state().show.as_ref().unwrap().presenter_grid); + bridge.borrow_mut().gone = true; + h.run_steps(2); + assert!(!h.state().show.as_ref().unwrap().presenter_grid); + bridge.borrow_mut().gone = false; bridge.borrow_mut().commands.push(Command::End); h.run_steps(2); assert!(h.state().show.is_none()); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 688d2bb6e..d048fb93c 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -80,6 +80,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && !show.reading && let Some(host) = app.services.presenter.as_mut() { + if show.remote_opened && show.presenter_grid && !host.is_open() { + // A grid left open in the closed popup would hold auto-advance for the rest of the show. + show.presenter_grid = false; + } if !show.remote_opened { show.remote_opened = true; if let Err(message) = host.open() { @@ -157,12 +161,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() { // A few thumbnails per update keep the audience window responsive on the web. for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) { - let size = (160, (160.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 640.0) as u32); - let image = deckcraft_render::render_slide( - &doc, - i, - &RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() }, - ); + // At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit. + let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0)); + let scale = REMOTE_THUMB / w.max(h); + let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1)); + let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() }); let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)); show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] })); } @@ -178,8 +181,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { blank: show.blank_name().map(str::to_owned), grid: show.presenter_grid, zoom_armed: show.zoom_arm, - current: show.remote_preview.clone(), - next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()), + // The grid replaces both previews in the popup; leaving them out keeps one message small. + current: show.remote_preview.clone().filter(|_| !show.presenter_grid), + next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()), slides: show.presenter_grid.then(|| show.remote_grid.clone()), }); show.remote_sent = now; @@ -189,6 +193,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { finish(app, show, &f, out.keep); } +/// Longest side of an All slides thumbnail sent to the web presenter window (#1866). +const REMOTE_THUMB: f64 = 96.0; + fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) { if show.started { let full = ui.max_rect(); diff --git a/crates/ui-egui/src/show/zoom.rs b/crates/ui-egui/src/show/zoom.rs index 9c45fd4a2..da6104905 100644 --- a/crates/ui-egui/src/show/zoom.rs +++ b/crates/ui-egui/src/show/zoom.rs @@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0; /// PowerPoint's slide show magnifier goes up to 400%. const WHEEL_ZOOM_MAX: f32 = 4.0; -pub(super) const GRID_CAP: usize = 48; +pub(crate) const GRID_CAP: usize = 48; const TOOL_BAR_W: f32 = 520.0; From efd26de81d0b27d091293d5ad89b2a0f175ac5a4 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:48:01 +0100 Subject: [PATCH 3/5] Show: clear the web presenter grid in hidden logic, let Enter and Space pick a focused thumbnail (#1866) --- crates/ui-egui/src/app/playback.rs | 4 ++++ crates/ui-egui/src/presenter.rs | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 73e765ff7..c959829cd 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -144,6 +144,10 @@ impl SlideApp { let visible = show::viewport_visible(ctx); let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; let Some(show) = self.show.as_mut() else { return }; + if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) { + // A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866). + show.presenter_grid = false; + } if visible { // A visible logic-only pass (`run_logic`) carries the last full frame's input time. // Anchoring that stale time to `now` would move the hidden clock backwards if the diff --git a/crates/ui-egui/src/presenter.rs b/crates/ui-egui/src/presenter.rs index 5d99e63c8..a58878830 100644 --- a/crates/ui-egui/src/presenter.rs +++ b/crates/ui-egui/src/presenter.rs @@ -175,6 +175,10 @@ impl View { .collect::>() }); for (key, modifiers) in keys { + if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) { + // A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open. + continue; + } // Consume once before widgets see Space/Enter, avoiding a second button activation. ui.input_mut(|i| i.consume_key(modifiers, key)); commands.push(Command::Key { @@ -525,6 +529,9 @@ mod tests { h.get_by_label("3").click(); h.run_steps(2); assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail"); } #[test] From ede154d6be565d47b4b9c608103ed88896569ee7 Mon Sep 17 00:00:00 2001 From: Zach Berry <1112351+zberry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:07:03 -0500 Subject: [PATCH 4/5] Outlines leave footer, date and slide-number placeholders out: export, RTF and the outline view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent run exporting a deck as an outline got the footer text and the slide-number field (‹#›) as body lines under each title. PowerPoint's outline view shows title and body placeholders only, so the text outline, the RTF outline (Save As ▸ Outline/RTF) and the outline view now skip the footer-row placeholders as they already skipped the title. Split out of the frame export PR at review, which asked for all three together. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 32 +++++++++++++++++++++++++++ crates/format/src/lib.rs | 4 +++- crates/format/src/rtf.rs | 3 ++- crates/ui-egui/src/sorter.rs | 5 +++-- crates/ui-egui/src/sorter/ui_tests.rs | 21 ++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 12fac5efc..6741f03d5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6509,3 +6509,35 @@ fn save_to_image_path_is_an_error_not_zip_bytes() { } let _ = std::fs::remove_dir_all(&dir); } + +/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer, +/// date and slide-number placeholders are not body lines. +#[test] +fn outline_export_skips_footer_date_and_slide_number() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap(); + s.execute( + "design.headerFooter", + &json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}), + ) + .unwrap(); + let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.md"); + s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}"); + assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}"); + assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}"); + assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}"); + // The RTF outline (Save As ▸ Outline/RTF) too. + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("deck.rtf"); + s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap(); + let rtf = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}"); + assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}"); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 97caafdb3..4125c2905 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -694,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String { out.push_str(&s.title().replace('\n', " ")); out.push('\n'); for sh in &s.shapes { - if sh.ph_type().is_some_and(|k| k.is_title()) { + // The title is the slide's line; footer, date and slide-number placeholders are not + // body text (PowerPoint's outline view shows title and body placeholders only). + if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) { continue; } if let Some(t) = &sh.text { diff --git a/crates/format/src/rtf.rs b/crates/format/src/rtf.rs index b546a832f..4e7e29583 100644 --- a/crates/format/src/rtf.rs +++ b/crates/format/src/rtf.rs @@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String { } out.push_str("\\b0\\par\n"); for sh in &s.shapes { - if sh.ph_type().is_some_and(|k| k.is_title()) { + // As in the text outline: footer, date and slide-number placeholders are not body text. + if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) { continue; } let Some(t) = &sh.text else { continue }; diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 6c9626380..5205a835d 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -476,7 +476,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 { let lines: usize = s .shapes .iter() - .filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) + .filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) .filter_map(|x| x.text.as_ref()) .filter(|tb| !tb.is_empty()) .map(|tb| tb.paragraphs.len().max(1)) @@ -512,7 +512,8 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck ui.data_mut(|d| d.insert_temp(id, v)); } }); - for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) { + // Footer, date and slide-number placeholders are not outline text, as in PowerPoint. + for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) { let Some(tb) = &sh.text else { continue }; if tb.is_empty() { continue; diff --git a/crates/ui-egui/src/sorter/ui_tests.rs b/crates/ui-egui/src/sorter/ui_tests.rs index 966974616..07b42d02f 100644 --- a/crates/ui-egui/src/sorter/ui_tests.rs +++ b/crates/ui-egui/src/sorter/ui_tests.rs @@ -259,3 +259,24 @@ fn shift_and_ctrl_shift_clicks_select_ranges() { click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT); assert_eq!(selected(&h), vec![0, 2, 3]); } + +/// The outline view lists a slide's title and body text, not its footer, date and slide-number +/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was. +#[test] +fn the_outline_view_leaves_footer_date_and_slide_number_out() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap(); + let slide = |s: &Session| { + let st = s.doc().unwrap(); + st.doc.slides[st.selection.slide].clone() + }; + let plain = super::outline_estimate(&slide(&s)); + s.execute( + "design.headerFooter", + &json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}), + ) + .unwrap(); + let with_footer = slide(&s); + assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide"); + assert_eq!(super::outline_estimate(&with_footer), plain); +} From a2d9e71b5e71bfbe2e96499b6dce304118153fe2 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 04:33:51 +0100 Subject: [PATCH 5/5] About: credits tables fit without side-scrolling and their columns resize (#1806) * About: credits tables fit without side-scrolling and their columns resize The Contributors table and the Models table were egui Grids inside a two-way scroll area: they opened wider than the dialog, so they scrolled sideways, and Company and Model sat almost touching. Both are now egui_extras tables. Each column opens at the width of its header and widest cell, the spare width goes to the text columns (Name; Company and Model), and every column edge can be dragged. Widening a column grows the table like a spreadsheet, and only then does it scroll sideways; the header stays put while the rows scroll. Counts and dates are right-aligned and every cell is vertically centred. The sorted column stays highlighted and its direction shows on the toolbar's sort-order button, so headers no longer reserve room for an arrow. The opening widths come from fit_widths, a pure function with unit tests that a fitted table fills its room exactly and that only the text columns give way when room is short. The About dialog opens 820 points wide, so every column fits at its natural width. Co-Authored-By: Claude Opus 5.5 * Let every credits column be dragged, and refit changed tables The last column was egui's remainder column, which has no drag handle, so it could only be resized from the edge before it. It is now an ordinary column with its own handle. A table keeps its column widths, so switching to a longer name left the Name column at its old width and clipped the text. The table now goes back to the opening fit whenever that fit changes: a longer widest cell or a resized window. Dragged widths are dropped at that point. fit_widths could overflow the room when one column hit the minimum, because the other columns did not take up its share. Columns now give way in proportion to their width above the minimum. Co-Authored-By: Claude Opus 5.5 * Refit credits tables on what they measure, not the fitted widths Comparing the fitted widths missed the case it was meant for. Name is the only growing column, so a longer name takes its extra width from the spare room and the fit comes out the same: a dragged Name width stayed, clipping the longer name. It could also drop dragged widths by accident. The fit comes from the available width inside the scroll area, which scrolling can nudge, so scrolling a widened table could reset it. The table now compares the natural widths and the clip rect's width, which scrolling does not move. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 41 ++- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs/about.rs | 2 +- crates/ui-egui/src/dialogs/credits.rs | 371 ++++++++++++++++++++++---- 4 files changed, 357 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c86df3540..417823304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1275,6 +1275,7 @@ dependencies = [ "deckcraft-render", "deckcraft-text", "egui", + "egui_extras", "egui_kittest", "log", "serde", @@ -1520,6 +1521,19 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", +] + [[package]] name = "egui_glow" version = "0.37.0" @@ -1587,6 +1601,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-map" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -3487,7 +3521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -6511,11 +6545,6 @@ name = "egui_demo_lib" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" -[[patch.unused]] -name = "egui_extras" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - [[patch.unused]] name = "egui_inspection" version = "0.37.0" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 88b9f770b..166eba220 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -19,6 +19,7 @@ deckcraft-anim = { workspace = true } deckcraft-engine = { workspace = true } deckcraft-media = { workspace = true } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_extras = { workspace = true } craft-ui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index 03c6629b2..059543b61 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -1,7 +1,7 @@ use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2}; /// Room the About credits need for their table; the About tab keeps its compact size. -pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0); +pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(820.0, 380.0); /// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the /// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index d1bf5b30e..b9b32e56e 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -246,12 +246,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); ui.separator(); - egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { - if list.is_empty() { - ui.label("No contributor data was built into this copy."); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label("No contributor data was built into this copy."); + } else if v.table { + // The table scrolls vertically itself, under a fixed header; it only scrolls sideways + // once its columns are dragged wider than the dialog. + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + } else { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -260,39 +262,153 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } }); - } - }); + }); + } ui.data_mut(|d| d.insert_temp(id, v)); } -fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { - for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { - if v.key == k { - v.ascending = !v.ascending; - } else { - v.key = k; - v.ascending = k.default_ascending(); - } +/// How the contributor table's spare width is shared out: all of it to Name. +const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// How the model table's spare width is shared out: to Company and Model, the text columns. +const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]; + +/// The narrowest a column can be dragged. +const MIN_COLUMN: f32 = 28.0; + +/// The width a column needs for its header and its widest cell. +fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 { + let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x; + let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max); + widest + 2.0 * ui.spacing().button_padding.x +} + +/// Opening column widths: each column's natural width, with the spare room shared out by `grow`, +/// so the columns fill `room` exactly. When there is too little room, only the growing columns +/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let slack = |w: f32| (w - MIN_COLUMN).max(0.0); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if *g > 0.0 && spare >= 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + w - (-spare).min(flexible) * slack(*w) / flexible + } else { + *w } - } - ui.end_row(); - for c in list { - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - ui.label(group(c.prs)); - ui.label(group(c.commits)); - ui.label(group(c.lines_added)); - ui.label(group(c.lines_deleted)); - ui.label(signed(c.lines_delta())); - ui.label(group(c.binary_added)); - ui.label(group(c.binary_deleted)); - ui.label(day(c.first_commit)); - ui.label(day(c.last_commit)); - ui.end_row(); - } - }); + }) + .collect() +} + +/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls +/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge: +/// only a table dragged wider than the window scrolls sideways. Text too long for its column is +/// clipped. When a column's natural width or the window changes, the columns go back to a new fit, +/// dropping any dragged widths. +fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> { + // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins. + let s = ui.spacing(); + let gaps = s.item_spacing.x * natural.len() as f32; + let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin; + let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0)); + // The table keeps its own column widths, so a change in what they were fitted to has to clear + // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available + // width, so this compares the natural widths and the clip rect, which scrolling never moves. + let fit = egui::Id::unique(("credits_fit", id)); + let measured = (natural.to_vec(), ui.clip_rect().width()); + let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured); + ui.data_mut(|d| d.insert_temp(fit, measured)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + if refit { + t.reset(); + } + for w in widths { + t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true)); + } + t +} + +/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred. +fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, add).inner +} + +fn row_height(ui: &egui::Ui) -> f32 { + ui.text_style_height(&egui::TextStyle::Body) + 6.0 +} + +/// One contributor's table cells after the name, in `SortKey::ALL` order. +fn cells(c: &Contributor) -> [String; 9] { + [ + group(c.prs), + group(c.commits), + group(c.lines_added), + group(c.lines_deleted), + signed(c.lines_delta()), + group(c.binary_added), + group(c.binary_deleted), + day(c.first_commit).to_string(), + day(c.last_commit).to_string(), + ] +} + +/// A contributor-table header. The sorted column is highlighted; its direction shows on the +/// toolbar's sort-order button, so no arrow widens the column. +fn header_text(k: SortKey) -> RichText { + RichText::new(k.label().1).strong() +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + let h = row_height(ui); + let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect(); + let widths: Vec = SortKey::ALL + .iter() + .enumerate() + .map(|(i, &k)| match i.checked_sub(1) { + None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))), + Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())), + }) + .collect(); + fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW) + .header(h + 4.0, |mut header| { + for k in SortKey::ALL { + header.col(|ui| { + let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0); + + if r.clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + } + }) + .body(|mut body| { + for (c, cells) in list.iter().zip(rows) { + body.row(h, |mut row| { + row.col(|ui| { + aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login))) + .on_hover_text(c.summary()); + }); + for text in cells { + row.col(|ui| { + aligned(ui, true, |ui| ui.label(text)); + }); + } + }); + } + }); } /// About ▸ Models: AI models credited in Co-Authored-By trailers. @@ -302,22 +418,48 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(h).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); - ui.end_row(); - } - }); + let h = row_height(ui); + let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"]; + // Company, Model and Version read as text; the counts line up on the right. + let right = [false, false, false, true, true, true]; + let rows: Vec<[String; 6]> = MODELS + .iter() + .map(|m| { + [ + m.company.to_string(), + m.model.to_string(), + m.version.to_string(), + group(m.commits), + format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64), + format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)), + ] + }) + .collect(); + let widths: Vec = titles + .iter() + .enumerate() + .map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { + fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + .header(h + 4.0, |mut header| { + for (title, r) in titles.into_iter().zip(right) { + header.col(|ui| { + aligned(ui, r, |ui| ui.label(RichText::new(title).strong())); + }); + } + }) + .body(|mut body| { + for cells in rows { + body.row(h, |mut row| { + for (text, r) in cells.into_iter().zip(right) { + row.col(|ui| { + aligned(ui, r, |ui| ui.label(text)); + }); + } + }); + } + }); }); } @@ -397,4 +539,131 @@ mod tests { assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); assert_eq!(day(""), "—"); } + + #[test] + fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]); + let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0); + assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}"); + } + + #[test] + fn too_little_room_shrinks_only_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]); + } + + #[test] + fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() { + let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0); + assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}"); + assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}"); + assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]); + } + + const WINDOW: f32 = 640.5; + const NATURAL: [f32; 3] = [120.0, 80.0, 80.0]; + const GROW: [f32; 3] = [1.0, 0.0, 0.0]; + + /// One frame of a table long enough to scroll; the rects of its header cells. + fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec { + scrolled_frame(ctx, width, natural, 0.0, events) + } + + /// [`table_frame`], scrolled `offset` points sideways. + fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0))); + let raw = egui::RawInput { screen_rect, events, ..Default::default() }; + let mut cells = Vec::new(); + let out = ctx.run_ui(raw, |ui| { + cells.clear(); + egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| { + fitted_table(ui, "test", natural, &GROW) + .header(20.0, |mut header| { + for _ in natural { + header.col(|ui| cells.push(ui.max_rect())); + } + }) + .body(|mut body| { + for _ in 0..40 { + body.row(20.0, |mut row| { + for _ in natural { + row.col(|ui| { + ui.label("x"); + }); + } + }); + } + }); + }); + }); + out.drop_without_applying_deltas(); + cells + } + + /// A new context with the table open and settled, and the rects of its header cells. + fn opened_table() -> (egui::Context, Vec) { + let ctx = egui::Context::default(); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + (ctx, cells) + } + + /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go. + fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) { + use egui::{Event, Modifiers, PointerButton}; + let start = egui::pos2(cell.right() + 4.0, cell.center().y); + let halfway = start + egui::vec2(by / 2.0, 0.0); + let end = start + egui::vec2(by, 0.0); + let moved = Event::PointerMoved; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] { + table_frame(ctx, WINDOW, &NATURAL, vec![event]); + } + } + + #[test] + fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() { + let (_, opened) = opened_table(); + for (dragged, cell) in opened.iter().enumerate() { + let (ctx, _) = opened_table(); + drag_edge(&ctx, *cell, -30.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + for (column, (was, now)) in opened.iter().zip(&after).enumerate() { + let want = if column == dragged { was.width() - 30.0 } else { was.width() }; + assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}"); + } + } + } + + #[test] + fn a_changed_fit_replaces_the_column_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], -30.0); + let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new()); + assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}"); + let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new()); + let widths: Vec = wider.iter().map(|c| c.width().round()).collect(); + assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}"); + assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}"); + let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new()); + assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}"); + } + + #[test] + fn scrolling_sideways_keeps_dragged_widths() { + let (ctx, opened) = opened_table(); + drag_edge(&ctx, opened[0], 100.0); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}"); + for offset in [13.3, 27.77, 41.1, 0.6] { + scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}"); + } + } }