Merge pull request #2047 from StrajnarFilip/claude/fix-1906-minimised-control

M8: a minimised window under Wayland answers control requests (#1906)
This commit is contained in:
BFlat authored and GitHub committed 2026-11-07 10:09:09 +08:00
commit 8ea82be66b
9 files changed
+91 -8

No files matched your search

+1
View File
@@ -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/<i>/<field>`) 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 |
+1
View File
@@ -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(),
+27 -1
View File
@@ -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<u64> =
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);
}
+47
View File
@@ -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()));
+3
View File
@@ -754,6 +754,9 @@ pub struct FigApp {
panels_unsaved: bool,
/// Control-channel requests waiting for the next frame.
pub control_queue: Vec<ControlRequest>,
/// 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<u64, (u64, f64)>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
pub control_input: Vec<egui::Event>,
/// Frames drawn and what asked for them (`ui.inspect`).
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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. |
+2 -1
View File
@@ -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.
+3 -2
View File
@@ -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