diff --git a/ROADMAP.md b/ROADMAP.md index ea84f01f3..a41678144 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -360,6 +360,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged | | 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged | | 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects//`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged | | 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e13acce68..279f76897 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -37,6 +37,7 @@ impl FigApp { field_focus: false, panels_unsaved: false, control_queue: Vec::new(), + control_waits: Default::default(), control_input: Vec::new(), frame_stats: frame_stats::FrameStats::default(), windows: windows::Windows::default(), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 322d9032c..2310f48e2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool { visibility(ctx, id) == Some(false) } +/// How long, in seconds, a window may draw no frame while a control request waits for it before +/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's +/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers. +pub const NOT_DRAWING_AFTER: f64 = 0.2; + +/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it. +/// The platform may still call it visible: under Wayland an app can't tell that its window is +/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the +/// window draws. +fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool { + let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id)); + let wait = app.control_waits.entry(id).or_insert((pass, now)); + if wait.0 != pass { + *wait = (pass, now); + } + now - wait.1 >= NOT_DRAWING_AFTER +} + /// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or /// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours /// this command; the shared web runner does not implement it yet, so a hidden browser tab remains @@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); continue; } - let is_hidden = hidden(ctx, target); + let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now); if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) { // Hidden, minimised, or with visibility the platform has not reported: admit it now // so the request gets its deadline and a forced paint. Waiting for a frame that may @@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) { } let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } + // A window a request still waits for is checked again soon: if it draws, its frame answers; + // if it doesn't (minimised under Wayland), the next logic pass does. + let waiting: std::collections::BTreeSet = + app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect(); + app.control_waits.retain(|id, _| waiting.contains(id)); + if !waiting.is_empty() { + crate::repaint_in(ctx, NOT_DRAWING_AFTER); + } request_paint(app, ctx); } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index ae34ded78..ad547b7cc 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() { let _ = std::fs::remove_dir_all(&dir); } +/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no +/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that +/// window's frame is answered from the logic passes once the window has drawn nothing for +/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws +/// keeps answering from its frame. +#[test] +fn a_window_that_stopped_drawing_answers_from_its_logic_passes() { + let (mut h, text) = document(); + h.settle(); + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); + let (tx, rx) = std::sync::mpsc::channel(); + let rename = |id: u64, name: &str| ControlRequest { + id: json!(id), + method: "engine.execute".into(), + params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}), + reply: tx.clone(), + state: Default::default(), + }; + let node = figcraft_doc::NodeId(text.as_u64().unwrap()); + let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone(); + // What Wayland reports for a minimised window: nothing about its visibility. + let unknown = RawInput::default(); + let ctx = h.ctx.clone(); + h.app.control_queue.push(rename(1, "while minimised")); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame"); + assert_eq!(h.app.control_queue.len(), 1); + // No frame comes: the next logic pass answers. + h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05)); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + let reply = rx.try_recv().expect("answered while the window draws nothing"); + assert_eq!(reply.id, json!(1)); + assert!(reply.result.is_ok(), "{:?}", reply.result); + assert_eq!(name(&h), "while minimised"); + assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); + // Back on screen: a frame drawn after the wait began starts it again, and the frame answers. + h.time += 1.0; + h.app.clock.set_test_now(Some(h.time)); + h.app.control_queue.push(rename(2, "on screen")); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05)); + h.frame(vec![]); + let reply = rx.try_recv().expect("the frame answers"); + assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string())); +} + #[test] fn auto_save_writes_a_changed_file_then_rests() { let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5a84e5a41..74aac1f2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -754,6 +754,9 @@ pub struct FigApp { panels_unsaved: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, + /// Windows a control request waits for: the window's pass count when the wait began and the + /// app-clock time it began, to tell a window that stopped drawing ([`control::background`]). + control_waits: std::collections::BTreeMap, /// Input events from the control channel (`ui.input`) for the next frame's raw input. pub control_input: Vec, /// Frames drawn and what asked for them (`ui.inspect`). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 82ba08bd1..6a2cbe00f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new` remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its -window is minimised: where the compositor then stops drawing it, a request waits for the window -and gets the 60 s timeout error. The browser's shared eframe runner does not implement +window is minimised, and the compositor stops asking it to draw: a window that draws no frame for +0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's +state are answered without a frame (about half a second each), while `ui.screenshot` and +`ui.input` keep waiting for one. The browser's shared eframe runner does not implement `RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there. Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index eb8ac2e77..1d64bc411 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | -| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | +| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` | | 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. | | 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged | | 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 211daee40..cebccc641 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error. The app keeps answering while its window is minimised or covered by other windows (see `docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and -renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture +renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland, +which can't report a minimised window, about half a second). Only a capture of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once; `screenshot` without `window` renders the document instead. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1a31adbed..a5709da85 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -643,8 +643,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th `figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every request that acts on the app's state without drawing (about 100 ms each on Windows), and the ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of - waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told - apart). + waiting 60 s (#720; not tested on macOS). Under Wayland a minimised window can't be told apart, + so a window that draws nothing for 0.2 s while a request waits is answered as a hidden one + (about half a second a request, #1906). - **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. ## Revision history