From 8d5a29dfda4817fb40a1d9338c233987ba45102f Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sat, 17 Oct 2026 16:49:22 +0200 Subject: [PATCH 1/3] M5: one presentation tab per page of a file Main plays a presentation in a single "Present" tab. Now each page of a file gets its own tab, named "file - page", and the name follows renames. Play on a page that has a tab shows that tab as it was; from another start frame it starts again. A tab keeps its own copy of the prototype: it plays on after its file closes, and a saved file opened again gets its tabs back. A tab whose file is closed or not in front never reads or changes the file in front (device, responsive, comments). Escape or the tab's X closes that tab and goes back to its file, or Home. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 26 +-- crates/ui-egui/src/present.rs | 38 +++- crates/ui-egui/src/present_tab_tests.rs | 146 ++++++++++++++++ crates/ui-egui/src/present_view_tests.rs | 4 +- crates/ui-egui/src/tabs.rs | 210 +++++++++++++++++------ docs/prototyping-parity.md | 4 +- 6 files changed, 352 insertions(+), 76 deletions(-) create mode 100644 crates/ui-egui/src/present_tab_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe766c36..4561e5a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -181,6 +181,8 @@ mod popup_width_tests; #[cfg(test)] mod present_links_tests; #[cfg(test)] +mod present_tab_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; @@ -538,10 +540,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, - /// A presentation kept alive while its design tab or Home is in front. - pub paused_present: Option, - /// The file that owns the presentation tab. - pub present_uid: Option, + pub presents: Vec, + /// The tab shown last. It is on screen while `present` has a value. + pub present_active: Option, + pub present_next: u64, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -692,8 +694,9 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, - paused_present: None, - present_uid: None, + presents: Vec::new(), + present_active: None, + present_next: 1, present_view: present::PresentView::default(), preview: None, picker: None, @@ -1015,12 +1018,7 @@ impl FigApp { "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - let mut p = present::Presenter::new(start); - p.view = self.ui.present_view; - self.ui.present = Some(p); - self.ui.paused_present = None; - self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); - self.ui.home = false; + tabs::open_present(self, start); } } "present.options" => { @@ -1563,6 +1561,7 @@ impl FigApp { // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; self.end_pen_outside_design(); + tabs::relink_presents(self); tabs::tab_bar(self, ui); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved @@ -1749,7 +1748,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, @@ -1803,6 +1802,7 @@ impl FigApp { state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); state } } diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..a579d376 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -330,6 +330,10 @@ impl Presenter { true } + pub fn start(&self) -> NodeId { + self.start + } + pub fn new(start: NodeId) -> Self { Self { current: start, @@ -1642,12 +1646,26 @@ enum BarAction { Inspect, } +pub struct PresentTab { + pub id: u64, + /// Empty while the tab is on screen: then the player is in `app.ui.present`. + pub pres: Option, + pub doc_uid: u64, + pub path: Option, + pub page: NodeId, + pub file: String, + pub page_name: String, +} + /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - if let Ok(ds) = app.session.ds() { + // A tab can play a file that is closed or not in front. Then it must not read or change + // the file in front. + let live = crate::tabs::present_owner_active(app); + if live && let Ok(ds) = app.session.ds() { pres.sync(&ds.doc, now); } poll_pads(app, &mut pres, &ctx); @@ -1684,7 +1702,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, _ => {} } - if pres.view.comments + if live + && pres.view.comments && let Some((frame, place, clip)) = pres.shown { crate::comments::presenter(app, &ctx, frame, place, clip); @@ -1694,17 +1713,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { finish(app, &mut pres); // The options carry over to the next presentation. app.ui.present_view = pres.view; - if let Some(BarAction::Responsive(on)) = action { + if live && let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if let Some(device) = picked_device { + if live && let Some(device) = picked_device { choose_device(app, &mut pres, &device); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.present_uid = None; - return; - } app.ui.present = Some(pres); + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(id) = app.ui.present_active { + crate::tabs::close_present(app, id); + } else { + app.ui.present = None; + } + } } /// Device choices in the presenter use the screen's real dimensions and reflow a copy of the diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs new file mode 100644 index 00000000..58f3c980 --- /dev/null +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -0,0 +1,146 @@ +use egui::{Key, Pos2, pos2}; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +const FILE_TAB: Pos2 = pos2(100.0, 20.0); + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +fn tabs(u: &Ui) -> Value { + u.app.inspect()["presentTabs"].clone() +} + +fn presenting(u: &Ui) -> bool { + u.app.inspect()["presenting"] == json!(true) +} + +/// 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) { + let width = |chars: usize, lo: f32, hi: f32| (12.0 * 0.6 * chars as f32 + 64.0).clamp(lo, hi); + let mut x = 52.0 + files as f32 * width("Untitled".len(), 120.0, 220.0); + let names: Vec = tabs(u).as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + for n in names.iter().take(k) { + x += width(n.chars().count(), 140.0, 260.0); + } + let w = width(names[k].chars().count(), 140.0, 260.0); + (pos2(x + w / 2.0, 20.0), pos2(x + w - 16.0, 20.0)) +} + +#[test] +fn the_tab_is_named_after_the_file_and_page_and_its_x_closes_it() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert!(presenting(&u)); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + click(&mut u, FILE_TAB); + assert!(!presenting(&u)); + assert_eq!(tabs(&u)[0]["active"], false); + let (tab, close) = present_tab(&u, 1, 0); + click(&mut u, tab); + assert!(presenting(&u)); + click(&mut u, close); + assert_eq!((presenting(&u), tabs(&u)), (false, json!([]))); + assert!(!u.app.ui.home, "back to its file"); +} + +#[test] +fn play_again_shows_the_tab_as_it_was_and_another_page_opens_a_new_one() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + click(&mut u, FILE_TAB); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1, "same page: the same tab"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "it shows the prototype as it was"); + u.run("proto.present", json!({"start": b})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1); + assert_eq!(u.app.inspect()["presenter"]["history"], json!([]), "another start frame starts again"); + + click(&mut u, FILE_TAB); + let page = id(&u.run("page.add", json!({"name": "Second"}))); + u.run("page.select", json!({"id": page})); + let c = id(&u.run("node.create", json!({"type": "frame", "name": "C", "x": 0, "y": 0, "width": 200, "height": 200}))); + u.run("proto.present", json!({"start": c})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}, {"name": "Untitled - Second", "active": true}])); + let (first, _) = present_tab(&u, 1, 0); + click(&mut u, first); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}, {"name": "Untitled - Second", "active": false}])); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b)); +} + +#[test] +fn the_presentation_plays_on_when_its_file_closes() { + let (mut u, a, b, _) = setup(); + let hot = id(&u.run("node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 0, "width": 393, "height": 80}))); + u.run("proto.interaction.add", json!({"id": hot, "dest": b})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0, "force": true})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}])); + let (tab, _) = present_tab(&u, 0, 0); + click(&mut u, tab); + assert!(presenting(&u)); + let spot = u.app.inspect()["presenter"]["hotspots"][0]["rect"].clone(); + let r: Vec = spot.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + click(&mut u, pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)); + u.frames(60, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the prototype still plays"); + u.key(pos2(700.0, 500.0), Key::Escape); + assert_eq!(tabs(&u), json!([])); + assert!(u.app.ui.home, "no file to go back to: Home"); +} + +#[test] +fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.new", json!({})); + u.run("file.close", json!({"index": 0, "force": true})); + let (tab, _) = present_tab(&u, 1, 0); + click(&mut u, tab); + u.frames(5, Vec::new); + 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"); + assert!(!u.app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn a_saved_file_opened_again_gets_its_tab_back() { + let (mut u, a, ..) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-present-tab-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Proto.figcraft").to_string_lossy().to_string(); + u.run("file.saveAs", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0})); + u.run("file.open", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + u.key(pos2(700.0, 500.0), Key::Escape); + assert!(!u.app.ui.home, "back to its file"); + assert_eq!(tabs(&u), json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn renaming_the_file_or_the_page_renames_the_tab() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.rename", json!({"name": "Playground"})); + u.run("page.rename", json!({"name": "Calendar"})); + u.frame(vec![]); + assert_eq!(tabs(&u), json!([{"name": "Playground - Calendar", "active": false}])); + u.run("file.close", json!({"index": 0, "force": true})); + u.frame(vec![]); + assert_eq!(tabs(&u)[0]["name"], "Playground - Calendar", "the names stay when the file closes"); +} diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 8fdd5eb0..42647dc0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -106,14 +106,14 @@ fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { u.button(design_tab, true); u.button(design_tab, false); assert!(u.app.ui.present.is_none()); - assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + assert_eq!(presenter(&u)["current"], json!(b)); let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); u.button(preview_tab, true); u.button(preview_tab, false); - assert!(u.app.ui.paused_present.is_none()); + assert!(u.app.ui.present.is_some()); assert_eq!(presenter(&u)["current"], json!(b)); } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c10edcfd..94cfe19b 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -3,11 +3,13 @@ //! window buttons; the window's edges resize it. use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::NodeId; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::present::{PresentTab, Presenter}; use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; @@ -113,9 +115,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.ui.home = true; } x += 44.0; @@ -141,7 +141,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; + let present_w: f32 = app.ui.presents.iter().map(present_tab_width).sum(); let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); @@ -282,37 +282,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); x += OVERFLOW_WIDTH; } - // Presentations live in their own tab. Switching back to the design keeps the player + // Presentations live in their own tabs. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. - if app.ui.present.is_some() || app.ui.paused_present.is_some() { - let w = 128.0; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); - let close_r = Rect::from_min_max(body_r.right_top(), r.max); - let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); - let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); - let active = app.ui.present.is_some(); - crate::a11y::tab(&resp, tr("Present"), active); - if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() || close_resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + let mut to_present = None; + let mut close_present_tab = None; + for tab in &app.ui.presents { + let active = app.ui.present.is_some() && app.ui.present_active == Some(tab.id); + let (w, clicked, closed) = present_tab(ui, &t, tab, x, rect, active); + if clicked && !active { + to_present = Some(tab.id); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); - if resp.hovered() || close_resp.hovered() || active { - if close_resp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - } - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); + if closed { + close_present_tab = Some(tab.id); } x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); @@ -325,9 +306,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("file.new", json!({})); app.ui.home = false; } @@ -355,12 +334,15 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } } if let Some(i) = switch { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("doc.switch", json!({"index": i})); app.ui.home = false; } + if let Some(id) = close_present_tab { + close_present(app, id); + } else if let Some(id) = to_present { + show_present(app, id); + } if let Some((i, at)) = detach { // Where the pointer let go, on the screen. let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0))); @@ -388,11 +370,6 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } - if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } } if !opening.is_empty() { ui.ctx().request_repaint(); @@ -400,16 +377,145 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } -fn resume_presentation(app: &mut FigApp) { - let Some(uid) = app.ui.present_uid else { return }; - let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { - app.ui.paused_present = None; - app.ui.present_uid = None; +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); + let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, HEIGHT)); + let resp = ui.interact(r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + crate::a11y::tab(&resp, &label, active); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(x + 34.0, r.center().y), &label, regular(12.0), t.text, w - 62.0); + let mut closed = false; + if resp.hovered() || active { + let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); + let cresp = ui.interact(cr, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + closed = cresp.clicked(); + } + (w, resp.clicked() && !closed, closed || resp.middle_clicked()) +} + +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) +} + +fn present_tab_name(tab: &PresentTab) -> String { + if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } +} + +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() +} + +pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { + app.ui.present.as_ref().or_else(|| { + let id = app.ui.present_active?; + app.ui.presents.iter().find(|t| t.id == id)?.pres.as_ref() + }) +} + +fn owner(app: &FigApp, uid: u64) -> Option { + app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) +} + +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) +} + +fn pause_present(app: &mut FigApp) { + let Some(p) = app.ui.present.take() else { return }; + let id = app.ui.present_active; + if let Some(t) = app.ui.presents.iter_mut().find(|t| Some(t.id) == id) { + t.pres = Some(p); + } +} + +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()); + let page = ds.doc.page_of(start).unwrap_or(ds.page); + let file = ds.doc.name.clone(); + let page_name = ds.doc.node(page).map(|n| n.name.clone()).unwrap_or_default(); + let mut pres = Presenter::new(start); + pres.view = app.ui.present_view; + let same = |t: &&PresentTab| t.page == page && (t.doc_uid == uid || (path.is_some() && t.path == path)); + if let Some(id) = app.ui.presents.iter().find(same).map(|t| t.id) { + show_present(app, id); + if app.ui.present.as_ref().is_some_and(|p| p.start() != start) { + app.ui.present = Some(pres); + } return; - }; - app.run("doc.switch", json!({"index": index})); + } + pause_present(app); + let id = app.ui.present_next; + app.ui.present_next += 1; + app.ui.presents.push(PresentTab { id, pres: None, doc_uid: uid, path, page, file, page_name }); + app.ui.present = Some(pres); + app.ui.present_active = Some(id); app.ui.home = false; - app.ui.present = app.ui.paused_present.take(); +} + +fn show_present(app: &mut FigApp, id: u64) { + app.ui.home = false; + if app.ui.present.is_some() && app.ui.present_active == Some(id) { + return; + } + pause_present(app); + let Some(tab) = app.ui.presents.iter_mut().find(|t| t.id == id) else { return }; + let Some(p) = tab.pres.take() else { return }; + let uid = tab.doc_uid; + if let Some(i) = owner(app, uid).filter(|i| *i != app.session.active) { + app.run("doc.switch", json!({"index": i})); + } + app.ui.present = Some(p); + app.ui.present_active = Some(id); +} + +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); + if app.ui.present_active != Some(id) { + return; + } + app.ui.present_active = None; + if app.ui.present.take().is_none() { + return; + } + match owner(app, tab.doc_uid) { + Some(i) => { + if i != app.session.active { + app.run("doc.switch", json!({"index": i})); + } + app.ui.home = false; + } + None => app.ui.home = true, + } +} + +/// A file that opens again has a new uid. Find it by its path to give its tabs back. +pub fn relink_presents(app: &mut FigApp) { + let docs = &app.session.docs; + for t in &mut app.ui.presents { + let found = docs.iter().find(|d| d.uid == t.doc_uid).or_else(|| docs.iter().find(|d| t.path.is_some() && d.path == t.path)); + if let Some(d) = found { + t.doc_uid = d.uid; + t.path = d.path.clone(); + t.file = d.doc.name.clone(); + if let Some(n) = d.doc.node(t.page) { + t.page_name = n.name.clone(); + } + } + } } /// What the tab of document `uid` says about its live session: others get in (`true`: a session diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index e4cf8c7f..c0a7c008 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -54,6 +54,7 @@ case pins the current behaviour). | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | +| Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` | | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead | | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` | | Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` | @@ -100,6 +101,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | From bbe691cb43f0739e60913214e87cac5609b82d96 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sun, 18 Oct 2026 10:47:35 +0200 Subject: [PATCH 2/3] 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 a579d376..184e7a9f 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 58f3c980..610c2ab1 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 a1e4ebe2..c1a28cd4 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 94cfe19b..ed1f692d 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); From 4a73289a91f3f8b9b88c018d8f7837087188fd23 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Mon, 19 Oct 2026 22:34:27 +0200 Subject: [PATCH 3/3] M5: presentation tabs: keep them off the window controls, close X on every frame Presentation tabs now take at most half the free strip while file tabs share it. They shrink to the minimum tab width first, then the rest go into an overflow menu, so they never reach the new-tab button, theme toggle or window buttons. The close X has its own hit rect next to the tab body and is registered every frame, so a click on it can no longer select the tab instead. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/tabs.rs | 89 ++++++++++++++++++++++------- 17 files changed, 84 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index eea0e8dc..4a4eda3c 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -898,6 +898,7 @@ status = "draft — needs native review" "Open branch" = "Otevřít větev" "Open file" = "Otevřít soubor" "Open files" = "Otevřít soubory" +"Open presentations" = "Otevřené prezentace" "Open in focus view" = "Otevřít v zaměřeném pohledu" "Open in new window" = "Otevřít v novém okně" "Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..e752b70a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -866,6 +866,7 @@ status = "draft — needs native review" "Open branch" = "Branch öffnen" "Open file" = "Datei öffnen" "Open files" = "Geöffnete Dateien" +"Open presentations" = "Geöffnete Präsentationen" "Open in focus view" = "In Fokusansicht öffnen" "Open in new window" = "In neuem Fenster öffnen" "Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf833be..0d849918 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -920,6 +920,7 @@ name = "English" "Open branch" = "Open branch" "Open file" = "Open file" "Open files" = "Open files" +"Open presentations" = "Open presentations" "Open in focus view" = "Open in focus view" "Open in new window" = "Open in new window" "Open invite links in FigCraft" = "Open invite links in FigCraft" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1187c658..598a152c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -921,6 +921,7 @@ status = "draft — needs native review" "Open branch" = "Abrir rama" "Open file" = "Abrir archivo" "Open files" = "Archivos abiertos" +"Open presentations" = "Presentaciones abiertas" "Open in focus view" = "Abrir en vista de enfoque" "Open in new window" = "Abrir en una ventana nueva" "Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..5ff69193 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -918,6 +918,7 @@ status = "draft — needs native review" "Open branch" = "Avaa haara" "Open file" = "Avaa tiedosto" "Open files" = "Avaa tiedostoja" +"Open presentations" = "Avoimet esitykset" "Open in focus view" = "Avaa kohdistusnäkymässä" "Open in new window" = "Avaa uudessa ikkunassa" "Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..4445c248 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -914,6 +914,7 @@ status = "draft — needs native review" "Open branch" = "Ouvrir la branche" "Open file" = "Ouvrir un fichier" "Open files" = "Fichiers ouverts" +"Open presentations" = "Présentations ouvertes" "Open in focus view" = "Ouvrir en vue ciblée" "Open in new window" = "Ouvrir dans une nouvelle fenêtre" "Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f21e1ca3..d6e8b84a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -902,6 +902,7 @@ status = "draft — needs native review" "Open branch" = "Buka branch" "Open file" = "Buka file" "Open files" = "Buka file" +"Open presentations" = "Presentasi yang terbuka" "Open in focus view" = "Buka di tampilan fokus" "Open in new window" = "Buka di jendela baru" "Open invite links in FigCraft" = "Buka link undangan di FigCraft" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index c42790f3..d3888548 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -907,6 +907,7 @@ status = "draft — needs native review" "Open branch" = "ブランチを開く" "Open file" = "ファイルを開く" "Open files" = "開いているファイル" +"Open presentations" = "開いているプレゼンテーション" "Open in focus view" = "フォーカスビューで開く" "Open in new window" = "新しいウィンドウで開く" "Open invite links in FigCraft" = "招待リンクを FigCraft で開く" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..af8d9364 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -862,6 +862,7 @@ status = "draft — needs native review" "Open branch" = "브랜치 열기" "Open file" = "파일 열기" "Open files" = "열린 파일" +"Open presentations" = "열린 프레젠테이션" "Open in focus view" = "포커스 보기로 열기" "Open in new window" = "새 창에서 열기" "Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..75cc2a24 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -918,6 +918,7 @@ status = "draft — needs native review" "Open branch" = "Branch openen" "Open file" = "Bestand openen" "Open files" = "Geopende bestanden" +"Open presentations" = "Geopende presentaties" "Open in focus view" = "Openen in focusweergave" "Open in new window" = "Openen in een nieuw venster" "Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..8051ee93 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -915,6 +915,7 @@ status = "draft — needs native review" "Open branch" = "Otwórz gałąź" "Open file" = "Otwórz plik" "Open files" = "Otwórz pliki" +"Open presentations" = "Otwarte prezentacje" "Open in focus view" = "Otwórz w widoku skupienia" "Open in new window" = "Otwórz w nowym oknie" "Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index b41d827a..d26f7926 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -861,6 +861,7 @@ status = "draft — needs native review" "Open branch" = "Abrir branch" "Open file" = "Abrir arquivo" "Open files" = "Arquivos abertos" +"Open presentations" = "Apresentações abertas" "Open in focus view" = "Abrir na visualização em foco" "Open in new window" = "Abrir em nova janela" "Open invite links in FigCraft" = "Abrir links de convite no FigCraft" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6dd68a17..2f9f5773 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -901,6 +901,7 @@ name = "Русский" "Open branch" = "Открыть ветку" "Open file" = "Открыть файл" "Open files" = "Открыть файлы" +"Open presentations" = "Открытые презентации" "Open in focus view" = "Открыть в режиме фокуса" "Open in new window" = "Открыть в новом окне" "Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..07602311 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -909,6 +909,7 @@ name = "Svenska" "Open branch" = "Öppna gren" "Open file" = "Öppna fil" "Open files" = "Öppna filer" +"Open presentations" = "Öppna presentationer" "Open in focus view" = "Öppna i fokusvy" "Open in new window" = "Öppna i nytt fönster" "Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index bd8bb7c9..fe45eaee 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -902,6 +902,7 @@ name = "Türkçe" "Open branch" = "Dalı aç" "Open file" = "Dosya aç" "Open files" = "Dosyaları aç" +"Open presentations" = "Açık sunumlar" "Open in focus view" = "Odak görünümünde aç" "Open in new window" = "Yeni pencerede aç" "Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..dd5f0d00 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -863,6 +863,7 @@ status = "draft — needs native review" "Open branch" = "打开分支" "Open file" = "打开文件" "Open files" = "打开的文件" +"Open presentations" = "打开的演示" "Open in focus view" = "在聚焦视图中打开" "Open in new window" = "在新窗口中打开" "Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接" diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index ed1f692d..adb1d08d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -141,8 +141,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w: f32 = app.ui.presents.iter().map(present_tab_width).sum(); - let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0); + let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect(); + let present_on = app.ui.present.as_ref().and(app.ui.present_active); + let active_present = present_on.and_then(|id| app.ui.presents.iter().position(|p| p.id == id)); + let present_layout = tab_layout(&present_pref, present_room(room, &present_pref, !document_tabs.is_empty()), active_present); + let present_used = present_layout.widths.iter().sum::() + if present_layout.overflow { OVERFLOW_WIDTH } else { 0.0 }; + let available = (room - present_used).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); let mut tab_rects = Vec::with_capacity(layout.widths.len()); @@ -286,9 +291,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { // state (current screen, navigation history and scroll position) for the return trip. let mut to_present = None; let mut close_present_tab = None; - for tab in &app.ui.presents { - let active = app.ui.present.is_some() && app.ui.present_active == Some(tab.id); - let (w, clicked, closed) = present_tab(ui, &t, tab, x, rect, active); + for (tab, &w) in app.ui.presents.iter().skip(present_layout.first).zip(&present_layout.widths) { + let active = present_on == Some(tab.id); + let (clicked, closed) = present_tab(ui, &t, tab, Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)), active); if clicked && !active { to_present = Some(tab.id); } @@ -298,6 +303,29 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + if present_layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("present_overflow"), Sense::click()).tip(tr("Open presentations")); + crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for tab in &app.ui.presents { + let on = present_on == Some(tab.id); + if crate::right::menu_check(ui, on, &present_tab_name(tab), "") { + if !on { + to_present = Some(tab.id); + } + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -377,32 +405,38 @@ 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) { +/// One presentation tab in `r`: 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, r: Rect, active: bool) -> (bool, bool) { let label = present_tab_name(tab); - let w = present_tab_width(tab); - let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let cresp = ui.interact(close_r, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); crate::a11y::tab(&resp, &label, active); + let hovered = resp.hovered() || cresp.hovered(); if active { ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { + } else if hovered { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - crate::bidi::paint_name(ui.painter(), pos2(x + 34.0, r.center().y), &label, regular(12.0), t.text, w - 62.0); - let mut closed = false; - if resp.hovered() || active { - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - let cresp = ui.interact(cr, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 34.0, r.center().y), &label, regular(12.0), t.text, r.width() - 62.0); + if hovered || active { + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); } icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - closed = cresp.clicked(); } - (w, resp.clicked() && !closed, closed || resp.middle_clicked()) + let closed = cresp.clicked() || resp.middle_clicked() || cresp.middle_clicked(); + (resp.clicked(), closed) +} + +/// The part of the strip's free `room` the presentation tabs may use: all they want, but at most +/// half of it while file tabs share the strip, so they shrink or overflow before covering controls. +fn present_room(room: f32, preferred: &[f32], files: bool) -> f32 { + preferred.iter().sum::().min(if files { room / 2.0 } else { room }) } fn present_tab_width(tab: &PresentTab) -> f32 { @@ -612,9 +646,22 @@ pub fn resize_edges(ctx: &egui::Context) { #[cfg(test)] mod layout_tests { - use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, present_room, tab_drop_position, tab_layout}; use egui::{Rect, pos2}; + #[test] + fn many_presentation_tabs_stay_inside_their_share_of_the_strip() { + let preferred = vec![260.0; 8]; + let room = 900.0; + let layout = tab_layout(&preferred, present_room(room, &preferred, true), Some(7)); + assert!(layout.overflow); + assert!(layout.first <= 7 && 7 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= room / 2.0 + 0.01); + assert_eq!(present_room(room, &preferred, false), room); + assert_eq!(present_room(room, &[200.0], true), 200.0); + } + #[test] fn tabs_shrink_to_fit_before_overflowing() { let preferred = vec![120.0; 10];