M12: session restore: windows and tabs come back as they were left

The interface keeps a saved session with the preferences, written whenever
it changes (at once, then at most once a second; nothing while idle): each
window's place, size, maximized or full-screen state and screen, its panels,
its tabs in order with the one in front, and per tab the saved file or your
online file (server and id, never a token), the page and the view.

At the next start the windows and tabs open again (`session.restore`): a
window off every screen moves onto one, files that are gone are skipped
with one message, online files are joined again as their owner and take
their place when they arrive, and a file or invite link from the command
line opens in front. Unsaved documents stay the recovery copies' job.

A mark written before restoring and cleared once the windows have settled
keeps a crash while restoring from repeating: the next run starts without
it and the home screen offers Restore last session. Preferences ▸ Reopen
windows and tabs from last time (`pref.restoreSession`) and `--fresh`
start without it. `window.list` reports each window's rectangle and state;
`ui.inspect` reports the kept session.

The saved-state reader takes junk, cut-off, huge and wrongly typed input
without failing; tests cover the round trip, order and front tab, missing
files, an online file through a scripted link, off-screen rectangles,
the crash guard, the preference, a command-line file and several windows.
This commit is contained in:
bflatastic 2026-10-13 09:44:53 +07:00
parent e56856f401
commit 264aa2a703
33 changed files with 2105 additions and 76 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (session restore: the app comes back the way it was left, numbers unchanged; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -131,6 +131,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-13** · Session restore: the windows and tabs open when the app was left come back at the next start, each
window where it was (or on a screen that is still there) with its panels, tabs, pages and views; kept with the
preferences as it changes, guarded against a crash while restoring, off with a preference or `--fresh`
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
@ -305,6 +308,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Session restore (M12): the app comes back the way it was left. Each window's place, size, maximized or full-screen state and screen, its panels, its tabs in order with the one in front, and per tab the saved file (or your online file, joined again as its owner through its record), the page and the view are kept with the preferences, written atomically whenever they change (at once, then at most once a second; nothing while idle), so a crash or a forced quit has them too. At the next start they open again: a window off every screen moves onto one, files that are gone are skipped with one message, a file or invite link given on the command line opens in front; unsaved documents stay the recovery copies' job (a normal quit keeps none). A mark written before restoring and cleared once the windows have settled stops a crash while restoring from repeating: the next run starts without it and the home screen offers Restore last session. Preferences ▸ Reopen windows and tabs from last time (`pref.restoreSession`), `--fresh`, `session.restore`; `window.list` reports each window's rectangle and state and `ui.inspect` the kept session. The saved-state reader is fuzzed (junk, cut-off, huge, wrong types). Several windows: positions are restored; the screen-reader tree in other windows stays blocked upstream. Headline numbers unchanged |
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |

View File

@ -1,4 +1,5 @@
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
//! The command line: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]`.
//! `--fresh` starts without reopening the windows and tabs from last time. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
@ -8,6 +9,8 @@
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
pub control: Option<u16>,
/// `--fresh`: start without restoring the last session.
pub fresh: bool,
pub files: Vec<String>,
/// The last invite link given.
pub join: Option<String>,
@ -51,6 +54,8 @@ pub fn parse(args: &[String]) -> Args {
while let Some(a) = it.next() {
if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if a == "--fresh" {
out.fresh = true;
} else if is_invite(a) {
out.join = Some(a.clone());
} else if let Some(path) = file_url_path(a) {

View File

@ -10,7 +10,7 @@ fn args(a: &[&str]) -> Vec<String> {
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None, fresh: false });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);

View File

@ -1,7 +1,10 @@
//! FigCraft desktop app.
//!
//! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that
//! live session; the app registers itself as the opener of such links: `collab.registerLinks`).
//! Usage: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]` (an invite link
//! joins that live session; the app registers itself as the opener of such links:
//! `collab.registerLinks`). The windows and tabs from last time open again (Preferences ▸ Reopen
//! windows and tabs from last time; `--fresh` starts without them), and files and links given
//! here open in front of them.
//!
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
@ -22,6 +25,8 @@ mod recovery_online_tests;
#[cfg(test)]
mod recovery_tests;
#[cfg(test)]
mod restore_tests;
#[cfg(test)]
mod services_tests;
use std::sync::{Arc, Mutex};
@ -40,10 +45,37 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// When (UI clock) the screens are looked up next.
monitors_at: f64,
}
/// How often the screens are looked up, in frames drawn anyway (seconds).
const MONITORS_EVERY: f64 = 2.0;
/// The screens there are, `[x, y, width, height]` in the window's points.
// shortcut: every screen is measured with this window's scale, so on screens of different
// scales a window can come back a little off; upgrade when a per-screen scale is needed.
fn monitors(frame: &eframe::Frame, pixels_per_point: f32) -> Vec<[f32; 4]> {
let Some(window) = frame.winit_window().filter(|_| pixels_per_point.is_finite() && pixels_per_point > 0.0) else { return Vec::new() };
let points = |px: f64| (px / f64::from(pixels_per_point)) as f32;
window
.available_monitors()
.map(|m| {
let (at, size) = (m.position(), m.size());
[points(f64::from(at.x)), points(f64::from(at.y)), points(f64::from(size.width)), points(f64::from(size.height))]
})
.collect()
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let now = ctx.input(|i| i.time);
// Before the first frame draws: restored windows open on the screens there are.
if now >= self.monitors_at {
self.monitors_at = now + MONITORS_EVERY;
let list = monitors(frame, ctx.pixels_per_point());
with_app(|fig| fig.monitors = list);
}
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if slot.is_none() {
@ -66,7 +98,6 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
@ -124,11 +155,6 @@ fn main() -> eframe::Result {
c.request_repaint();
}
}))));
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
}
}
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
// The preferences first: they hold the online files a recovered document may be one of.
@ -144,6 +170,15 @@ fn main() -> eframe::Result {
r.adopt(recovered.waiting);
}
*recovery::lock(&recovery) = copies;
// The windows and tabs from last time, then what this run was started with, in front.
let config = services::config_dir();
let saved_main = session.prefs.session.windows.first().cloned();
let start = services::start_session(config.as_deref(), &mut session, args.fresh);
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
}
}
// Plugins installed from a server's plugin directory live with the user's settings.
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
// Invite links open this app (Windows registers per user at run time; installed builds by
@ -153,11 +188,16 @@ fn main() -> eframe::Result {
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
// The home screen offers the last session after a run that didn't get through restoring it;
// a restored online file on its way from its server is this run's file.
let offered = start == services::Start::Guarded;
if session.docs.is_empty() && !first_run && !offered && session.restore.joining().next().is_none() {
let _ = session.execute("file.new", &json!({}));
}
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
fig.ui.home = offered;
// What restoring asked of the interface (its windows, files that are gone).
fig.handle_ui_requests();
fig.note_recovered(recovered.copies);
if let Some(link) = &args.join {
figcraft_ui_egui::live::open_invite(&mut fig, link);
@ -172,6 +212,13 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true);
// The main window opens where it was (the first frame moves it onto a screen if none shows it).
if let Some(w) = saved_main.filter(|_| start == services::Start::Restore) {
if let Some([x, y, width, height]) = w.rect {
viewport = viewport.with_position([x, y]).with_inner_size([width, height]);
}
viewport = viewport.with_maximized(w.maximized).with_fullscreen(w.fullscreen);
}
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
// window is frameless and FigCraft draws the buttons and resize edges.
if cfg!(target_os = "macos") {
@ -186,5 +233,9 @@ fn main() -> eframe::Result {
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
figcraft_ui_egui::windows::install(fig);
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery, monitors_at: 0.0 }))),
)
}

View File

@ -0,0 +1,224 @@
//! Starting the app with the last session: the windows and tabs kept in the preferences file
//! open again, with the restoring mark up; a mark left by a run that never got through restoring
//! starts this one without them, on offer; the preference off and `--fresh` start without them
//! too; a file given on the command line opens in front of them; a preferences file that is
//! junk, cut off or huge never stops the app starting; and after a crash the unsaved documents
//! come back from their recovery copies beside the restored tabs, while a normal quit keeps none.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use figcraft_engine::Session;
use figcraft_ui_egui::FigApp;
use serde_json::json;
use crate::recovery::{Recovery, recover};
use crate::services::{Desktop, Start, mark_restoring, start_session};
fn root(name: &str) -> PathBuf {
let r = std::env::temp_dir().join(format!("figcraft-session-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&r);
std::fs::create_dir_all(&r).unwrap();
r
}
/// A run of the app with its preferences in `dir`: files `names` saved there and open, drawn
/// until the session is kept, then gone without a word (a crash or a kill; a quit writes the
/// same preferences). Returns the files' paths.
fn left_open(dir: &Path, names: &[&str]) -> Vec<String> {
let mut s = Session::empty();
let mut paths = Vec::new();
for n in names {
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
s.execute("file.new", &json!({"name": n})).unwrap();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
paths.push(path);
}
s.execute("doc.switch", &json!({"index": 0})).unwrap();
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)))));
let ctx = egui::Context::default();
for _ in 0..3 {
ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear();
}
paths
}
/// The next run's session, with the preferences in `dir` loaded.
fn next(dir: &Path) -> Session {
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
let mut s = Session::empty();
s.prefs = svc.prefs.clone().expect("the preferences were kept");
s
}
fn names(s: &Session) -> Vec<String> {
s.docs.iter().map(|d| d.doc.name.clone()).collect()
}
fn marked(dir: &Path) -> bool {
dir.join("restoring").exists()
}
#[test]
fn the_tabs_left_open_come_back_and_a_command_line_file_opens_in_front() {
let dir = root("restart");
let paths = left_open(&dir, &["A", "B", "C"]);
let extra = left_open(&root("restart-extra"), &["Extra"]).remove(0);
let mut s = next(&dir);
assert_eq!(s.prefs.session.tabs(), 3, "kept while the app ran, not only when it quits");
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
assert!(marked(&dir), "the mark is up until the interface says the restore settled");
assert_eq!((names(&s), s.ds().unwrap().doc.name.as_str()), (vec!["A".to_string(), "B".into(), "C".into()], "A"));
assert_eq!(s.docs.iter().map(|d| d.path.clone().unwrap()).collect::<Vec<_>>(), paths);
// What the app was started with opens in addition, in front.
s.execute("file.open", &json!({"path": extra})).unwrap();
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
app.handle_ui_requests();
let ctx = egui::Context::default();
for i in 0..4 {
let input = egui::RawInput { time: Some(f64::from(i)), ..Default::default() };
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
}
assert_eq!(
(names(&app.session), app.session.ds().unwrap().doc.name.as_str()),
(vec!["A".to_string(), "B".into(), "C".into(), "Extra".into()], "Extra")
);
assert!(!marked(&dir), "settled: the mark is down");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_run_that_never_got_through_restoring_is_not_tried_again() {
let dir = root("guard");
left_open(&dir, &["A", "B"]);
// The last run put the mark up and never took it down.
mark_restoring(&dir, true);
let mut s = next(&dir);
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
assert!(s.docs.is_empty() && !marked(&dir));
assert_eq!(s.restore.last.as_ref().map(figcraft_engine::restore::SavedSession::tabs), Some(2), "on offer instead");
// The home screen's button restores it, with the mark up meanwhile; a crash then is
// guarded the same way.
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
assert!(app.restore_offered());
app.restore_last();
assert!(marked(&dir));
assert_eq!(names(&app.session), ["A", "B"]);
let mut s = next(&dir);
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
// The run after that one starts as any other.
let mut s = next(&dir);
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
assert_eq!(names(&s), ["A", "B"]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_preference_off_and_fresh_start_without_restoring() {
let given: Vec<String> = ["--fresh", "a.figcraft"].iter().map(|a| a.to_string()).collect();
let args = crate::args::parse(&given);
assert!(args.fresh && args.files == ["a.figcraft"] && !crate::args::parse(&given[1..]).fresh);
let dir = root("off");
left_open(&dir, &["A"]);
let mut s = next(&dir);
assert_eq!(start_session(Some(&dir), &mut s, true), Start::Fresh, "--fresh");
assert!(s.docs.is_empty() && !marked(&dir));
assert!(s.restore.last.is_some(), "`session.restore` still brings it back when asked");
let mut s = next(&dir);
s.execute("pref.restoreSession", &json!({"on": false})).unwrap();
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Fresh, "the preference");
assert!(s.docs.is_empty() && !marked(&dir));
s.execute("session.restore", &json!({})).unwrap();
assert_eq!(names(&s), ["A"]);
// Nothing kept (a first run): nothing to restore, nothing offered.
let mut s = Session::empty();
assert_eq!(start_session(None, &mut s, false), Start::Fresh);
assert!(s.restore.last.is_none());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_preferences_file_that_is_junk_never_stops_the_app_starting() {
let dir = root("junk");
let paths = left_open(&dir, &["A"]);
let state = dir.join("state.json");
let whole = std::fs::read_to_string(&state).unwrap();
let load = || Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None))).prefs;
// Cut off anywhere: the preferences are the defaults, or whole.
for cut in (0..whole.len()).step_by((whole.len() / 60).max(1)).filter(|c| whole.is_char_boundary(*c)) {
std::fs::write(&state, &whole[..cut]).unwrap();
assert!(load().is_none_or(|p| p.session.tabs() <= 1));
}
// The session as anything at all: the other preferences are still read.
let nan_rect = "[1e999, 0, 800, 600]";
for session in
["null", "7", "\"x\"", "[]", "{\"windows\": 4}", "{\"windows\": [[], 3, {\"tabs\": [{\"path\": 9}, 4], \"rect\": \"wide\"}]}", nan_rect]
{
std::fs::write(&state, format!("{{\"prefs\": {{\"theme\": \"dark\", \"session\": {session}}}}}")).unwrap();
let mut s = Session::empty();
if let Some(p) = load() {
assert_eq!((p.theme.as_str(), p.session.tabs()), ("dark", 0), "{session}");
s.prefs = p;
}
// (A window without tabs still comes back where it was.)
assert_ne!(start_session(Some(&dir), &mut s, false), Start::Guarded, "{session}");
assert!(s.docs.is_empty(), "{session}");
mark_restoring(&dir, false);
}
// Wrong types beside usable tabs, absurd rectangles: what is usable comes back.
let session = json!({"windows": [{"rect": [0, 0, 0, 0], "maximized": "no", "tabs": [{"path": paths[0], "page": "first", "view": [1, 2]}, {"path": 5}], "front": 99}]});
std::fs::write(&state, json!({"prefs": {"session": session}}).to_string()).unwrap();
let mut s = next(&dir);
assert_eq!(s.prefs.session.windows[0].rect, None);
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
assert_eq!(names(&s), ["A"]);
mark_restoring(&dir, false);
// Huge: thousands of windows and tabs are cut to the limits; a file too large to be ours
// isn't read at all.
let tabs: Vec<_> = (0..2000).map(|i| json!({"path": format!("{i}.figcraft")})).collect();
let windows: Vec<_> = (0..100).map(|_| json!({"tabs": tabs})).collect();
std::fs::write(&state, json!({"prefs": {"session": {"windows": windows}}}).to_string()).unwrap();
let p = load().unwrap();
assert_eq!(
(p.session.windows.len(), p.session.windows[0].tabs.len()),
(figcraft_engine::restore::MAX_WINDOWS, figcraft_engine::restore::MAX_TABS)
);
let file = std::fs::File::create(&state).unwrap();
file.set_len(80 << 20).unwrap();
drop(file);
assert!(load().is_none());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn after_a_crash_unsaved_documents_come_back_beside_the_restored_tabs_and_a_quit_keeps_none() {
let dir = root("crash");
let recovery = dir.join("Recovery");
let paths = left_open(&dir, &["A"]);
// The run had A open with a change that wasn't saved, and a document never saved.
let mut s = next(&dir);
s.execute("file.open", &json!({"path": paths[0]})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let mut r = Recovery::start(&recovery).unwrap();
r.write(&s, true);
// Killed: the lock goes with the process. The next start gets both back, and the tab.
drop(r);
let mut killed = next(&dir);
assert_eq!(recover(&recovery, &mut killed), 2);
assert_eq!(start_session(Some(&dir), &mut killed, false), Start::Restore);
assert_eq!(killed.docs.iter().filter(|d| d.is_dirty() && d.path.is_none()).count(), 2, "the recovered copies, unsaved");
assert_eq!(killed.docs.iter().filter(|d| d.path.as_deref() == Some(paths[0].as_str()) && !d.is_dirty()).count(), 1, "the tab, as saved");
mark_restoring(&dir, false);
// A normal quit instead (Don't save was the answer, or there was nothing to ask): the
// copies go, and the saved file is all that comes back.
let mut r = Recovery::start(&recovery).unwrap();
r.write(&s, true);
r.finish();
let mut quit = next(&dir);
assert_eq!(recover(&recovery, &mut quit), 0);
assert_eq!(start_session(Some(&dir), &mut quit, false), Start::Restore);
assert_eq!((names(&quit), quit.ds().unwrap().is_dirty()), (vec!["A".to_string()], false));
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -38,8 +38,13 @@ impl Desktop {
/// With the preferences kept in `dir`.
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
let state: Value =
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
// A preferences file this large isn't one the app wrote: starting never waits on it.
let read = |d: &PathBuf| {
let path = d.join(STATE);
std::fs::metadata(&path).ok().filter(|m| m.len() <= MAX_STATE)?;
std::fs::read_to_string(path).ok()
};
let state: Value = dir.as_ref().and_then(read).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
// Recent files lived next to the preferences before they moved into them.
if p.recent_files.is_empty()
@ -131,12 +136,69 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// The preferences' file in the config folder.
/// The preferences' file in the config folder, and the most it holds.
const STATE: &str = "state.json";
const MAX_STATE: u64 = 64 << 20;
/// The mark kept in the config folder while the last session is being restored: one left behind
/// means the run that wrote it never got through restoring (it crashed, or was killed).
const RESTORING: &str = "restoring";
/// Put up or take down the restoring mark in `dir`.
pub fn mark_restoring(dir: &Path, on: bool) {
let mark = dir.join(RESTORING);
let done = if on { std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&mark, b"")) } else { std::fs::remove_file(&mark) };
if let Err(e) = done
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("session restore: couldn't update {}: {e}", mark.display());
}
}
/// How this run starts.
#[derive(Debug, PartialEq, Eq)]
pub enum Start {
/// The windows and tabs from last time open again.
Restore,
/// Without them: the preference is off, `--fresh`, or there were none.
Fresh,
/// Without them, on the home screen, which offers them: the last run didn't get through
/// restoring.
Guarded,
}
/// Decide how to start and get the session ready for it: the last run's windows and tabs open
/// again (`session.restore`, with the restoring mark up until the interface says it settled),
/// or stay on offer (`session.restore` later, the home screen's button) when this run starts
/// without them. A mark left by the last run means it never got through: this run doesn't try
/// again by itself.
pub fn start_session(dir: Option<&Path>, session: &mut Session, fresh: bool) -> Start {
let crashed = dir.is_some_and(|d| d.join(RESTORING).exists());
if let Some(d) = dir.filter(|_| crashed) {
mark_restoring(d, false);
}
let saved = session.prefs.session.clone();
if saved.windows.is_empty() {
return Start::Fresh;
}
if crashed || fresh || !session.prefs.restore_session {
session.restore.last = Some(saved);
return if crashed { Start::Guarded } else { Start::Fresh };
}
if let Some(d) = dir {
mark_restoring(d, true);
}
if let Err(e) = session.execute("session.restore", &json!({})) {
log::error!("session restore: {e}");
}
Start::Restore
}
pub fn save_state(app: &FigApp) {
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
// A normal quit: whatever was being restored, this run got through it.
mark_restoring(&dir, false);
}
}
@ -192,6 +254,11 @@ impl Services for Desktop {
write_prefs(dir, prefs);
}
}
fn restoring(&mut self, on: bool) {
if let Some(dir) = &self.dir {
mark_restoring(dir, on);
}
}
fn emergency_save(&mut self, session: &Session) {
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
r.write(session, true);

View File

@ -649,7 +649,7 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"files": files}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = req_string("file.open", p, "path")?;
let variables = variables_param("file.open", p)?;
// Already open: switch to it.

View File

@ -107,6 +107,7 @@ fn registry() -> &'static Registry {
list.extend(crate::online::specs());
list.extend(crate::invite_links::specs());
list.extend(crate::templates::specs());
list.extend(crate::restore::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {

View File

@ -42,6 +42,7 @@ pub mod plugin_directory;
pub mod plugins;
pub mod proto_run;
pub mod rename;
pub mod restore;
pub mod rotation_origin;
pub mod shape_handles;
pub mod shapebuilder;
@ -591,6 +592,10 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// Reopen windows and tabs from last time when the app starts (`pref.restoreSession`).
pub restore_session: bool,
/// The windows and tabs open now, kept as they change ([`restore`]).
pub session: restore::SavedSession,
}
impl Default for Prefs {
@ -616,6 +621,8 @@ impl Default for Prefs {
recent_files: Vec::new(),
online_files: Vec::new(),
getting_started_dismissed: false,
restore_session: true,
session: restore::SavedSession::default(),
}
}
}
@ -673,6 +680,8 @@ pub struct Session {
pub live: live::Lives,
/// Commands the UI runs without waiting on a server ([`Session::start_background`]).
pub background: background::Background,
/// Session restore: the last run's session while it is on offer, online files on their way.
pub restore: restore::State,
}
impl Default for Session {
@ -707,6 +716,7 @@ impl Session {
plugins: plugins::Runtime::default(),
live: live::Lives::default(),
background: background::Background::default(),
restore: restore::State::default(),
}
}
@ -1493,6 +1503,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod restore_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;

View File

@ -461,7 +461,7 @@ fn named(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
s.online_named(cmd, file.trim(), string(p, "server").as_deref().map(str::trim))
}
fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.openFile";
let i = named(s, CMD, p)?;
let Some(f) = s.prefs.online_files.get(i).cloned() else { return Err(bad(CMD, "no such file")) };

View File

@ -0,0 +1,398 @@
//! Session restore (milestone M12): the app comes back the way it was left.
//!
//! The interface keeps a [`SavedSession`] with the preferences ([`Prefs::session`](crate::Prefs)),
//! written whenever it changes: each window's place on screen and panels, its tabs in order and
//! the one in front. A tab is a saved file (its path) or one of your online files (its server and
//! id, never a token: it is joined again through the record, `collab.openFile`). Documents that
//! were never saved aren't in it: unsaved work is the recovery copies' job.
//!
//! `session.restore` opens the tabs again and asks the interface to put them in their windows.
//! Anything may be in a preferences file: [`SavedSession::read`] keeps what is usable, within
//! limits, and never fails.
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::{DocState, Result, Session, ViewState, bad, spec};
/// Most windows and most tabs in a window a saved session holds.
pub const MAX_WINDOWS: usize = 16;
pub const MAX_TABS: usize = 100;
/// A window's sides, in screen points: anything outside isn't a window anyone left.
const SIDE: std::ops::RangeInclusive<f32> = 100.0..=16_384.0;
const FAR: f32 = 1_000_000.0;
/// The windows the app had open, the main window first.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct SavedSession {
pub windows: Vec<SavedWindow>,
}
/// A window: where it was (the interface checks it against the screens there are now), its
/// panels and its tabs.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedWindow {
/// `[x, y, width, height]` while it was neither maximized nor full screen (screen points).
pub rect: Option<[f32; 4]>,
pub maximized: bool,
pub fullscreen: bool,
/// The screen it was on, `[x, y, width, height]`.
pub monitor: Option<[f32; 4]>,
pub tabs: Vec<SavedTab>,
/// Which of `tabs` was in front.
pub front: usize,
/// The side panels' tabs (the interface's own names) and widths.
pub left_tab: String,
pub right_tab: String,
pub left_collapsed: bool,
pub panel_widths: Option<[f32; 2]>,
}
/// A tab: a saved file, or one of your online files.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct SavedTab {
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// An online file's server (`wss://host/`, never with a key) and id.
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
/// The page shown (its place among the file's pages).
pub page: usize,
/// The view: the page point at the centre, and the zoom.
pub view: Option<[f64; 3]>,
}
/// `n` finite numbers.
fn numbers<const N: usize>(v: Option<&Value>) -> Option<[f64; N]> {
let list = v?.as_array().filter(|a| a.len() == N)?;
let mut out = [0.0; N];
for (slot, n) in out.iter_mut().zip(list) {
*slot = n.as_f64().filter(|n| n.is_finite())?;
}
Some(out)
}
/// A rectangle a window or a screen can have: on a sane part of the plane, neither empty nor huge.
fn rect(v: Option<&Value>) -> Option<[f32; 4]> {
let r = numbers::<4>(v)?.map(|n| n as f32);
(r[0].abs() <= FAR && r[1].abs() <= FAR && SIDE.contains(&r[2]) && SIDE.contains(&r[3])).then_some(r)
}
/// A short text without control characters (`None`: not one, or longer than `max`).
fn text(v: Option<&Value>, max: usize) -> Option<String> {
v?.as_str().filter(|s| !s.is_empty() && s.len() <= max && !s.contains(char::is_control)).map(str::to_string)
}
impl SavedTab {
fn read(v: &Value) -> Option<Self> {
let path = text(v.get("path"), 4096);
let server = text(v.get("server"), 512).filter(|s| s.starts_with("ws://") || s.starts_with("wss://"));
let file = text(v.get("file"), 64).filter(|f| f.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
let (server, file) = server.zip(file).unzip();
if path.is_none() && file.is_none() {
return None;
}
let page = v.get("page").and_then(Value::as_u64).map_or(0, |p| p.min(100_000) as usize);
let far = |n: f64| n.clamp(-1e9, 1e9);
let view = numbers::<3>(v.get("view")).map(|[x, y, z]| [far(x), far(y), z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM)]);
Some(Self { path, server, file, page, view })
}
/// The same file as `other`.
fn is(&self, other: &SavedTab) -> bool {
match (&self.path, &other.path) {
(Some(a), Some(b)) => a == b,
(None, None) => self.file == other.file && self.server == other.server,
_ => false,
}
}
/// The file's name, for a message.
fn name(&self) -> String {
let path =
self.path.as_deref().map(|p| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |f| f.to_string_lossy().into_owned()));
path.or_else(|| self.file.clone()).unwrap_or_default()
}
}
impl SavedWindow {
fn read(v: &Value) -> Option<Self> {
v.as_object()?;
let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false);
let mut tabs: Vec<SavedTab> = Vec::new();
let listed = v.get("tabs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
// The tab in front keeps its place when tabs before it are dropped.
let asked = v.get("front").and_then(Value::as_u64).unwrap_or(0);
let mut front = 0;
for (i, tab) in listed.iter().take(4 * MAX_TABS).enumerate() {
let Some(tab) = SavedTab::read(tab).filter(|t| tabs.len() < MAX_TABS && !tabs.iter().any(|o| o.is(t))) else { continue };
if i as u64 <= asked {
front = tabs.len();
}
tabs.push(tab);
}
Some(Self {
rect: rect(v.get("rect")),
maximized: flag("maximized"),
fullscreen: flag("fullscreen"),
monitor: rect(v.get("monitor")),
tabs,
front,
left_tab: text(v.get("leftTab"), 32).unwrap_or_default(),
right_tab: text(v.get("rightTab"), 32).unwrap_or_default(),
left_collapsed: flag("leftCollapsed"),
panel_widths: numbers::<2>(v.get("panelWidths")).map(|w| w.map(|w| (w as f32).clamp(0.0, 10_000.0))),
})
}
}
impl SavedSession {
/// What of `v` is a usable saved session (nothing, for anything else).
pub fn read(v: &Value) -> Self {
let listed = v.get("windows").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
Self { windows: listed.iter().take(4 * MAX_WINDOWS).filter_map(SavedWindow::read).take(MAX_WINDOWS).collect() }
}
/// How many tabs it would open.
pub fn tabs(&self) -> usize {
self.windows.iter().map(|w| w.tabs.len()).sum()
}
}
impl<'de> Deserialize<'de> for SavedSession {
/// Junk here never costs the other preferences.
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
Ok(Value::deserialize(d).map(|v| Self::read(&v)).unwrap_or_default())
}
}
/// One of your online files on its way back into a restored window.
struct Pending {
/// The live session joining it.
session: u64,
/// Which of the restored windows it belongs to, and whether it is the tab in front there.
window: usize,
front: bool,
tab: SavedTab,
/// The tab before it in its window (`None`: it leads).
after: Option<u64>,
}
/// A restored online file that arrived from its server ([`Session::restore_arrivals`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Arrival {
/// The open document.
pub uid: u64,
/// Which of the restored windows it belongs to (as `session.restore` listed them).
pub window: usize,
/// It was the tab in front there.
pub front: bool,
}
/// A restored window for the interface to place: what was saved of it, its tabs that are open
/// (documents, in order) and the one in front.
#[derive(Clone, Debug, PartialEq)]
pub struct Placed {
pub saved: SavedWindow,
pub docs: Vec<u64>,
pub front: Option<u64>,
}
/// What a session knows about restoring.
#[derive(Default)]
pub struct State {
/// The session of the last run, when this run started without restoring it: what
/// `session.restore` brings back (once).
pub last: Option<SavedSession>,
/// The windows `session.restore` opened the tabs of, until the interface places them, and
/// how many files were gone.
pub placing: Option<(Vec<Placed>, usize)>,
pending: Vec<Pending>,
}
impl State {
/// The online files still on their way, with the restored window each belongs to: a session
/// saved meanwhile keeps them.
pub fn joining(&self) -> impl Iterator<Item = (usize, &SavedTab)> {
self.pending.iter().map(|p| (p.window, &p.tab))
}
}
/// Show `tab`'s page and view in its document.
fn show(ds: &mut DocState, tab: &SavedTab) {
if let Some(page) = ds.doc.pages.get(tab.page).copied().filter(|p| *p != ds.page && !ds.doc.is_divider(*p)) {
ds.page = page;
ds.selection.clear();
}
if let Some([cx, cy, zoom]) = tab.view {
(ds.view.cx, ds.view.cy, ds.view.zoom) = (cx, cy, zoom);
// The interface fits a document to its window the first time it sizes its view (one
// still exactly at the default size): this one has its view already.
ds.view.vw += 1e-6;
}
}
impl Session {
/// Open document `d` as a tab of a saved session (`None`: one that was never saved, or a
/// branch's tab, which opens from its file).
pub fn saved_tab(&self, d: &DocState) -> Option<SavedTab> {
if d.branch_of.is_some() {
return None;
}
let online = || self.online_of(d.uid).map(|(server, file, _)| (server, file));
let (server, file) = if d.path.is_some() { (None, None) } else { online().unzip() };
if d.path.is_none() && file.is_none() {
return None;
}
let page = d.doc.pages.iter().position(|p| *p == d.page).unwrap_or(0);
Some(SavedTab { path: d.path.clone(), server, file, page, view: Some([d.view.cx, d.view.cy, d.view.zoom]) })
}
/// Move open document `uid` to just after document `after` (`None`: before every other).
fn tab_after(&mut self, uid: u64, after: Option<u64>) {
let active = self.docs.get(self.active).map(|d| d.uid);
let Some(from) = self.docs.iter().position(|d| d.uid == uid) else { return };
let d = self.docs.remove(from);
let to = match after {
Some(a) => self.docs.iter().position(|d| d.uid == a).map_or(self.docs.len(), |i| i + 1),
None => 0,
};
self.docs.insert(to, d);
self.active = self.docs.iter().position(|d| Some(d.uid) == active).unwrap_or(self.active);
}
/// The restored online files that arrived from their servers since the last call, each in
/// its place among the tabs with its page and view; one that couldn't be joined is skipped
/// with a message. The interface puts them in their windows.
pub fn restore_arrivals(&mut self) -> Vec<Arrival> {
let mut out = Vec::new();
for p in std::mem::take(&mut self.restore.pending) {
let live = self.live.get(p.session).map(|l| (l.doc_uid, l.status.clone()));
match live {
Some((Some(uid), _)) if self.docs.iter().any(|d| d.uid == uid) => {
self.tab_after(uid, p.after);
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
show(ds, &p.tab);
}
// The tabs that follow it there come after it.
for later in self.restore.pending.iter_mut().filter(|l| l.window == p.window && l.after == p.after) {
later.after = Some(uid);
}
out.push(Arrival { uid, window: p.window, front: p.front });
}
Some((_, status)) if status.active() => self.restore.pending.push(p),
gone => {
let why = gone.map(|(_, status)| format!(": {}", status.label())).unwrap_or_default();
self.message = Some(format!("{} couldn't be reopened from its server{why}", p.tab.name()));
}
}
}
out
}
}
/// How `tab` came back.
enum Opened {
Doc(u64),
Joining(u64),
Missing,
}
fn open(s: &mut Session, tab: &SavedTab) -> Opened {
let uid_at =
|s: &Session, r: &Value| r.get("index").or(r.get("document")).and_then(Value::as_u64).and_then(|i| s.docs.get(i as usize)).map(|d| d.uid);
if let Some(path) = &tab.path {
return match crate::cmd::file::open(s, &json!({"path": path})) {
Ok(r) => uid_at(s, &r).map_or(Opened::Missing, Opened::Doc),
Err(e) => {
log::info!("session restore: {e}");
Opened::Missing
}
};
}
match crate::online::open_file(s, &json!({"file": tab.file, "server": tab.server})) {
Ok(r) => match (uid_at(s, &r), r.get("session").and_then(Value::as_u64)) {
(Some(uid), _) => Opened::Doc(uid),
(None, Some(session)) => Opened::Joining(session),
(None, None) => Opened::Missing,
},
Err(e) => {
log::info!("session restore: {e}");
Opened::Missing
}
}
}
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "session.restore";
let saved = match p.get("session") {
None | Some(Value::Null) => s.restore.last.take().unwrap_or_else(|| s.prefs.session.clone()),
Some(v) if v.is_object() => SavedSession::read(v),
Some(_) => return Err(bad(CMD, "`session` must be a saved session: {windows: [{tabs: [{path} | {server, file}]}]}")),
};
let was_open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
let (mut windows, mut missing, mut joining, mut opened) = (Vec::new(), Vec::new(), 0, 0);
// The tabs take the saved order: each after the one placed before it.
let (mut front_doc, mut last) = (None, None);
for (k, w) in saved.windows.iter().enumerate() {
let (mut docs, mut front) = (Vec::new(), None);
for (i, tab) in w.tabs.iter().enumerate() {
match open(s, tab) {
// A file is a tab of one window: the first that lists it.
Opened::Doc(uid) if docs.contains(&uid) || windows.iter().any(|w: &Placed| w.docs.contains(&uid)) => {}
Opened::Doc(uid) => {
if !was_open.contains(&uid) {
opened += 1;
if let Some(ds) = s.docs.iter_mut().find(|d| d.uid == uid) {
show(ds, tab);
}
}
s.tab_after(uid, last);
docs.push(uid);
last = Some(uid);
if i == w.front {
front = Some(uid);
}
}
Opened::Joining(session) => {
joining += 1;
s.restore.pending.retain(|p| p.session != session);
s.restore.pending.push(Pending { session, window: k, front: i == w.front, tab: tab.clone(), after: docs.last().copied() });
}
Opened::Missing => missing.push(tab.name()),
}
}
front_doc = front_doc.or(front).or(docs.first().copied());
windows.push(Placed { saved: w.clone(), docs, front });
}
if let Some(i) = front_doc.and_then(|u| s.docs.iter().position(|d| d.uid == u)) {
s.active = i;
}
let out = json!({"windows": windows.len(), "opened": opened, "joining": joining, "missing": missing});
if !windows.is_empty() {
s.restore.placing = Some((windows, missing.len()));
s.request_ui(CMD, json!({}));
}
Ok(out)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"session.restore",
"Restore last session",
"{session? (a saved session, as the control channel's `ui.inspect` gives it under `session.saved`: {windows: [{tabs: [{path} | {server, file}, page?, view?: [cx, cy, zoom]], front?, rect?: [x, y, width, height], maximized?, fullscreen?}]}; default: the one kept with the preferences, or the last run's when this run started without it)} → {windows, opened (tabs opened now), joining (your online files on their way from their servers), missing: [names of files that are gone]}: opens the saved windows' tabs again in order, with their page and view (a file that is open already is used as it is), and the app puts them in their windows where they were (a window that would be off every screen moves onto one). Headless (CLI, MCP): the tabs only",
false,
restore
),
spec!("pref.restoreSession", "Reopen windows and tabs from last time", "{on?}", false, |s, p| {
s.prefs.restore_session = crate::params::boolean(p, "on").unwrap_or(!s.prefs.restore_session);
Ok(json!({"on": s.prefs.restore_session}))
}),
]
}

View File

@ -0,0 +1,367 @@
//! Session restore: a saved session survives the preferences file (and junk in it never costs the
//! other preferences, nor a panic); `session.restore` opens the tabs again in their order with
//! their page and view and the one in front, skips files that are gone and says which, joins
//! your online files again through their records (a scripted link) and puts each in its place
//! when it arrives; windows keep their own tabs; the last run's session on offer is restored
//! once; the preference toggles; junk parameters are refused or ignored.
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, HostInfo};
use serde_json::{Value, json};
use crate::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Role, SessionInfo, Shared, Status};
use crate::restore::{Arrival, MAX_TABS, MAX_WINDOWS, SavedSession, SavedTab, SavedWindow};
use crate::{Prefs, Session};
const SERVER: &str = "wss://share.example/";
/// A folder of saved files named `names`, and their paths.
fn files(test: &str, names: &[&str]) -> (std::path::PathBuf, Vec<String>) {
let dir = std::env::temp_dir().join(format!("figcraft-restore-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::empty();
let paths = names
.iter()
.map(|n| {
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
s.execute("file.new", &json!({"name": n})).unwrap();
s.execute("page.add", &json!({"name": "Second"})).unwrap();
s.execute("page.select", &json!({"index": 0})).unwrap();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
path
})
.collect();
(dir, paths)
}
fn tab(path: &str) -> SavedTab {
SavedTab { path: Some(path.into()), ..SavedTab::default() }
}
fn window(tabs: Vec<SavedTab>, front: usize) -> SavedWindow {
SavedWindow { tabs, front, ..SavedWindow::default() }
}
fn names(s: &Session) -> Vec<String> {
s.docs.iter().map(|d| d.doc.name.clone()).collect()
}
/// A session with `saved` kept in its preferences, as a new run has it.
fn run_with(saved: SavedSession) -> Session {
let mut s = Session::empty();
s.prefs.session = saved;
s.prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
s
}
#[test]
fn a_saved_session_survives_the_preferences_file() {
let saved = SavedSession {
windows: vec![
SavedWindow {
rect: Some([10.0, 20.0, 1200.0, 800.0]),
maximized: true,
fullscreen: false,
monitor: Some([0.0, 0.0, 1920.0, 1080.0]),
tabs: vec![
SavedTab { path: Some("C:/work/a.figcraft".into()), page: 2, view: Some([10.5, -4.0, 2.0]), ..SavedTab::default() },
SavedTab { server: Some(SERVER.into()), file: Some("f1".into()), ..SavedTab::default() },
],
front: 1,
left_tab: "assets".into(),
right_tab: "inspect".into(),
left_collapsed: true,
panel_widths: Some([300.0, 280.0]),
},
window(vec![tab("/home/me/b.figcraft")], 0),
],
};
let s = run_with(saved.clone());
assert_eq!(s.prefs.session, saved);
assert_eq!(s.prefs.session.tabs(), 3);
assert!(s.prefs.restore_session, "on unless turned off");
// No token is ever in it.
assert!(!serde_json::to_string(&s.prefs.session).unwrap().contains("owner"));
}
#[test]
fn junk_in_a_saved_session_never_panics_or_costs_the_other_preferences() {
let deep = (0..200).fold(json!(1), |v, _| json!([v]));
let junk = [
json!(null),
json!(7),
json!("windows"),
json!([]),
json!({"windows": 3}),
json!({"windows": {"0": {}}}),
json!({"windows": [null, 1, "w", [], {"tabs": "none"}, {"tabs": [null, 2, {}, {"path": 5}, {"path": ""}, {"file": "f"}, {"server": "http://x/", "file": "f"}]}]}),
json!({"windows": [{"rect": [0, 0, 0, 0], "tabs": [{"path": "a"}]}, {"rect": [1e30, 0, 800, 600]}, {"rect": ["a", null, {}, []]}, {"rect": [0, 0, 1e9, 1e9]}, {"rect": [0, 0, 800]}]}),
json!({"windows": [{"front": -4, "maximized": "yes", "fullscreen": 1, "leftTab": 9, "rightTab": "x".repeat(5000), "panelWidths": [1e300, -1e300], "tabs": [{"path": "a", "page": -1, "view": [0, 0]}, {"path": "b", "page": 1e99, "view": [1e308, -1e308, 0]}]}]}),
json!({"windows": [{"tabs": [{"path": "a\u{0}b"}, {"path": "p".repeat(100_000)}, {"server": "wss://x/", "file": "bad id"}, {"server": "wss://x/", "file": "i".repeat(65)}]}]}),
json!({"windows": deep}),
];
for j in &junk {
let read = SavedSession::read(j);
assert!(read.windows.len() <= MAX_WINDOWS);
for w in &read.windows {
assert!(w.tabs.is_empty() || w.front < w.tabs.len(), "{j}");
assert!(w.rect.is_none_or(|r| r.iter().all(|n| n.is_finite()) && r[2] >= 100.0 && r[3] >= 100.0 && r[2] <= 16_384.0), "{j}");
for t in &w.tabs {
assert!(t.path.is_some() || (t.server.is_some() && t.file.is_some()), "{j}");
assert!(t.view.is_none_or(|v| v.iter().all(|n| n.is_finite()) && v[2] > 0.0), "{j}");
}
}
// The other preferences load whatever the session is.
let prefs: Prefs = serde_json::from_value(json!({"theme": "dark", "session": j, "restore_session": false})).unwrap();
assert_eq!((prefs.theme.as_str(), prefs.restore_session, &prefs.session), ("dark", false, &read));
}
// Zero-sized and absurd rectangles are dropped, their windows kept.
let read = SavedSession::read(&junk[7]);
assert_eq!(read.windows.len(), 5);
assert!(read.windows.iter().all(|w| w.rect.is_none()));
// Unusable tabs are skipped; the tab in front keeps its place among the ones kept.
let read = SavedSession::read(&json!({"windows": [{"front": 3, "tabs": [{"path": "a"}, {}, {"path": "a"}, {"path": "b"}, {"path": "c"}]}]}));
assert_eq!((read.windows[0].tabs.len(), read.windows[0].front), (3, 1));
// Huge lists are cut to the limits.
let many: Vec<Value> = (0..600).map(|i| json!({"path": format!("f{i}")})).collect();
let windows: Vec<Value> = (0..200).map(|_| json!({"tabs": many})).collect();
let read = SavedSession::read(&json!({"windows": windows}));
assert_eq!((read.windows.len(), read.windows[0].tabs.len()), (MAX_WINDOWS, MAX_TABS));
// A preferences file cut off anywhere is an error to read, never a panic.
let whole = serde_json::to_string(&json!({"prefs": run_with(read).prefs})).unwrap();
for cut in (0..whole.len().min(4000)).step_by(7).filter(|c| whole.is_char_boundary(*c)) {
let _ = serde_json::from_str::<Value>(&whole[..cut]).map(|v| serde_json::from_value::<Prefs>(v["prefs"].clone()));
}
}
#[test]
fn tabs_come_back_in_order_with_their_page_view_and_the_one_in_front() {
let (dir, paths) = files("order", &["A", "B", "C"]);
let saved = SavedSession {
windows: vec![window(vec![tab(&paths[2]), SavedTab { page: 1, view: Some([40.0, -30.0, 3.0]), ..tab(&paths[0]) }, tab(&paths[1])], 1)],
};
let mut s = run_with(saved);
let r = s.execute("session.restore", &json!({})).unwrap();
assert_eq!(r, json!({"windows": 1, "opened": 3, "joining": 0, "missing": []}));
assert_eq!(names(&s), ["C", "A", "B"]);
let front = s.ds().unwrap();
assert_eq!(front.doc.name, "A", "the tab in front");
assert_eq!(front.doc.pages.iter().position(|p| *p == front.page), Some(1), "its page");
assert_eq!((front.view.cx, front.view.cy, front.view.zoom), (40.0, -30.0, 3.0), "its view");
assert!(!front.is_dirty());
// What the interface places: the window with its documents and the one in front.
let (placed, missing) = s.restore.placing.take().unwrap();
assert_eq!((placed.len(), missing), (1, 0));
assert_eq!(placed[0].docs, s.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
assert_eq!(placed[0].front, Some(s.docs[1].uid));
assert!(s.ui_requests.iter().any(|u| u.kind == "session.restore"));
// The open tabs as a saved session again.
let tabs: Vec<SavedTab> = s.docs.iter().filter_map(|d| s.saved_tab(d)).collect();
assert_eq!(tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(), [paths[2].clone(), paths[0].clone(), paths[1].clone()]);
assert_eq!((tabs[1].page, tabs[1].view), (1, Some([40.0, -30.0, 3.0])));
// Restoring again opens nothing twice; files open already are put in order.
s.execute("doc.switch", &json!({"index": 2})).unwrap();
let again = json!({"windows": [{"tabs": [{"path": paths[1]}, {"path": paths[0]}], "front": 0}]});
let r = s.execute("session.restore", &json!({"session": again})).unwrap();
assert_eq!((&r["opened"], names(&s)), (&json!(0), vec!["B".to_string(), "A".into(), "C".into()]));
assert_eq!(s.ds().unwrap().doc.name, "B");
// A document that was never saved isn't a tab of a saved session.
s.execute("file.new", &json!({})).unwrap();
assert_eq!(s.saved_tab(s.ds().unwrap()), None);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn files_that_are_gone_are_skipped_and_named() {
let (dir, paths) = files("missing", &["A", "B", "C"]);
std::fs::remove_file(&paths[0]).unwrap();
std::fs::write(&paths[2], b"not a file of ours").unwrap();
let saved = SavedSession { windows: vec![window(paths.iter().map(|p| tab(p)).collect(), 0)] };
let mut s = run_with(saved);
let r = s.execute("session.restore", &json!({})).unwrap();
assert_eq!(r, json!({"windows": 1, "opened": 1, "joining": 0, "missing": ["A.figcraft", "C.figcraft"]}));
assert_eq!(names(&s), ["B"]);
assert_eq!(s.restore.placing.as_ref().map(|p| p.1), Some(2), "the interface says how many");
// The tab in front was gone: the first one that opened shows.
assert_eq!(s.ds().unwrap().doc.name, "B");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn windows_keep_their_own_tabs() {
let (dir, paths) = files("windows", &["A", "B", "C"]);
let saved = SavedSession {
windows: vec![
SavedWindow { rect: Some([0.0, 0.0, 1000.0, 700.0]), ..window(vec![tab(&paths[0])], 0) },
// (A file listed in two windows is a tab of the first.)
SavedWindow {
rect: Some([200.0, 100.0, 900.0, 600.0]),
maximized: true,
..window(vec![tab(&paths[1]), tab(&paths[2]), tab(&paths[0])], 1)
},
],
};
let mut s = run_with(saved.clone());
let r = s.execute("session.restore", &json!({})).unwrap();
assert_eq!((&r["windows"], &r["opened"]), (&json!(2), &json!(3)));
let (placed, _) = s.restore.placing.take().unwrap();
let uid = |n: &str| s.docs.iter().find(|d| d.doc.name == n).unwrap().uid;
assert_eq!((placed[0].docs.clone(), placed[0].front), (vec![uid("A")], Some(uid("A"))));
assert_eq!((placed[1].docs.clone(), placed[1].front), (vec![uid("B"), uid("C")], Some(uid("C"))));
assert_eq!(placed[1].saved, saved.windows[1]);
assert_eq!(s.ds().unwrap().doc.name, "A", "the main window's tab in front is the session's");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_last_runs_session_on_offer_is_restored_once() {
let (dir, paths) = files("offer", &["A", "B"]);
// This run started without restoring: its own session is what the preferences hold now.
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[1])], 0)] });
s.restore.last = Some(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
s.execute("session.restore", &json!({})).unwrap();
assert_eq!(names(&s), ["A"]);
assert!(s.restore.last.is_none());
s.execute("session.restore", &json!({})).unwrap();
assert_eq!(names(&s), ["B", "A"], "then the one kept with the preferences");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_preference_toggles_and_is_kept() {
let mut s = Session::empty();
assert_eq!(s.execute("pref.restoreSession", &json!({"on": false})).unwrap(), json!({"on": false}));
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(!prefs.restore_session);
assert_eq!(s.execute("pref.restoreSession", &json!({})).unwrap(), json!({"on": true}));
}
#[test]
fn junk_parameters_are_refused_or_ignored() {
let (dir, paths) = files("sweep", &["A"]);
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
for j in [json!(7), json!("all"), json!([1, 2]), json!(true)] {
assert!(s.execute("session.restore", &json!({"session": j})).unwrap_err().to_string().contains("saved session"));
assert!(s.execute("pref.restoreSession", &json!({"on": j})).is_ok());
}
assert!(s.docs.is_empty() && s.restore.placing.is_none());
for j in [json!({}), json!({"windows": 5}), json!({"windows": [{"tabs": [{"path": 3}, {"path": "no/such/file.figcraft"}]}]})] {
let r = s.execute("session.restore", &json!({"session": j})).unwrap();
assert_eq!(r["opened"], 0, "{j}");
}
assert!(s.docs.is_empty());
// Nothing saved: nothing to do, and nothing asked of the interface.
let mut empty = Session::empty();
assert_eq!(empty.execute("session.restore", &json!(null)).unwrap(), json!({"windows": 0, "opened": 0, "joining": 0, "missing": []}));
assert!(empty.ui_requests.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
/// What the scripted server was asked to join and the events its link hands out next.
#[derive(Default)]
struct Script {
joined: Vec<(u64, String)>,
events: Vec<(u64, Event)>,
}
struct Link(Arc<Mutex<Script>>);
impl LiveLink for Link {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
self.0.lock().unwrap().joined.push((session, link.to_string()));
Ok(())
}
fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> {
Err("not in this test".into())
}
fn hosting(&mut self, _: &str, _: &str, _: &str, _: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
Err("not in this test".into())
}
fn share(&mut self, _: &str, _: &str, _: &str, _: &[String], _: bool) -> Result<Option<Shared>, String> {
Err("not in this test".into())
}
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, _: ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, Event)> {
std::mem::take(&mut self.0.lock().unwrap().events)
}
}
fn edit_link(file: &str) -> String {
format!("figcraft://join?server={SERVER}&file={file}&token=EditSecretEditSecret00")
}
/// The record of online file `file`, as the preferences file holds it.
fn record(file: &str) -> Value {
json!({"server": SERVER, "file": file, "name": format!("Online {file}"), "owner": "OwnerSecretOwnerSecret", "edit": edit_link(file), "view": "", "created": 1, "opened": 1})
}
/// The file of session `session` arrives from the scripted server.
fn arrive(script: &Arc<Mutex<Script>>, session: u64, file: &str) {
let info = SessionInfo {
role: Role::Guest,
me: 1,
name: "Me".into(),
color: "#0D99FF".into(),
file_name: format!("Online {file}"),
pages: vec![],
link: edit_link(file),
url: String::new(),
address: SERVER.into(),
network: true,
access: Access::Edit,
};
let (_, _, sync) = crate::live_tests::serve(&Session::new().doc().unwrap().clone());
script.lock().unwrap().events.extend([(session, Event::Welcome { info, peers: vec![] }), (session, sync)]);
}
#[test]
fn online_files_are_joined_again_and_take_their_place_when_they_arrive() {
let (dir, paths) = files("online", &["A", "B"]);
let online =
|file: &str| SavedTab { server: Some(SERVER.into()), file: Some(file.into()), page: 0, view: Some([5.0, 6.0, 1.5]), ..SavedTab::default() };
let saved =
SavedSession { windows: vec![window(vec![tab(&paths[0]), online("f1"), tab(&paths[1])], 1), window(vec![online("f2"), online("gone")], 0)] };
let mut s = run_with(saved);
s.prefs.online_files = serde_json::from_value::<Prefs>(json!({"online_files": [record("f1"), record("f2")]})).unwrap().online_files;
let script = Arc::new(Mutex::new(Script::default()));
s.live.install(Box::new(Link(script.clone())));
let r = s.execute("session.restore", &json!({})).unwrap();
// The one no longer recorded on this computer counts as gone.
assert_eq!(r, json!({"windows": 2, "opened": 2, "joining": 2, "missing": ["gone"]}));
assert!(!r.to_string().contains("Secret"));
let joined = script.lock().unwrap().joined.clone();
assert_eq!(joined.iter().map(|j| j.1.clone()).collect::<Vec<_>>(), [edit_link("f1"), edit_link("f2")], "as their owner, with the recorded links");
assert_eq!(names(&s), ["A", "B"]);
// Until they arrive they are still tabs of their windows.
let waiting: Vec<(usize, Option<String>)> = s.restore.joining().map(|(k, t)| (k, t.file.clone())).collect();
assert_eq!(waiting, [(0, Some("f1".into())), (1, Some("f2".into()))]);
assert!(s.restore_arrivals().is_empty());
// The first arrives: between A and B, in front of its window, with its view.
arrive(&script, joined[0].0, "f1");
s.live_poll();
let arrived = s.restore_arrivals();
let uid = s.docs.iter().find(|d| d.path.is_none()).unwrap().uid;
assert_eq!(arrived, [Arrival { uid, window: 0, front: true }]);
assert_eq!(s.docs.iter().map(|d| d.path.is_some()).collect::<Vec<_>>(), [true, false, true]);
let ds = s.docs.iter().find(|d| d.uid == uid).unwrap();
assert_eq!((ds.view.cx, ds.view.cy, ds.view.zoom), (5.0, 6.0, 1.5));
assert_eq!(s.saved_tab(ds).map(|t| (t.server, t.file)), Some((Some(SERVER.into()), Some("f1".into()))), "a tab of the next saved session");
assert_eq!(s.restore.joining().count(), 1);
// The other's server can't be reached: it is skipped, and the message says so.
script.lock().unwrap().events.push((joined[1].0, Event::Status(Status::Failed { reason: "no answer from the server (timed out)".into() })));
s.live_poll();
assert!(s.restore_arrivals().is_empty());
assert_eq!(s.restore.joining().count(), 0);
assert_eq!(s.message.as_deref(), Some("f2 couldn't be reopened from its server: no answer from the server (timed out)"));
assert_eq!(s.docs.len(), 3);
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -1011,6 +1011,8 @@ status = "draft — needs native review"
"Rename on the server…" = "Auf dem Server umbenennen…"
"Rename page" = "Seite umbenennen"
"Rename to" = "Umbenennen in"
"Reopen its windows and tabs" = "Fenster und Tabs wieder öffnen"
"Reopen windows and tabs from last time" = "Fenster und Tabs vom letzten Mal erneut öffnen"
"Replace all" = "Alle ersetzen"
"Replace mixed export settings" = "Gemischte Exporteinstellungen ersetzen"
"Replace with" = "Ersetzen durch"
@ -1037,6 +1039,7 @@ status = "draft — needs native review"
"Responsive" = "Responsiv"
"Restart" = "Neu starten"
"Restore" = "Wiederherstellen"
"Restore last session" = "Letzte Sitzung wiederherstellen"
"Restore main component" = "Hauptkomponente wiederherstellen"
"Restore this version" = "Diese Version wiederherstellen"
"Restored “{name}”" = "„{name}“ wiederhergestellt"
@ -1468,6 +1471,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { one = "{n} Konflikt zu lösen", other = "{n} Konflikte zu lösen" }
"{n} days ago" = { one = "vor {n} Tag", other = "vor {n} Tagen" }
"{n} files" = { one = "{n} Datei", other = "{n} Dateien" }
"{n} files couldn't be reopened" = { one = "{n} Datei konnte nicht wieder geöffnet werden", other = "{n} Dateien konnten nicht wieder geöffnet werden" }
"{n} hours ago" = { one = "vor {n} Stunde", other = "vor {n} Stunden" }
"{n} instances" = { one = "{n} Instanz", other = "{n} Instanzen" }
"{n} layers" = { one = "{n} Ebene", other = "{n} Ebenen" }
@ -1792,6 +1796,7 @@ status = "draft — needs native review"
"pref.publishServer" = "Server für das Online-Teilen"
"pref.reduceMotion" = "Bewegung reduzieren"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.restoreSession" = "Fenster und Tabs vom letzten Mal erneut öffnen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.userName" = "Ihr Name in Kommentaren"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
@ -1836,6 +1841,7 @@ status = "draft — needs native review"
"select.sameStroke" = "Gleiche Kontur auswählen"
"select.sameText" = "Gleiche Texteigenschaften auswählen"
"select.set" = "Auswählen"
"session.restore" = "Letzte Sitzung wiederherstellen"
"shape.arrow" = "Pfeil"
"shape.ellipse" = "Ellipse"
"shape.line" = "Linie"

View File

@ -1037,6 +1037,8 @@ name = "English"
"Rename on the server…" = "Rename on the server…"
"Rename page" = "Rename page"
"Rename to" = "Rename to"
"Reopen its windows and tabs" = "Reopen its windows and tabs"
"Reopen windows and tabs from last time" = "Reopen windows and tabs from last time"
"Replace all" = "Replace all"
"Replace mixed export settings" = "Replace mixed export settings"
"Replace with" = "Replace with"
@ -1063,6 +1065,7 @@ name = "English"
"Responsive" = "Responsive"
"Restart" = "Restart"
"Restore" = "Restore"
"Restore last session" = "Restore last session"
"Restore main component" = "Restore main component"
"Restore this version" = "Restore this version"
"Restored “{name}”" = "Restored “{name}”"
@ -1510,6 +1513,7 @@ name = "English"
"{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" }
"{n} days ago" = { one = "{n} day ago", other = "{n} days ago" }
"{n} files" = { one = "{n} file", other = "{n} files" }
"{n} files couldn't be reopened" = { one = "{n} file couldn't be reopened", other = "{n} files couldn't be reopened" }
"{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" }
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
"{n} layers" = { one = "{n} layer", other = "{n} layers" }

View File

@ -1038,6 +1038,8 @@ status = "draft — needs native review"
"Rename on the server…" = "Cambiar el nombre en el servidor…"
"Rename page" = "Cambiar nombre de página"
"Rename to" = "Nuevo nombre"
"Reopen its windows and tabs" = "Reabre sus ventanas y pestañas"
"Reopen windows and tabs from last time" = "Volver a abrir ventanas y pestañas de la última vez"
"Replace all" = "Reemplazar todo"
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
"Replace with" = "Reemplazar con"
@ -1064,6 +1066,7 @@ status = "draft — needs native review"
"Responsive" = "Adaptable"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
"Restore last session" = "Restaurar la última sesión"
"Restore main component" = "Restaurar componente principal"
"Restore this version" = "Restaurar esta versión"
"Restored “{name}”" = "“{name}” restaurada"
@ -1511,6 +1514,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { one = "{n} conflicto por resolver", other = "{n} conflictos por resolver" }
"{n} days ago" = { one = "hace {n} día", other = "hace {n} días" }
"{n} files" = { one = "{n} archivo", other = "{n} archivos" }
"{n} files couldn't be reopened" = { one = "No se pudo volver a abrir {n} archivo", other = "No se pudieron volver a abrir {n} archivos" }
"{n} hours ago" = { one = "hace {n} hora", other = "hace {n} horas" }
"{n} instances" = { one = "{n} instancia", other = "{n} instancias" }
"{n} layers" = { one = "{n} capa", other = "{n} capas" }
@ -1841,6 +1845,7 @@ status = "draft — needs native review"
"pref.publishServer" = "Servidor para compartir en línea"
"pref.reduceMotion" = "Reducir movimiento"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
"pref.restoreSession" = "Volver a abrir ventanas y pestañas de la última vez"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.userName" = "Tu nombre en los comentarios"
"proto.action.add" = "Añadir acción de prototipo"
@ -1885,6 +1890,7 @@ status = "draft — needs native review"
"select.sameStroke" = "Seleccionar mismo trazo"
"select.sameText" = "Seleccionar mismas propiedades de texto"
"select.set" = "Seleccionar"
"session.restore" = "Restaurar la última sesión"
"shape.arrow" = "Flecha"
"shape.ellipse" = "Elipse"
"shape.line" = "Línea"

View File

@ -1011,6 +1011,8 @@ status = "draft — needs native review"
"Rename on the server…" = "Renommer sur le serveur…"
"Rename page" = "Renommer la page"
"Rename to" = "Renommer en"
"Reopen its windows and tabs" = "Rouvrir ses fenêtres et onglets"
"Reopen windows and tabs from last time" = "Rouvrir les fenêtres et onglets de la dernière fois"
"Replace all" = "Tout remplacer"
"Replace mixed export settings" = "Remplacer les réglages d’exportation mixtes"
"Replace with" = "Remplacer par"
@ -1037,6 +1039,7 @@ status = "draft — needs native review"
"Responsive" = "Adaptatif"
"Restart" = "Recommencer"
"Restore" = "Restaurer"
"Restore last session" = "Restaurer la dernière session"
"Restore main component" = "Restaurer le composant principal"
"Restore this version" = "Restaurer cette version"
"Restored “{name}”" = "« {name} » restaurée"
@ -1468,6 +1471,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { one = "{n} conflit à résoudre", other = "{n} conflits à résoudre" }
"{n} days ago" = { one = "il y a {n} jour", other = "il y a {n} jours" }
"{n} files" = { one = "{n} fichier", other = "{n} fichiers" }
"{n} files couldn't be reopened" = { one = "{n} fichier n’a pas pu être rouvert", other = "{n} fichiers n’ont pas pu être rouverts" }
"{n} hours ago" = { one = "il y a {n} heure", other = "il y a {n} heures" }
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
"{n} layers" = { one = "{n} calque", other = "{n} calques" }
@ -1792,6 +1796,7 @@ status = "draft — needs native review"
"pref.publishServer" = "Serveur pour le partage en ligne"
"pref.reduceMotion" = "Réduire les animations"
"pref.renameDuplicates" = "Renommer les calques dupliqués"
"pref.restoreSession" = "Rouvrir les fenêtres et onglets de la dernière fois"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.userName" = "Votre nom dans les commentaires"
"proto.action.add" = "Ajouter une action de prototype"
@ -1836,6 +1841,7 @@ status = "draft — needs native review"
"select.sameStroke" = "Sélectionner le même contour"
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
"select.set" = "Sélectionner"
"session.restore" = "Restaurer la dernière session"
"shape.arrow" = "Flèche"
"shape.ellipse" = "Ellipse"
"shape.line" = "Ligne"

View File

@ -1013,6 +1013,8 @@ status = "draft — needs native review"
"Rename on the server…" = "サーバー上の名前を変更…"
"Rename page" = "ページ名を変更"
"Rename to" = "新しい名前"
"Reopen its windows and tabs" = "ウィンドウとタブをもう一度開く"
"Reopen windows and tabs from last time" = "前回のウィンドウとタブを再度開く"
"Replace all" = "すべて置換"
"Replace mixed export settings" = "混在した書き出し設定を置き換え"
"Replace with" = "置換後の文字列"
@ -1039,6 +1041,7 @@ status = "draft — needs native review"
"Responsive" = "レスポンシブ"
"Restart" = "再起動"
"Restore" = "復元"
"Restore last session" = "前回のセッションを復元"
"Restore main component" = "メインコンポーネントを復元"
"Restore this version" = "このバージョンを復元"
"Restored “{name}”" = "「{name}」を復元済み"
@ -1470,6 +1473,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { other = "解決が必要な競合 {n} 件" }
"{n} days ago" = { other = "{n} 日前" }
"{n} files" = { other = "{n} 個のファイル" }
"{n} files couldn't be reopened" = { other = "{n} 個のファイルを再度開けませんでした" }
"{n} hours ago" = { other = "{n} 時間前" }
"{n} instances" = { other = "{n} 個のインスタンス" }
"{n} layers" = { other = "{n} 個のレイヤー" }
@ -1794,6 +1798,7 @@ status = "draft — needs native review"
"pref.publishServer" = "オンライン共有用のサーバー"
"pref.reduceMotion" = "動きを減らす"
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
"pref.restoreSession" = "前回のウィンドウとタブを再度開く"
"pref.showDimensions" = "オブジェクトに寸法を表示"
"pref.userName" = "コメントに表示する名前"
"proto.action.add" = "プロトタイプのアクションを追加"
@ -1838,6 +1843,7 @@ status = "draft — needs native review"
"select.sameStroke" = "同じ線を選択"
"select.sameText" = "同じテキストプロパティを選択"
"select.set" = "選択"
"session.restore" = "前回のセッションを復元"
"shape.arrow" = "矢印"
"shape.ellipse" = "楕円"
"shape.line" = "線"

View File

@ -1012,6 +1012,8 @@ status = "draft — needs native review"
"Rename on the server…" = "서버에서 이름 바꾸기…"
"Rename page" = "페이지 이름 바꾸기"
"Rename to" = "새 이름"
"Reopen its windows and tabs" = "창과 탭 다시 열기"
"Reopen windows and tabs from last time" = "지난번 창과 탭 다시 열기"
"Replace all" = "모두 바꾸기"
"Replace mixed export settings" = "혼합된 내보내기 설정 바꾸기"
"Replace with" = "바꿀 내용"
@ -1038,6 +1040,7 @@ status = "draft — needs native review"
"Responsive" = "반응형"
"Restart" = "다시 시작"
"Restore" = "복원"
"Restore last session" = "마지막 세션 복원"
"Restore main component" = "메인 컴포넌트 복원"
"Restore this version" = "이 버전 복원"
"Restored “{name}”" = "\"{name}\" 복원됨"
@ -1469,6 +1472,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { other = "해결할 충돌 {n}개" }
"{n} days ago" = { other = "{n}일 전" }
"{n} files" = { other = "파일 {n}개" }
"{n} files couldn't be reopened" = { other = "파일 {n}개를 다시 열 수 없음" }
"{n} hours ago" = { other = "{n}시간 전" }
"{n} instances" = { other = "인스턴스 {n}개" }
"{n} layers" = { other = "레이어 {n}개" }
@ -1793,6 +1797,7 @@ status = "draft — needs native review"
"pref.publishServer" = "온라인 공유용 서버"
"pref.reduceMotion" = "동작 줄이기"
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
"pref.restoreSession" = "지난번 창과 탭 다시 열기"
"pref.showDimensions" = "개체에 치수 표시"
"pref.userName" = "댓글에 표시할 이름"
"proto.action.add" = "프로토타입 액션 추가"
@ -1837,6 +1842,7 @@ status = "draft — needs native review"
"select.sameStroke" = "같은 선 선택"
"select.sameText" = "같은 텍스트 속성 선택"
"select.set" = "선택"
"session.restore" = "마지막 세션 복원"
"shape.arrow" = "화살표"
"shape.ellipse" = "타원"
"shape.line" = "선"

View File

@ -1010,6 +1010,8 @@ status = "draft — needs native review"
"Rename on the server…" = "Renomear no servidor…"
"Rename page" = "Renomear página"
"Rename to" = "Renomear para"
"Reopen its windows and tabs" = "Reabra as janelas e abas"
"Reopen windows and tabs from last time" = "Reabrir janelas e abas da última vez"
"Replace all" = "Substituir tudo"
"Replace mixed export settings" = "Substituir configurações de exportação mistas"
"Replace with" = "Substituir por"
@ -1036,6 +1038,7 @@ status = "draft — needs native review"
"Responsive" = "Responsivo"
"Restart" = "Reiniciar"
"Restore" = "Restaurar"
"Restore last session" = "Restaurar a última sessão"
"Restore main component" = "Restaurar componente principal"
"Restore this version" = "Restaurar esta versão"
"Restored “{name}”" = "“{name}” restaurada"
@ -1467,6 +1470,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { one = "{n} conflito para resolver", other = "{n} conflitos para resolver" }
"{n} days ago" = { one = "há {n} dia", other = "há {n} dias" }
"{n} files" = { one = "{n} arquivo", other = "{n} arquivos" }
"{n} files couldn't be reopened" = { one = "{n} arquivo não pôde ser reaberto", other = "{n} arquivos não puderam ser reabertos" }
"{n} hours ago" = { one = "há {n} hora", other = "há {n} horas" }
"{n} instances" = { one = "{n} instância", other = "{n} instâncias" }
"{n} layers" = { one = "{n} camada", other = "{n} camadas" }
@ -1791,6 +1795,7 @@ status = "draft — needs native review"
"pref.publishServer" = "Servidor para compartilhamento online"
"pref.reduceMotion" = "Reduzir movimento"
"pref.renameDuplicates" = "Renomear camadas duplicadas"
"pref.restoreSession" = "Reabrir janelas e abas da última vez"
"pref.showDimensions" = "Mostrar dimensões nos objetos"
"pref.userName" = "Seu nome nos comentários"
"proto.action.add" = "Adicionar ação de protótipo"
@ -1835,6 +1840,7 @@ status = "draft — needs native review"
"select.sameStroke" = "Selecionar mesmo traço"
"select.sameText" = "Selecionar mesmas propriedades de texto"
"select.set" = "Selecionar"
"session.restore" = "Restaurar a última sessão"
"shape.arrow" = "Seta"
"shape.ellipse" = "Elipse"
"shape.line" = "Linha"

View File

@ -1013,6 +1013,8 @@ status = "draft — needs native review"
"Rename on the server…" = "在服务器上重命名…"
"Rename page" = "重命名页面"
"Rename to" = "重命名为"
"Reopen its windows and tabs" = "重新打开其窗口和标签页"
"Reopen windows and tabs from last time" = "重新打开上次的窗口与标签页"
"Replace all" = "全部替换"
"Replace mixed export settings" = "替换混合的导出设置"
"Replace with" = "替换为"
@ -1039,6 +1041,7 @@ status = "draft — needs native review"
"Responsive" = "响应式"
"Restart" = "重新开始"
"Restore" = "恢复"
"Restore last session" = "恢复上次会话"
"Restore main component" = "恢复主组件"
"Restore this version" = "恢复此版本"
"Restored “{name}”" = "已恢复“{name}”"
@ -1470,6 +1473,7 @@ status = "draft — needs native review"
"{n} conflicts to resolve" = { other = "{n} 个冲突待解决" }
"{n} days ago" = { other = "{n} 天前" }
"{n} files" = { other = "{n} 个文件" }
"{n} files couldn't be reopened" = { other = "{n} 个文件无法重新打开" }
"{n} hours ago" = { other = "{n} 小时前" }
"{n} instances" = { other = "{n} 个实例" }
"{n} layers" = { other = "{n} 个图层" }
@ -1794,6 +1798,7 @@ status = "draft — needs native review"
"pref.publishServer" = "在线分享所用的服务器"
"pref.reduceMotion" = "减少动态效果"
"pref.renameDuplicates" = "重命名副本图层"
"pref.restoreSession" = "重新打开上次的窗口与标签页"
"pref.showDimensions" = "在对象上显示尺寸"
"pref.userName" = "评论中显示的名字"
"proto.action.add" = "添加原型操作"
@ -1838,6 +1843,7 @@ status = "draft — needs native review"
"select.sameStroke" = "选择相同描边"
"select.sameText" = "选择相同文本属性"
"select.set" = "选择"
"session.restore" = "恢复上次会话"
"shape.arrow" = "箭头"
"shape.ellipse" = "椭圆"
"shape.line" = "直线"

View File

@ -5,7 +5,7 @@
//! - `engine.execute {command, params}` — run any command (no dialogs open)
//! - `engine.commands {}` — every command with its params doc
//! - `document.inspect {depth?, allPages?}` / `node.get {id, depth?}`
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings, the kept session (`session`)
//! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}`
//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas
//! - `ui.type {text}` — type into the text being edited
@ -18,8 +18,8 @@
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, focusRegion?, preview?, presentScroll?, …}`
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
//! windows (desktop)
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` (each window's files, rectangle
//! and state) / `window.focus {id}` — several windows (desktop)
//! - `app.quit {}`
//!
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and

View File

@ -155,6 +155,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
(tr("Reopen windows and tabs from last time"), prefs.restore_session, "pref.restoreSession"),
] {
if crate::widgets::checkbox(ui, val, label) {
toggles.push(cmd);

View File

@ -208,11 +208,18 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b
fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
heading(ui, t, tr("Recents"));
let (mut new_file, mut open) = (false, false);
let (mut new_file, mut open, mut restore) = (false, false, false);
ui.horizontal(|ui| {
new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas"));
open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images"));
// This run started without the last one's windows and tabs.
if app.restore_offered() {
restore = action_card(ui, t, Icon::WinRestore, tr("Restore last session"), tr("Reopen its windows and tabs"));
}
});
if restore {
app.restore_last();
}
if new_file {
app.run("file.new", json!({}));
app.ui.home = false;

View File

@ -45,6 +45,7 @@ pub mod proto_canvas;
pub mod proto_editor;
pub mod proto_panel;
pub mod render_worker;
pub mod restore;
pub mod right;
pub mod scale_panel;
pub mod section_panel;
@ -135,6 +136,8 @@ mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod restore_tests;
#[cfg(test)]
mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
@ -189,6 +192,9 @@ pub trait Services {
/// Keep the preferences now (the installed plugins or their settings changed), as the app
/// does when it quits.
fn save_prefs(&mut self, _prefs: &figcraft_engine::Prefs) {}
/// A restore of the last session begins (`on`) or has settled: the app keeps a mark meanwhile,
/// so a crash on the way starts the next run without restoring.
fn restoring(&mut self, _on: bool) {}
/// The OS or browser language as a tag ("es-ES"), for the "system" language preference.
fn system_language(&self) -> Option<String> {
None
@ -495,6 +501,11 @@ pub struct FigApp {
window_requests: Vec<figcraft_engine::UiRequest>,
/// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results.
waiting: Vec<(String, Done)>,
/// The screens there are, `[x, y, width, height]` in screen points (the app says; none known
/// in the browser and in tests): restored windows open on them.
pub monitors: Vec<[f32; 4]>,
/// Keeping and restoring the session.
restore: restore::Restore,
}
/// What to do with the result of a command run in the background.
@ -522,6 +533,8 @@ impl FigApp {
windows: windows::Windows::default(),
window_requests: Vec::new(),
waiting: Vec::new(),
monitors: Vec::new(),
restore: restore::Restore::default(),
}
}
@ -735,7 +748,7 @@ impl FigApp {
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
"dialog.plugins" => self.ui.plugins_window = true,
"dialog.cursorChat" => live::open_chat(self),
"window.new" | "window.focus" => self.window_requests.push(r),
"window.new" | "window.focus" | "session.restore" => self.window_requests.push(r),
_ => {}
}
}
@ -942,6 +955,7 @@ impl FigApp {
windows::repaint_others(self, &ctx);
}
windows::show(self, &ctx);
restore::keep(self, &ctx);
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
// So are the owner tokens of files put online.
if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) {
@ -1176,6 +1190,7 @@ impl FigApp {
"live": live::inspect(self),
"window": self.windows.current,
"windows": windows::list(self),
"session": self.inspect_session(),
"tool": self.session.tool_info(),
"cursor": self.canvas.cursor.map(cursors::Shown::name),
"panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed},

View File

@ -0,0 +1,344 @@
//! Session restore in the interface (the engine's [`figcraft_engine::restore`] has the tabs): each
//! window's place on screen, state and panels are kept with the preferences as they change, and
//! put back when `session.restore` opened the tabs again. A window comes back where it was only
//! where a screen still shows it ([`place`]).
use egui::{Context, Pos2, Vec2, ViewportCommand, ViewportId};
use figcraft_engine::restore::{Placed, SavedSession, SavedTab, SavedWindow};
use serde_json::{Value, json};
use crate::{FigApp, LeftTab, RightTab, UiState};
/// How much of a window's top strip (where it is grabbed to move it) a screen must show for the
/// window to count as on it: this high, and this wide (or the whole window, when narrower).
const STRIP: f32 = 32.0;
const HOLD: f32 = 120.0;
/// The seconds at most between two writes of a session that keeps changing, and how long after
/// windows were put back a crash still counts as one during the restore.
const WRITE_EVERY: f64 = 1.0;
const SETTLED_AFTER: f64 = 1.0;
/// Where a window is: its outer position and inner size while it is neither maximized nor full
/// screen (screen points), and those states.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Geometry {
pub pos: Option<Pos2>,
pub size: Vec2,
pub maximized: bool,
pub fullscreen: bool,
}
impl Default for Geometry {
fn default() -> Self {
Self { pos: None, size: Vec2::new(1280.0, 800.0), maximized: false, fullscreen: false }
}
}
impl Geometry {
/// Follow the window drawing now. A minimized window says nothing about where it is.
pub(crate) fn track(&mut self, ctx: &Context) {
ctx.input(|i| {
let v = i.viewport();
self.maximized = v.maximized.unwrap_or(self.maximized);
self.fullscreen = v.fullscreen.unwrap_or(self.fullscreen);
if v.minimized == Some(true) || self.maximized || self.fullscreen {
return;
}
self.pos = v.outer_rect.map(|r| r.min).or(self.pos);
self.size = v.inner_rect.map_or(self.size, |r| r.size());
});
}
fn rect(&self) -> Option<[f32; 4]> {
self.pos.map(|p| [p.x, p.y, self.size.x, self.size.y])
}
/// As saved, fitted to the screens there are now.
fn of(saved: &SavedWindow, monitors: &[[f32; 4]]) -> Self {
let rect = saved.rect.map(|r| place(r, saved.monitor, monitors));
Self {
pos: rect.map(|r| Pos2::new(r[0], r[1])),
size: rect.map_or(Self::default().size, |r| Vec2::new(r[2], r[3])),
maximized: saved.maximized,
fullscreen: saved.fullscreen,
}
}
pub(crate) fn json(&self, monitors: &[[f32; 4]]) -> Value {
json!({"rect": self.rect(), "maximized": self.maximized, "fullscreen": self.fullscreen, "monitor": self.rect().and_then(|r| monitor_of(r, monitors))})
}
}
/// How much of `[x, y, width, height]` rectangles `a` and `b` share: width and height.
fn shared(a: [f32; 4], b: [f32; 4]) -> (f32, f32) {
let side = |a0: f32, al: f32, b0: f32, bl: f32| ((a0 + al).min(b0 + bl) - a0.max(b0)).max(0.0);
(side(a[0], a[2], b[0], b[2]), side(a[1], a[3], b[1], b[3]))
}
/// The screen showing most of `rect`.
pub fn monitor_of(rect: [f32; 4], monitors: &[[f32; 4]]) -> Option<[f32; 4]> {
let area = |m: &[f32; 4]| {
let (w, h) = shared(rect, *m);
w * h
};
monitors.iter().copied().filter(|m| area(m) > 0.0).max_by(|a, b| area(a).total_cmp(&area(b)))
}
/// Where a window saved at `rect` (`[x, y, width, height]`) on screen `monitor` opens on the
/// screens there are now: where it was while a screen shows enough of its top strip to grab;
/// otherwise moved onto its screen if that is still there, else the nearest one, and no larger
/// than it. With no screens known it stays as saved.
pub fn place(rect: [f32; 4], monitor: Option<[f32; 4]>, monitors: &[[f32; 4]]) -> [f32; 4] {
let [x, y, w, h] = rect;
let sane = |m: &&[f32; 4]| m.iter().all(|n| n.is_finite()) && m[2] >= 1.0 && m[3] >= 1.0;
let monitors: Vec<[f32; 4]> = monitors.iter().filter(sane).copied().collect();
if monitors.is_empty() || !rect.iter().all(|n| n.is_finite()) {
return rect;
}
let grabbable = |m: &[f32; 4]| {
let (sw, sh) = shared([x, y, w, STRIP.min(h)], *m);
sw >= HOLD.min(w) && sh >= (STRIP / 2.0).min(h)
};
if monitors.iter().any(grabbable) {
return rect;
}
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
let away = |m: &[f32; 4]| (m[0] + m[2] / 2.0 - cx).hypot(m[1] + m[3] / 2.0 - cy);
let same = |m: &&[f32; 4]| monitor.is_some_and(|s| m.iter().zip(s).all(|(a, b)| (a - b).abs() < 1.0));
let Some(m) = monitors.iter().find(same).or_else(|| monitors.iter().min_by(|a, b| away(a).total_cmp(&away(b)))) else { return rect };
let (w, h) = (w.min(m[2]), h.min(m[3]));
[x.clamp(m[0], m[0] + m[2] - w), y.clamp(m[1], m[1] + m[3] - h), w, h]
}
fn left_id(t: LeftTab) -> &'static str {
match t {
LeftTab::File => "file",
LeftTab::Assets => "assets",
}
}
fn right_id(t: RightTab) -> &'static str {
match t {
RightTab::Design => "design",
RightTab::Prototype => "prototype",
RightTab::Inspect => "inspect",
}
}
/// Put a saved window's panels back into `ui`.
fn panels(ui: &mut UiState, saved: &SavedWindow) {
ui.left_tab = if saved.left_tab == "assets" { LeftTab::Assets } else { LeftTab::File };
ui.right_tab = match saved.right_tab.as_str() {
"prototype" => RightTab::Prototype,
"inspect" => RightTab::Inspect,
_ => RightTab::Design,
};
ui.left_collapsed = saved.left_collapsed;
if let Some([left, right]) = saved.panel_widths {
let within = |w: f32, range: std::ops::RangeInclusive<f32>| w.clamp(*range.start(), *range.end());
(ui.left_width, ui.right_width) = (within(left, crate::LEFT_PANEL), within(right, crate::RIGHT_PANEL));
}
}
/// The windows and tabs open now (the main window draws: `app.ui` is its own).
pub(crate) fn snapshot(app: &FigApp) -> SavedSession {
let wins = &app.windows;
// Online files still on their way back stay tabs of their windows (of the main window, when
// theirs is gone).
let joining = |id: u64| app.session.restore.joining().filter(move |(k, _)| wins.restored(*k) == id).map(|(_, tab)| tab.clone());
let window = |id: u64, geo: &Geometry, ui: &UiState, active: Option<u64>| {
let open = app.session.docs.iter().filter(|d| wins.of(d.uid) == id).filter_map(|d| Some((d.uid, app.session.saved_tab(d)?)));
let (uids, mut tabs): (Vec<u64>, Vec<SavedTab>) = open.unzip();
tabs.extend(joining(id));
tabs.truncate(figcraft_engine::restore::MAX_TABS);
SavedWindow {
rect: geo.rect(),
maximized: geo.maximized,
fullscreen: geo.fullscreen,
monitor: geo.rect().and_then(|r| monitor_of(r, &app.monitors)),
front: uids.iter().position(|u| Some(*u) == active).unwrap_or(0),
tabs,
left_tab: left_id(ui.left_tab).into(),
right_tab: right_id(ui.right_tab).into(),
left_collapsed: ui.left_collapsed,
panel_widths: Some([ui.left_width, ui.right_width]),
}
};
let main = window(0, &wins.main, &app.ui, app.session.ds().ok().map(|d| d.uid));
let others = wins.list.iter().map(|w| window(w.id, &w.geo, &w.pane.ui, w.active)).filter(|w| !w.tabs.is_empty());
SavedSession { windows: std::iter::once(main).chain(others).take(figcraft_engine::restore::MAX_WINDOWS).collect() }
}
/// After the main window drew: keep the session with the preferences when it changed (at once,
/// then at most every [`WRITE_EVERY`] seconds while it keeps changing; nothing while it doesn't),
/// and tell the app once a restore has settled.
pub(crate) fn keep(app: &mut FigApp, ctx: &Context) {
// The browser keeps no preferences: nothing to keep a session in.
if cfg!(target_arch = "wasm32") {
return;
}
let now = ctx.input(|i| i.time);
if let Some(at) = app.restore.settles {
if now >= at {
app.restore.settles = None;
app.services.restoring(false);
} else {
crate::repaint_in(ctx, at - now);
}
}
// A window on its way out isn't how the app was left.
if app.ui.quit || app.session.restore.placing.is_some() {
return;
}
let session = snapshot(app);
if session != app.session.prefs.session {
app.session.prefs.session = session;
app.restore.unsaved = true;
}
if !app.restore.unsaved {
return;
}
// Not while something is dragged (a panel's edge): the frame it is let go in writes.
if ctx.input(|i| i.pointer.any_down()) {
return;
}
if now < app.restore.next_write {
crate::repaint_in(ctx, app.restore.next_write - now);
return;
}
app.restore.unsaved = false;
app.restore.next_write = now + WRITE_EVERY;
app.services.save_prefs(&app.session.prefs);
}
/// What the interface remembers between frames about keeping and restoring the session.
#[derive(Default)]
pub struct Restore {
/// The saved session changed and isn't written yet; when (UI clock) it may be written next.
unsaved: bool,
next_write: f64,
/// When (UI clock) the restore under way counts as done (the app's crash guard goes then).
settles: Option<f64>,
}
impl FigApp {
/// Restore the session on offer (the home screen's button): the app's crash guard is up
/// first, so a crash on the way starts the next run without restoring.
pub fn restore_last(&mut self) {
self.services.restoring(true);
self.run("session.restore", json!({}));
self.ui.home = !self.has_files();
}
/// The last run's session is on offer (this run started without it).
pub fn restore_offered(&self) -> bool {
self.session.restore.last.as_ref().is_some_and(|s| s.tabs() > 0)
}
/// The saved session and what restoring knows (`ui.inspect`'s `session`).
pub(crate) fn inspect_session(&self) -> Value {
json!({
"saved": self.session.prefs.session,
"restore": self.session.prefs.restore_session,
"offered": self.restore_offered(),
"joining": self.session.restore.joining().count(),
"monitors": self.monitors,
})
}
}
/// The main window's frame begins: put the windows `session.restore` opened the tabs of where
/// they were, and restored online files that arrived since into theirs.
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
if let Some((placed, missing)) = app.session.restore.placing.take() {
apply(app, ctx, placed);
if missing > 0 {
// With what is waiting to be said (documents recovered after a crash), not instead.
let said = app
.ui
.toast
.take()
.filter(|(_, shown)| shown.is_none())
.map(|(m, _)| {
format!(
"{m}
"
)
})
.unwrap_or_default();
app.toast(said + &crate::i18n::trn("{n} files couldn't be reopened", missing, &[]));
}
app.restore.settles = Some(ctx.input(|i| i.time) + SETTLED_AFTER);
}
if app.session.restore.joining().next().is_none() {
return;
}
for a in app.session.restore_arrivals() {
let id = app.windows.restored(a.window);
crate::windows::adopt(app, id, &[a.uid], a.front.then_some(a.uid));
ctx.request_repaint_of(crate::windows::viewport(id));
}
// One that couldn't be joined says so.
if let Some(m) = app.session.message.take() {
app.toast(m);
}
}
fn apply(app: &mut FigApp, ctx: &Context, placed: Vec<Placed>) {
// One window (the browser, a screen reader running): every tab stays here.
let one = ctx.embed_viewports() || crate::a11y::active(ctx);
let mut any = false;
// A file opened since (one the app was started with) stays in front.
let active = app.session.ds().ok().map(|d| d.uid);
let keep_front = !placed.iter().any(|p| active.is_some_and(|a| p.docs.contains(&a)));
for w in &mut app.windows.list {
w.restored = None;
}
for (k, p) in placed.iter().enumerate() {
any |= !p.docs.is_empty();
let geo = Geometry::of(&p.saved, &app.monitors);
if k == 0 {
let (root, now) = (ViewportId::ROOT, app.windows.main);
let send = |cmd| ctx.send_viewport_cmd_to(root, cmd);
// A window is placed while it is neither maximized nor full screen: it leaves a
// state first and enters one last, and one it stays in keeps the place under it.
if now.fullscreen && !geo.fullscreen {
send(ViewportCommand::Fullscreen(false));
}
if now.maximized && !geo.maximized {
send(ViewportCommand::Maximized(false));
}
let stays = (now.maximized && geo.maximized) || (now.fullscreen && geo.fullscreen);
if let Some(pos) = geo.pos.filter(|_| !stays) {
send(ViewportCommand::OuterPosition(pos));
send(ViewportCommand::InnerSize(geo.size));
}
if geo.maximized && !now.maximized {
send(ViewportCommand::Maximized(true));
}
if geo.fullscreen && !now.fullscreen {
send(ViewportCommand::Fullscreen(true));
}
app.windows.main = Geometry { pos: geo.pos.or(app.windows.main.pos), ..geo };
panels(&mut app.ui, &p.saved);
app.session.prefs.panel_widths = (app.ui.left_width, app.ui.right_width);
crate::windows::adopt(app, 0, &p.docs, p.front.filter(|_| !keep_front));
} else if one || (p.docs.is_empty() && !app.session.restore.joining().any(|(w, _)| w == k)) {
// Its tabs (none open yet) are the main window's.
crate::windows::adopt(app, 0, &p.docs, None);
} else {
let mut ui = UiState { title_bar: app.ui.title_bar, ..UiState::default() };
panels(&mut ui, &p.saved);
let id = crate::windows::push(app, ui, geo);
if let Some(w) = app.windows.list.last_mut() {
w.restored = Some(k);
}
crate::windows::adopt(app, id, &p.docs, p.front);
}
}
app.windows.focused = 0;
if any {
app.ui.home = false;
}
}

View File

@ -0,0 +1,391 @@
//! Session restore in the interface, driven through egui headless: a window comes back where it
//! was only where a screen shows it; the windows and tabs open now are kept with the preferences
//! as they change (at once, then at most once a second, never while nothing changes) and come
//! back in their windows with their panels, place and state; a window off every screen moves
//! onto one; a build with one window keeps every tab; files that are gone get one message; a
//! file opened since stays in front; the home screen offers the last session after a run that
//! started without it, with the app's crash guard up while it is restored.
use std::sync::{Arc, Mutex};
use egui::{Pos2, RawInput, Rect, ViewportId, ViewportInfo, vec2};
use figcraft_engine::Session;
use figcraft_engine::restore::{SavedSession, SavedTab, SavedWindow};
use serde_json::{Value, json};
use crate::restore::{monitor_of, place};
use crate::{FigApp, RightTab, Services};
const A: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0];
const B: [f32; 4] = [1920.0, 0.0, 1280.0, 1024.0];
type Screen = [f32; 4];
/// What the case is, the saved rectangle, the screen it was on, the screens now, where it opens.
type Case = (&'static str, [f32; 4], Option<Screen>, &'static [Screen], [f32; 4]);
#[test]
fn a_window_comes_back_where_a_screen_shows_it() {
let nan = f32::NAN;
let table: &[Case] = &[
("on its screen", [100.0, 80.0, 1200.0, 800.0], Some(A), &[A, B], [100.0, 80.0, 1200.0, 800.0]),
("across two screens", [1500.0, 50.0, 900.0, 700.0], Some(A), &[A, B], [1500.0, 50.0, 900.0, 700.0]),
("on the second screen", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A, B], [2000.0, 100.0, 1000.0, 700.0]),
("its screen is gone", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A], [920.0, 100.0, 1000.0, 700.0]),
("far off every screen", [-5000.0, -5000.0, 800.0, 600.0], None, &[A, B], [0.0, 0.0, 800.0, 600.0]),
("only a sliver shows", [1900.0, 100.0, 800.0, 600.0], Some(A), &[A], [1120.0, 100.0, 800.0, 600.0]),
("its top strip is above the screen", [300.0, -200.0, 800.0, 600.0], Some(A), &[A], [300.0, 0.0, 800.0, 600.0]),
("its top strip is below the screen", [300.0, 1075.0, 800.0, 600.0], Some(A), &[A], [300.0, 480.0, 800.0, 600.0]),
("the bottom hangs off: it can still be grabbed", [300.0, 900.0, 800.0, 600.0], Some(A), &[A], [300.0, 900.0, 800.0, 600.0]),
("larger than the screen", [5000.0, 5000.0, 4000.0, 3000.0], None, &[A], [0.0, 0.0, 1920.0, 1080.0]),
("off screen: back to its own screen, not the nearest", [-900.0, 100.0, 800.0, 600.0], Some(B), &[A, B], [1920.0, 100.0, 800.0, 600.0]),
(
"off screen: the nearest when its own is gone",
[3300.0, 100.0, 800.0, 600.0],
Some([3200.0, 0.0, 1000.0, 1000.0]),
&[A, B],
[2400.0, 100.0, 800.0, 600.0],
),
("narrower than the strip to grab", [1850.0, 100.0, 100.0, 100.0], Some(A), &[A], [1820.0, 100.0, 100.0, 100.0]),
("no screens known", [-5000.0, 0.0, 800.0, 600.0], None, &[], [-5000.0, 0.0, 800.0, 600.0]),
(
"screens that aren't",
[-5000.0, 0.0, 800.0, 600.0],
None,
&[[0.0, 0.0, 0.0, 0.0], [f32::NAN, 0.0, 100.0, 100.0]],
[-5000.0, 0.0, 800.0, 600.0],
),
];
for (what, rect, monitor, monitors, want) in table {
assert_eq!(place(*rect, *monitor, monitors), *want, "{what}");
// Where it opens, a screen shows its top strip.
if let Some(m) = monitor_of(*want, monitors) {
assert!(want[1] >= m[1] - 0.5 && want[1] < m[1] + m[3], "{what}");
}
}
assert!(place([nan, 0.0, 800.0, 600.0], None, &[A])[0].is_nan(), "never a panic on a rectangle that isn't one");
assert_eq!(monitor_of([1500.0, 50.0, 900.0, 700.0], &[A, B]), Some(B), "the screen showing most of it");
assert_eq!(monitor_of([-5000.0, 0.0, 800.0, 600.0], &[A, B]), None);
}
/// What the app's services were asked: the sessions kept, and the restoring mark put up or down.
#[derive(Default)]
struct Asked {
kept: Vec<SavedSession>,
marks: Vec<bool>,
}
struct Keeps(Arc<Mutex<Asked>>);
impl Services for Keeps {
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
self.0.lock().unwrap().kept.push(prefs.session.clone());
}
fn restoring(&mut self, on: bool) {
self.0.lock().unwrap().marks.push(on);
}
}
/// The app with egui's context and clock, as the desktop app runs it (windows are native).
struct Run {
ctx: egui::Context,
app: FigApp,
asked: Arc<Mutex<Asked>>,
time: f64,
/// Where the system says the main window is: `[x, y, width, height]`, maximized.
main: Option<([f32; 4], bool)>,
/// Where the main window was told to go and what state to take (the commands' names), in order.
told: Vec<String>,
}
impl Run {
fn new(s: Session) -> Self {
let asked = Arc::new(Mutex::new(Asked::default()));
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
Self { ctx, app: FigApp::new(s, Box::new(Keeps(asked.clone()))), asked, time: 0.0, main: None, told: Vec::new() }
}
/// One main-window frame `secs` later.
fn frame(&mut self, secs: f64) {
self.time += secs;
let mut input = RawInput { time: Some(self.time), ..Default::default() };
if let Some(([x, y, w, h], maximized)) = self.main {
let rect = Rect::from_min_size(Pos2::new(x, y), vec2(w, h));
let info = ViewportInfo { outer_rect: Some(rect), inner_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
input.viewports.insert(ViewportId::ROOT, info);
}
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
let commands = out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.as_slice()).unwrap_or_default();
let name = |c: &egui::ViewportCommand| format!("{c:?}").split('(').next().unwrap_or_default().to_string();
self.told.extend(commands.iter().map(name).filter(|c| ["OuterPosition", "InnerSize", "Maximized", "Fullscreen"].contains(&c.as_str())));
}
/// Frames until the interface is up (the first one only installs the fonts).
fn up(mut self) -> Self {
for _ in 0..3 {
self.frame(0.01);
}
self
}
fn kept(&self) -> usize {
self.asked.lock().unwrap().kept.len()
}
fn windows(&self) -> Value {
crate::windows::list(&self.app)
}
}
/// A folder of saved files named `names`, and a session with them open.
fn open_files(test: &str, names: &[&str]) -> (std::path::PathBuf, Session, Vec<String>) {
let dir = std::env::temp_dir().join(format!("figcraft-ui-restore-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::empty();
let mut paths = Vec::new();
for n in names {
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
s.execute("file.new", &json!({"name": n})).unwrap();
s.execute("file.saveAs", &json!({"path": path})).unwrap();
paths.push(path);
}
(dir, s, paths)
}
/// The next run: nothing open, the preferences as they were kept.
fn next_run(prefs: &figcraft_engine::Prefs) -> Run {
let mut s = Session::empty();
s.prefs = serde_json::from_value(serde_json::to_value(prefs).unwrap()).unwrap();
Run::new(s)
}
fn tab(path: &str) -> SavedTab {
SavedTab { path: Some(path.into()), ..SavedTab::default() }
}
#[test]
fn the_session_is_kept_as_it_changes_and_not_while_it_doesnt() {
let (dir, mut s, paths) = open_files("kept", &["A", "B"]);
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
s.execute("doc.switch", &json!({"index": 0})).unwrap();
let mut r = Run::new(s);
r.main = Some(([40.0, 30.0, 1300.0, 820.0], false));
r = r.up();
assert_eq!(r.kept(), 1, "kept at once");
let saved = r.app.session.prefs.session.clone();
assert_eq!(saved.windows.len(), 1);
let w = &saved.windows[0];
assert_eq!(
w.tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(),
paths,
"saved files only: unsaved work is the recovery copies' job"
);
assert_eq!((w.front, w.rect, w.maximized), (0, Some([40.0, 30.0, 1300.0, 820.0]), false));
assert_eq!((w.left_tab.as_str(), w.right_tab.as_str(), w.panel_widths), ("file", "design", Some([240.0, 240.0])));
// Nothing changes: nothing is written, and nothing asks for another frame to write.
for _ in 0..30 {
r.frame(0.5);
}
assert_eq!(r.kept(), 1);
// The tab in front, the panel and the window's place change in quick succession: one more
// write once a second has passed since the last, with all of it.
r.frame(5.0);
r.app.session.execute("doc.switch", &json!({"index": 1})).unwrap();
r.frame(0.1);
assert_eq!(r.kept(), 2);
r.app.ui.right_tab = RightTab::Inspect;
r.frame(0.1);
r.main = Some(([300.0, 200.0, 1000.0, 700.0], false));
r.frame(0.1);
assert_eq!(r.kept(), 2, "not on every change");
r.frame(1.0);
assert_eq!(r.kept(), 3);
let last = r.asked.lock().unwrap().kept.last().cloned().unwrap();
assert_eq!(last, r.app.session.prefs.session);
let w = &last.windows[0];
assert_eq!((w.front, w.right_tab.as_str(), w.rect), (1, "inspect", Some([300.0, 200.0, 1000.0, 700.0])));
// Maximized: the state is kept, and the place it goes back to.
r.main = Some(([0.0, 0.0, 1920.0, 1080.0], true));
r.frame(2.0);
let w = r.app.session.prefs.session.windows[0].clone();
assert_eq!((w.maximized, w.rect), (true, Some([300.0, 200.0, 1000.0, 700.0])));
assert_eq!(crate::control::handle(&mut r.app, &r.ctx, "ui.inspect", &json!({})).unwrap()["session"]["saved"]["windows"][0]["maximized"], true);
// Quitting: the window on its way out isn't how the app was left.
let before = r.kept();
r.app.ui.quit = true;
r.app.session.execute("file.close", &json!({"index": 0, "force": true})).unwrap();
r.frame(2.0);
assert_eq!(r.kept(), before);
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn windows_and_tabs_come_back_as_they_were_left() {
let (dir, s, _) = open_files("windows", &["A", "B", "C"]);
let mut r = Run::new(s).up();
// B and C move to a second window, which shows C; the main window is left on Dev Mode.
let w = crate::control::handle(&mut r.app, &r.ctx, "window.new", &json!({"index": 1})).unwrap()["window"].as_u64().unwrap();
r.frame(0.1);
let c = r.app.session.docs[2].uid;
crate::windows::adopt(&mut r.app, w, &[c], Some(c));
r.app.ui.right_tab = RightTab::Inspect;
// A is zoomed in on a corner.
r.frame(0.1);
(r.app.session.docs[0].view.cx, r.app.session.docs[0].view.zoom) = (77.0, 3.0);
r.app.set_panel_widths(300.0, 320.0);
r.main = Some(([10.0, 20.0, 1100.0, 760.0], false));
r.frame(2.0);
let mut prefs = r.app.session.prefs.clone();
assert_eq!(prefs.session.windows.iter().map(|w| w.tabs.len()).collect::<Vec<_>>(), [1, 2]);
assert_eq!(prefs.session.windows[1].front, 1);
// The second window was left maximized, over this place.
prefs.session.windows[1].rect = Some([200.0, 150.0, 1000.0, 720.0]);
prefs.session.windows[1].maximized = true;
prefs.session.windows[1].right_tab = "prototype".into();
let left = prefs.session.clone();
// The app quits and starts again.
let mut r = next_run(&prefs);
let out = r.app.run("session.restore", json!({})).unwrap();
assert_eq!((&out["windows"], &out["opened"]), (&json!(2), &json!(3)));
r = r.up();
let list = r.windows();
assert_eq!((&list[0]["files"], &list[0]["active"], &list[0]["rect"]), (&json!(["A"]), &json!("A"), &json!([10.0, 20.0, 1100.0, 760.0])));
assert_eq!((&list[1]["files"], &list[1]["active"]), (&json!(["B", "C"]), &json!("C")));
assert_eq!(
(&list[1]["rect"], &list[1]["maximized"], &list[1]["fullscreen"]),
(&json!([200.0, 150.0, 1000.0, 720.0]), &json!(true), &json!(false))
);
assert_eq!((r.app.ui.right_tab, r.app.ui.left_width, r.app.ui.right_width), (RightTab::Inspect, 300.0, 320.0), "the main window's panels");
assert_eq!(r.app.windows.list[0].pane.ui.right_tab, RightTab::Prototype, "the other window's own");
assert!(!r.app.ui.home && r.app.session.ds().unwrap().doc.name == "A");
let view = &r.app.session.ds().unwrap().view;
assert_eq!((view.cx, view.zoom), (77.0, 3.0), "drawn, and still where it was left (not fitted to the window again)");
assert_eq!(r.app.session.prefs.session, left, "what is kept now is what was left");
// The crash guard goes once the restore has settled, not before.
assert_eq!(r.asked.lock().unwrap().marks, Vec::<bool>::new());
r.frame(1.5);
assert_eq!(r.asked.lock().unwrap().marks, [false]);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_window_off_every_screen_moves_onto_one_and_one_window_keeps_every_tab() {
let (dir, s, paths) = open_files("screens", &["A", "B"]);
let mut prefs = s.prefs.clone();
prefs.session = SavedSession {
windows: vec![
SavedWindow {
rect: Some([-4000.0, 100.0, 1200.0, 800.0]),
monitor: Some([-3840.0, 0.0, 1920.0, 1080.0]),
tabs: vec![tab(&paths[0])],
..SavedWindow::default()
},
SavedWindow { rect: Some([2500.0, 300.0, 5000.0, 4000.0]), tabs: vec![tab(&paths[1])], ..SavedWindow::default() },
],
};
let mut r = next_run(&prefs);
r.app.monitors = vec![A];
r.app.run("session.restore", json!({}));
r = r.up();
let list = r.windows();
assert_eq!((&list[0]["rect"], &list[0]["monitor"]), (&json!([0.0, 100.0, 1200.0, 800.0]), &json!(A)));
assert_eq!((&list[1]["rect"], &list[1]["files"]), (&json!(A), &json!(["B"])));
// A build with one window (the browser): the tabs of every window, in this one.
let mut r = next_run(&prefs);
r.ctx.set_embed_viewports(true);
r.app.run("session.restore", json!({}));
r = r.up();
let list = r.windows();
assert_eq!((list.as_array().map(Vec::len), &list[0]["files"]), (Some(1), &json!(["A", "B"])));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn gone_files_get_one_message_and_a_file_opened_since_stays_in_front() {
let (dir, s, paths) = open_files("gone", &["A", "B", "C", "D"]);
let mut prefs = s.prefs.clone();
prefs.session =
SavedSession { windows: vec![SavedWindow { tabs: paths[..3].iter().map(|p| tab(p)).collect(), front: 1, ..SavedWindow::default() }] };
std::fs::remove_file(&paths[0]).unwrap();
std::fs::remove_file(&paths[2]).unwrap();
let mut r = next_run(&prefs);
r.app.run("session.restore", json!({}));
// The file the app was started with opens after, in front; documents recovered after a
// crash are still announced.
r.app.session.execute("file.open", &json!({"path": paths[3]})).unwrap();
r.app.note_recovered(1);
r = r.up();
let toast = r.app.ui.toast.clone().unwrap().0;
assert!(toast.starts_with("Recovered an unsaved file") && toast.ends_with("\n2 files couldn't be reopened"), "{toast}");
assert_eq!(r.windows()[0]["files"], json!(["B", "D"]));
assert_eq!(r.app.session.ds().unwrap().doc.name, "D");
// The files that are gone stay in Open recent (greyed there), and leave the kept session.
assert!(r.app.session.prefs.recent_files.contains(&paths[0]));
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn the_home_screen_offers_the_last_session_with_the_crash_guard_up() {
let (dir, s, paths) = open_files("offer", &["A", "B"]);
let mut prefs = s.prefs.clone();
prefs.session = SavedSession { windows: vec![SavedWindow { tabs: vec![tab(&paths[0]), tab(&paths[1])], front: 1, ..SavedWindow::default() }] };
// A run that starts without restoring: the last session is on offer, on the home screen.
let mut r = next_run(&prefs);
r.app.session.restore.last = Some(r.app.session.prefs.session.clone());
r.app.ui.home = true;
r = r.up();
assert!(r.app.restore_offered());
let inspect = r.app.inspect();
assert_eq!((&inspect["session"]["offered"], &inspect["home"]), (&json!(true), &json!(true)));
assert!(r.app.session.prefs.session.tabs() == 0, "this run's own session is what is kept now");
// The button: the guard is up before anything opens, and down once it settled.
r.app.restore_last();
assert_eq!(r.asked.lock().unwrap().marks, [true]);
r.frame(0.1);
assert_eq!((r.windows()[0]["files"].clone(), r.windows()[0]["active"].clone()), (json!(["A", "B"]), json!("B")));
assert!(!r.app.ui.home && !r.app.restore_offered());
r.frame(1.5);
assert_eq!(r.asked.lock().unwrap().marks, [true, false]);
assert_eq!(r.app.session.prefs.session.tabs(), 2);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_window_left_maximized_comes_back_maximized_over_its_place() {
let (dir, s, paths) = open_files("maximized", &["A"]);
let mut prefs = s.prefs.clone();
let rect = [200.0, 120.0, 1300.0, 850.0];
prefs.session =
SavedSession { windows: vec![SavedWindow { rect: Some(rect), maximized: true, tabs: vec![tab(&paths[0])], ..SavedWindow::default() }] };
// The app starts: its window opens maximized (the app asked for that), and placing it again
// would take it out of that state.
let mut r = next_run(&prefs);
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
r.app.run("session.restore", json!({}));
r = r.up();
assert_eq!(r.told, Vec::<String>::new());
let w = r.app.session.prefs.session.windows[0].clone();
assert_eq!((w.maximized, w.rect), (true, Some(rect)), "the place under it is still the one to go back to");
// Restored while the app runs, from a window that isn't maximized: placed first, then maximized.
let mut r = next_run(&prefs);
r.main = Some(([50.0, 50.0, 1000.0, 700.0], false));
r = r.up();
r.app.run("session.restore", json!({"session": serde_json::to_value(&prefs.session).unwrap()}));
r.frame(0.1);
assert_eq!(r.told, ["OuterPosition", "InnerSize", "Maximized"]);
// And back: a maximized window leaves that state before it is placed.
let mut normal = prefs.session.clone();
normal.windows[0].maximized = false;
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
r.frame(0.1);
r.told.clear();
r.app.run("session.restore", json!({"session": serde_json::to_value(&normal).unwrap()}));
r.frame(0.1);
assert_eq!(r.told, ["Maximized", "OuterPosition", "InnerSize"]);
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -17,6 +17,7 @@ use serde_json::{Value, json};
use crate::canvas::CanvasState;
use crate::i18n::{tr, trf};
use crate::render_worker::Worker;
use crate::restore::Geometry;
use crate::{FigApp, TitleBar, UiState};
/// Another window: its tabs and the UI state it swaps into the app while it draws.
@ -25,17 +26,19 @@ pub struct Window {
/// Its open files (document uids; the tabs keep the session's order).
pub docs: Vec<u64>,
/// The file it shows.
active: Option<u64>,
pane: Pane,
/// Where it opens and its size, then where it is (screen points).
pos: Option<Pos2>,
size: Vec2,
pub(crate) active: Option<u64>,
pub(crate) pane: Pane,
/// Where it opens, then where it is.
pub(crate) geo: Geometry,
/// Which window of the restored session it is: online files still on their way open in it
/// (and keep it open until they arrive, or can't).
pub(crate) restored: Option<usize>,
}
/// A window's own UI state.
#[derive(Default)]
struct Pane {
ui: UiState,
pub(crate) struct Pane {
pub(crate) ui: UiState,
canvas: CanvasState,
worker: Option<Worker>,
field_focus: bool,
@ -50,6 +53,8 @@ pub struct Windows {
pub current: u64,
/// The window last in front: control requests without a `window` go to it.
pub focused: u64,
/// Where the main window is.
pub(crate) main: Geometry,
/// The main window's file while another window draws.
main_active: Option<u64>,
/// Windows to close (their files close with them), at the main window's next frame.
@ -63,6 +68,12 @@ impl Windows {
self.list.iter().find(|w| w.docs.contains(&uid)).map_or(0, |w| w.id)
}
/// The window that is window `k` of the restored session (the main window when it is gone,
/// or never opened).
pub(crate) fn restored(&self, k: usize) -> u64 {
self.list.iter().find(|w| w.restored == Some(k)).map_or(0, |w| w.id)
}
pub fn exists(&self, id: u64) -> bool {
id == 0 || self.list.iter().any(|w| w.id == id)
}
@ -197,36 +208,51 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, path: Option<
if let Some(why) = one {
return Err(why.into());
}
for w in &mut app.windows.list {
w.docs.retain(|u| *u != uid);
}
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
app.windows.last_id += 1;
let id = app.windows.last_id;
app.windows.list.push(Window {
id,
docs: vec![uid],
active: Some(uid),
pane: Pane {
// A new window opens like the one it comes from: the same tabs and panel widths.
ui: UiState {
title_bar: app.ui.title_bar,
right_tab: app.ui.right_tab,
left_tab: app.ui.left_tab,
left_width: app.ui.left_width,
right_width: app.ui.right_width,
..UiState::default()
},
..Pane::default()
},
// A new window opens like the one it comes from: the same tabs and panel widths.
let ui = UiState {
title_bar: app.ui.title_bar,
right_tab: app.ui.right_tab,
left_tab: app.ui.left_tab,
left_width: app.ui.left_width,
right_width: app.ui.right_width,
..UiState::default()
};
let geo = Geometry {
pos: at.or_else(|| outer.map(|r| r.min + Vec2::splat(32.0))),
size: inner.map_or(Vec2::new(1280.0, 800.0), |r| r.size()),
});
size: inner.map_or(Geometry::default().size, |r| r.size()),
..Geometry::default()
};
let id = push(app, ui, geo);
adopt(app, id, &[uid], Some(uid));
app.windows.focused = id;
ctx.request_repaint_of(ViewportId::ROOT);
Ok(id)
}
/// A new window with its panels as in `ui`, at `geo`, without files yet ([`adopt`]). Returns its id.
pub(crate) fn push(app: &mut FigApp, ui: UiState, geo: Geometry) -> u64 {
app.windows.last_id += 1;
let id = app.windows.last_id;
app.windows.list.push(Window { id, docs: Vec::new(), active: None, pane: Pane { ui, ..Pane::default() }, geo, restored: None });
id
}
/// Open files `docs` become tabs of window `id` (0: the main window), which shows `front`.
pub(crate) fn adopt(app: &mut FigApp, id: u64, docs: &[u64], front: Option<u64>) {
for w in &mut app.windows.list {
w.docs.retain(|u| !docs.contains(u));
if w.id == id {
w.docs.extend(docs);
w.active = front.or(w.active);
}
}
// The main window draws now: it shows the session's file.
if let Some(i) = front.filter(|_| id == 0).and_then(|u| app.session.docs.iter().position(|d| d.uid == u)) {
app.session.active = i;
}
}
/// Bring window `id` to the front; control requests go to it from now on.
pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String> {
if !app.windows.exists(id) {
@ -242,6 +268,11 @@ pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String>
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
for r in std::mem::take(&mut app.window_requests) {
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
// The main window places restored windows when its frame begins.
if r.kind == "session.restore" {
ctx.request_repaint_of(ViewportId::ROOT);
continue;
}
let res = if r.kind == "window.new" {
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
} else {
@ -253,7 +284,9 @@ pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
}
}
/// Every window: id, title, its files and the one it shows (`window.list`).
/// Every window: id, title, its files and the one it shows, where it is on screen (`rect`:
/// `[x, y, width, height]` while neither maximized nor full screen) and the screen it is on
/// (`window.list`).
pub fn list(app: &FigApp) -> Value {
let active_of = |id: u64| -> Option<u64> {
if id == app.windows.current {
@ -269,14 +302,19 @@ pub fn list(app: &FigApp) -> Value {
ids.map(|id| {
let mine = |d: &&DocState| app.windows.of(d.uid) == id;
let shown = app.session.docs.iter().filter(mine).find(|d| Some(d.uid) == active_of(id));
json!({
let geo = app.windows.list.iter().find(|w| w.id == id).map_or(&app.windows.main, |w| &w.geo);
let mut out = json!({
"id": id,
"main": id == 0,
"title": title_of(shown),
"files": app.session.docs.iter().filter(mine).map(|d| d.doc.name.as_str()).collect::<Vec<_>>(),
"active": shown.map(|d| d.doc.name.as_str()),
"focused": id == app.windows.focused,
})
});
if let (Some(out), Value::Object(geo)) = (out.as_object_mut(), geo.json(&app.monitors)) {
out.extend(geo);
}
out
})
.collect(),
)
@ -338,6 +376,8 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
/// (a window left with none closes) and the main window shows one of its own files.
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
app.windows.current = 0;
app.windows.main.track(ctx);
crate::restore::begin(app, ctx);
for id in std::mem::take(&mut app.windows.closing) {
if id == 0 && app.windows.list.is_empty() {
app.ui.quit = true;
@ -357,10 +397,10 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
// The main window takes over another window: its files, panels and place on screen.
let mut w = app.windows.list.remove(0);
exchange(app, &mut w.pane);
if let Some(p) = w.pos {
if let Some(p) = w.geo.pos {
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::OuterPosition(p));
}
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.size));
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.geo.size));
app.windows.focused = 0;
show_own(app, w.active);
}
@ -368,7 +408,9 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
for w in &mut app.windows.list {
w.docs.retain(|u| open.contains(u));
}
app.windows.list.retain(|w| !w.docs.is_empty());
// A restored window whose online files are still on their way waits for them.
let waiting: Vec<usize> = app.session.restore.joining().map(|(k, _)| k).collect();
app.windows.list.retain(|w| !w.docs.is_empty() || w.restored.is_some_and(|k| waiting.contains(&k)));
if !app.windows.exists(app.windows.focused) {
app.windows.focused = 0;
}
@ -408,8 +450,14 @@ pub(crate) fn repaint_others(app: &FigApp, ctx: &Context) {
pub(crate) fn show(app: &FigApp, ctx: &Context) {
for w in &app.windows.list {
let title = title_of(app.session.docs.iter().find(|d| Some(d.uid) == w.active));
let mut b = ViewportBuilder::default().with_title(title).with_inner_size(w.size).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true);
if let Some(p) = w.pos {
let mut b = ViewportBuilder::default()
.with_title(title)
.with_inner_size(w.geo.size)
.with_min_inner_size([800.0, 500.0])
.with_maximized(w.geo.maximized)
.with_fullscreen(w.geo.fullscreen)
.with_drag_and_drop(true);
if let Some(p) = w.geo.pos {
b = b.with_position(p);
}
b = match w.pane.ui.title_bar {
@ -459,12 +507,10 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
repaint_others(app, &ctx);
}
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id);
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
exchange(app, &mut pane);
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
w.active = shown.or(w.active);
w.pos = outer.map(|r| r.min).or(w.pos);
w.size = inner.map_or(w.size, |r| r.size());
w.geo.track(&ctx);
w.pane = pane;
}
app.windows.current = 0;

View File

@ -133,6 +133,12 @@ live sessions (`collab.*`: without a key on a server open to the public, with `c
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
(`plugin.directory.*`); the one-shot `run` can't (no network link).
The desktop app's own command line is `figcraft [--control PORT] [--fresh] [files…]
[figcraft://join?…]`: files and an invite link open in front of the windows and tabs from last
time, which open again by themselves (Preferences ▸ Reopen windows and tabs from last time);
`--fresh` starts without them once, leaving the preference as it is (`session.restore` still
brings them back in that run).
### `plugin publish <folder> --server ws://host:port/[?key=KEY]`
Publishes a plugin folder (`figcraft-plugin.json`, the module its `main` names, an optional `assets/`

View File

@ -241,9 +241,12 @@ for menus, the Actions palette and `engine.execute`.
### `window.list {}`
Every window: `[{id, main, title, files, active, focused}]` (`files` are its tabs' names, `active`
the one it shows, `focused` the window requests go to). `ui.inspect` reports it as `windows`, plus
`window` (the window it ran in).
Every window: `[{id, main, title, files, active, focused, rect, maximized, fullscreen, monitor}]`
(`files` are its tabs' names, `active` the one it shows, `focused` the window requests go to;
`rect` is `[x, y, width, height]` in screen points, its outer position and inner size while it is
neither maximized nor full screen, so a maximized window reports the place it goes back to;
`monitor` is the screen showing most of it, `[x, y, width, height]`, or `null` when none is known).
`ui.inspect` reports it as `windows`, plus `window` (the window it ran in).
### `window.focus {id}`
@ -257,6 +260,26 @@ closing while others are open takes over one of them, and closing the last windo
Close the app (every window, without asking about unsaved changes).
### Session restore
The desktop app keeps its windows and tabs with the preferences as they change and opens them
again at the next start (see [ui-parity.md](ui-parity.md)); `figcraft --fresh` starts without
them. `ui.inspect` reports it as `session`:
- `saved`: the session kept now, `{windows: [{rect, maximized, fullscreen, monitor, tabs: [{path}
| {server, file}, page, view: [cx, cy, zoom]], front, leftTab, rightTab, leftCollapsed,
panelWidths}]}`, the main window first. Tabs are saved files and your online files (their
server and id, never a token); documents never saved aren't in it.
- `restore`: the preference (`pref.restoreSession {on?}`).
- `offered`: this run started without the last run's session (the preference, `--fresh`, or the
last run never got through restoring), and `session.restore` brings it back.
- `joining`: restored online files still on their way from their servers.
- `monitors`: the screens the app knows, `[[x, y, width, height]]`.
`engine.execute {command: "session.restore", params: {session?}}` restores the session on offer
(else the one kept), or the saved session given: it returns `{windows, opened, joining, missing}`
and the windows are placed at the main window's next frame (`window.list` shows them).
## Example session (shell)
```sh

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (session restore; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@ -357,9 +357,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
dragged off the strip; each window has its own tabs, panels and canvas and draws only when it
needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and
`window` on any control request.
- **Landed (2026-10-13):** session restore: every window's place, size, state, screen, panels and
tabs are kept with the preferences as they change and come back at the next start (off-screen
windows move onto a screen; `session.restore`, `pref.restoreSession`, `--fresh`).
- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on
screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36
gives only the main window one); window positions restored at the next start.
screen; exports keep them). A screen-reader tree in windows other than the main one is blocked
upstream (eframe 0.36 gives only the main window an accessibility adapter): nothing to do here
until it adapts every window; meanwhile files stay tabs of the main window while a screen reader
runs.
- **Estimate:** 4–8 h.
### 19. AI features
@ -394,6 +399,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Gap 18: window places are restored at the next start (session restore: windows, tabs, pages, views and panels come back as they were left); what is left is wide-gamut colour on screen and the screen-reader tree in other windows, both blocked upstream; estimate and ranking unchanged |
| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged |
| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged |
| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged |

View File

@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (window places restored at the next start; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@ -18,7 +18,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs (blocked upstream); each window's place, size, state and screen come back at the next start, and one whose screen is gone moves onto a screen there is; the presenter stays inside its window; one window in the browser | 1–2 |
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Several windows: places, sizes, maximized and full-screen states and screens are kept with the preferences and restored at the next start (a window off every screen moves onto one); numbers unchanged |
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |

View File

@ -922,6 +922,18 @@ opens one (a new file, the file at `path`, or open file `index` moved out of its
focus a window before working on its files, and pass its id to `screenshot {window}` to see it.
Headless (one session, no windows) and in the browser the two commands do nothing.
**Session restore.** The desktop app opens the windows and tabs it was left with at the next
start. `run_command` with `session.restore {session?}` does it on demand: without `session` it
restores the last run's session when this run started without it (else the one kept with the
preferences); with one, `{windows: [{tabs: [{path} | {server, file}, page?, view?: [cx, cy,
zoom]], front?, rect?, maximized?, fullscreen?}]}`, it opens those tabs in that order with their
page and view. It returns `{windows, opened, joining (your online files on their way from their
servers), missing (the names of files that are gone)}`; a file that is open already is used as it
is. Headless there are no windows and no preferences file, so pass `session`: the tabs of every
window open in the one session, in order, and the first window's front tab is the active document.
`pref.restoreSession {on?}` turns reopening at start on or off (on by default). Attached,
`inspect_ui` reports the kept session as `session.saved`.
### Live sessions
People (and agents) edit one file together and see each other's cursors, selections and

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (session restore: windows and tabs come back as they were left; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@ -24,7 +24,8 @@ interaction feel not yet compared.
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open and recent files, a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window comes back at the next start where it was, with its size, state, panels and tabs (session restore, next row). A screen-reader tree in windows other than the main one is blocked upstream: eframe 0.36 gives only the main window an accessibility adapter, so while a screen reader runs files stay tabs of the main window (restored ones too) |
| Session restore | done (ours) | The app comes back the way it was left. Kept with the preferences, written whenever it changes (at once, then at most once a second, never while nothing changes; a crash or a forced quit has it too): each window's place, size, maximized or full-screen state and screen, its side panels' tabs and widths, its tabs in order with the one in front, and per tab the saved file's path or, for one of your online files, its server and id (never a token), the page shown and the view (one view per file, not per page). At the next start the windows and tabs open again; a window whose screen is gone or that no screen shows enough of to grab moves onto a screen (its own if it is still there, else the nearest) and is never larger than it; files that are gone are skipped with one message ("2 files couldn't be reopened") and stay in Open recent; an online file is joined again as its owner and takes its place among the tabs when it arrives (one whose server can't be reached is skipped with a message saying so, and stays under Home ▸ Online); a file or invite link the app was started with opens in addition, in front. Documents never saved aren't part of it: unsaved work comes back from the recovery copies after a crash, and a normal quit keeps none. Preferences ▸ Reopen windows and tabs from last time (`pref.restoreSession`, on by default) and the `--fresh` flag start without it. If the last run never got through restoring (a mark written before and cleared once the windows have settled), this run starts on the home screen without restoring, and the home screen offers Restore last session (`session.restore`) instead. A first run is still the home screen. Holding Shift at start isn't read (the flag is the way); the browser keeps no preferences, so nothing is restored there |
| Multiplayer avatars, Share button | missing | waits for multiplayer |
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
@ -164,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Session restore: the windows and tabs open when the app was left open again at the next start: each window's place, size, maximized or full-screen state, screen, panels and tabs in order with the one in front; per tab the file (or your online file, joined again as its owner), its page and view. Written with the preferences as it changes; windows off every screen move onto one; files that are gone get one message; a crash during restoring starts the next run without it and offers it on the home screen; Preferences ▸ Reopen windows and tabs from last time, `--fresh`, `session.restore`, `pref.restoreSession`; `window.list` reports each window's rectangle and state. Several windows: positions are restored; the screen-reader tree in other windows stays blocked upstream |
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing |
| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first |