From 79c363466a46b96f989b73c7cf20240bc168b7a2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 9 Nov 2026 06:59:31 -0500 Subject: [PATCH 1/9] fix(web): allow up to Cloudflare 25 MiB cap in package.sh (#2155) --- packaging/web/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/web/package.sh b/packaging/web/package.sh index f9ee34a78..9e5ba520f 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -27,7 +27,7 @@ fi # Size gate: Cloudflare Pages/Workers reject any single file over 25 MiB, and other # hosts and CDNs have similar caps. Fail well before that so a regression shows up here, not at # upload time. Override with FIGCRAFT_WASM_MAX_BYTES only to investigate. -MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB +MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-26214400}" # 25 MiB (Cloudflare per-file cap) WASM_COUNT=0 for wasm in "$SITE"/*.wasm; do [[ -f "$wasm" ]] || continue From a2e87433d8bda2b0c909587da08cb27c51e3b17e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 9 Nov 2026 15:44:02 +0100 Subject: [PATCH 2/9] Naming: Developer Mode follow-ups (#2165) * Naming: record Developer Mode in the feature-names doc and the stage paragraph docs/feature-names.md gains the entry for the rename: the old label, the new one, Figma's US registration for DEV MODE (7574348) as the reason with its TSDR record, the identifiers that keep their names, and the translations that moved from a generic "dev mode" to the developer mode. The stage paragraph in ROADMAP.md listed the mode as Dev Mode. * Actions palette: put each search doc comment back on its function search_text had grown a second doc line under matches's, so the sentence about the query sat above a function that takes none. search_text now says what it reads and matches keeps its own. --- ROADMAP.md | 2 +- crates/ui-egui/src/menus/palette.rs | 6 +++--- docs/feature-names.md | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 651f3ab2e..dc6204b44 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -45,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human: ## Stage **Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components -with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but +with variants and overrides, styles, variables, prototyping, Developer Mode, SVG/PNG/JPG/PDF), but FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs ~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured diff --git a/crates/ui-egui/src/menus/palette.rs b/crates/ui-egui/src/menus/palette.rs index 7b3697660..8d25a8583 100644 --- a/crates/ui-egui/src/menus/palette.rs +++ b/crates/ui-egui/src/menus/palette.rs @@ -241,9 +241,8 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui resp } -/// Whether `label` (or `extra`) matches the query, in any case. -/// What a search reads besides the label in the UI's language: the English label, the id, and a -/// name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]). +/// The text a search reads besides the label in the UI's language: the English label, the id, and +/// a name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]). fn search_text(english: &str, id: &str) -> String { let mut text = format!("{english} {id}"); for (alias_id, alias) in figcraft_engine::catalog::SEARCH_ALIASES { @@ -255,6 +254,7 @@ fn search_text(english: &str, id: &str) -> String { text } +/// Whether `label` (or `extra`) matches the query, in any case. fn matches(q: &str, label: &str, extra: &str) -> bool { q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) } diff --git a/docs/feature-names.md b/docs/feature-names.md index c3df4d593..437fdb98a 100644 --- a/docs/feature-names.md +++ b/docs/feature-names.md @@ -3,3 +3,9 @@ FigCraft describes itself as an **interface design tool**, without the comparative Figma-class tag. Interface-size command help describes the size steps directly. Command IDs, file-format interoperability references, behavioral documentation and attribution remain unchanged. Regression tests prevent the retired comparative display phrases from returning to MCP instructions or command help. This copy change makes no legal claim about the comparative terms and does not change behavior. + +## Developer Mode + +**Old label:** "Dev Mode". **New label:** "Developer Mode", since #2135. + +Figma holds a US registration for DEV MODE ([US 7574348](https://tsdr.uspto.gov/statusview/sn98045640), registered 2024-11-19, owner Figma, Inc.), so neither a label nor a translation calls the mode by the old name. The identifiers keep their names (`view.devMode`, `devMode.*`), and so do the preference keys and saved document fields; the Actions palette still finds the command by the old name. Some translations had a generic "dev mode" where the proper name belongs (French "Mode Dev", Portuguese "Modo Dev", Dutch "Dev Mode"); they now read "Mode développeur", "Modo do desenvolvedor" and "Ontwikkelaarsmodus", so every language names the mode the same way. `crates/ui-egui/src/naming_tests.rs` keeps the retired name out of the labels and the catalogs. From 73e3720083caef9f1e59827845311ef3ebcd7f6f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:22:16 +0100 Subject: [PATCH 3/9] sonar: SHA-1 image names in .fig export are the format's, not a choice --- sonar-project.properties | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sonar-project.properties b/sonar-project.properties index 92562d54c..5f58f42ae 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,8 @@ sonar.projectKey=storytold_figcraft sonar.organization=storytold + +# S4790 (weak hash): a .fig file names each image by the SHA-1 of its bytes (20 bytes, the only +# length readers accept). It is an identifier the format fixes, not a security choice. +sonar.issue.ignore.multicriteria=fig_image_sha1 +sonar.issue.ignore.multicriteria.fig_image_sha1.ruleKey=rust:S4790 +sonar.issue.ignore.multicriteria.fig_image_sha1.resourceKey=crates/format/src/fig/export.rs From 2af535c132e7a40208c0c0ac440f459012e449e5 Mon Sep 17 00:00:00 2001 From: amorSoftLabs Date: Tue, 10 Nov 2026 22:28:20 +0530 Subject: [PATCH 4/9] fix(ui): access preferences from home screen via shortcut and sidebar (#956) (#962) * fix(ui): access preferences from home screen via shortcut and sidebar (#956) * fix(ui): handle preferences shortcut before popup guard * docs: add docstrings for modified functions to satisfy coverage check --------- Co-authored-by: bflatastic --- crates/ui-egui/src/app.rs | 4 ++++ crates/ui-egui/src/home.rs | 24 ++++++++++++++++++++++-- crates/ui-egui/src/home_tests.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f0513520b..0267dd769 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -272,6 +272,10 @@ impl FigApp { // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen // (cleared after the home widgets, which may have just asked for it). self.ui.shortcuts_panel = false; + // The keymap runs with the canvas; here the one shortcut that still applies is read directly. + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Comma)) { + self.run("app.preferences", serde_json::json!({})); + } dialogs::keys(self, &ctx); dialogs::show(self, &ctx); updates_ui::show(self, &ctx); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9587c6623..d5babe72a 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -190,8 +190,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } -/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) -/// and section tabs; choosing a section clears the file query. +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it), section +/// tabs (choosing one clears the file query) and Preferences at the bottom. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -229,6 +229,26 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { } y += 34.0; } + let pref_y = (left.max.y - 44.0).max(y + 12.0); + if pref_y > y + 12.0 { + ui.painter().line_segment([pos2(left.min.x + 12.0, pref_y - 6.0), pos2(left.max.x - 12.0, pref_y - 6.0)], Stroke::new(1.0, t.border)); + } + let r = Rect::from_min_size(pos2(left.min.x + 8.0, pref_y), vec2(SIDEBAR - 16.0, 32.0)); + let mut resp = ui.interact(r, ui.scope_id().with("home_preferences"), Sense::click()); + let current = app.ui.preferences; + a11y::button(&resp, tr("Preferences")); + if current || resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover }); + } + focus_ring(ui, t, r, &resp, 5); + icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), Icon::Adjust, t.icon); + ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr("Preferences"), regular(12.0), t.text); + let key_label = figcraft_engine::keymap::display("Cmd+,", crate::shortcuts::platform()); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, &key_label, regular(11.0), t.text_secondary); + resp = resp.on_hover_text(format!("{} ({})", tr("Preferences"), key_label)); + if resp.clicked() { + app.ui.preferences = !app.ui.preferences; + } } fn file_matches(name: &str, location: &str, query: &str) -> bool { diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index dcbbb3b6f..dfe3a0a7e 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -917,3 +917,34 @@ fn a_jam_file_is_a_document_open_and_drops_take() { assert!(crate::open::opens(path) && crate::open::is_document(path), "{name}"); } } + +/// The Preferences dialog can be opened and toggled from the home screen via +/// the `Cmd/Ctrl + ,` shortcut as well as by clicking Preferences in the sidebar. +#[test] +fn home_screen_preferences_shortcut_and_sidebar_access() { + let mut u = first_run(); + u.app.ui.home = true; + u.frame(vec![]); + assert!(!u.app.ui.preferences); + + // Ctrl/Cmd + Comma opens Preferences on the Home screen. + u.frame(vec![ + Event::Key { key: Key::Comma, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }, + Event::Key { key: Key::Comma, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }, + ]); + assert!(u.app.ui.preferences, "Ctrl/Cmd+, should open Preferences from the Home screen"); + + // Escape closes Preferences. + press(&mut u, Key::Escape); + assert!(!u.app.ui.preferences, "Escape should close Preferences"); + + // Clicking Preferences in the sidebar opens Preferences. + let s = shapes(&mut u); + click(&mut u, text_center(&s, "Preferences")); + assert!(u.app.ui.preferences, "Clicking Preferences in sidebar should open Preferences"); + + // Clicking it again toggles it closed. + let s = shapes(&mut u); + click(&mut u, text_center(&s, "Preferences")); + assert!(!u.app.ui.preferences, "Clicking Preferences in sidebar again should close it"); +} From 0f0826906786563a4f287d2145a1ede733eb8cb7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 08:36:29 -0500 Subject: [PATCH 5/9] fix(collab-net): never send the server key over plain ws:// to another machine (#1923) * fix(collab-net): never send the server key over plain ws:// to another machine Publishing a plugin, uploading or sharing with the server's key accepted a ws:// address for a remote host, so the reusable administrative key went out in clear text. The key now goes only over wss://, or over ws:// to this machine (loopback or localhost); browsing without a key is unchanged. * address review: connect only to loopback addresses for a plain ws://localhost endpoint --- crates/collab-net/src/link.rs | 4 ++++ crates/collab-net/src/net.rs | 38 +++++++++++++++++++++++++++---- crates/collab-net/src/requests.rs | 27 ++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 1cf23c9a2..30bc587c3 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -220,6 +220,7 @@ impl LiveLink for NetLink { let (server, query) = Endpoint::parse(&o.server)?; // Without a key: as anyone may, when the server is open to the public (it says if not). let key = requests::server_key(o.key, query); + requests::key_over_tls(&server, key.as_deref())?; // Until the upload is done the invite names only the server. let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me); let inbox = self.sessions.inbox(); @@ -342,6 +343,7 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result { let (server, query) = Endpoint::parse(&o.server)?; let key = requests::server_key(o.key, query) .ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?; + requests::key_over_tls(&server, Some(&key))?; upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published) } @@ -358,6 +360,7 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option, limits: &N fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result, String> { let (endpoint, query) = Endpoint::parse(server)?; let key = requests::server_key(None, query); + requests::key_over_tls(&endpoint, key.as_deref())?; manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT) } @@ -365,6 +368,7 @@ fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result { let (server, query) = Endpoint::parse(server)?; let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default(); + requests::key_over_tls(&server, Some(&key))?; manage::plugins(&server, key.trim(), req, limits, CONNECT) } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 9c35b5d1c..c8c29bbdb 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -242,18 +242,30 @@ pub fn shutdown(s: &TcpStream) { /// Connect to `server` (through TLS for `wss://`): the reader's and the writer's handles, and the /// socket to shut it down with. pub fn open(server: &Endpoint, timeout: Duration, limits: &NetLimits) -> Result<(Duplex, Duplex, TcpStream), String> { - let stream = dial(&server.host, timeout, limits)?; + // A plain `ws://localhost` is trusted as this machine (a key may go over it), so only loopback + // addresses are connected to: a resolver mapping the name elsewhere must not carry it off-machine. + let stream = dial(&server.host, !server.secure && server.loopback(), timeout, limits)?; let tls = if server.secure { Some(tls::handshake(&stream, &server.host)?) } else { None }; Duplex::split(stream, tls).map_err(|e| e.to_string()) } -/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off -/// and reads and writes bounded by `limits`' hello and write timeouts. -fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result { +/// Whether `a` may be connected to: any address, or with `loopback_only` just this machine's. +fn dialable(a: &std::net::SocketAddr, loopback_only: bool) -> bool { + !loopback_only || a.ip().is_loopback() +} + +/// Connect to `host` (`name:port`), trying each of its addresses within `timeout` (only loopback +/// ones with `loopback_only`), with Nagle off and reads and writes bounded by `limits`' hello and +/// write timeouts. +fn dial(host: &str, loopback_only: bool, timeout: Duration, limits: &NetLimits) -> Result { use std::net::ToSocketAddrs; let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?; let mut last = format!("can't find {host}"); for a in addrs { + if !dialable(&a, loopback_only) { + last = format!("{host} resolves to {a}, which is not this machine"); + continue; + } match TcpStream::connect_timeout(&a, timeout) { Ok(s) => { let _ = s.set_nodelay(true); @@ -318,3 +330,21 @@ impl Bucket { self.tokens = (self.tokens + n).min(self.burst); } } + +#[cfg(test)] +mod dial_tests { + use super::dialable; + use std::net::SocketAddr; + + #[test] + fn a_loopback_only_dial_skips_addresses_off_this_machine() { + let at = |s: &str| s.parse::().unwrap(); + for a in ["127.0.0.1:9", "[::1]:9", "127.5.5.5:9"] { + assert!(dialable(&at(a), true), "{a}"); + } + for a in ["10.0.0.5:9", "203.0.113.7:9", "[2001:db8::1]:9"] { + assert!(!dialable(&at(a), true), "{a}"); + assert!(dialable(&at(a), false), "{a} when anywhere is allowed"); + } + } +} diff --git a/crates/collab-net/src/requests.rs b/crates/collab-net/src/requests.rs index e9c63283c..a2c7f9439 100644 --- a/crates/collab-net/src/requests.rs +++ b/crates/collab-net/src/requests.rs @@ -32,6 +32,15 @@ pub(crate) fn server_key(given: Option, query: Vec<(String, String)>) -> given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty()) } +/// Refuse to send the server's key (its whole administrative authority, reusable) in the clear: +/// `ws://` is only for a server on this machine; anywhere else needs `wss://`. +pub(crate) fn key_over_tls(server: &Endpoint, key: Option<&str>) -> Result<(), String> { + if key.is_some_and(|k| !k.trim().is_empty()) && !server.secure && !server.loopback() { + return Err("the server's key is only sent over wss:// (or ws:// to this machine): it would be readable on the network".into()); + } + Ok(()) +} + /// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't /// read the question. pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String { @@ -145,3 +154,21 @@ pub(crate) fn hosted(server: &Endpoint, reply: Option) -> Option other, }) } + +#[cfg(test)] +mod key_tests { + use super::*; + + #[test] + fn the_key_never_goes_over_plain_tcp_to_another_machine() { + let at = |url: &str| Endpoint::parse(url).unwrap().0; + assert!(key_over_tls(&at("ws://share.example:9000/"), Some("k")).is_err()); + assert!(key_over_tls(&at("ws://10.0.0.5:9000/"), Some("k")).is_err()); + assert!(key_over_tls(&at("wss://share.example/"), Some("k")).is_ok()); + assert!(key_over_tls(&at("ws://127.0.0.1:9000/"), Some("k")).is_ok()); + assert!(key_over_tls(&at("ws://localhost:9000/"), Some("k")).is_ok()); + // Browsing or publishing as anyone sends no key, so plain is fine. + assert!(key_over_tls(&at("ws://share.example:9000/"), None).is_ok()); + assert!(key_over_tls(&at("ws://share.example:9000/"), Some(" ")).is_ok()); + } +} From 401f449729210e69029aac0af8852c1697f787d7 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 09:02:45 -0500 Subject: [PATCH 6/9] M8: zoom previews: scan system fonts before the zoom-preview tests render (#2230) pans_and_edits_get_no_preview_and_build_on_the_kept_image failed in full lib runs with "a pan after a previewed zoom renders strips only: Full". The render kept from the zoom is stale once the global font generation changes (retained.rs prepare: last.fonts == layout_generation()), and the once-per-process system font scan that many other tests start bumps it (fontdb.rs scan_system_fonts -> clear_layout_cache). When that scan landed between the test's zoom and its pan, the pan redrew in full instead of patching. Unrelated to the clock or the threshold. document() now runs scan_system_fonts() first (a concurrent caller waits for the scan), so the generation is settled before any render. Test-only change; the shipped staleness check is intended behaviour. Stress, full lib binary with 16 test threads: 3 of 6 runs failed before, 0 of 25 after. Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium Co-authored-by: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/zoom_preview_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 62951f8bd..bb36c0aae 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -23,8 +23,12 @@ use crate::render_worker::{ Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, }; -/// A page of ellipses with a rectangle on top; the session and the rectangle. +/// A page of ellipses with a rectangle on top; the session and the rectangle. The system fonts are +/// scanned first (once per process, and a concurrent caller waits for it): the scan another test +/// starts bumps the global font generation, and a render kept from before it is stale, so a pan +/// or an edit meanwhile would redraw in full instead of patching. fn document() -> (Session, NodeId) { + figcraft_text::scan_system_fonts(); let mut s = Session::new(); for k in 0..12 { let k = f64::from(k); From 9e815546ba1f51782bf2ef788e725f111a49c745 Mon Sep 17 00:00:00 2001 From: Pavel Ivanov Date: Wed, 11 Nov 2026 15:05:04 +0100 Subject: [PATCH 7/9] M8: switching back to a tab shows its page at once (#2075) * **No blank canvas** when switching back to a document tab or to a page in a file: the image it had when last shown is drawn from the first frame while it renders again * Images of up to four recently shown files and pages are kept; closing a tab frees its image right away Refs: #2074 --- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/canvas.rs | 49 ++++++ crates/ui-egui/src/canvas/tab_switch_tests.rs | 159 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 crates/ui-egui/src/canvas/tab_switch_tests.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0267dd769..45dc80f09 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -268,6 +268,8 @@ impl FigApp { return; } if !self.has_files() || self.ui.home { + let docs = &self.session.docs; + self.canvas.drop_closed(|uid| docs.iter().any(|d| d.uid == uid)); home::show(self, ui); // The shortcuts panel belongs to the canvas; it doesn't hang over the home screen // (cleared after the home widgets, which may have just asked for it). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 251ff25e6..02d0415ff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -57,6 +57,10 @@ pub struct CanvasState { pub tex_coarse: bool, /// The request the texture answers ([`crate::render_worker::Done::seq`]). tex_seq: u64, + /// Refined images of the files and pages shown before, newest first: switching back to one + /// shows its image at once while it renders again, instead of a blank canvas (and, where + /// renders are slow, a coarse preview). + kept: Vec, submitted: Option, /// The view of the last render asked for. want_view: Option, @@ -98,6 +102,7 @@ impl Default for CanvasState { tex_of: None, tex_coarse: false, tex_seq: 0, + kept: Vec::new(), submitted: None, want_view: None, render_work: None, @@ -119,6 +124,45 @@ impl Default for CanvasState { } } +/// At most this many images of files and pages not shown are kept (each is a canvas-sized texture). +const KEPT: usize = 4; + +/// A refined canvas image of a file and page not shown now ([`CanvasState::kept`]). +struct Kept { + texture: egui::TextureHandle, + view: figcraft_render::View, + of: (u64, NodeId), + seq: u64, +} + +impl CanvasState { + /// When the file or page shown is no longer the one the texture is of, set the texture aside + /// (if refined) and bring back the image kept for `shown`, if any. Images of files that are no + /// longer open (`open` says which are) are dropped every frame, not only on a switch: a + /// background tab can close while the file shown stays the same. + fn switch_texture(&mut self, shown: Option<(u64, NodeId)>, open: impl Fn(u64) -> bool) { + if shown.is_some() && self.tex_of != shown { + let back = self.kept.iter().position(|k| Some(k.of) == shown).map(|i| self.kept.remove(i)); + let (texture, view, of) = (self.texture.take(), self.tex_view.take(), self.tex_of.take()); + if let (Some(texture), Some(view), Some(of), false) = (texture, view, of, self.tex_coarse) { + self.kept.insert(0, Kept { texture, view, of, seq: self.tex_seq }); + } + self.tex_coarse = false; + if let Some(k) = back { + (self.texture, self.tex_view, self.tex_of, self.tex_seq) = (Some(k.texture), Some(k.view), Some(k.of), k.seq); + } + } + self.drop_closed(open); + self.kept.truncate(KEPT); + } + + /// Drop the kept images of files that are no longer open (`open` says which are). Home calls + /// this too: the canvas isn't shown there, and closing the last file leads to it. + pub(crate) fn drop_closed(&mut self, open: impl Fn(u64) -> bool) { + self.kept.retain(|k| open(k.of.0)); + } +} + /// What the canvas shows while an IME composition is in progress: the document with the /// composition inserted at the caret (the real document only gets it on commit). pub struct PreeditView { @@ -264,6 +308,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); + let docs = &app.session.docs; + app.canvas.switch_texture(shown, |uid| docs.iter().any(|d| d.uid == uid)); if let Some(done) = worker.poll() && shows(&app.canvas, &done, shown) { @@ -408,3 +454,6 @@ mod wheel_zoom_tests; #[cfg(test)] mod zoom_preview_tests; + +#[cfg(test)] +mod tab_switch_tests; diff --git a/crates/ui-egui/src/canvas/tab_switch_tests.rs b/crates/ui-egui/src/canvas/tab_switch_tests.rs new file mode 100644 index 000000000..f5629d3ef --- /dev/null +++ b/crates/ui-egui/src/canvas/tab_switch_tests.rs @@ -0,0 +1,159 @@ +//! Switching tabs: the canvas shows the image a file had when it was last on screen from the first +//! frame back, instead of a blank page (and, where renders are slow, a coarse preview) while the +//! file renders again. + +use egui::{Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::idle_tests::wait_for_render; +use crate::{FigApp, NoServices}; + +/// The whole UI, driven headless, with files of their own names open. +struct Harness { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Harness { + /// The UI with a file named after each of `names` (each with a rectangle); the last is shown. + fn new(names: &[&str]) -> Self { + figcraft_text::scan_system_fonts(); + let mut s = Session::empty(); + for n in names { + s.execute("file.new", &json!({"name": n})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 120})).unwrap(); + } + Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 } + } + + /// One frame with no input, 1/60 s after the last. + fn frame(&mut self) { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// Frames until the canvas shows a refined image of the file and page on screen. + fn settle(&mut self) { + for _ in 0..200 { + self.frame(); + wait_for_render(&self.app); + let ds = self.app.session.ds().unwrap(); + let c = &self.app.canvas; + if c.tex_of == Some((ds.uid, ds.page)) && !c.tex_coarse && c.texture.is_some() { + return; + } + } + panic!("the canvas never showed a refined image"); + } + + /// Show the file named `name`, as clicking its tab does. + fn switch(&mut self, name: &str) { + let i = self.app.session.docs.iter().position(|d| d.doc.name == name).unwrap(); + self.app.run("doc.switch", json!({"index": i})); + } + + /// The file and page on screen. + fn shown(&self) -> (u64, figcraft_doc::NodeId) { + let ds = self.app.session.ds().unwrap(); + (ds.uid, ds.page) + } +} + +#[test] +fn switching_back_to_a_tab_shows_its_image_on_the_first_frame() { + let mut h = Harness::new(&["A", "B"]); + h.switch("A"); + h.settle(); + let a = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id)); + h.switch("B"); + h.settle(); + let b = (h.shown(), h.app.canvas.tex_seq); + assert_ne!(h.app.canvas.tex_of, Some(a.0)); + + // The image brought back is the one A had when it was last shown. Looked at without a frame: + // in one, a render of A asked for in it could already have come back and replaced it. + let c = &mut h.app.canvas; + c.switch_texture(Some(a.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(a.0), a.1, a.2)); + c.switch_texture(Some(b.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq), (Some(b.0), b.1), "B's image was kept in turn"); + + h.switch("A"); + h.frame(); + let c = &h.app.canvas; + assert_eq!(c.tex_of, Some(a.0), "A's image is on screen at once"); + assert!(!c.tex_coarse, "and it is the refined one, not a preview"); + assert!(c.kept.iter().any(|k| k.of == b.0), "B's image is kept in turn"); + h.settle(); +} + +#[test] +fn switching_back_to_a_page_shows_its_image_on_the_first_frame() { + let mut h = Harness::new(&["A"]); + h.settle(); + let first = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id)); + h.app.run("page.add", json!({})); + h.app.run("shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120})); + h.settle(); + let second = (h.shown(), h.app.canvas.tex_seq); + assert_ne!(first.0, second.0); + + // Looked at without a frame, as for tabs: a render asked for in one could replace the image. + let c = &mut h.app.canvas; + c.switch_texture(Some(first.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(first.0), first.1, first.2)); + c.switch_texture(Some(second.0), |_| true); + assert_eq!((c.tex_of, c.tex_seq), (Some(second.0), second.1), "the second page's image was kept in turn"); + + h.app.run("page.select", json!({"index": 0})); + h.frame(); + let c = &h.app.canvas; + assert_eq!(c.tex_of, Some(first.0), "the first page's image is on screen at once"); + assert!(!c.tex_coarse); + assert!(c.kept.iter().any(|k| k.of == second.0), "the second page's image is kept in turn"); + h.settle(); +} + +#[test] +fn kept_images_are_bounded_and_dropped_with_their_files() { + let names = ["A", "B", "C", "D", "E", "F", "G"]; + let mut h = Harness::new(&names); + for n in names { + h.switch(n); + h.settle(); + } + assert_eq!(h.app.canvas.kept.len(), super::KEPT); + + let f = h.app.session.docs.iter().position(|d| d.doc.name == "F").unwrap(); + let f_uid = h.app.session.docs[f].uid; + assert!(h.app.canvas.kept.iter().any(|k| k.of.0 == f_uid)); + // A tab closed in the background: the file shown stays the same. + let before = h.shown(); + h.app.run("file.close", json!({"index": f, "force": true})); + h.frame(); + assert_eq!(h.shown(), before); + assert!(h.app.canvas.kept.iter().all(|k| k.of.0 != f_uid), "a closed file's image goes"); +} + +#[test] +fn closing_the_last_file_drops_its_kept_images() { + let mut h = Harness::new(&["A", "B"]); + h.switch("A"); + h.settle(); + h.switch("B"); + h.settle(); + assert_eq!(h.app.canvas.kept.len(), 1, "A's image is kept"); + // Both close before the next frame, which then shows Home, not the canvas. + let a = h.app.session.docs.iter().position(|d| d.doc.name == "A").unwrap(); + h.app.run("file.close", json!({"index": a, "force": true})); + h.app.run("file.close", json!({"index": 0, "force": true})); + assert!(h.app.session.docs.is_empty()); + h.frame(); + assert!(h.app.canvas.kept.is_empty(), "no closed file's image stays behind Home"); +} From 2dd1b5a53a58c36c40ec8fb7d4d74a894675c208 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 09:32:21 -0500 Subject: [PATCH 8/9] Menus: the main menu and its submenus stay inside the window and scroll when taller (#2217) (#2220) Submenus of the main menu were not constrained to the window (constrain: false, so they sit beside their row), so the Object submenu ran past the bottom edge with its lowest commands out of reach. Menus and submenus are now held within the window's height (moved up, never above the top, 8 pt margins, still beside their row so menu-aim is unchanged), and a panel taller than that scrolls with the wheel; the keyboard's focus scrolls into view. Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium Co-authored-by: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menu_overflow_tests.rs | 107 ++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 71 +++++++------- crates/ui-egui/src/menus/tree.rs | 57 +++++++++++- docs/ui-parity.md | 5 +- 5 files changed, 207 insertions(+), 35 deletions(-) create mode 100644 crates/ui-egui/src/menu_overflow_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60c68f526..1c87b5660 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -180,6 +180,8 @@ mod menu_contrast_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod menu_overflow_tests; +#[cfg(test)] mod mirror_tests; #[cfg(test)] mod missing_fonts_tests; diff --git a/crates/ui-egui/src/menu_overflow_tests.rs b/crates/ui-egui/src/menu_overflow_tests.rs new file mode 100644 index 000000000..5cb2885b6 --- /dev/null +++ b/crates/ui-egui/src/menu_overflow_tests.rs @@ -0,0 +1,107 @@ +//! The main menu and its submenus stay inside the window (#2217): a submenu that would run past +//! the bottom moves up to fit, and one taller than the window scrolls. + +use egui::{Event, Id, MouseWheelUnit, Rect, TouchPhase, pos2, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; +use crate::menus::{HOVER_DELAY, main_menu_id}; +use crate::present_tests::id; + +/// A window `h` tall with the main menu open and the Object submenu showing. +fn object_menu_open(s: Session, h: f32) -> Ui { + let mut u = Ui::new(s); + u.screen = vec2(1280.0, h); + u.app.ui.main_menu = true; + u.frame(vec![Event::PointerMoved(pos2(1000.0, 10.0))]); + u.frame(vec![]); + let m = u.ctx.memory(|m| m.area_rect(main_menu_id())).expect("main menu drawn"); + // Frame margin, the Actions row and the space under it, then three rows down to Object. + let y = m.min.y + 8.0 + 30.0 + 6.0 + 28.0 * 3.0 + 14.0; + u.frame(vec![Event::PointerMoved(pos2(m.min.x + 60.0, y))]); + for _ in 0..((HOVER_DELAY + 0.3) * 60.0) as usize { + u.frame(vec![]); + } + u +} + +fn object_panel(u: &Ui) -> Rect { + u.ctx.memory(|m| m.area_rect(Id::unique(("menu", "Object")))).expect("the Object submenu is drawn") +} + +#[test] +fn the_object_submenu_stays_inside_a_short_window() { + let u = object_menu_open(Session::new(), 600.0); + let panel = object_panel(&u); + let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 600.0)); + assert!(window.contains_rect(panel), "the Object submenu {panel:?} runs outside the window {window:?}"); +} + +#[test] +fn a_menu_taller_than_the_window_scrolls_to_its_last_command() { + let mut s = Session::new(); + let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap()); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let mut u = object_menu_open(s, 420.0); + let panel = object_panel(&u); + assert!(Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 420.0)).contains_rect(panel), "{panel:?} outside the window"); + // Turn the wheel over the panel until the end of the list is in view. + for _ in 0..4 { + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -1500.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(panel.center()), wheel]); + for _ in 0..20 { + u.frame(vec![]); + } + } + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Move to page"); + assert!(panel.contains(at), "the last commands are out of reach: at {at:?} outside {panel:?}"); + // And a command above it, reached by scrolling back up, runs on click. + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, 1.0e5), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(panel.center()), wheel]); + for _ in 0..20 { + u.frame(vec![]); + } + // Scroll down in small steps until "Send to back" shows inside the panel, then click it. + let mut hit = None; + for _ in 0..40 { + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Send to back"); + if panel.contains(at) { + hit = Some(at); + break; + } + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -120.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(panel.center()), wheel]); + for _ in 0..10 { + u.frame(vec![]); + } + } + let at = hit.expect("Send to back scrolls into view"); + crate::component_swap_default_tests::click(&mut u, at); + let doc = u.app.session.doc().unwrap(); + let page = u.app.session.page().unwrap(); + let kids = &doc.node(page).unwrap().children; + assert_eq!((kids.first().copied(), kids.last().copied()), (Some(b), Some(a)), "Send to back ran"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a5361778c..c9c197a50 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -382,40 +382,49 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { // Main menu. if app.ui.main_menu { let mut close = false; - let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| { - menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| { - // Quick actions search row. - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - plain_row_hover(ui, &resp); - a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); - ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim); - ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim); - ui.painter().text( - pos2(r.max.x - 10.0, r.center().y), - Align2::RIGHT_CENTER, - pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")), - regular(12.0), - t.menu_dim, - ); - if resp.clicked() { - set_palette(&mut app.ui, true); - close = true; - } - ui.add_space(6.0); - for top in TOP_MENUS { - if submenu_at( - ui, - SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t }, - |ui| menu_level(app, ui, top).then_some(()), - ) - .is_some() - { + let area = egui::Area::new(main_menu_id()) + .fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)) + .order(egui::Order::Foreground) + .constrain_to(tree::vertical_bounds(ctx)) + .show(ctx, |ui| { + menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| { + // Quick actions search row. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + plain_row_hover(ui, &resp); + a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); + ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), + Icon::Search, + t.menu_dim, + ); + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim); + ui.painter().text( + pos2(r.max.x - 10.0, r.center().y), + Align2::RIGHT_CENTER, + pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")), + regular(12.0), + t.menu_dim, + ); + if resp.clicked() { + set_palette(&mut app.ui, true); close = true; } - } + ui.add_space(6.0); + for top in TOP_MENUS { + if submenu_at( + ui, + SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t }, + |ui| menu_level(app, ui, top).then_some(()), + ) + .is_some() + { + close = true; + } + } + }); }); - }); let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape)); if close || (clicked_outside && !area.response.hovered()) { app.ui.main_menu = false; diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index f0c645509..17ab9eb76 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -289,6 +289,7 @@ pub(super) fn submenu_at(ui: &mut Ui, options: SubmenuOptions<'_>, add: impl .fixed_pos(pos) .order(egui::Order::Foreground) .constrain(constrain || cramped) + .constrain_to(if constrain || cramped { screen } else { vertical_bounds(ui.ctx()) }) .show(ui.ctx(), |ui| menu_frame(ui, t, child, add)); let panel = shown.response.rect; with_level(ui.ctx(), &at, |l| l.panel = Some(panel)); @@ -316,12 +317,64 @@ pub(super) fn menu_frame(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&m ui.set_width(w); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let key = at_key(ui); - ui.data_mut(|d| d.insert_temp(key, at)); - add(ui) + ui.data_mut(|d| d.insert_temp(key, at.clone())); + // Taller than the window (as measured last frame): its rows scroll. Otherwise they + // draw straight into the panel, so a menu that fits behaves as it always did. + let max_h = (ui.ctx().content_rect().height() - 2.0 * EDGE_MARGIN - 16.0).max(MENU_ROW_H); + let height_key = at_key(ui).with("content_h"); + let last_h = ui.data(|d| d.get_temp::(height_key)).unwrap_or(0.0); + if last_h <= max_h { + let top = ui.cursor().min.y; + let r = add(ui); + let h = ui.min_rect().max.y - top; + ui.data_mut(|d| d.insert_temp(height_key, h)); + if h > max_h { + ui.ctx().request_repaint(); + } + return r; + } + // The scroll area's ui has its own scope, so it gets the level and width state of this + // one, and hands back the width its rows asked for. + let outer_w = width_key(ui); + let asked = ui.data(|d| d.get_temp::(outer_w)); + let shown = egui::ScrollArea::vertical() + .max_height(max_h) + .auto_shrink([false, true]) + .scroll_source(egui::scroll_area::ScrollSource::MOUSE_WHEEL | egui::scroll_area::ScrollSource::SCROLL_BAR) + .show(ui, |ui| { + let (inner_at, inner_w) = (at_key(ui), width_key(ui)); + ui.data_mut(|d| { + d.insert_temp(inner_at, at); + if let Some(a) = asked { + d.insert_temp(inner_w, a); + } + }); + let r = add(ui); + (r, ui.data(|d| d.get_temp::(inner_w))) + }); + ui.data_mut(|d| d.insert_temp(height_key, shown.content_size.y)); + let (r, wanted) = shown.inner; + if let Some(wanted) = wanted.filter(|w| asked.is_none_or(|a| *w > a)) { + ui.data_mut(|d| d.insert_temp(outer_w, wanted)); + } + r }) .inner } +/// The gap a menu keeps from the window's top and bottom edges. +pub(super) const EDGE_MARGIN: f32 = 8.0; + +/// A menu row's height. +const MENU_ROW_H: f32 = 28.0; + +/// Where a menu may sit: anywhere across, within the window's height less the margins. A menu that +/// would run past the bottom moves up (never above the top); one taller than that scrolls. +pub(super) fn vertical_bounds(ctx: &egui::Context) -> Rect { + let s = ctx.content_rect(); + Rect::from_min_max(pos2(-1.0e5, s.min.y + EDGE_MARGIN), pos2(1.0e5, s.max.y - EDGE_MARGIN)) +} + /// A menu's narrowest width. const MENU_W: f32 = 240.0; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..4c684c81b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -25,7 +25,7 @@ interaction feel not yet compared. | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 or the keyboard's Menu key opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard (`layers_keyboard_tests`) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | -| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | +| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). A menu or submenu that would run past the window's bottom moves up to fit (never above the top), and one taller than the window scrolls with the wheel, the keyboard's focus kept in view; the context menu's submenus share this (#2217, `menu_overflow_tests`). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | @@ -187,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 7bf098d75d187416633d9ebcceb9eb83fbb10df8 Mon Sep 17 00:00:00 2001 From: rumangerst Date: Wed, 11 Nov 2026 15:37:20 +0100 Subject: [PATCH 9/9] fix(ui): paste uses the event's clipboard text instead of re-reading it (#2021) (#2243) The paste handler read the system clipboard synchronously (2 s timeout) even though the paste event already carries that text, and discarded the read's result. On the desktop, the clipboard bridge delivers `event_text` that it read while the window thread was free; re-reading from the paste handler blocks that same window thread - and when the app itself owns the clipboard (a self-paste), its own data source can only be served by that very thread, so every self-paste froze the window for the full 2 s read timeout on Linux/Wayland. Prefer the event's text and only fall back to the synchronous read when the platform handed over none (chords, image-only clipboard, context-menu paste). A regression test pins that a paste event carrying text never triggers the synchronous read. --- crates/ui-egui/src/menus/clipboard.rs | 14 +++++-- crates/ui-egui/src/web_clipboard_tests.rs | 49 +++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index bae7aaca9..bd7020150 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -7,8 +7,8 @@ use crate::i18n::trf; /// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied /// from another design app, an image, SVG or text from another app); otherwise our copied layers. /// `event_text` is the text the platform handed over with the paste event (the browser has no -/// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the -/// middle of the view. +/// other way to read it; the desktop's clipboard bridge has already read it). `at`: the page +/// point to paste at (Paste here); `None` pastes in the middle of the view. /// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { // A gesture injected through the control channel pastes only our own copied layers: it must not @@ -47,7 +47,15 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } return; } - let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); + // The text the platform handed over with the paste event is the system clipboard's text: the + // browser delivers it, and the desktop's async clipboard bridge read it while the window + // thread was free to serve the transfer. Never re-read the clipboard synchronously for it — + // on the desktop that read blocks the window thread for the read timeout, and when this app + // itself owns the clipboard (a self-paste) its own source can only be served by that very + // thread, so the read waits the full timeout and the window freezes for seconds (#2021). + // A paste the platform didn't hand text over (a chord, an image, the menu) reads it, as before. + let sys_text = + if let Some(t) = event_text { (!t.is_empty()).then(|| t.to_string()) } else { app.services.clipboard_get_text().filter(|t| !t.is_empty()) }; // The text matching `last_copy` means the system clipboard still holds what we mirrored only // while the engine clipboard is the one we mirrored. A control-channel copy changes the engine // clipboard without writing the system clipboard, so its generation no longer matches and the diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index bda57373b..30d7e2b85 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -3,6 +3,9 @@ //! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that //! was there before as a text layer instead of the copied layers. +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2}; use figcraft_doc::NodeKind; use figcraft_engine::Session; @@ -223,3 +226,49 @@ fn a_mismatched_image_buffer_is_dropped() { assert!(out.platform_output.commands.is_empty(), "{w}x{h}"); } } + +/// The system clipboard's text read, counted so a test can assert when it ran. The desktop's +/// arboard read blocks the window thread up to `CLIPBOARD_TIMEOUT`; against the app's own Wayland +/// source (a self-paste) it waits the full timeout — the whole UI freezes for seconds (#2021) — +/// and its result is discarded in favour of the paste event's text. A paste whose platform event +/// already carries the clipboard text must never perform that synchronous re-read. +struct CountingReads { + /// How often the synchronous text read was asked for. + reads: Arc, +} + +impl Services for CountingReads { + fn clipboard_get_text(&mut self) -> Option { + self.reads.fetch_add(1, Ordering::Relaxed); + None + } +} + +#[test] +fn a_paste_event_its_own_text_never_triggers_the_synchronous_text_read() { + // The platform (the desktop's async clipboard bridge, or the browser) already read the + // clipboard and handed the text with the paste event. + let reads = Arc::new(AtomicUsize::new(0)); + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut p = Page { + ctx: egui::Context::default(), + app: FigApp::new(s, Box::new(CountingReads { reads: reads.clone() })), + time: 0.0, + clipboard: String::new(), + }; + for _ in 0..3 { + p.frame(vec![]); + } + + // The desktop delivers the copied layers' SVG as the paste event's text. + let text = ""; + p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]); + p.frame(vec![Page::key(Key::V, false)]); + assert_eq!(reads.load(Ordering::Relaxed), 0, "the text came with the paste event: no blocking re-read of the system clipboard"); + + // Even a second paste must not re-read: the clipboard still holds the pasted text. + p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]); + p.frame(vec![Page::key(Key::V, false)]); + assert_eq!(reads.load(Ordering::Relaxed), 0, "still the event's text: no blocking re-read"); +}