From bd482ea6dd007da03dd9908949b40176f3410418 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:15:32 +0200 Subject: [PATCH 01/29] fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) --- crates/ui-egui/src/layers.rs | 115 ++++++++++--------- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++ 2 files changed, 106 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 956709f4..3a3c4ef9 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -215,64 +215,73 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - if renaming { - let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = - crate::widgets::text_field_inline(ui, Id::new(("pagename", id)), &name, tr, if id == cur { semibold(11.0) } else { regular(11.0) }) + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) { - rename_done = Some((id, new)); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - if resp.double_clicked() { - app.ui.rename_page = Some((id, name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + if renaming { + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + if let Some(new) = crate::widgets::text_field_inline( + ui, + Id::new(("pagename", id)), + &name, + tr, + if id == cur { semibold(11.0) } else { regular(11.0) }, + ) { + rename_done = Some((id, new)); + } + } else if !divider { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); - } - if let Some((from, to)) = reorder.finish(ui) + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e1fdecc4..b82e8d7b 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -226,3 +226,47 @@ fn pages_drag_to_reorder_and_dividers_open_nothing() { assert_eq!(u.app.session.page().unwrap(), p1, "clicking a divider opens nothing"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +} From 8b8825ce681837a3166536bc4c0ac55ca49f1d06 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 17:37:59 +0300 Subject: [PATCH 02/29] M1: say why when the window can't open (#261) Release builds on Windows have no console, so an eframe::run_native error (no usable graphics adapter, a device the driver refuses) went to an unseen standard error and the app quit silently. The error is now logged and shown in a message box with a hint: unset WGPU_BACKEND when it is set, otherwise update the driver or try another graphics API (OpenGL on Windows), and where to report it. With --control the box is skipped so agents aren't blocked. --- apps/figcraft/src/main.rs | 10 ++++++- apps/figcraft/src/startup.rs | 44 ++++++++++++++++++++++++++++++ apps/figcraft/src/startup_tests.rs | 35 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 apps/figcraft/src/startup.rs create mode 100644 apps/figcraft/src/startup_tests.rs diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d8..4533f0bf 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,7 @@ mod gamepad; mod graphics; mod recovery; mod services; +mod startup; #[cfg(test)] mod args_tests; @@ -26,6 +27,8 @@ mod recovery_online_tests; mod recovery_tests; #[cfg(test)] mod services_tests; +#[cfg(test)] +mod startup_tests; use std::sync::{Arc, Mutex}; @@ -188,5 +191,10 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); 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 })))) + let result = eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))); + // Say why instead of quitting silently: release builds on Windows have no console. + if let Err(e) = &result { + startup::failed(e, port.is_some()); + } + result } diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs new file mode 100644 index 00000000..8a50d7a5 --- /dev/null +++ b/apps/figcraft/src/startup.rs @@ -0,0 +1,44 @@ +//! When the window can't open. +//! +//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable +//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees +//! and the app quit without a word (#261). Now it is logged and shown in a message box. + +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named +/// the graphics API, so the hint is to unset it rather than to try another one. +pub fn message(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 + // adapter it picked even when its device then fails, so naming OpenGL alone can still help. + "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" + } else { + "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." + }; + format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") +} + +/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would +/// keep the caller waiting, and it reads standard error), show it in a message box. +pub fn failed(error: &eframe::Error, quiet: bool) { + let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // Standard error even when RUST_LOG turned errors off. + if log::log_enabled!(log::Level::Error) { + log::error!("{text}"); + } else { + eprintln!("figcraft: {text}"); + } + if quiet { + return; + } + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("FigCraft") + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs new file mode 100644 index 00000000..6818ae77 --- /dev/null +++ b/apps/figcraft/src/startup_tests.rs @@ -0,0 +1,35 @@ +//! The message shown when the window can't open: it carries the error, a next step and where to +//! report it, instead of the app quitting silently (#261). + +use crate::startup::message; + +const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; + +#[test] +fn the_message_carries_the_error_and_where_to_report_it() { + let text = message(ERROR, false); + assert!(text.contains(ERROR), "the error itself: {text}"); + assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}"); +} + +#[test] +fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { + let text = message(ERROR, false); + assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); + assert!(!text.contains("unsetting"), "{text}"); +} + +#[cfg(windows)] +#[test] +fn on_windows_it_shows_how_to_start_with_opengl() { + let text = message(ERROR, false); + assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}"); +} + +#[test] +fn with_wgpu_backend_set_it_suggests_unsetting_it() { + let text = message(ERROR, true); + assert!(text.contains("unsetting"), "{text}"); + assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); +} From 00ac44f6caf4dd8f6081aa96927c507081c35ba1 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Wed, 14 Oct 2026 16:48:56 +0200 Subject: [PATCH 03/29] M1: save files automatically Preferences > Save files automatically (pref.autoSave, off by default; hidden in the browser, which has no files to save to). With it on, each open file that has a path is saved 3 s after it changes, never during a gesture, a live text preview or while a version is shown. A failed save says why in a toast and tries again a minute later. Version history checkpoints (version.autosave, every 30 minutes) only ran for files with unsaved changes, so a file that saves itself would stop getting them; with auto-save on, edits made since the file was opened count. Closes #296 --- ROADMAP.md | 4 ++- crates/engine/src/autosave_tests.rs | 19 +++++++++++++ crates/engine/src/cmd/file.rs | 7 +++-- crates/engine/src/cmd/view.rs | 1 + crates/engine/src/lib.rs | 4 +++ crates/ui-egui/locales/de.toml | 3 +++ crates/ui-egui/locales/en.toml | 2 ++ crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/locales/fr.toml | 3 +++ crates/ui-egui/locales/ja.toml | 3 +++ crates/ui-egui/locales/ko.toml | 3 +++ crates/ui-egui/locales/pt-BR.toml | 3 +++ crates/ui-egui/locales/zh-Hans.toml | 3 +++ crates/ui-egui/src/dialogs.rs | 4 +++ crates/ui-egui/src/idle_tests.rs | 23 ++++++++++++++++ crates/ui-egui/src/lib.rs | 42 +++++++++++++++++++++++++++++ docs/mcp.md | 2 ++ docs/roadmap.md | 3 ++- 18 files changed, 128 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..f19a72e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c..e9f0427d 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,22 @@ fn autosaves_keep_their_flag_in_the_file() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn files_that_save_themselves_still_get_checkpoints() { + let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("pref.autoSave", &json!({"on": true})).unwrap(); + rect(&mut s, 0.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(autosave(&mut s), "saved, but changed since the last version"); + assert!(!autosave(&mut s), "no change since the checkpoint"); + // Just opened: nothing to checkpoint. + let mut t = Session::empty(); + t.execute("pref.autoSave", &json!({"on": true})).unwrap(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert!(!autosave(&mut t)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..1b6fe49c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { spec!( "version.autosave", "Autosave version", - "{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}", + "{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}", false, autosave ), @@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; let ds = s.ds_mut()?; - if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5df..9c1371cc 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -334,6 +334,7 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..9ee0c372 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -564,6 +564,9 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// Files that have a path are saved by themselves a few seconds after they change (the app's + /// timer; off: only File ▸ Save writes them). + pub auto_save: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -616,6 +619,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + auto_save: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..aad1d643 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -301,6 +301,7 @@ status = "draft — needs native review" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1070,6 +1071,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1784,6 +1786,7 @@ status = "draft — needs native review" "plugin.ui" = "Plugin-Bereich" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" +"pref.autoSave" = "Dateien automatisch speichern" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..550268f7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -310,6 +310,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1112,6 +1113,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..fa5e5ff2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -311,6 +311,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1113,6 +1114,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -1865,6 +1867,7 @@ status = "draft — needs native review" "plugin.ui" = "Panel del plugin" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" +"pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..e3022acb 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,6 +301,7 @@ status = "draft — needs native review" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -1070,6 +1071,7 @@ status = "draft — needs native review" "Save" = "Enregistrer" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" +"Save files automatically" = "Enregistrer les fichiers automatiquement" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -1784,6 +1786,7 @@ status = "draft — needs native review" "plugin.ui" = "Panneau du plugin" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" +"pref.autoSave" = "Enregistrer les fichiers automatiquement" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9f..36bdfd43 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -303,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" "Create a variable collection first" = "先に変数コレクションを作成" @@ -1072,6 +1073,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" +"Save files automatically" = "ファイルを自動保存" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "plugin.ui" = "プラグインパネル" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" +"pref.autoSave" = "ファイルを自動保存" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb2386..c81420b3 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -302,6 +302,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" "Create a variable collection first" = "먼저 변수 컬렉션을 만드세요" @@ -1071,6 +1072,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1785,6 +1787,7 @@ status = "draft — needs native review" "plugin.ui" = "플러그인 패널" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" +"pref.autoSave" = "파일 자동 저장" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..ea0904d7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" "Create a variable collection first" = "Crie primeiro uma coleção de variáveis" @@ -1069,6 +1070,7 @@ status = "draft — needs native review" "Save" = "Salvar" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" +"Save files automatically" = "Salvar arquivos automaticamente" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1783,6 +1785,7 @@ status = "draft — needs native review" "plugin.ui" = "Painel do plugin" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" +"pref.autoSave" = "Salvar arquivos automaticamente" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df0..08246546 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -303,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" "Create a variable collection first" = "请先创建变量集合" @@ -1072,6 +1073,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "另存为" "Save as preset…" = "存储为预设…" +"Save files automatically" = "自动保存文件" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "plugin.ui" = "插件面板" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" +"pref.autoSave" = "自动保存文件" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..0b712ccb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -163,6 +163,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if app.services.reduce_motion() { crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary); } + // The browser has no files to save to. + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { + toggles.push("pref.autoSave"); + } for c in toggles { app.run(c, json!({})); } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..33a9086c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,26 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn auto_save_writes_a_changed_file_then_rests() { + let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let (mut h, _) = document(); + h.run("file.save", json!({"path": path})); + h.run("pref.autoSave", json!({"on": true})); + h.assert_idle("a saved file"); + h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); + assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); + assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); + h.time += crate::AUTO_SAVE_SECS; + h.frame(vec![]); + assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + let ds = t.ds().unwrap(); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 540.0, "the new rectangle is in the file"); + h.assert_idle("an auto-saved file"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..6ba2d5da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -241,6 +241,11 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// With auto-save on: how long after a file changes it is saved, and how long a failed save waits +/// before trying again (seconds). +pub const AUTO_SAVE_SECS: f64 = 3.0; +const AUTO_SAVE_RETRY_SECS: f64 = 60.0; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -376,6 +381,8 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved. + pub auto_save_at: Option, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -460,6 +467,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, + auto_save_at: None, libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -921,6 +929,39 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they change + /// (not during a gesture, a live preview or while a version is shown). + fn auto_save_tick(&mut self, ctx: &egui::Context) { + let s = &self.session; + let unsaved = |d: &figcraft_engine::DocState| d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty(); + let due: Vec = s.docs.iter().enumerate().filter(|(_, d)| unsaved(d)).map(|(i, _)| i).collect(); + if !s.prefs.auto_save || due.is_empty() { + self.ui.auto_save_at = None; + return; + } + let now = ctx.input(|i| i.time); + let at = *self.ui.auto_save_at.get_or_insert(now + AUTO_SAVE_SECS); + if now < at || s.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + return; + } + let active = self.session.active; + let mut failed = None; + for i in due { + self.session.active = i; + if let Err(e) = self.session.execute("file.save", &json!({})) { + failed.get_or_insert(e.to_string()); + } + } + self.session.active = active; + if let Some(e) = failed { + self.ui.auto_save_at = Some(now + AUTO_SAVE_RETRY_SECS); + repaint_in(ctx, AUTO_SAVE_RETRY_SECS); + self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); + } + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] @@ -930,6 +971,7 @@ impl FigApp { let first = !self.fonts_installed; self.logic(&ctx); self.autosave_tick(&ctx); + self.auto_save_tick(&ctx); if first { // New fonts apply from the next frame; draw nothing until then. ctx.request_repaint(); diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..352e96e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,6 +349,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change + (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d6..e31aa387 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | From ed0e7a9b2d6b0bd82a49a3939c9d7daaca911183 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:11:20 +0200 Subject: [PATCH 04/29] M1: leaving Design ends the pen path and goes back to Move (fixes #307) --- crates/ui-egui/src/lib.rs | 20 ++++++ crates/ui-egui/src/pen_mode_tests.rs | 94 ++++++++++++++++++++++++++++ crates/ui-egui/src/toolbar.rs | 2 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/pen_mode_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..f851adb5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,6 +122,8 @@ mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; #[cfg(test)] +mod pen_mode_tests; +#[cfg(test)] mod perf_tests; #[cfg(test)] mod plugin_panel_tests; @@ -280,6 +282,8 @@ pub struct UiState { pub left_width: f32, pub right_width: f32, pub right_tab: RightTab, + /// The tab of the previous frame, to see the right panel change mode. + pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, pub expanded: BTreeSet, @@ -400,6 +404,7 @@ impl Default for UiState { left_width: PANEL_DEFAULT, right_width: PANEL_DEFAULT, right_tab: RightTab::Design, + last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, expanded: BTreeSet::new(), @@ -610,6 +615,19 @@ impl FigApp { } } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect + /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels + /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. + fn end_pen_outside_design(&mut self) { + if self.ui.right_tab == self.ui.last_right_tab { + return; + } + self.ui.last_right_tab = self.ui.right_tab; + if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen { + self.run("tool.set", json!({"tool": "move"})); + } + } + /// Once a frame, after the panels: a preview nothing asked for this frame ends. fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { if !std::mem::take(&mut self.ui.text_preview_wanted) { @@ -984,6 +1002,7 @@ impl FigApp { widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + self.end_pen_outside_design(); if self.ui.present.is_some() { present::show(self, ui); // What went wrong while presenting (a widget's plugin failing) shows over it. @@ -1086,6 +1105,7 @@ impl FigApp { variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); self.end_unwanted_text_preview(&ctx); + self.end_pen_outside_design(); dialogs::show(self, &ctx); self.toast_ui(&ctx); // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs new file mode 100644 index 00000000..6fa54959 --- /dev/null +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -0,0 +1,94 @@ +//! Leaving Design with a pen path open (#307): the path ends as Escape ends it, the tool goes back +//! to Move, and the canvas of Prototype or Inspect takes no more pen points. Driven by the panel +//! keys and by the toolbar's mode switcher. + +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::json; + +use crate::RightTab; +use crate::layers_drag_tests::Ui; + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn layers(u: &Ui) -> usize { + let doc = u.app.session.doc().unwrap(); + doc.node(u.app.session.page().unwrap()).unwrap().children.len() +} + +/// The middle of the toolbar's mode button named `label`, found in the AccessKit tree. +fn mode_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// A pen path with two anchors still open on an empty page. +fn drawing() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + click(&mut u, pos2(600.0, 400.0)); + click(&mut u, pos2(700.0, 450.0)); + assert_eq!(u.app.session.tool.pen.as_ref().map(|p| p.points.len()), Some(2), "the path is open"); + u +} + +#[test] +fn switching_mode_ends_the_pen_path_as_escape_does() { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + assert!(esc.app.session.tool.pen.is_none()); + let finished = layers(&esc); + assert_eq!(finished, 1, "Escape keeps the two-anchor path"); + + // Shift+E is Prototype, Shift+D is Inspect. + for key in [Key::E, Key::D] { + let mut u = drawing(); + press(&mut u, key, Modifiers::SHIFT); + assert_ne!(u.app.ui.right_tab, RightTab::Design, "{key:?}"); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{key:?}: back to Move"); + assert_eq!(layers(&u), finished, "{key:?}: the path is kept as Escape keeps it"); + // The canvas takes no more pen points. + click(&mut u, pos2(800.0, 300.0)); + assert!(u.app.session.tool.pen.is_none(), "{key:?}: a click starts no path"); + assert_eq!(layers(&u), finished, "{key:?}: a click adds nothing"); + } +} + +#[test] +fn the_mode_switcher_ends_the_pen_path_too() { + let finished = { + let mut esc = drawing(); + press(&mut esc, Key::Escape, Modifiers::NONE); + layers(&esc) + }; + // Design is already the mode while the pen draws: pressing it still leaves the pen, as it leaves + // the pencil of Draw. + for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect), ("Design", RightTab::Design)] { + let mut u = drawing(); + let at = mode_button(&mut u, label); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, tab, "{label}"); + assert!(u.app.session.tool.pen.is_none(), "{label}: the path is finished"); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move"); + assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1e6e38aa..24af4a64 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } 1 => { app.ui.right_tab = crate::RightTab::Design; - if app.session.tool.tool == ToolId::Pencil { + if matches!(app.session.tool.tool, ToolId::Pencil | ToolId::Pen) { app.run("tool.set", json!({"tool": "move"})); } } From 1aeb5abb74b4b057af82d76bc7c9b42dd9233c66 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:43:32 +0200 Subject: [PATCH 05/29] M1: a click on the toolbar no longer reaches the canvas under it (#307) --- crates/ui-egui/src/pen_mode_tests.rs | 16 ++++++++++++++++ crates/ui-egui/src/toolbar.rs | 4 ++++ 2 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs index 6fa54959..31af1c66 100644 --- a/crates/ui-egui/src/pen_mode_tests.rs +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -92,3 +92,19 @@ fn the_mode_switcher_ends_the_pen_path_too() { assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); } } + +#[test] +fn a_press_on_the_mode_switcher_does_not_reach_the_canvas() { + // The switcher floats over the canvas: the pen must not place an anchor under it. + for label in ["Prototype", "Design"] { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "pen"})); + let at = mode_button(&mut u, label); + u.button(at, true); + assert!(u.app.session.tool.pen.is_none(), "{label}: the press starts no path"); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.tool.pen.is_none(), "{label}: no path after the click"); + assert_eq!(layers(&u), 0, "{label}: the click adds nothing"); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 24af4a64..3e4a224e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -329,6 +329,10 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives + // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. + // Not focusable: the keyboard walks the buttons, not the pill. + ui.interact(rect, ui.id().with("pill"), Sense::CLICK | Sense::DRAG); a11y::area(nav, Region::Toolbar, rect); a11y::container_bounds(ui, rect); let painter = ui.painter(); From 698e249fbf2ef679f58d1c81e0efd51bbb37fff6 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Wed, 14 Oct 2026 18:45:15 +0300 Subject: [PATCH 06/29] M1: drop shadows under a background blur (#255) A layer with a background blur (or glass) drew its blurred backdrop into its own pixels before casting its drop shadows from them, so a translucent fill cast a shadow as dark as an opaque one, and with "show behind transparent areas" the shadow was cast from the backdrop's pixels. The backdrop was also read before the shadows were drawn, so a shadow showing behind was never seen through the blur. When a layer has both, it now casts its shadows from what it paints (with the layer blur, noise and texture it will get), draws them, and only then reads the backdrop, which goes under its content; the content still sees the first backdrop under it while it's drawn. Layers without both draw as before. --- crates/render/src/backdrop_shadow_tests.rs | 79 ++++++++++++++++++++ crates/render/src/draw.rs | 86 +++++++++++++++------- crates/render/src/lib.rs | 2 + 3 files changed, 142 insertions(+), 25 deletions(-) create mode 100644 crates/render/src/backdrop_shadow_tests.rs diff --git a/crates/render/src/backdrop_shadow_tests.rs b/crates/render/src/backdrop_shadow_tests.rs new file mode 100644 index 00000000..9c5fb562 --- /dev/null +++ b/crates/render/src/backdrop_shadow_tests.rs @@ -0,0 +1,79 @@ +//! A drop shadow under a layer with a background blur (#255): the shadow is cast by what the layer +//! paints (a translucent fill casts a faint one), not by the blurred backdrop drawn through its +//! shape, and the blur reads what lies under the layer with its own shadow there, so a shadow +//! that shows behind transparent areas is seen, blurred, through the layer. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +/// A white 240 × 200 frame with blue and green stripes and, on top, a pane at (60, 40) 120 × 80 +/// filled 20% black, with a hard black drop shadow 40 down (below it and under its lower half) +/// and, when `blur`, a background blur. +fn scene(show_behind: bool, blur: bool) -> (Document, NodeId) { + let mut d = Document::new("shadow behind blur"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "Frame", 0.0, 0.0, 240.0, 200.0).unwrap(); + d.get_mut(f).unwrap().fills = vec![Paint::solid(Color::from_hex("FFFFFF").unwrap())]; + for (i, x) in [20.0, 80.0, 140.0, 200.0].into_iter().enumerate() { + let s = d.create(f, NodeKind::Rectangle, "Stripe", x, 0.0, 20.0, 200.0).unwrap(); + d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex(if i % 2 == 0 { "2040E0" } else { "20C060" }).unwrap())]; + } + let pane = d.create(f, NodeKind::Rectangle, "Pane", 60.0, 40.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.2))]; + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 40.0), radius: 0.0, show_behind, ..Shadow::default() }; + n.effects = vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]; + if blur { + n.effects.push(Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(8.0)), visible: true }); + } + (d, f) +} + +fn px(p: &Pixmap, x: u32, y: u32) -> [u8; 4] { + let i = ((y * p.width() + x) * 4) as usize; + [p.data()[i], p.data()[i + 1], p.data()[i + 2], p.data()[i + 3]] +} + +/// The largest channel difference between `a` and `b` over the shadow below the pane +/// (60..180 × 122..160). +fn max_diff_below_pane(a: &Pixmap, b: &Pixmap) -> u8 { + let mut worst = 0; + for y in 122..160 { + for x in 60..180 { + worst = worst.max(px(a, x, y).iter().zip(px(b, x, y)).map(|(c, d)| c.abs_diff(d)).max().unwrap()); + } + } + worst +} + +#[test] +fn a_background_blur_doesnt_change_the_shadow_its_layer_casts() { + for show_behind in [false, true] { + let (with_blur, f) = scene(show_behind, true); + let (without, _) = scene(show_behind, false); + let got = render_node(&with_blur, f, 1.0).unwrap(); + let want = render_node(&without, f, 1.0).unwrap(); + let diff = max_diff_below_pane(&got, &want); + assert!(diff <= 2, "show behind {show_behind}: the shadow below the pane differs by {diff}"); + // A 20% fill casts a faint shadow: the white between the stripes stays light. + let light = px(&got, 120, 140); + assert!(light[0] > 180, "show behind {show_behind}: {light:?}"); + } +} + +#[test] +fn a_shadow_showing_behind_is_seen_through_the_background_blur() { + let (d, f) = scene(true, true); + let got = render_node(&d, f, 1.0).unwrap(); + // Between the stripes, the pane's top (no shadow under it) and its lower half (its own shadow + // under it, from y = 80). + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0] > low[0] + 20, "the shadow under the pane doesn't show through: top {top:?}, lower half {low:?}"); + // Hidden behind the layer, it doesn't. + let (d, f) = scene(false, true); + let got = render_node(&d, f, 1.0).unwrap(); + let (top, low) = (px(&got, 120, 50), px(&got, 120, 110)); + assert!(top[0].abs_diff(low[0]) <= 2, "a hidden shadow shows: top {top:?}, lower half {low:?}"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index be839b27..af73aeeb 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -423,16 +423,15 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if backdrop::has_effects(n) - && let Some(sh) = &backdrop::outline(ctx.doc, n) - { - let pad = backdrop::pad(n, ctx.scale); - let p = i32::try_from(pad).unwrap_or(0); - if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) - && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) - { - layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); - } + let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None }; + let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf)); + // With drop shadows too, the layer casts them from what it paints, not from the backdrop, and + // the backdrop is read again once they lie under it (a shadow showing behind is seen through + // it). Until then, the layer's content sees the backdrop read first under it (#255). + let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); + let first_back = read_backdrop(pix); + if !casts && let Some(b) = &first_back { + layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } let op = n.opacity.clamp(0.0, 1.0); { @@ -448,9 +447,12 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } }; let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))); - // Backdrops further down see this layer composited onto `pix`. + // Backdrops further down see this layer composited onto `pix` (over its backdrop, when + // that is kept apart for now). let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; - draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); + let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under))); + let content_under = over_back.as_ref().unwrap_or(&under); + draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // A group is no outline of its own: its inner shadows are cast from around what its layers // draw together, over them. @@ -465,24 +467,58 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep } } // Layer blur, noise and texture, in list order, over everything else. + if !casts { + layer_effects(ctx, &mut layer, n, id, lxf); + } + // Drop shadows go under the layer, in effect-list order. + { + // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content + // with the layer effects it will get. + let content = casts.then(|| { + let mut c = layer.clone(); + layer_effects(ctx, &mut c, n, id, lxf); + c + }); + let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::DropShadow(s) = &e.kind + && let Some(sh) = shadow::drop_shadow(&caster, s) + { + composite(pix, (lx, ly), &sh, op, s.blend); + } + } + } + if casts { + // The backdrop with the shadows under it, the content over it, then the layer effects. + if let Some(mut b) = read_backdrop(pix) { + b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + layer = b; + } + layer_effects(ctx, &mut layer, n, id, lxf); + } + composite(pix, (lx, ly), &layer, op, n.blend); +} + +/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on +/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it. +fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option { + let ((lx, ly), (lw, lh)) = (at, size); + let pad = backdrop::pad(n, ctx.scale); + let p = i32::try_from(pad).unwrap_or(0); + let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?; + backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) +} + +/// Node `n`'s layer blur, noise and texture on its `layer`, in list order. +fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) { for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { - EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), - EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), - EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), + EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale), + EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32), + EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } - // Drop shadows go under the layer, in effect-list order. - let caster = shadow::Caster::new(ctx.doc, n, &layer, lxf, ctx.scale); - for e in n.effects.iter().filter(|e| e.visible) { - if let EffectKind::DropShadow(s) = &e.kind - && let Some(sh) = shadow::drop_shadow(&caster, s) - { - composite(pix, (lx, ly), &sh, op, s.blend); - } - } - composite(pix, (lx, ly), &layer, op, n.blend); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c2bf9dda..1025d507 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -425,6 +425,8 @@ pub fn png_insert_chunk(png: Vec, kind: &[u8; 4], data: &[u8]) -> Vec { out } +#[cfg(test)] +mod backdrop_shadow_tests; #[cfg(test)] mod backdrop_tests; #[cfg(test)] From 9404226235d2f78a9116d16e05c62c2370dd120c Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:35:06 -0400 Subject: [PATCH 07/29] M4: vector edit mode keeps the canvas still under the pointer With no points picked, the Design panel's hint ("Select points to edit their position, radius and mirroring") didn't wrap and made the canvas 49 points wider, so entering vector edit mode moved the view by about 25 points and picking a region moved it back. The jump came in the middle of the click that picked the region: with the smallest pointer movement the release landed elsewhere on the page and the region was dragged by that distance (about 47 units at 70%). Both edit mode hints now wrap. Regression test: the canvas rectangle stays the same entering edit mode, picking a region and switching to the width tool. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/vector_panel.rs | 4 ++-- crates/ui-egui/src/vector_panel_tests.rs | 26 ++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 6 ++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/vector_panel_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..5441390e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod vector_panel_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 13f1ac8d..5a327ecc 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -40,7 +40,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let t = crate::theme::Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_space(PAD); - widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary); + widgets::label_wrapped(ui, tr("Drag the stroke to add a width point"), t.text_secondary); }); ui.add_space(8.0); return; @@ -87,7 +87,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let t = crate::theme::Tokens::get(ui.ctx()); ui.horizontal(|ui| { ui.add_space(PAD); - widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); + widgets::label_wrapped(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); }); ui.add_space(8.0); return; diff --git a/crates/ui-egui/src/vector_panel_tests.rs b/crates/ui-egui/src/vector_panel_tests.rs new file mode 100644 index 00000000..d0a5e72e --- /dev/null +++ b/crates/ui-egui/src/vector_panel_tests.rs @@ -0,0 +1,26 @@ +//! The Design panel in vector edit mode keeps its width, so the canvas doesn't move under the +//! pointer when the panel's contents change. +use serde_json::json; + +#[test] +fn the_canvas_stays_put_entering_vector_edit_mode_and_picking_a_region() { + let (mut h, _) = crate::live_tests::Harness::new(); + h.app.session.execute("file.newFromTemplate", &json!({"template": "mobile-app"})).unwrap(); + // A wide vector with a filled area: the panel shows its long hint until something is picked. + let id = { + let doc = h.app.session.doc().unwrap(); + doc.nodes().filter(|n| n.name == "Near ridge").max_by(|a, b| a.height.total_cmp(&b.height)).map(|n| n.id).unwrap() + }; + let canvas = |h: &mut crate::live_tests::Harness| { + h.frames(4); + h.app.inspect()["canvas"]["rect"].clone() + }; + h.app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = canvas(&mut h); + h.app.session.execute("vector.edit.begin", &json!({})).unwrap(); + assert_eq!(canvas(&mut h), before, "the hint for an empty point selection wraps instead of widening the panel"); + h.app.session.execute("vector.edit.select", &json!({"regions": [0]})).unwrap(); + assert_eq!(canvas(&mut h), before, "picking a region doesn't move the canvas either"); + h.app.session.execute("vector.edit.tool", &json!({"tool": "width"})).unwrap(); + assert_eq!(canvas(&mut h), before, "nor does the width tool's hint"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc4..73f8f95f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -934,6 +934,12 @@ pub fn label(ui: &mut Ui, text: &str, color: Color32) { ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); } +/// Text that wraps at the width it is given, so a long hint never widens its panel (a wider +/// panel would move the canvas under the pointer). +pub fn label_wrapped(ui: &mut Ui, text: &str, color: Color32) { + ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(color)).wrap()); +} + /// Text shown in `rect` that turns into an editor on click (or when its editor has focus, e.g. /// after a double-click rename). Returns the new text on Enter / focus loss. pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId) -> Option { From 3e777d2485833a2dfd3d5b05ca0aa1a5593f060a Mon Sep 17 00:00:00 2001 From: Rob Coenen <753704+rcoenen@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:30:10 -0400 Subject: [PATCH 08/29] M7: home screen test waits for the recent-file reader The first-run test settled the home screen with a fixed 12 frames, but the recent files are looked at on a thread of their own and a card shows no name until that thread has answered. On a busy CI runner it hadn't yet, so "ridgeline" was still a grey placeholder and the test failed (seen on this PR and on codex/issue-250-async-dialog). settle() now waits until every recent file has been looked at, as home_recents_tests does. Checked with a reader slowed to 200 ms: the old test fails at the same line as CI, the new one passes. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/home_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 1179b1c4..a8f06476 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -22,8 +22,18 @@ fn shapes(u: &mut Ui) -> Vec { flat(&u.frame_output(vec![]).shapes) } -/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click. +/// Enough frames for every thumbnail in view (they render one a frame) and what follows a click, +/// after every recent file has been looked at (the reader is a thread of its own, and a card +/// shows no name until it has answered). fn settle(u: &mut Ui) -> Vec { + for _ in 0..400 { + u.frame(vec![]); + let files = u.app.inspect()["homeRecents"]["files"].as_array().cloned().unwrap_or_default(); + if files.iter().all(|f| !f["exists"].is_null()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } for _ in 0..12 { u.frame(vec![]); } From fa0365470e6029b03335eadff7985f968960a0ed Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:31:58 +0300 Subject: [PATCH 09/29] M9: remove someone from a live session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server. The hub closes their connection as the end of the session for them (close 1001: they keep their copy and don't reconnect) and bars their address from that file while it runs, so the code or link they hold no longer gets them in; everyone else stays connected. Protocol addition within version 2: `Admin::RemovePeer {file, client}` on an open hosting connection with the file's owner token (or the server key). Older servers say they can't. Tests: loopback (removed, can't rejoin, others stay), engine (host can, guest can't, bad ids refused), codec round trip; the collab junk sweep covers the new command. --- apps/figcraft-server/src/store.rs | 4 + crates/collab-net/src/host.rs | 11 +++ crates/collab-net/src/hub.rs | 101 +++++++++++++++++++++++- crates/collab-net/src/link.rs | 14 ++++ crates/collab-net/src/loopback_tests.rs | 35 +++++++- crates/collab/src/codec_tests.rs | 3 + crates/collab/src/message.rs | 6 ++ crates/engine/src/live.rs | 71 +++++++++++++++++ crates/engine/src/live_tests.rs | 49 ++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/live.rs | 23 +++++- docs/collab-protocol.md | 12 ++- docs/collaboration-parity.md | 5 +- docs/mcp.md | 7 ++ 14 files changed, 335 insertions(+), 7 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8f..352e3086 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1438,6 +1438,10 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + // start feat: remove-from-session + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e..a198ab5c 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -14,6 +14,9 @@ use figcraft_engine::live::{Event, PEOPLE}; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; +// start feat: remove-from-session +pub use crate::hub::REMOVED; +// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -129,6 +132,14 @@ impl Host { self.hosting.send(msg); } + // start feat: remove-from-session + /// Remove client `id` from the session: they are disconnected and can't join again from + /// their address while it runs. The host can't remove themselves. + pub fn remove(&self, id: u32) { + self.hosting.remove(id); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211..69910754 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -21,7 +21,7 @@ //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is //! answered `200 ok` for uptime checks. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -413,6 +413,10 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + // start feat: remove-from-session + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +465,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { let addr = listener.local_addr()?; let in_flight = InFlight(Arc::default()); - let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() }; + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; if let Some(l) = local { let mut room = Room::new(l.server); room.local = Some((l.id, l.inbox)); @@ -551,6 +563,14 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + // start feat: remove-from-session + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1128,18 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; +// start feat: remove-from-session +/// What someone removed from a file is told: the session's end for them (they keep their copy; +/// nothing retries). +pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; + +/// Why a removed person's address can't join the file again. +const BARRED: &str = "you were removed from this session"; + +/// The most addresses one file keeps barred. +const MAX_BARRED: usize = 256; +// end feat: remove-from-session + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1121,6 +1153,10 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + // start feat: remove-from-session + /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + barred: HashMap>, + // end feat: remove-from-session } impl Hub { @@ -1208,6 +1244,15 @@ impl Hub { self.client_message(&file, id, msg, None); } } + // start feat: remove-from-session + HubIn::Remove(id) => { + if let Some(file) = self.local.clone() + && let Err(why) = self.remove_peer(&file, id) + { + log::info!("live session: couldn't remove client {id}: {why}"); + } + } + // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1277,16 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = if owns.as_deref() == Some(file.as_str()) { + self.removed_reply(&file, client) + } else { + AdminReply::Failed { reason: "an owner token manages only its own file".into() } + }; + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1298,12 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1305,6 +1366,12 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + // start feat: remove-from-session + if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { + log::info!("event=refused ip={} reason=removed", c.ip); + return refuse(&c.out, BARRED); + } + // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1429,36 @@ impl Hub { } } + // start feat: remove-from-session + /// Remove client `id` from `file`: they are told the session is over for them and + /// disconnected, and their address can't join the file again while the hub runs. The person + /// hosting can't be removed. + fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { + let room = self.rooms.get(file).ok_or("nobody is in this file")?; + if room.local.as_ref().is_some_and(|(local, _)| *local == id) { + return Err("the person hosting can't be removed".into()); + } + let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let barred = self.barred.entry(file.to_string()).or_default(); + if barred.len() < MAX_BARRED { + barred.insert(c.ip); + } + log::info!("event=removed file={file} client={id} ip={}", c.ip); + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + self.gone(conn); + Ok(()) + } + + /// [`remove_peer`](Self::remove_peer) as a managing connection's answer. + fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply { + match self.remove_peer(file, id) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + } + } + // end feat: remove-from-session + /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f1529..49e085ed 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,20 @@ impl LiveLink for NetLink { } } + // start feat: remove-from-session + fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { + match self.sessions.get(session) { + Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), + Some(Active::Host(h)) => { + h.remove(client); + Ok(()) + } + Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()), + None => Err("this session is over".into()), + } + } + // end feat: remove-from-session + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d0004..65cbef36 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; use crate::guest::{Guest, GuestConfig}; -use crate::host::{ENDED, Host, HostConfig}; +use crate::host::{ENDED, Host, HostConfig, REMOVED}; use crate::inbox::Inbox; use crate::invite::Invite; use crate::net::{Bucket, NetLimits}; @@ -611,3 +611,36 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +// start feat: remove-from-session +#[test] +fn the_host_removes_someone_and_they_cant_come_back() { + let (mut host, mut hp, invite) = start(); + let (_a, mut ap) = join(&invite, "Ada"); + let (b, mut bp) = join(&invite, "Bo"); + ap.wait("welcome", welcomed); + bp.wait("welcome", welcomed); + let (ada, bo) = (ap.me(), bp.me()); + connections_settle(&host, 2); + // The host can't remove themselves, nor someone who isn't there: nothing happens. + host.remove(host.me().client_id); + host.remove(9999); + host.remove(ada); + // Ada is told the session is over for her (no reconnecting); the others see her leave. + ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED))); + hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + connections_settle(&host, 1); + assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect"); + assert!(!hp.left().contains(&bo), "only Ada was removed"); + // Her address can't join again with the same invite (every guest here comes from 127.0.0.1). + let (_again, mut againp) = join(&invite, "Ada"); + againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed")))); + assert!(!welcomed(&againp.events)); + // Bo, already in, stays and can still talk to the host. + b.presence(presence(3.0, 4.0)); + hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo))); + host.stop(); + assert_eq!(host.connections(), 0); +} +// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 84931904..08f15d22 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,9 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), + // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c..1dc22bec 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,12 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, + // start feat: remove-from-session + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while the server runs. With the server key, or on an open + /// hosting connection with `file`'s owner token. Servers older than this request can't read it. + RemovePeer { file: String, client: u32 }, + // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831d..e621fd36 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,13 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + // start feat: remove-from-session + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } + // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1889,6 +1896,61 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +// start feat: remove-from-session +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} +// end feat: remove-from-session + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2059,15 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + // start feat: remove-from-session + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), + // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b..f2d8cfb8 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,10 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + // start feat: remove-from-session + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, + // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +66,12 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + // start feat: remove-from-session + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } + // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +324,45 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } +// start feat: remove-from-session +#[test] +fn the_host_removes_people_and_guests_cant() { + let (mut s, script) = session(); + let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); + // Not in a session: nobody to remove. + assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); + assert!(!crate::live::can_remove_people(&s)); + s.execute("collab.host", &json!({})).unwrap(); + let id = s.live().id; + push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); + s.live_poll(); + assert!(crate::live::can_remove_people(&s)); + // Only someone who is here, by a client id. + for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { + assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + let r = remove(&mut s, json!(2)).unwrap(); + assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); + assert_eq!(script.lock().unwrap().removed, [(id, 2)]); + assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); + // The host's own session tells them she left. + push(&script, vec![Event::PeerLeft(2)]); + s.live_poll(); + assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); + s.execute("collab.stop", &json!({})).unwrap(); + + // A guest in someone else's session can't remove anyone. + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); + s.live_poll(); + assert!(!crate::live::can_remove_people(&s)); + let e = remove(&mut s, json!(3)).unwrap_err().to_string(); + assert!(e.contains("only the person hosting"), "{e}"); + assert!(script.lock().unwrap().removed.is_empty()); +} +// end feat: remove-from-session + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..56a7acd7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1045,6 +1045,7 @@ name = "English" "Remove the else and its actions" = "Remove the else and its actions" "Remove this condition and its actions" = "Remove this condition and its actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." +"Remove {name} from session" = "Remove {name} from session" "Remove {name} from {server}?" = "Remove {name} from {server}?" "Removed" = "Removed" "Removed from the server" = "Removed from the server" diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a68..03bfd3ce 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32 } /// You and the others at the top of the right panel, within `area`: click someone to follow -/// them (again to stop); the chevron lists everyone. +/// them (again to stop); right-click them to remove them from the session (when you host it or +/// own the file); the chevron lists everyone. pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let t = Tokens::get(ui.ctx()); const R: f32 = 12.0; @@ -459,6 +460,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); + // start feat: remove-from-session + let can_remove = figcraft_engine::live::can_remove_people(&app.session); + let mut remove = None; + // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -496,6 +501,16 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } + // start feat: remove-from-session + if can_remove { + crate::right::context_popup(&resp).show(|ui| { + if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { + remove = Some(*id); + ui.close(); + } + }); + } + // end feat: remove-from-session } if more > 0 { x += STEP; @@ -539,6 +554,12 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } + // start feat: remove-from-session + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } + // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d..7f99ddfb 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,6 +386,16 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. + +- **Removing someone.** Right-click their avatar ▸ "Remove from session" + (`collab.removePerson {client}`), when you host the session or own the file on a server. The + hub closes their connection with "You were removed from this session" (close code 1001: the + session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file + while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On + a server the owner asks on an open hosting connection with the file's owner token + (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are + the only identity a session has: someone behind the same address (one NAT) is barred too. + - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above @@ -463,7 +473,7 @@ resuming with its offline edits, and leaving the session going back to snapshot `collab.share {via?: "online" | "local", access?, …}`, `collab.host {port?, bind?: "local" | "lan" | }`, `collab.stop`, `collab.join {code | link}`, `collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's - pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`. + pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`. - **Sessions are per document.** Every open file has its own session or none: hosting, joined, being published, reconnecting or failed. Several run at once (host one file, join another, publish a third), each with its own people, links, undo history, cursor chat, spotlight and diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index e7eb468f..95084d74 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged | | 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..0f20ed6b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,6 +1090,13 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). + +- `collab.removePerson {client}` removes that person from the session: they are told the session + is over for them (they keep their copy and don't reconnect), and their address can't join the + file again while the session (or the server) runs. Only the person hosting, or the owner of a + file on a server (with its owner token), can; a server older than this request says it can't. + Returns `{removed, name}`. + - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From f387d3f464dfcec5e76c1aabba83d50a69854ca3 Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:00:42 +0300 Subject: [PATCH 10/29] M9: translate Remove from session The menu item and the collab.removePerson label in es, de, fr, ja, ko, pt-BR and zh-Hans (es must be complete). --- crates/ui-egui/locales/de.toml | 2 ++ crates/ui-egui/locales/es.toml | 2 ++ crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..83aa6df9 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet." +"Remove {name} from session" = "{name} aus der Sitzung entfernen" "Remove {name} from {server}?" = "{name} von {server} entfernen?" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Auf Server veröffentlichen" "collab.recoverFile" = "Online-Datei wiederherstellen" "collab.registerLinks" = "Einladungslinks in FigCraft öffnen" +"collab.removePerson" = "Aus der Sitzung entfernen" "collab.report" = "Datei dem Serverbetreiber melden" "collab.serverInfo" = "Serverinformationen" "collab.share" = "Mit Beitrittscode teilen" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..06f38c15 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1046,6 +1046,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Quitar el «si no» y sus acciones" "Remove this condition and its actions" = "Quitar esta condición y sus acciones" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí." +"Remove {name} from session" = "Quitar a {name} de la sesión" "Remove {name} from {server}?" = "¿Quitar {name} de {server}?" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" @@ -1643,6 +1644,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar en el servidor" "collab.recoverFile" = "Recuperar archivo en línea" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.removePerson" = "Quitar de la sesión" "collab.report" = "Denunciar archivo al operador del servidor" "collab.serverInfo" = "Información del servidor" "collab.share" = "Compartir con un código para unirse" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..2f904c60 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove {name} from session" = "Retirer {name} de la session" "Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Publier sur un serveur" "collab.recoverFile" = "Récupérer un fichier en ligne" "collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft" +"collab.removePerson" = "Retirer de la session" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9f..1801f0c4 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "「それ以外」とそのアクションを削除" "Remove this condition and its actions" = "この条件とそのアクションを削除" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。" +"Remove {name} from session" = "{name} をセッションから削除" "Remove {name} from {server}?" = "{name} を {server} から削除しますか?" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "サーバーに公開" "collab.recoverFile" = "オンラインファイルを復元" "collab.registerLinks" = "招待リンクを FigCraft で開く" +"collab.removePerson" = "セッションから削除" "collab.report" = "ファイルをサーバーの運営者に報告" "collab.serverInfo" = "サーバー情報" "collab.share" = "参加コードで共有" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb2386..657b6817 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -998,6 +998,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "\"그 외\"와 해당 액션 제거" "Remove this condition and its actions" = "이 조건과 해당 액션 제거" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨." +"Remove {name} from session" = "{name} 님을 세션에서 제거" "Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" @@ -1569,6 +1570,7 @@ status = "draft — needs native review" "collab.publish" = "서버에 게시" "collab.recoverFile" = "온라인 파일 복구" "collab.registerLinks" = "초대 링크를 FigCraft에서 열기" +"collab.removePerson" = "세션에서 제거" "collab.report" = "서버 운영자에게 파일 신고" "collab.serverInfo" = "서버 정보" "collab.share" = "참여 코드로 공유" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..40336098 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -996,6 +996,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Remover o \"senão\" e suas ações" "Remove this condition and its actions" = "Remover esta condição e suas ações" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui." +"Remove {name} from session" = "Remover {name} da sessão" "Remove {name} from {server}?" = "Remover {name} de {server}?" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" @@ -1567,6 +1568,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar no servidor" "collab.recoverFile" = "Recuperar arquivo online" "collab.registerLinks" = "Abrir links de convite no FigCraft" +"collab.removePerson" = "Remover da sessão" "collab.report" = "Denunciar arquivo ao operador do servidor" "collab.serverInfo" = "Informações do servidor" "collab.share" = "Compartilhar com um código de acesso" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df0..87edf016 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "移除“否则”及其操作" "Remove this condition and its actions" = "移除此条件及其操作" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。" +"Remove {name} from session" = "将 {name} 移出会话" "Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "发布到服务器" "collab.recoverFile" = "恢复在线文件" "collab.registerLinks" = "在 FigCraft 中打开邀请链接" +"collab.removePerson" = "移出会话" "collab.report" = "向服务器运营者举报文件" "collab.serverInfo" = "服务器信息" "collab.share" = "用加入代码分享" From 8efb66ba194c2a8d183d98f9823f79fed8cf84df Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:24:40 -0500 Subject: [PATCH 11/29] fix(ui): step through the font picker with Up and Down (#345) Up and Down now move a highlight through the family list, starting from the current family and scrolling to keep it in view. The text panel previews the highlighted family on the canvas, as it does for a hovered font style, and Enter applies it. Escape leaves the text as it was. --- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/typography.rs | 128 ++++++++++++++++++++++++++--- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..2c5e539d 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..783b541b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,10 +181,14 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let pick = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); + } else if let Some(f) = pick.highlighted { + // The canvas shows the family the arrow keys are on. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": f}))); } }); }); @@ -1049,14 +1053,31 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// What the family list did this frame. +#[derive(Default)] +pub(crate) struct FontPick { + /// The family clicked, or the one Enter picked. + pub picked: Option, + /// The family Up and Down are on (Enter picks it). + pub highlighted: Option, +} + +/// The searchable family list: click a family, or step through it with Up and Down and press Enter. +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); + // The row Up and Down are on, and the search it is a row of. + let hi_id = Id::new("font_highlight"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); if just_opened { q.clear(); + ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); } + // Taken before the search field sees them (it would move its cursor). + let step = ui.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); if just_opened { search.request_focus(); @@ -1065,9 +1086,6 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let ql = q.trim().to_lowercase(); let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); - if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); - } let mut rows: Vec = Vec::new(); if ql.is_empty() { let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); @@ -1079,13 +1097,64 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return FontPick::default(); } rows.extend(matches.into_iter().map(PickRow::Family)); + let family = |i: usize| match rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }; + // A new search starts again; the first step starts from the current family. + let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); + let stride = ROW + ui.spacing().item_spacing.y; + let mut offset = None; + if step != 0 { + let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; + let mut from = + hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); + for _ in 0..step.unsigned_abs() { + let mut at = match from { + Some(i) => next(i), + None if step > 0 => Some(0), + None => rows.len().checked_sub(1), + }; + // Past the headers; at either end it stays where it was. + while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { + at = next(i); + } + if let Some(i) = at.filter(|i| family(*i).is_some()) { + from = Some(i); + hi = Some(i); + } + } + if let Some(i) = hi { + // Scrolled just enough to show it. + let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); + let top = i as f32 * stride; + offset = Some(seen.max(top + stride - 380.0).min(top)); + } + } + match hi { + Some(i) => ui.data_mut(|d| { + d.insert_temp(hi_id, (q.clone(), i)); + }), + None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), + } + if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let first = rows.iter().find_map(|r| match r { + PickRow::Family(f) => Some(f.clone()), + PickRow::Header(_) => None, + }); + return FontPick { picked: hi.and_then(family).or(first), highlighted: None }; + } let mut budget = 4; let mut picked = None; - egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { - for r in rows.get(range).unwrap_or(&[]) { + let mut area = egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]); + if let Some(y) = offset { + area = area.vertical_scroll_offset(y); + } + let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| { + for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) { let w = ui.available_width(); let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); match r { @@ -1093,7 +1162,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary); } PickRow::Family(f) => { - let hovered = resp.hovered(); + let hovered = resp.hovered() || hi == Some(i); if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } @@ -1129,7 +1198,8 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } }); - picked + ui.data_mut(|d| d.insert_temp(hi_id.with("offset"), shown.state.offset.y)); + FontPick { highlighted: hi.and_then(family), picked } } #[cfg(test)] @@ -1153,6 +1223,42 @@ mod tests { assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None); } + /// One frame of the font picker with `events`. + fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick { + let mut out = FontPick::default(); + let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened)); + shown.textures_delta.clear(); + out + } + + fn key(key: egui::Key) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE } + } + + #[test] + fn arrow_keys_step_through_the_families_and_enter_picks() { + // #345 + figcraft_text::scan_system_fonts(); + let all = figcraft_text::family_names(); + if all.len() < 3 { + eprintln!("fewer than three font families; skipping"); + return; + } + let ctx = egui::Context::default(); + let current = all[1].as_str(); + assert!(pick(&ctx, current, true, vec![]).highlighted.is_none()); + // The first step starts from the current family; each press is one step. + let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str())); + let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]); + assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str())); + // At the top it stays; Enter picks the family it is on, not the first. + assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str())); + pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]); + let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]); + assert_eq!(enter.picked.as_deref(), Some(all[1].as_str())); + } + #[test] fn axis_values_round_by_axis_width() { let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false }; From b87d2e476f13c08abad1cae57447c477c07f2bf5 Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:29:09 -0500 Subject: [PATCH 12/29] fix(engine): drag with the Text tool to make a fixed-width text box (#360) The Text tool made its text on the press and ignored the drag, so a drag gave auto-width text like a click. The new text now stays in a TextBox gesture while pressed: past the drag slop it becomes a box as wide as the drag, its top-left at the drag's, with a fixed width and a height that follows its text. A click, or a drag within the slop, still makes auto-width text. --- crates/engine/src/text_behaviour_tests.rs | 35 ++++++++++++++++++++++ crates/engine/src/tools.rs | 36 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 51550f7e..48d7582f 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -192,3 +192,38 @@ fn paragraph_indent_only_moves_left_aligned_text() { let (x, w2) = first_x(&s); assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it"); } + +/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding +/// `chars`: its box and resize mode. +fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) { + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let mut s = Session::new(); + s.set_tool(ToolId::Text); + s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap(); + crate::textedit::insert(&mut s, chars).unwrap(); + let t = s.ds().unwrap().text_edit.as_ref().unwrap().id; + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") }; + (doc.world_bounds(t).unwrap(), td.auto_resize) +} + +#[test] +fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { + // #360: as wide as the drag, its top-left where the drag started, and its text wraps in it; a + // click, or a drag within a few pixels, still makes auto-width text. + use figcraft_doc::TextAutoResize; + figcraft_text::scan_system_fonts(); + let long = "A sentence much longer than eighty pixels"; + let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); + assert_eq!(auto, TextAutoResize::WidthAndHeight); + assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 160.0), long); + assert_eq!(auto, TextAutoResize::Height); + assert_eq!((b.x0, b.y0, b.width()), (100.0, 100.0, 80.0)); + assert!(b.height() > line.height(), "the text wraps: {} vs one line {}", b.height(), line.height()); + // Dragged up and to the left, the box is the same. + let (b2, _) = text_tool((180.0, 160.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width()), (100.0, 100.0, 80.0)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..95880587 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -370,6 +370,11 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, + /// A new text from the Text tool, still pressed: a drag makes it a box as wide as the drag. + TextBox { + id: NodeId, + start: Point, + }, PenDrag { anchor: Point, }, @@ -1347,6 +1352,11 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } + Gesture::TextBox { id, start } => { + let (id, start) = (*id, *start); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start); + } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); self.tool.gesture = g; @@ -1760,7 +1770,7 @@ impl Session { self.tool.overlay.pen.clear(); self.pencil_finish(&points, &pressure)?; } - Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} Gesture::Vector(_) => self.commit_interaction()?, } @@ -1888,12 +1898,36 @@ impl Session { d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p() }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } + /// Drag out a new text (Figma's text box): past the drag slop it is as wide as the drag, its + /// top-left at the drag's, and its height follows its text. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.child_layout.sizing_h = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::Height; + } + if let Some((_, h)) = figcraft_text::auto_size(n) { + n.height = h; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let doc = ds.doc.clone(); From 01a0f4f332092c4fdb16961c68e4f92295600a5b Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Wed, 14 Oct 2026 20:01:53 -0300 Subject: [PATCH 13/29] M3: variables export as a download in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build has no save dialogs, so Export… ▸ Variables JSON / CSS custom properties returned silently. Services gains downloads() and download(name, bytes); the browser hands the file over through a Blob link, and the variables panel uses it where the platform saves by downloads. --- Cargo.lock | 1 + apps/figcraft-web/Cargo.toml | 3 +- apps/figcraft-web/src/web.rs | 36 ++++++- crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/src/lib.rs | 11 +++ .../ui-egui/src/variables_export_ui_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/variables_panel.rs | 15 ++- docs/components-parity.md | 5 +- 12 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/variables_export_ui_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f68..02c221f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1718,6 +1718,7 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-ui-egui", + "js-sys", "log", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 93f9eb41..2f1a8190 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,5 +24,6 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 63689038..bb563422 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,14 +69,27 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads; the clipboard goes through egui's own web clipboard handling. struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, } impl Services for WebServices { + fn downloads(&self) -> bool { + true + } + /// A Blob behind a temporary `` link, clicked and removed. + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + match download(name, bytes) { + Ok(()) => true, + Err(e) => { + log::error!("couldn't download {name}: {e:?}"); + false + } + } + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { @@ -108,3 +121,22 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ + let o = web_sys::BlobPropertyBag::new(); + o.set_type("application/octet-stream"); + o + })?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + body.append_child(&a)?; + a.click(); + a.remove(); + web_sys::Url::revoke_object_url(&url) +} diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..4f38f7cb 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "{value} kopieren" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" +"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Count" = "Anzahl" "Create" = "Erstellen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..61fd1528 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -309,6 +309,7 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" +"Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Count" = "Count" "Create" = "Create" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..a725bca5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..d5110c1b 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "Copier {value}" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" +"Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." "Count" = "Nombre" "Create" = "Créer" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..4f6760e7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -299,6 +299,7 @@ status = "draft — needs native review" "Copy {value}" = "Copiar {value}" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" +"Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." "Count" = "Quantidade" "Create" = "Criar" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..b68279de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_export_ui_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; @@ -171,6 +173,15 @@ pub trait Services { fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { None } + /// The platform saves files by handing them over as downloads (the browser), not through + /// save dialogs. + fn downloads(&self) -> bool { + false + } + /// Hand `bytes` over as a download named `name`; false when nothing was delivered. + fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool { + false + } /// A folder to write several files into. fn pick_folder(&mut self, _title: &str) -> Option { None diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs new file mode 100644 index 00000000..40a84393 --- /dev/null +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -0,0 +1,93 @@ +//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save +//! dialogs): the export arrives as a download instead of doing nothing. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::variables_panel::export_dialog; +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn session() -> Session { + let mut s = Session::new(); + for (cmd, p) in [ + ("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})), + ( + "variable.create", + json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}), + ), + ("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})), + ] { + s.execute(cmd, &p).unwrap(); + } + s +} + +fn browser(deliver: bool) -> (FigApp, Downloads) { + let got = Downloads::default(); + (FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn json_export_downloads_what_variable_export_writes() { + let (mut app, got) = browser(true); + let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string(); + export_dialog(&mut app, "json"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.json"), "{name}"); + assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected); + let parsed: Value = serde_json::from_slice(bytes).unwrap(); + assert!(parsed.is_object()); + assert!(toast(&app).contains(name.as_str()), "{}", toast(&app)); +} + +#[test] +fn css_export_downloads_custom_properties() { + let (mut app, got) = browser(true); + export_dialog(&mut app, "css"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.css"), "{name}"); + let css = String::from_utf8(bytes.clone()).unwrap(); + assert!(css.contains(":root") && css.contains("--"), "{css}"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got) = browser(false); + export_dialog(&mut app, "json"); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download the variables"); +} + +#[test] +fn platforms_with_save_dialogs_keep_them() { + // No dialog answer (cancelled) and no downloads: nothing happens, as before. + let mut app = FigApp::new(session(), Box::new(NoServices)); + export_dialog(&mut app, "json"); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a5..7f0dc435 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -202,9 +202,20 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } -/// Export this file's collections through a save dialog. -fn export_dialog(app: &mut FigApp, format: &str) { +/// Export this file's collections through a save dialog, or as a download where the platform has +/// no save dialogs (the browser). +pub(crate) fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); + if app.services.downloads() { + let Some(r) = app.run("variable.export", json!({"format": format})) else { return }; + let text = r["text"].as_str().unwrap_or_default(); + if app.services.download(&name, text.as_bytes()) { + app.toast(trf("Exported {file}", &[("file", &name)])); + } else { + app.toast(tr("Couldn't download the variables")); + } + return; + } let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 46a012fe..659676fd 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. 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 | 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. | | 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | From 13691c1cc032a1ac43b7820d6196b25076793a9a Mon Sep 17 00:00:00 2001 From: Edivan Teixeira Date: Wed, 14 Oct 2026 23:43:31 -0300 Subject: [PATCH 14/29] Let batch steps reference ids created by earlier steps Inside command.batch, `{"$step": n}` anywhere in a step's params is replaced by the `id` in step n's result before the step runs (`{"$step": n, "field": "..."}` picks another field). A nested tree, such as a frame with children, now takes one batch and one undo step, not one round-trip per level with ids copied between calls by hand. Malformed references (forward or self, negative or fractional index, missing or non-string field, extra keys) fail the batch with BadParams naming the step, and the existing group machinery rolls it back. The params walk is depth-capped. Steps are journalled with resolved params. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/batch_step_ref_tests.rs | 91 +++++++++++++++++++++++ crates/engine/src/lib.rs | 56 +++++++++++++- crates/mcp/src/tools.rs | 2 +- crates/mcp/tests/agent_tasks.rs | 37 +++++++++ docs/control-protocol.md | 8 ++ docs/mcp.md | 28 ++++++- 6 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/batch_step_ref_tests.rs diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs new file mode 100644 index 00000000..7474d546 --- /dev/null +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -0,0 +1,91 @@ +use serde_json::json; + +use crate::{EngineError, Session}; + +fn id(value: &serde_json::Value) -> crate::NodeId { + crate::params::id_of(&value["id"]).unwrap() +} + +#[test] +fn later_steps_target_layers_created_by_earlier_steps() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({ + "label": "Card", + "commands": [ + {"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}} + ] + }), + ) + .unwrap(); + + let frame = id(&results[0]); + let rect = id(&results[1]); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +#[test] +fn field_picks_another_part_of_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}} + ]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0]))); +} + +#[test] +fn bad_references_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}}); + for (bad, needle) in [ + (json!({"$step": 1}), "earlier step"), + (json!({"$step": 5}), "earlier step"), + (json!({"$step": -1}), "step index"), + (json!({"$step": "0"}), "step index"), + (json!({"$step": 0, "field": "nope"}), "no `nope`"), + (json!({"$step": 0, "field": 3}), "`field` must be a string"), + (json!({"$step": 0, "extra": true}), "not `extra`"), + ] { + let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]})); + match r { + Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"), + other => panic!("expected BadParams for {bad}, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn deep_params_are_refused_not_overflowed() { + let mut s = Session::new(); + let mut deep = json!(1); + for _ in 0..200 { + deep = json!([deep]); + } + let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]})); + assert!(matches!(r, Err(EngineError::BadParams { .. }))); +} + +#[test] +fn text_that_looks_like_a_reference_is_left_alone() { + let mut s = Session::new(); + let results = s + .execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]})) + .unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..0a085711 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } +/// Deepest params nesting `resolve_step_refs` walks before giving up. +const STEP_REF_MAX_DEPTH: usize = 64; + +/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default +/// `id`) of `results[n]`, the result of an earlier batch step. +fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { + if depth > STEP_REF_MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") => { + if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { + return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); + } + let n = o + .get("$step") + .and_then(Value::as_u64) + .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; + let field = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), + }; + let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); + }; + result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) + } + Value::Object(o) => { + let mut out = serde_json::Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } +} + /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -1071,9 +1112,18 @@ impl Session { self.ui_requests.push(UiRequest { kind: kind.to_string(), data }); } - /// Run several commands as one undo step. + /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` + /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), + /// so a step can target a layer that an earlier step of the same batch created. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { - self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect()) + self.grouped(label, |s| { + let mut results: Vec = Vec::with_capacity(cmds.len()); + for (step, (id, p)) in cmds.iter().enumerate() { + let p = resolve_step_refs(p, &results, step, 0)?; + results.push(s.execute(id, &p)?); + } + Ok(results) + }) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their @@ -1393,6 +1443,8 @@ mod autosave_tests; #[cfg(test)] mod batch_rollback_tests; #[cfg(test)] +mod batch_step_ref_tests; +#[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod booleans_tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad..68b78ad8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -127,7 +127,7 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. Note: later commands cannot reference ids created by earlier ones in the same batch (ids are only known afterwards), so create containers first, then batch the edits.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", obj( json!({ "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, diff --git a/crates/mcp/tests/agent_tasks.rs b/crates/mcp/tests/agent_tasks.rs index cc7974ab..b35f3e7f 100644 --- a/crates/mcp/tests/agent_tasks.rs +++ b/crates/mcp/tests/agent_tasks.rs @@ -307,3 +307,40 @@ fn task_vector_edit_keys() { assert_eq!(c.page_children().len(), 1); assert!(c.call("run_command", json!({"command": "vector.edit.state", "params": {}}))["isError"] == true, "Enter left edit mode"); } + +/// A whole card in one `batch` call: later steps reach the frames earlier steps created through +/// `{"$step": n}`, so the tree is built in one round-trip and one undo step. +#[test] +fn task_card_in_one_batch() { + let mut c = Client::new(); + let out = c.ok( + "batch", + json!({"label": "Card", "commands": [ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 320, "height": 50, + "props": {"layout": "vertical", "padding": 24, "gap": 12, "sizingV": "hug"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Ada Lovelace"}}, + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 0}, "name": "Button", "height": 44, + "props": {"layout": "horizontal", "sizingH": "fill"}}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 2}, "text": "Follow"}}, + {"command": "node.set", "params": {"ids": [{"$step": 2}], "props": {"fill": "#3B5BDB"}}}, + ]}), + ); + let results = ids(&out); + assert_eq!(results.len(), 5); + let (card, button) = (results[0]["id"].clone(), results[2]["id"].clone()); + let n = c.node(&card); + assert_eq!(ids(&n["children"]).len(), 2, "title and button are inside the card"); + let b = c.node(&button); + assert_eq!(ids(&b["children"]).len(), 1, "the label is inside the button"); + assert_eq!(b["fills"][0]["color"].as_str().unwrap().to_ascii_uppercase(), "#3B5BDB"); + assert_eq!(c.doc()["history"]["undo"][0], "Card", "one undo step"); + // A forward reference fails the whole batch and names the step. + let e = c.err( + "batch", + json!({"commands": [ + {"command": "node.create", "params": {"type": "frame", "parent": {"$step": 1}}}, + {"command": "node.create", "params": {"type": "frame"}}, + ]}), + ); + assert!(e.contains("step 0") && e.contains("earlier step"), "{e}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d..5cb3dc04 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,6 +60,14 @@ commands return `{"id": N}`). {"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}} ``` +Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result +(zero-based; `{"$step": n, "field": "…"}` for another field), so one batch can create a frame and +fill it: + +```json +{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} +``` + ### `engine.commands {}` Every command: `[{id, label, params, menu, shortcut, undoable}]`. diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..8068ac01 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -219,7 +219,7 @@ Or in a project's `.mcp.json`: |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | -| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. | +| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | @@ -1509,6 +1509,26 @@ Align and distribute as one undo step: {"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}} ``` -The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of ten realistic -tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark -variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools). +### Step references + +A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's +params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n` +(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that +result. This builds a nested tree in one call and one undo step, without guessing ids: + +```json +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[ + {"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}}, + {"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}}, + {"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}} +``` + +A reference to the same or a later step, a negative or non-integer index, a missing `field` or any +other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. + +The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks +(auto layout, a card built in one batch with step references, components and overrides, gradients +and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten, +export, undo/redo, drawing with tools). From 8616f3d1c5f0af0c4cb68280df0c5c20cb0bf738 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:52 +0700 Subject: [PATCH 15/29] #305: save a file from a snapshot encoded elsewhere FileSnapshot takes what saving writes, encodes it (thumbnail and all) on any thread, and writes it back as of the snapshot: edits since stay unsaved, and bytes from before another save never overwrite the newer file. --- crates/engine/src/autosave_tests.rs | 30 ++++++++++++ crates/engine/src/cmd/file.rs | 73 ++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index e9f0427d..1e8ba30f 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -92,3 +92,33 @@ fn files_that_save_themselves_still_get_checkpoints() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() { + use crate::cmd::file::FileSnapshot; + let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("snap.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": path})).unwrap(); + rect(&mut s, 0.0); + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 50.0); + assert!(file.save(&mut s, &bytes).unwrap()); + assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot"); + // Saved meanwhile: the older bytes never overwrite the newer file. + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 100.0); + s.execute("file.save", &json!({})).unwrap(); + assert!(!file.save(&mut s, &bytes).unwrap()); + assert!(!s.ds().unwrap().is_dirty()); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c3b243c7..5043ea40 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -696,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option> { /// The `.figcraft` file for a document: content, thumbnail and version history. pub fn file_bytes(ds: &DocState) -> Result> { - let mut doc = (*ds.doc).clone(); - doc.current_page = Some(ds.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string())) + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } } fn color_profile(s: &mut Session, p: &Value) -> Result { @@ -724,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.mark_saved(); ds.revision += 1; let uid = ds.uid; - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } + branch_tabs_saved(s, uid); remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } From b75e5bea3bed0062914d583fbb8b952f2fc7de44 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:54 +0700 Subject: [PATCH 16/29] #305: auto-save after a pause, off the UI thread Nothing is collected while auto-save is off. The wait restarts whenever an unsaved file changes, so continuous editing doesn't save every few seconds, and the file is encoded on a thread of its own, then written. --- crates/ui-egui/src/idle_tests.rs | 19 +++++- crates/ui-egui/src/lib.rs | 103 ++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 33a9086c..44d76806 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -301,13 +301,26 @@ fn auto_save_writes_a_changed_file_then_rests() { h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); - h.time += crate::AUTO_SAVE_SECS; + // Another change restarts the wait. + h.time += crate::AUTO_SAVE_SECS - 0.5; + h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40})); h.frame(vec![]); - assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + h.time += 1.0; + h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes"); + h.time += crate::AUTO_SAVE_SECS; + // The file is encoded on a thread of its own, then written. + let start = Instant::now(); + while h.app.session.ds().unwrap().is_dirty() { + assert!(start.elapsed() < Duration::from_secs(20), "saved"); + let delay = h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded"); + std::thread::sleep(Duration::from_millis(5)); + } let mut t = Session::empty(); t.execute("file.open", &json!({"path": path})).unwrap(); let ds = t.ds().unwrap(); - assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 540.0, "the new rectangle is in the file"); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file"); h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad811b43..25fe084e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -271,11 +271,14 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; -/// With auto-save on: how long after a file changes it is saved, and how long a failed save waits -/// before trying again (seconds). +/// With auto-save on: how long after a file last changed it is saved, and how long a failed save +/// waits before trying again (seconds). pub const AUTO_SAVE_SECS: f64 = 3.0; const AUTO_SAVE_RETRY_SECS: f64 = 60.0; +/// A file being encoded for auto-save: its snapshot and, once done, its bytes. +pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result>)>; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -411,8 +414,11 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved. - pub auto_save_at: Option, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// the revisions of those files it waits on (a change restarts the wait). + pub auto_save_at: Option<(f64, u64)>, + /// Files being encoded for auto-save on threads of their own (tab `uid`). + pub auto_saving: Vec<(u64, AutoSaveEncoding)>, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -498,6 +504,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, auto_save_at: None, + auto_saving: Vec::new(), libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -961,39 +968,79 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } - /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they change - /// (not during a gesture, a live preview or while a version is shown). + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last + /// changed (not during a gesture, a live preview or while a version is shown). The file is + /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. fn auto_save_tick(&mut self, ctx: &egui::Context) { - let s = &self.session; - let unsaved = |d: &figcraft_engine::DocState| d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty(); - let due: Vec = s.docs.iter().enumerate().filter(|(_, d)| unsaved(d)).map(|(i, _)| i).collect(); - if !s.prefs.auto_save || due.is_empty() { - self.ui.auto_save_at = None; - return; - } let now = ctx.input(|i| i.time); - let at = *self.ui.auto_save_at.get_or_insert(now + AUTO_SAVE_SECS); - if now < at || s.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { - // Due but busy: look again in a second. - repaint_in(ctx, if now < at { at - now } else { 1.0 }); - return; - } - let active = self.session.active; - let mut failed = None; - for i in due { - self.session.active = i; - if let Err(e) = self.session.execute("file.save", &json!({})) { - failed.get_or_insert(e.to_string()); + let mut failed = self.auto_save_done(); + // The browser has no files to save to. + let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; + let saving = &self.ui.auto_saving; + let due = |d: &&figcraft_engine::DocState| { + d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid) + }; + let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; + match changes { + None => self.ui.auto_save_at = None, + Some(changes) => { + let at = match self.ui.auto_save_at { + Some((at, c)) if c == changes && failed.is_none() => at, + _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, + }; + self.ui.auto_save_at = Some((at, changes)); + if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + } else { + self.ui.auto_save_at = None; + let files: Vec<_> = + self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); + for (uid, file) in files { + let encode = move || { + let bytes = file.encode(); + (file, bytes) + }; + match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { + Ok(h) => self.ui.auto_saving.push((uid, h)), + Err(e) => { + failed.get_or_insert(e.to_string()); + self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); + } + } + } + } } } - self.session.active = active; + if !self.ui.auto_saving.is_empty() { + // Look again shortly for the files being encoded. + repaint_in(ctx, 0.1); + } if let Some(e) = failed { - self.ui.auto_save_at = Some(now + AUTO_SAVE_RETRY_SECS); - repaint_in(ctx, AUTO_SAVE_RETRY_SECS); self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); } } + /// Write the files whose auto-save encoding finished; the first error, if one failed. + fn auto_save_done(&mut self) -> Option { + if self.ui.auto_saving.is_empty() { + return None; + } + let mut failed = None; + let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); + for (_, h) in done { + let saved = match h.join() { + Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), + Ok((_, Err(e))) => Err(e.to_string()), + Err(_) => Err("the file couldn't be encoded".to_string()), + }; + if let Err(e) = saved { + failed.get_or_insert(e); + } + } + failed + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] From adf96ca2e78226314026030b8fc31c06de32d5e0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:25:06 +0700 Subject: [PATCH 17/29] #363: a dragged text box keeps the drag's width and height A drag with the Text tool now makes a fixed-size box (the drag's size, at least one line tall) instead of a fixed width with an auto height. --- crates/engine/src/text_behaviour_tests.rs | 18 +++++++++------- crates/engine/src/tools.rs | 26 ++++++++++++----------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 48d7582f..84315f1f 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -210,8 +210,8 @@ fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::R } #[test] -fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { - // #360: as wide as the drag, its top-left where the drag started, and its text wraps in it; a +fn a_drag_with_the_text_tool_makes_a_fixed_size_box() { + // #360: the drag's size, its top-left where the drag started, and its text wraps in it; a // click, or a drag within a few pixels, still makes auto-width text. use figcraft_doc::TextAutoResize; figcraft_text::scan_system_fonts(); @@ -219,11 +219,13 @@ fn a_drag_with_the_text_tool_makes_a_fixed_width_box() { let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long); assert_eq!(auto, TextAutoResize::WidthAndHeight); assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click"); - let (b, auto) = text_tool((100.0, 100.0), (180.0, 160.0), long); - assert_eq!(auto, TextAutoResize::Height); - assert_eq!((b.x0, b.y0, b.width()), (100.0, 100.0, 80.0)); - assert!(b.height() > line.height(), "the text wraps: {} vs one line {}", b.height(), line.height()); + let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long); + assert_eq!(auto, TextAutoResize::None); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size"); // Dragged up and to the left, the box is the same. - let (b2, _) = text_tool((180.0, 160.0), (100.0, 100.0), long); - assert_eq!((b2.x0, b2.y0, b2.width()), (100.0, 100.0, 80.0)); + let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long); + assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0)); + // A flat drag still makes a box one line tall. + let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A"); + assert_eq!((b3.width(), b3.height()), (80.0, line.height())); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3efd487f..733fc08c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -376,10 +376,12 @@ pub(crate) enum Gesture { TextSelect { id: NodeId, }, - /// A new text from the Text tool, still pressed: a drag makes it a box as wide as the drag. + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). TextBox { id: NodeId, start: Point, + line: f64, }, PenDrag { anchor: Point, @@ -1405,10 +1407,10 @@ impl Session { self.tool.gesture = g; return self.draw_drag(ev, start, id, parent, &targets); } - Gesture::TextBox { id, start } => { - let (id, start) = (*id, *start); + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); self.tool.gesture = g; - return self.text_box_drag(ev, id, start); + return self.text_box_drag(ev, id, start, line); } Gesture::Arc { id, handle, arc } => { let (id, handle, arc) = (*id, *handle, *arc); @@ -1953,19 +1955,20 @@ impl Session { n.width = w.max(1.0); n.height = h; } + let line = n.height; d.insert(n, parent, None)?; ds.selection = vec![id]; textedit::begin(self, id, Some(0), true)?; - self.tool.gesture = Gesture::TextBox { id, start: ev.p() }; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; if !self.prefs.keep_tool { self.tool.tool = ToolId::Move; } Ok(()) } - /// Drag out a new text (Figma's text box): past the drag slop it is as wide as the drag, its - /// top-left at the drag's, and its height follows its text. - fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point) -> Result<()> { + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { if within_slop(ev, start) { return Ok(()); } @@ -1975,12 +1978,11 @@ impl Session { let n = d.get_mut(id)?; n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = TextAutoResize::Height; - } - if let Some((_, h)) = figcraft_text::auto_size(n) { - n.height = h; + t.auto_resize = TextAutoResize::None; } self.ds_mut()?.revision += 1; Ok(()) From e11a182afd13392292e04014921029222bdfbbb5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:30:05 +0700 Subject: [PATCH 18/29] #400: a nested batch keeps its own step references The outer batch passes a command.batch step on as written, so the inner steps' `$step` references point at the inner batch's steps. The docs say that `$step` is a reserved key in step params. --- crates/engine/src/batch_step_ref_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/lib.rs | 6 ++++-- docs/control-protocol.md | 5 +++-- docs/mcp.md | 5 ++++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index 7474d546..ac69118e 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -89,3 +89,24 @@ fn text_that_looks_like_a_reference_is_left_alone() { let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}")); } + +#[test] +fn a_nested_batch_refers_to_its_own_steps() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}} + ]}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eee9e17a..1585aa38 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1124,12 +1124,14 @@ impl Session { /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. + /// so a step can target a layer that an earlier step of the same batch created. Any object + /// with a `$step` key in a step's params is such a reference, except inside a nested + /// `command.batch` step: its references are to its own steps. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = resolve_step_refs(p, &results, step, 0)?; + let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; results.push(s.execute(id, &p)?); } Ok(results) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3e0cf9ba..134a6637 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -63,8 +63,9 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"] ``` Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result -(zero-based; `{"$step": n, "field": "…"}` for another field), so one batch can create a frame and -fill it: +(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read +as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch +can create a frame and fill it: ```json {"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}} diff --git a/docs/mcp.md b/docs/mcp.md index 862ade2d..07d84e60 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1532,7 +1532,10 @@ result. This builds a nested tree in one call and one undo step, without guessin A reference to the same or a later step, a negative or non-integer index, a missing `field` or any other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are -`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. +`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The +`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable +value too) is read as a reference. A step that is itself a `command.batch` is passed on as written, +so the references in its steps are to its own steps, not the outer batch's. The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks (auto layout, a card built in one batch with step references, components and overrides, gradients From 8bcb0f78ddc1d922723cef291a388383633bf59c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:37:52 +0700 Subject: [PATCH 19/29] #338: drop the start/end feat marker comments --- apps/figcraft-server/src/store.rs | 2 -- crates/collab-net/src/host.rs | 6 +----- crates/collab-net/src/hub.rs | 18 ------------------ crates/collab-net/src/link.rs | 2 -- crates/collab-net/src/loopback_tests.rs | 2 -- crates/collab/src/codec_tests.rs | 2 -- crates/collab/src/message.rs | 2 -- crates/engine/src/live.rs | 6 ------ crates/engine/src/live_tests.rs | 6 ------ crates/ui-egui/src/live.rs | 6 ------ docs/collab-protocol.md | 2 -- docs/mcp.md | 2 -- 12 files changed, 1 insertion(+), 55 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 9f97ee35..4886bc88 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1489,10 +1489,8 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), - // start feat: remove-from-session // The hub removes people itself (it knows who is connected); it never asks the store. Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index a198ab5c..24be22d3 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,12 +11,10 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; -// start feat: remove-from-session -pub use crate::hub::REMOVED; -// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -132,13 +130,11 @@ impl Host { self.hosting.send(msg); } - // start feat: remove-from-session /// Remove client `id` from the session: they are disconnected and can't join again from /// their address while it runs. The host can't remove themselves. pub fn remove(&self, id: u32) { self.hosting.remove(id); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 69910754..cdd06915 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -413,10 +413,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), - // start feat: remove-from-session /// The person hosting removes client `id` from their file (app sessions). Remove(u32), - // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -563,13 +561,11 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } - // start feat: remove-from-session /// The hosting person removes client `id` from their file (app sessions; see /// [`HubIn::Remove`]). pub(crate) fn remove(&self, id: u32) { let _ = self.hub.send(HubIn::Remove(id)); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] @@ -1128,7 +1124,6 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; -// start feat: remove-from-session /// What someone removed from a file is told: the session's end for them (they keep their copy; /// nothing retries). pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; @@ -1138,7 +1133,6 @@ const BARRED: &str = "you were removed from this session"; /// The most addresses one file keeps barred. const MAX_BARRED: usize = 256; -// end feat: remove-from-session /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1153,10 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - // start feat: remove-from-session /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. barred: HashMap>, - // end feat: remove-from-session } impl Hub { @@ -1244,7 +1236,6 @@ impl Hub { self.client_message(&file, id, msg, None); } } - // start feat: remove-from-session HubIn::Remove(id) => { if let Some(file) = self.local.clone() && let Err(why) = self.remove_peer(&file, id) @@ -1252,7 +1243,6 @@ impl Hub { log::info!("live session: couldn't remove client {id}: {why}"); } } - // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1277,7 +1267,6 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = if owns.as_deref() == Some(file.as_str()) { self.removed_reply(&file, client) @@ -1286,7 +1275,6 @@ impl Hub { }; self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1298,12 +1286,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1366,12 +1352,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; - // start feat: remove-from-session if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { log::info!("event=refused ip={} reason=removed", c.ip); return refuse(&c.out, BARRED); } - // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1429,7 +1413,6 @@ impl Hub { } } - // start feat: remove-from-session /// Remove client `id` from `file`: they are told the session is over for them and /// disconnected, and their address can't join the file again while the hub runs. The person /// hosting can't be removed. @@ -1457,7 +1440,6 @@ impl Hub { Err(reason) => AdminReply::Failed { reason }, } } - // end feat: remove-from-session /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 49e085ed..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,7 +243,6 @@ impl LiveLink for NetLink { } } - // start feat: remove-from-session fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { match self.sessions.get(session) { Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), @@ -255,7 +254,6 @@ impl LiveLink for NetLink { None => Err("this session is over".into()), } } - // end feat: remove-from-session fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 65cbef36..32c86bd0 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -612,7 +612,6 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } -// start feat: remove-from-session #[test] fn the_host_removes_someone_and_they_cant_come_back() { let (mut host, mut hp, invite) = start(); @@ -643,4 +642,3 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } -// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 08f15d22..9f5d12fb 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,9 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), - // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 1dc22bec..0463ccd7 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,12 +274,10 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, - // start feat: remove-from-session /// Remove client `client` from `file`'s session: they are disconnected and their address /// can't join the file again while the server runs. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, - // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index e621fd36..4001b156 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,13 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } - // start feat: remove-from-session /// Remove client `client` from session `session`, which this link hosts: they are /// disconnected and can't join again from their address while it runs. Returns at once. fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { Err("removing people needs the desktop app or the MCP server".into()) } - // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1896,7 +1894,6 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } -// start feat: remove-from-session /// Remove someone from the open file's session: in a session this app hosts, the hub drops them; /// in a file of yours on a server, the server does (with the file's owner token). They are told /// the session is over for them, keep their copy, and can't join again from their address while @@ -1949,7 +1946,6 @@ pub fn can_remove_people(s: &Session) -> bool { None => false, } } -// end feat: remove-from-session fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { @@ -2059,7 +2055,6 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), - // start feat: remove-from-session spec!( "collab.removePerson", "Remove from session", @@ -2067,7 +2062,6 @@ pub fn specs() -> Vec { false, remove_person ), - // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index f2d8cfb8..cacc4f37 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,10 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, - // start feat: remove-from-session /// The people removed: `(session, client)`. pub(crate) removed: Vec<(u64, u32)>, - // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -66,12 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } - // start feat: remove-from-session fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { self.0.lock().unwrap().removed.push((session, client)); Ok(()) } - // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -324,7 +320,6 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } -// start feat: remove-from-session #[test] fn the_host_removes_people_and_guests_cant() { let (mut s, script) = session(); @@ -361,7 +356,6 @@ fn the_host_removes_people_and_guests_cant() { assert!(e.contains("only the person hosting"), "{e}"); assert!(script.lock().unwrap().removed.is_empty()); } -// end feat: remove-from-session #[test] fn collab_commands_survive_junk() { diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 03bfd3ce..5cd5f723 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -460,10 +460,8 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); - // start feat: remove-from-session let can_remove = figcraft_engine::live::can_remove_people(&app.session); let mut remove = None; - // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -501,7 +499,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } - // start feat: remove-from-session if can_remove { crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { @@ -510,7 +507,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { } }); } - // end feat: remove-from-session } if more > 0 { x += STEP; @@ -554,12 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } - // start feat: remove-from-session // A server is asked without the window waiting for it. if let Some(id) = remove { app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); } - // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 21396651..38ef96ec 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,7 +386,6 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. - - **Removing someone.** Right-click their avatar ▸ "Remove from session" (`collab.removePerson {client}`), when you host the session or own the file on a server. The hub closes their connection with "You were removed from this session" (close code 1001: the @@ -395,7 +394,6 @@ resuming with its offline edits, and leaving the session going back to snapshot a server the owner asks on an open hosting connection with the file's owner token (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are the only identity a session has: someone behind the same address (one NAT) is barred too. - - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above diff --git a/docs/mcp.md b/docs/mcp.md index af49937a..0e147bb0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,13 +1090,11 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). - - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the file again while the session (or the server) runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From aeeb1a7cdeae25dfa59b2e4aaabf06140ebfd4a2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:41:10 +0700 Subject: [PATCH 20/29] #338: forget a file's barred addresses when its session closes The hub kept the addresses removed from every file it ever had open. The entry now goes when the file is closed (everyone left) or removed from the server, so a long-running public server doesn't keep collecting them. --- crates/collab-net/src/barred_tests.rs | 53 +++++++++++++++++++++++++++ crates/collab-net/src/hub.rs | 13 +++++-- crates/collab/src/message.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 crates/collab-net/src/barred_tests.rs diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 00000000..c9355a5f --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index cdd06915..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -1147,7 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). barred: HashMap>, } @@ -1414,8 +1415,8 @@ impl Hub { } /// Remove client `id` from `file`: they are told the session is over for them and - /// disconnected, and their address can't join the file again while the hub runs. The person - /// hosting can't be removed. + /// disconnected, and their address can't join the file again while its session lasts. The + /// person hosting can't be removed. fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { let room = self.rooms.get(file).ok_or("nobody is in this file")?; if room.local.as_ref().is_some_and(|(local, _)| *local == id) { @@ -1558,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1635,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1740,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0463ccd7..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -275,7 +275,7 @@ pub enum Admin { /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, /// Remove client `client` from `file`'s session: they are disconnected and their address - /// can't join the file again while the server runs. With the server key, or on an open + /// can't join the file again while its session lasts. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, } diff --git a/docs/mcp.md b/docs/mcp.md index 0e147bb0..8459987d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1092,7 +1092,7 @@ only: a host stops hosting it, a guest leaves it. pan or zoom of your own, or Escape). - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the - file again while the session (or the server) runs. Only the person hosting, or the owner of a + file again while the session runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the From 50f12376179a34ba91c4fb3c01bcb65a265ddac8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:52:54 +0700 Subject: [PATCH 21/29] #272: refuse page drops released outside the visible pages list A page drag released over the Layers below could still reorder against a row scrolled out of view, and a layer dropped there could move to that page. The drop now counts only inside the list's visible area. The guard is ported from #366 by Skyfuller1303 (@Skyfuller1303). --- crates/ui-egui/src/layers.rs | 8 +++++++- crates/ui-egui/src/layers_behaviour_tests.rs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index fd51c67e..97203f94 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -217,7 +217,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); // A long list scrolls in its own area, so the layers keep their room below it. let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); - let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); @@ -279,6 +279,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } reorder.finish(ui) }); + // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, + // even one scrolled out of view. + if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { + moved.inner = None; + move_to = None; + } if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index b4bb1c13..43a8b76e 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -271,6 +271,26 @@ fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); } +#[test] +fn a_page_dropped_below_the_pages_list_stays_where_it_was() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let second = id(s.execute("page.add", &json!({})).unwrap()); + for _ in 0..58 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let (row, layer) = (page_row(&mut u, second, first), u.row_at(rect)); + // Released over the layers, where pages scrolled out of view would be: no page row is there. + u.drag_to(row, layer); + u.button(layer, false); + assert_eq!(u.app.session.doc().unwrap().pages, pages, "the pages keep their order"); +} + /// A double click at `p`, with the screen reader tree checked on every frame: the platform adapter /// (UI Automation on Windows) aborts the app when the focused node isn't in the tree. fn double_click_checking_focus(u: &mut Ui, p: Pos2) { From dd84097b6d3611f6aeefbf01f5156f68e8f71021 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:04:16 +0700 Subject: [PATCH 22/29] #368: revoke a download's object URL after a delay Revoking it right after the click can cancel the download in some browsers; it now goes 40 s later on a timer. --- apps/figcraft-web/src/web.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 43ec2cf2..8cadb013 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -131,7 +131,8 @@ impl Services for WebServices { } fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { - let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; let body = document.body().ok_or("no body")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ @@ -146,5 +147,14 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { body.append_child(&a)?; a.click(); a.remove(); - web_sys::Url::revoke_object_url(&url) + // Revoked later, not at once: some browsers cancel a download whose URL goes before it starts. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // Without the timer the URL just stays until the page closes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS); + Ok(()) } + +/// How long a download's object URL is kept (milliseconds). +const REVOKE_DOWNLOAD_MS: i32 = 40_000; From 62ed3452d6c32eef9ec63ef648ab2e65cd8c0592 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:07:26 +0700 Subject: [PATCH 23/29] #352: name the font list's height once The list's max height and the arrow keys' scroll offset now share one LIST_HEIGHT const. --- crates/ui-egui/src/typography.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index c76aeecd..0d9d3b8b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1065,6 +1065,8 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option) -> Optio const RECENT_KEY: &str = "figcraft_recent_fonts"; const ROW: f32 = 26.0; +/// The font list's tallest (it scrolls past that). +const LIST_HEIGHT: f32 = 380.0; fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::new(RECENT_KEY))).unwrap_or_default() @@ -1162,7 +1164,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) // Scrolled just enough to show it. let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); let top = i as f32 * stride; - offset = Some(seen.max(top + stride - 380.0).min(top)); + offset = Some(seen.max(top + stride - LIST_HEIGHT).min(top)); } } match hi { @@ -1180,7 +1182,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } let mut budget = 4; let mut picked = None; - let mut area = egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]); + let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]); if let Some(y) = offset { area = area.vertical_scroll_offset(y); } From 8f58c39bd740901bc70aab798d2badf76fb81d7c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:12:26 +0700 Subject: [PATCH 24/29] #352: keep the highlighted font in view in a shorter list Where the window has less room the font list is shorter than its tallest, and stepping down left the highlighted row cut off at the bottom. The scroll now uses the list's height as last drawn. --- crates/ui-egui/src/typography.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 0d9d3b8b..35f6df76 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1161,10 +1161,11 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } if let Some(i) = hi { - // Scrolled just enough to show it. - let seen: f32 = ui.data(|d| d.get_temp(hi_id.with("offset"))).unwrap_or(0.0); + // Scrolled just enough to show it (the list is shorter than its tallest where the + // window has less room). + let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT)); let top = i as f32 * stride; - offset = Some(seen.max(top + stride - LIST_HEIGHT).min(top)); + offset = Some(seen.max(top + stride - height).min(top)); } } match hi { @@ -1231,7 +1232,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } } }); - ui.data_mut(|d| d.insert_temp(hi_id.with("offset"), shown.state.offset.y)); + ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.offset.y, shown.inner_rect.height()))); FontPick { highlighted: hi.and_then(family), picked } } From 9894ee0e6542ce4fe9fd492338c229920b336ce1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:14:46 +0700 Subject: [PATCH 25/29] #308: pressing Design again keeps the pen path going Design is already the mode while the pen draws, so re-pressing it leaves nothing: only the Pencil goes back to Move there, as before. Leaving for Prototype or Inspect still ends the path. --- crates/ui-egui/src/pen_mode_tests.rs | 12 +++++++++--- crates/ui-egui/src/toolbar.rs | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/pen_mode_tests.rs b/crates/ui-egui/src/pen_mode_tests.rs index 31af1c66..d29d637c 100644 --- a/crates/ui-egui/src/pen_mode_tests.rs +++ b/crates/ui-egui/src/pen_mode_tests.rs @@ -80,9 +80,7 @@ fn the_mode_switcher_ends_the_pen_path_too() { press(&mut esc, Key::Escape, Modifiers::NONE); layers(&esc) }; - // Design is already the mode while the pen draws: pressing it still leaves the pen, as it leaves - // the pencil of Draw. - for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect), ("Design", RightTab::Design)] { + for (label, tab) in [("Prototype", RightTab::Prototype), ("Inspect", RightTab::Inspect)] { let mut u = drawing(); let at = mode_button(&mut u, label); click(&mut u, at); @@ -91,6 +89,14 @@ fn the_mode_switcher_ends_the_pen_path_too() { assert_eq!(u.app.session.tool.tool, ToolId::Move, "{label}: back to Move"); assert_eq!(layers(&u), finished, "{label}: the path is kept as Escape keeps it"); } + // Design is already the mode while the pen draws: pressing it again leaves nothing, so the + // path goes on. + let mut u = drawing(); + let at = mode_button(&mut u, "Design"); + click(&mut u, at); + assert_eq!(u.app.ui.right_tab, RightTab::Design); + assert!(u.app.session.tool.pen.is_some(), "Design: the path goes on"); + assert_eq!(u.app.session.tool.tool, ToolId::Pen, "Design: still the pen"); } #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 3e4a224e..a921cc29 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -174,7 +174,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } 1 => { app.ui.right_tab = crate::RightTab::Design; - if matches!(app.session.tool.tool, ToolId::Pencil | ToolId::Pen) { + if app.session.tool.tool == ToolId::Pencil { app.run("tool.set", json!({"tool": "move"})); } } From 6291e436050449f5dc1757b36a77bb2a1ddd528f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:20:32 +0700 Subject: [PATCH 26/29] #322: use widgets::note for the vector edit hints The wrapped hint label duplicated widgets::note (a wrapped secondary note inside the panel's side padding); the two hints use it, and the copy and the extra spacing after them go. --- crates/ui-egui/src/vector_panel.rs | 14 +++----------- crates/ui-egui/src/widgets.rs | 6 ------ 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 5a327ecc..e965da35 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -12,7 +12,7 @@ use crate::FigApp; use crate::i18n::tr; use crate::icons::Icon; use crate::right::{CAP_ART_W, cap_art, join_label, menu_item, menu_item_with, menu_popup, row}; -use crate::widgets::{self, FieldEdit, Lead, PAD}; +use crate::widgets::{self, FieldEdit, Lead}; /// Run a point command from a field: typed values are one step, scrubs one step when released. fn point_field(app: &mut FigApp, cmd: &str, edit: Option, params: impl Fn(f64) -> Value) { @@ -38,11 +38,7 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?))); let Some((i, p)) = sel else { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label_wrapped(ui, tr("Drag the stroke to add a width point"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Drag the stroke to add a width point"), t.text_secondary); return; }; let weight = stroke.map_or(1.0, |s| s.weight); @@ -85,11 +81,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false); if pts.is_empty() { let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.add_space(PAD); - widgets::label_wrapped(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); - }); - ui.add_space(8.0); + widgets::note(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary); return; } let col = widgets::col_width(ui.available_width()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index fe9e29cb..db696a1e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -949,12 +949,6 @@ pub fn label(ui: &mut Ui, text: &str, color: Color32) { ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); } -/// Text that wraps at the width it is given, so a long hint never widens its panel (a wider -/// panel would move the canvas under the pointer). -pub fn label_wrapped(ui: &mut Ui, text: &str, color: Color32) { - ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(color)).wrap()); -} - /// Text shown in `rect` that turns into an editor on click (or when its editor has focus, e.g. /// after a double-click rename). Returns the new text on Enter / focus loss. pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId) -> Option { From 4dc7c1b59c955fdece07f326ee61df9c33b2a628 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:25:05 +0700 Subject: [PATCH 27/29] #317: compute the backdrop once under a casting layer With drop shadows the backdrop was read twice: before the content and again after the shadows. The first read is only what backdrop effects inside the content see, so it now runs only when the layer has such descendants, and the content is copied for the shadow caster only when it gets a layer blur, noise or texture. The output is unchanged. --- crates/render/src/draw.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index af73aeeb..45b48500 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -429,7 +429,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // the backdrop is read again once they lie under it (a shadow showing behind is seen through // it). Until then, the layer's content sees the backdrop read first under it (#255). let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_))); - let first_back = read_backdrop(pix); + // Kept apart, the first read is only what backdrop effects inside the content see: without + // any, the backdrop (blur or glass, the costly part) is computed once, after the shadows. + let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects)); + let first_back = if !casts || nested() { read_backdrop(pix) } else { None }; if !casts && let Some(b) = &first_back { layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } @@ -473,8 +476,10 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep // Drop shadows go under the layer, in effect-list order. { // Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content - // with the layer effects it will get. - let content = casts.then(|| { + // with the layer effects it will get (a copy only when it gets some). + let layer_fx = + n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_))); + let content = (casts && layer_fx).then(|| { let mut c = layer.clone(); layer_effects(ctx, &mut c, n, id, lxf); c From 51549f9a606939e774467ee4062de4e26f75d685 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:36:00 +0700 Subject: [PATCH 28/29] #306: say why the window can't open in the interface language The message now comes from the UI crate (dialogs::window_failed) through tr() and trf(), with entries in every catalog, and the app picks the language from the preferences before the event loop starts, so the box reads in it even though no frame was ever drawn. The commands to type stay as written. --- apps/figcraft/src/main.rs | 3 +++ apps/figcraft/src/startup.rs | 21 ++---------------- apps/figcraft/src/startup_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 5 +++++ crates/ui-egui/locales/en.toml | 5 +++++ crates/ui-egui/locales/es.toml | 5 +++++ crates/ui-egui/locales/fr.toml | 5 +++++ crates/ui-egui/locales/ja.toml | 5 +++++ crates/ui-egui/locales/ko.toml | 5 +++++ crates/ui-egui/locales/pt-BR.toml | 5 +++++ crates/ui-egui/locales/zh-Hans.toml | 5 +++++ crates/ui-egui/src/dialogs.rs | 33 +++++++++++++++++++++++++++++ 12 files changed, 79 insertions(+), 20 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1babfd5b..5debb538 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -169,6 +169,9 @@ fn main() -> eframe::Result { if session.docs.is_empty() && !first_run { let _ = session.execute("file.new", &json!({})); } + // The interface language from the first frame on, and for the message when the window can't + // open (no frame then). + figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); fig.ui.home = false; fig.note_recovered(recovered.copies); diff --git a/apps/figcraft/src/startup.rs b/apps/figcraft/src/startup.rs index 8a50d7a5..a25821f3 100644 --- a/apps/figcraft/src/startup.rs +++ b/apps/figcraft/src/startup.rs @@ -4,28 +4,11 @@ //! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees //! and the app quit without a word (#261). Now it is logged and shown in a message box. -/// Where a report goes. -const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; - -/// The text shown when the window couldn't open with `error`. `backend_set`: `WGPU_BACKEND` named -/// the graphics API, so the hint is to unset it rather than to try another one. -pub fn message(error: &str, backend_set: bool) -> String { - let hint = if backend_set { - "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." - } else if cfg!(windows) { - // Direct3D 12 and OpenGL are both offered (`graphics.rs`), but wgpu keeps the Direct3D 12 - // adapter it picked even when its device then fails, so naming OpenGL alone can still help. - "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:\n\nset WGPU_BACKEND=gl\nfigcraft.exe" - } else { - "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." - }; - format!("FigCraft couldn't open its window: {error}\n\n{hint}\n\nIf it keeps happening, please report it at {ISSUES} with this message.") -} - /// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would /// keep the caller waiting, and it reads standard error), show it in a message box. pub fn failed(error: &eframe::Error, quiet: bool) { - let text = message(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); + // In the interface language: the text and its catalog entries are the UI crate's. + let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some()); // Standard error even when RUST_LOG turned errors off. if log::log_enabled!(log::Level::Error) { log::error!("{text}"); diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 6818ae77..0c92bd19 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -1,7 +1,7 @@ //! The message shown when the window can't open: it carries the error, a next step and where to //! report it, instead of the app quitting silently (#261). -use crate::startup::message; +use figcraft_ui_egui::dialogs::window_failed as message; const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index affda97d..5987ec70 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Verblassen" "False" = "Falsch" "Fetching from the server…" = "Wird vom Server abgerufen…" +"FigCraft couldn't open its window: {error}" = "FigCraft konnte sein Fenster nicht öffnen: {error}" "File" = "Datei" "File name" = "Dateiname" "File published" = "Datei veröffentlicht" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Farbton" "Hug" = "Umschließen" "Hug contents" = "Inhalt umschließen" +"If it keeps happening, please report it at {link} with this message." = "Wenn es weiterhin passiert, melden Sie es bitte mit dieser Meldung unter {link}." "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Plugin aktualisieren" "Updated {components} and {styles}" = "{components} und {styles} aktualisiert" "Updated {name}" = "{name} aktualisiert" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Oft hilft es, den Grafiktreiber zu aktualisieren. Um nur OpenGL zu verwenden, starten Sie FigCraft in der Eingabeaufforderung mit:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Oft hilft es, den Grafiktreiber zu aktualisieren. Mit WGPU_BACKEND (vulkan, gl, metal) lässt sich eine andere Grafik-API ausprobieren." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lädt diese Datei auf einen FigCraft-Server hoch und hält diesen Tab mit ihm synchron. Sie erhalten einen Link zum Bearbeiten und einen zum Ansehen." "Uppercase" = "Großbuchstaben" "Use a fixed value" = "Festen Wert verwenden" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" "Wavy" = "Gewellt" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba..21df8bfe 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -475,6 +475,7 @@ name = "English" "False" = "False" "Feb" = "Feb" "Fetching from the server…" = "Fetching from the server…" +"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -594,6 +595,7 @@ name = "English" "Hug contents" = "Hug contents" "Icon components on a 24 px grid with keylines and a size preview" = "Icon components on a 24 px grid with keylines and a size preview" "Icon set starter" = "Icon set starter" +"If it keeps happening, please report it at {link} with this message." = "If it keeps happening, please report it at {link} with this message." "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" @@ -1389,6 +1391,8 @@ name = "English" "Update from selection" = "Update from selection" "Updated {components} and {styles}" = "Updated {components} and {styles}" "Updated {name}" = "Updated {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." "Uppercase" = "Uppercase" "Use a fixed value" = "Use a fixed value" @@ -1431,6 +1435,7 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" "Wavy" = "Wavy" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e891..af5c6a15 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -476,6 +476,7 @@ status = "draft — needs native review" "False" = "Falso" "Feb" = "feb" "Fetching from the server…" = "Obteniendo del servidor…" +"FigCraft couldn't open its window: {error}" = "FigCraft no pudo abrir su ventana: {error}" "File" = "Archivo" "File name" = "Nombre del archivo" "File not found" = "No se encuentra el archivo" @@ -595,6 +596,7 @@ status = "draft — needs native review" "Hug contents" = "Ajustar al contenido" "Icon components on a 24 px grid with keylines and a size preview" = "Componentes de icono en una cuadrícula de 24 px con líneas guía y una vista de tamaños" "Icon set starter" = "Base de conjunto de iconos" +"If it keeps happening, please report it at {link} with this message." = "Si sigue pasando, infórmalo en {link} con este mensaje." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" @@ -1390,6 +1392,8 @@ status = "draft — needs native review" "Update from selection" = "Actualizar desde la selección" "Updated {components} and {styles}" = "Actualizados {components} y {styles}" "Updated {name}" = "{name} actualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Actualizar el controlador gráfico suele ayudar. Para usar solo OpenGL, inicia FigCraft desde el Símbolo del sistema con:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Actualizar el controlador gráfico suele ayudar. WGPU_BACKEND (vulkan, gl, metal) indica otra API gráfica que probar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." "Uppercase" = "Mayúsculas" "Use a fixed value" = "Usar un valor fijo" @@ -1432,6 +1436,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 69798340..1f5544c2 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -457,6 +457,7 @@ status = "draft — needs native review" "Fade" = "Fondu" "False" = "Faux" "Fetching from the server…" = "Récupération depuis le serveur…" +"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}" "File" = "Fichier" "File name" = "Nom du fichier" "File published" = "Fichier publié" @@ -568,6 +569,7 @@ status = "draft — needs native review" "Hue" = "Teinte" "Hug" = "Ajuster" "Hug contents" = "Ajuster au contenu" +"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message." "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" @@ -1323,6 +1325,8 @@ status = "draft — needs native review" "Update plugin" = "Mettre à jour le plugin" "Updated {components} and {styles}" = "{components} et {styles} mis à jour" "Updated {name}" = "{name} mis à jour" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Mettre à jour le pilote graphique aide souvent. Pour n’utiliser qu’OpenGL, lancez FigCraft depuis l’invite de commandes avec :" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Mettre à jour le pilote graphique aide souvent. WGPU_BACKEND (vulkan, gl, metal) désigne une autre API graphique à essayer." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envoie ce fichier sur un serveur FigCraft et garde cet onglet synchronisé avec lui. Vous obtenez un lien de modification et un lien de consultation." "Uppercase" = "Majuscules" "Use a fixed value" = "Utiliser une valeur fixe" @@ -1364,6 +1368,7 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" "Wavy" = "Ondulé" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb81b042..9174a65a 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -478,6 +478,7 @@ status = "draft — needs native review" "False" = "false" "Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" +"FigCraft couldn't open its window: {error}" = "FigCraft のウィンドウを開けなかった:{error}" "File" = "ファイル" "File name" = "ファイル名" "File not found" = "ファイルが見つからない" @@ -597,6 +598,7 @@ status = "draft — needs native review" "Hug contents" = "内容に合わせる" "Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" "Icon set starter" = "アイコンセットの雛形" +"If it keeps happening, please report it at {link} with this message." = "繰り返し起きる場合は、このメッセージを添えて {link} に報告。" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -1392,6 +1394,8 @@ status = "draft — needs native review" "Update plugin" = "プラグインを更新" "Updated {components} and {styles}" = "{components}と{styles}を更新済み" "Updated {name}" = "{name} を更新済み" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "グラフィックスドライバーの更新で解決することが多い。OpenGL だけを使うには、コマンドプロンプトから次のように FigCraft を起動:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "グラフィックスドライバーの更新で解決することが多い。WGPU_BACKEND(vulkan、gl、metal)で別のグラフィックス API を試せる。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "このファイルを FigCraft サーバーにアップロードし、このタブを同期した状態に保つ。編集用リンクと閲覧用リンクが発行される。" "Uppercase" = "大文字" "Use a fixed value" = "固定値を使用" @@ -1434,6 +1438,7 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" "Wavy" = "波線" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index aed3529a..e672efe7 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -458,6 +458,7 @@ status = "draft — needs native review" "Fade" = "페이드" "False" = "거짓" "Fetching from the server…" = "서버에서 가져오는 중…" +"FigCraft couldn't open its window: {error}" = "FigCraft 창을 열 수 없음: {error}" "File" = "파일" "File name" = "파일 이름" "File published" = "파일 게시됨" @@ -569,6 +570,7 @@ status = "draft — needs native review" "Hue" = "색조" "Hug" = "맞춤" "Hug contents" = "내용에 맞춤" +"If it keeps happening, please report it at {link} with this message." = "계속 발생하면 이 메시지와 함께 {link}에 알려 주세요." "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" @@ -1324,6 +1326,8 @@ status = "draft — needs native review" "Update plugin" = "플러그인 업데이트" "Updated {components} and {styles}" = "{components} 및 {styles} 업데이트됨" "Updated {name}" = "{name} 업데이트됨" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. OpenGL만 사용하려면 명령 프롬프트에서 다음과 같이 FigCraft를 시작하세요:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "그래픽 드라이버를 업데이트하면 해결되는 경우가 많습니다. WGPU_BACKEND(vulkan, gl, metal)로 다른 그래픽 API를 시도할 수 있습니다." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "이 파일을 FigCraft 서버에 업로드하고 이 탭을 동기화된 상태로 유지함. 편집 링크와 보기 링크가 생성됨." "Uppercase" = "대문자" "Use a fixed value" = "고정 값 사용" @@ -1365,6 +1369,7 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" "Wavy" = "물결선" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 97500236..f49beb30 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -456,6 +456,7 @@ status = "draft — needs native review" "Fade" = "Esmaecer" "False" = "Falso" "Fetching from the server…" = "Buscando no servidor…" +"FigCraft couldn't open its window: {error}" = "O FigCraft não conseguiu abrir a janela: {error}" "File" = "Arquivo" "File name" = "Nome do arquivo" "File published" = "Arquivo publicado" @@ -567,6 +568,7 @@ status = "draft — needs native review" "Hue" = "Matiz" "Hug" = "Ajustar" "Hug contents" = "Ajustar ao conteúdo" +"If it keeps happening, please report it at {link} with this message." = "Se continuar acontecendo, informe em {link} com esta mensagem." "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" @@ -1322,6 +1324,8 @@ status = "draft — needs native review" "Update plugin" = "Atualizar plugin" "Updated {components} and {styles}" = "{components} e {styles} atualizados" "Updated {name}" = "{name} atualizado" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Atualizar o driver de vídeo costuma ajudar. Para usar só o OpenGL, abra o FigCraft pelo Prompt de Comando com:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Atualizar o driver de vídeo costuma ajudar. WGPU_BACKEND (vulkan, gl, metal) indica outra API gráfica para tentar." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Envia este arquivo para um servidor FigCraft e mantém esta aba sincronizada com ele. Você recebe um link para editar e outro para visualizar." "Uppercase" = "Maiúsculas" "Use a fixed value" = "Usar um valor fixo" @@ -1363,6 +1367,7 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" "Wavy" = "Ondulado" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d1887735..70a05c5f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -459,6 +459,7 @@ status = "draft — needs native review" "Fade" = "褪色" "False" = "假" "Fetching from the server…" = "正在从服务器获取…" +"FigCraft couldn't open its window: {error}" = "FigCraft 无法打开窗口:{error}" "File" = "文件" "File name" = "文件名" "File published" = "文件已发布" @@ -570,6 +571,7 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "适应" "Hug contents" = "适应内容" +"If it keeps happening, please report it at {link} with this message." = "如果问题持续出现,请附上此消息到 {link} 报告。" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" @@ -1325,6 +1327,8 @@ status = "draft — needs native review" "Update plugin" = "更新插件" "Updated {components} and {styles}" = "已更新 {components} 和 {styles}" "Updated {name}" = "已更新 {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "更新显卡驱动通常会有帮助。若只想使用 OpenGL,请在命令提示符中这样启动 FigCraft:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "更新显卡驱动通常会有帮助。可用 WGPU_BACKEND(vulkan、gl、metal)指定另一个图形 API 来尝试。" "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "将此文件上传到 FigCraft 服务器,并使此标签页与之保持同步。你会获得一个编辑链接和一个查看链接。" "Uppercase" = "大写" "Use a fixed value" = "使用固定值" @@ -1366,6 +1370,7 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" "Wavy" = "波浪线" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..5f51a610 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,39 @@ use crate::theme::{ACCENT, Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Where a report goes. +const ISSUES: &str = "https://github.com/storytold/figcraft/issues"; + +/// What the desktop app says when its window couldn't open with `error` (no frame was drawn: the +/// language is the one [`i18n::set`] picked from the preferences). `backend_set`: `WGPU_BACKEND` +/// named the graphics API, so the hint is to unset it rather than to try another one. +pub fn window_failed(error: &str, backend_set: bool) -> String { + let hint = if backend_set { + tr("WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.").to_string() + } else if cfg!(windows) { + // Direct3D 12 and OpenGL are both offered, but wgpu keeps the Direct3D 12 adapter it picked + // even when its device then fails, so naming OpenGL alone can still help. + format!( + "{} + +set WGPU_BACKEND=gl +figcraft.exe", + tr("Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:") + ) + } else { + tr("Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try.").to_string() + }; + format!( + "{} + +{hint} + +{}", + trf("FigCraft couldn't open its window: {error}", &[("error", &error)]), + trf("If it keeps happening, please report it at {link} with this message.", &[("link", &ISSUES)]) + ) +} + /// A dialog window, centred. pub(crate) fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> { floating(title, ctx).anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) From 1d5c9ae0105252db695b8d47816f3febafeca8e7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 11:15:32 +0200 Subject: [PATCH 29/29] fix(ui): scroll a long pages list in its own area so the layers stay in view (fixes #258) --- crates/ui-egui/src/layers.rs | 117 ++++++++++--------- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++ 2 files changed, 105 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 927e3ae4..fd51c67e 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -215,66 +215,71 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); - a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); - let r = r.shrink2(vec2(8.0, 0.0)); - a11y::ring_around(ui, r, &resp); - let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); - if divider && !renaming { - // A divider is a line between pages: it opens nothing and takes no layers. - ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); - } else if resp.hovered() || id == cur { - ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); - } - if dragging.is_some() - && id != cur - && !divider - // The gap between rows counts too. - && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) - { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - move_to = Some((id, name.clone())); + // A long list scrolls in its own area, so the layers keep their room below it. + let max_h = (ui.available_height() * 0.4).max(3.0 * ROW); + let moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); + let r = r.shrink2(vec2(8.0, 0.0)); + a11y::ring_around(ui, r, &resp); + let renaming = app.ui.rename_page.as_ref().is_some_and(|(i, _)| *i == id); + if divider && !renaming { + // A divider is a line between pages: it opens nothing and takes no layers. + ui.painter().line_segment([pos2(r.min.x + 10.0, r.center().y), pos2(r.max.x - 10.0, r.center().y)], Stroke::new(1.0, t.border)); + } else if resp.hovered() || id == cur { + ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - } - if renaming { - let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); - let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { - rename_done = Some((id, new)); + if dragging.is_some() + && id != cur + && !divider + // The gap between rows counts too. + && pointer.is_some_and(|p| resp.interact_rect.expand2(vec2(0.0, 3.0)).contains(p)) + { + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + move_to = Some((id, name.clone())); + } } - } else if !divider { - let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); - } - if resp.clicked() && !divider { - select = Some(id); - } - if resp.double_clicked() { - app.ui.rename_page = Some((id, name.clone())); - // The field takes the focus next frame, once it's drawn: focusing it now would put a - // node that isn't there yet in the screen reader tree, which AccessKit aborts on. - ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); - } - crate::right::context_popup(&resp).show(|ui| { - if ui.button(tr("Rename page")).clicked() { + if renaming { + let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); + let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { + rename_done = Some((id, new)); + } + } else if !divider { + let font = if id == cur { semibold(11.0) } else { regular(11.0) }; + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 10.0, r.center().y), &name, font, t.text, r.width() - 14.0); + } + if resp.clicked() && !divider { + select = Some(id); + } + if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); - ui.close(); + // The field takes the focus next frame, once it's drawn: focusing it now would put a + // node that isn't there yet in the screen reader tree, which AccessKit aborts on. + ui.data_mut(|d| d.insert_temp(page_rename_start_id(), true)); } - if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); - ui.close(); - } - if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); - ui.close(); - } - }); - } - if let Some((from, to)) = reorder.finish(ui) + crate::right::context_popup(&resp).show(|ui| { + if ui.button(tr("Rename page")).clicked() { + app.ui.rename_page = Some((id, name.clone())); + ui.close(); + } + if ui.button(tr("Duplicate page")).clicked() { + app.run("page.duplicate", json!({"id": id})); + ui.close(); + } + if ui.button(tr("Delete page")).clicked() { + app.run("page.delete", json!({"id": id})); + ui.close(); + } + }); + } + reorder.finish(ui) + }); + if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) { app.run("page.move", json!({"id": id, "index": to})); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 155f0d6d..1d214aad 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -267,3 +267,47 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1)); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } + +/// The page opened by a click on each spot from just under the pages header down to `to`. +fn pages_down_to(u: &mut Ui, to: f32) -> Vec { + let mut opened = Vec::new(); + for y in (190..to as i32).step_by(8) { + click(u, Pos2::new(150.0, y as f32), Modifiers::NONE); + opened.push(u.app.session.page().unwrap()); + } + opened +} + +#[test] +fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { + let mut s = Session::new(); + let first = s.page().unwrap(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..59 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The layers list stays on screen under 60 pages. + let layer = u.row_at(rect); + // The wheel over the pages list scrolls it to its last page. + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(Pos2::new(150.0, 200.0)), wheel.clone()]); + } + for _ in 0..30 { + u.frame(vec![]); + } + let opened = pages_down_to(&mut u, layer.y - 60.0); + assert!(opened.contains(&last), "the last page can be reached"); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 30, "the list scrolled past its first pages (top row: page {top})"); +}