From bbe691cb43f0739e60913214e87cac5609b82d96 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sun, 18 Oct 2026 10:47:35 +0200 Subject: [PATCH] M5: presentation tabs: test both owner guards, document the tab functions Review follow-up. The closed-owner test now picks a device and switches Responsive in the presenter and checks that the file in front keeps its prototype settings, so removing either `live` guard in present::show fails it. The present harness gets frame_output for the AccessKit tree. The new tab functions get doc comments. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/present.rs | 1 + crates/ui-egui/src/present_tab_tests.rs | 46 +++++++++++++++++++++++++ crates/ui-egui/src/present_tests.rs | 9 ++++- crates/ui-egui/src/tabs.rs | 13 +++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index a579d376a..184e7a9f8 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -330,6 +330,7 @@ impl Presenter { true } + /// The frame the presentation started from (Restart goes back there). pub fn start(&self) -> NodeId { self.start } diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs index 58f3c9806..610c2ab1c 100644 --- a/crates/ui-egui/src/present_tab_tests.rs +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -1,4 +1,6 @@ +use egui::accesskit::{Node, Role}; use egui::{Key, Pos2, pos2}; +use figcraft_doc::proto::PrototypeSettings; use serde_json::{Value, json}; use crate::present_tests::{Ui, id, setup}; @@ -19,6 +21,41 @@ fn presenting(u: &Ui) -> bool { u.app.inspect()["presenting"] == json!(true) } +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click_label(u: &mut Ui, label: &str) { + let all = nodes(u); + let n = all.iter().find(|n| n.label().is_some_and(|l| l.trim() == label)).unwrap_or_else(|| panic!("no {label:?} on screen")); + click(u, centre(n)); +} + +/// The presenter bar's Options button: it has no label, and sits right of Show comments. +fn open_options(u: &mut Ui) { + let all = nodes(u); + let comments = all.iter().find(|n| n.label() == Some("Show comments (Shift+C)")).unwrap().bounds().unwrap(); + let options = all + .iter() + .filter(|n| n.role() == Role::Unknown && n.label().is_none()) + .filter(|n| n.bounds().is_some_and(|b| b.y0 == comments.y0 && b.x0 > comments.x1)) + .min_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0)) + .expect("the Options button"); + click(u, centre(options)); +} + +fn front_settings(u: &Ui) -> PrototypeSettings { + let page = u.app.session.page().unwrap(); + crate::present::page_settings(u.app.session.doc().unwrap(), page) +} + /// The middle of presentation tab `k` and of its close button, after `files` file tabs named /// "Untitled". fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { @@ -109,6 +146,15 @@ fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { assert!(presenting(&u)); assert!(!crate::tabs::present_owner_active(&u.app)); assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + let before = front_settings(&u); + click_label(&mut u, "iPhone 16"); + click_label(&mut u, "iPhone 17 402×874"); + assert!(presenting(&u)); + for size in ["Fixed size", "Responsive"] { + open_options(&mut u); + click_label(&mut u, size); + } + assert_eq!(front_settings(&u), before, "picking a device or Responsive leaves the file in front alone"); assert!(!u.app.session.ds().unwrap().is_dirty()); } diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe23..c1a28cd45 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -23,11 +23,18 @@ impl Ui { } pub(crate) fn frame(&mut self, events: Vec) { + self.frame_output(events); + } + + /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). + pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; - self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out } pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 94cfe19bb..ed1f692d5 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -377,6 +377,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } +/// One presentation tab at `x`: its width, whether it was clicked and whether it was closed (its +/// X or a middle click). fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, x: f32, bar: Rect, active: bool) -> (f32, bool, bool) { let label = present_tab_name(tab); let w = present_tab_width(tab); @@ -407,15 +409,18 @@ fn present_tab_width(tab: &PresentTab) -> f32 { (regular(12.0).size * 0.6 * present_tab_name(tab).chars().count() as f32 + 64.0).clamp(140.0, 260.0) } +/// "file - page", or the file alone when the page has no name. fn present_tab_name(tab: &PresentTab) -> String { if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } } +/// The presentation tabs for `ui.inspect`: each one's name and whether it is on screen. pub fn present_tabs_inspect(app: &FigApp) -> serde_json::Value { let on_screen = |id| app.ui.present.is_some() && app.ui.present_active == Some(id); app.ui.presents.iter().map(|t| json!({"name": present_tab_name(t), "active": on_screen(t.id)})).collect() } +/// The presenter on screen, else the paused one of the active presentation tab. pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { app.ui.present.as_ref().or_else(|| { let id = app.ui.present_active?; @@ -423,15 +428,19 @@ pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { }) } +/// The index of the open file with `uid`, when a file tab shows it. fn owner(app: &FigApp, uid: u64) -> Option { app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) } +/// The presentation on screen plays the file in front (or no tab holds it). Only then may it read +/// and change that file. pub fn present_owner_active(app: &FigApp) -> bool { let Some(tab) = app.ui.present_active.and_then(|id| app.ui.presents.iter().find(|t| t.id == id)) else { return true }; owner(app, tab.doc_uid).is_some_and(|i| i == app.session.active) } +/// Puts the presenter on screen back into its tab, where it keeps its state. fn pause_present(app: &mut FigApp) { let Some(p) = app.ui.present.take() else { return }; let id = app.ui.present_active; @@ -440,6 +449,8 @@ fn pause_present(app: &mut FigApp) { } } +/// Presents from `start`: the tab of its page in this file shows again (from `start` when that +/// changed), else a new tab opens. pub fn open_present(app: &mut FigApp, start: NodeId) { let Ok(ds) = app.session.ds() else { return }; let (uid, path) = (ds.uid, ds.path.clone()); @@ -465,6 +476,7 @@ pub fn open_present(app: &mut FigApp, start: NodeId) { app.ui.home = false; } +/// Brings tab `id` to the front and its file too, when that file is open. fn show_present(app: &mut FigApp, id: u64) { app.ui.home = false; if app.ui.present.is_some() && app.ui.present_active == Some(id) { @@ -481,6 +493,7 @@ fn show_present(app: &mut FigApp, id: u64) { app.ui.present_active = Some(id); } +/// Closes tab `id`. If it was on screen, its file comes to the front, or Home when it is closed. pub fn close_present(app: &mut FigApp, id: u64) { let Some(k) = app.ui.presents.iter().position(|t| t.id == id) else { return }; let tab = app.ui.presents.remove(k);