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 <noreply@anthropic.com>
This commit is contained in:
JimKnoxx
2026-10-19 22:15:13 +02:00
co-authored by Claude Opus 5.5
parent 9d9cb9915a
commit 8d5a29dfda
6 changed files with 352 additions and 76 deletions
+13 -13
View File
@@ -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<present::Presenter>,
/// A presentation kept alive while its design tab or Home is in front.
pub paused_present: Option<present::Presenter>,
/// The file that owns the presentation tab.
pub present_uid: Option<u64>,
pub presents: Vec<present::PresentTab>,
/// The tab shown last. It is on screen while `present` has a value.
pub present_active: Option<u64>,
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
}
}
+30 -8
View File
@@ -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<Presenter>,
pub doc_uid: u64,
pub path: Option<String>,
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
+146
View File
@@ -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<String> = 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<f32> = 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");
}
+2 -2
View File
@@ -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));
}
+158 -52
View File
@@ -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<usize> {
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
+3 -1
View File
@@ -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 |