From 64eae8c8c5d78584a7980cb6ce52732ebbe948f7 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 20:45:19 +0200 Subject: [PATCH 1/2] M0.9: every hidden window answers control requests, not only the main one (#269) `drain_hidden` answered only requests for the main window, and only while that window was hidden. A request addressed to another window (`window.new`, `window` param) that is minimised or covered still waited for that window's frame and timed out after 60 s, whether the main window was on screen or not. An agent that works in its own window lost it as soon as the window went behind others or to another Space. `drain_hidden` now looks at the target window of each request: if that window is hidden (`ViewportInfo::visible() == Some(false)` for its viewport) and the method needs no frame, it is answered from the main window's logic with the target window's state swapped in, as while it draws, so it acts on that window's file. `draw` and the new `windows::as_window` share the swap (`enter`, `adopt`, `leave`). The main window's logic runs on every request (the control server asks for a repaint), so this works while the main window is on screen too. What needs a frame keeps waiting, as before; a request for a window that doesn't exist is an error at once instead of waiting for a frame. --- crates/ui-egui/src/control.rs | 30 ++++++---- crates/ui-egui/src/windows.rs | 85 +++++++++++++++++++++-------- crates/ui-egui/src/windows_tests.rs | 62 ++++++++++++++++++++- docs/control-protocol.md | 7 ++- 4 files changed, 146 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 28f21671..e25100ce 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -139,25 +139,33 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { /// Methods that need no frame drawn (#269): a hidden window answers them too. const NO_FRAME: &[&str] = &["engine.execute", "engine.commands", "document.inspect", "node.get", "window.list", "ui.render"]; -/// While the main window is hidden (minimised, or covered by other windows) it draws no frames, -/// but the desktop app's `logic` still runs: it answers the requests for that window that need no -/// frame, so an attached agent can edit, inspect and save. The rest (screenshots, input, the -/// interface's own state) wait for the window to show. +/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also +/// on another Space). +fn hidden(ctx: &Context, id: u64) -> bool { + ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) +} + +/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's +/// `logic` still runs, for the main window even while every window is hidden: it answers the +/// requests for any hidden window that need no frame, in that window's file, so an attached agent +/// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the +/// window to show. A request for a window that doesn't exist is an error at once. pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { - if ctx.input(|i| i.viewport().visible()) != Some(false) { - return; - } for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { continue; } - let main = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused) == 0; - if !main || !NO_FRAME.contains(&r.method.as_str()) { + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if !app.windows.exists(target) { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + continue; + } + if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { app.control_queue.push(r); continue; } - let res = handle(app, ctx, &r.method, &r.params); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); + let res = crate::windows::as_window(app, target, |app| handle(app, ctx, &r.method, &r.params)); + let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 82da7555..510c24f6 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -433,21 +433,74 @@ pub(crate) fn show(app: &FigApp, ctx: &Context) { } } -/// One frame of window `id`, with its state swapped in. -fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { - let Some(k) = app.windows.list.iter().position(|w| w.id == id) else { return }; - let ctx = ui.ctx().clone(); +/// A window's state swapped into the app ([`enter`]) until [`leave`] swaps it back out. +struct Entered { + id: u64, + pane: Pane, + /// The newest open file before: files opened after it are the window's own. + newest: u64, +} + +/// Swap window `id`'s state into the app and show its file; `None` when there is no such window. +fn enter(app: &mut FigApp, id: u64) -> Option { + let k = app.windows.list.iter().position(|w| w.id == id)?; let main_active = app.session.ds().ok().map(|d| d.uid); let newest = app.session.docs.iter().map(|d| d.uid).max().unwrap_or(0); - let files_before = app.session.docs.len(); - let prefs = app.session.prefs.clone(); - let Some(w) = app.windows.list.get_mut(k) else { return }; + let w = app.windows.list.get_mut(k)?; let want = w.active; let mut pane = std::mem::take(&mut w.pane); exchange(app, &mut pane); app.windows.current = id; app.windows.main_active = main_active; show_own(app, want); + Some(Entered { id, pane, newest }) +} + +/// Files the entered window opened are its own. +fn adopt(app: &mut FigApp, e: &Entered) { + let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > e.newest && app.windows.of(*u) == 0).collect(); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.docs.extend(new); + } +} + +/// Swap the entered window's state back out (with where it is on screen, when known) and the +/// main window's back in. +fn leave(app: &mut FigApp, mut e: Entered, outer: Option, inner: Option) { + let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == e.id); + exchange(app, &mut e.pane); + if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == e.id) { + w.active = shown.or(w.active); + w.pos = outer.map(|r| r.min).or(w.pos); + w.size = inner.map_or(w.size, |r| r.size()); + w.pane = e.pane; + } + app.windows.current = 0; + let main_active = app.windows.main_active.take(); + show_own(app, main_active); +} + +/// Run `f` as window `id` (0: the main window) outside its frame, with its state swapped in as +/// while it draws; `None` when there is no such window. For a window that draws nothing now +/// (minimized, or occluded: behind other windows or on another Space). +pub(crate) fn as_window(app: &mut FigApp, id: u64, f: impl FnOnce(&mut FigApp) -> R) -> Option { + if id == 0 { + // Between frames the app holds the main window's state. + return Some(f(app)); + } + let e = enter(app, id)?; + let r = f(app); + adopt(app, &e); + leave(app, e, None, None); + Some(r) +} + +/// One frame of window `id`, with its state swapped in. +fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let files_before = app.session.docs.len(); + let prefs = app.session.prefs.clone(); + let Some(e) = enter(app, id) else { return }; if ctx.input(|i| i.viewport().focused == Some(true)) { app.windows.focused = id; } @@ -455,25 +508,11 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { app.logic(&ctx); // Within the swap, so a panic leaves every window its own state. guard(app, &ctx, |app| app.frame(ui)); - // Files it opened are its own. - let new: Vec = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > newest && app.windows.of(*u) == 0).collect(); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.docs.extend(new); - } + adopt(app, &e); settle(app, &ctx, files_before); if app.session.prefs != prefs { repaint_others(app, &ctx); } - let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id); let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect)); - exchange(app, &mut pane); - if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) { - w.active = shown.or(w.active); - w.pos = outer.map(|r| r.min).or(w.pos); - w.size = inner.map_or(w.size, |r| r.size()); - w.pane = pane; - } - app.windows.current = 0; - let main_active = app.windows.main_active.take(); - show_own(app, main_active); + leave(app, e, outer, inner); } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index e3c54ee0..5d92881a 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -1,10 +1,13 @@ //! Several windows: a window holds its own tabs; closing it closes its files, asking first when //! one has unsaved changes, and the main window takes over another window instead of quitting. +use std::sync::mpsc::{Receiver, channel}; + use egui::{RawInput, ViewportEvent, ViewportId, ViewportInfo}; use figcraft_engine::Session; -use serde_json::json; +use serde_json::{Value, json}; +use crate::control::{ControlReply, ControlRequest}; use crate::{FigApp, NoServices}; /// One main-window frame, as the desktop app runs it; `close` asks the window to close. @@ -21,6 +24,23 @@ fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) { .clear(); } +/// A control request queued for the UI, and where its reply arrives. +fn request(app: &mut FigApp, method: &str, params: Value) -> Receiver { + let (reply, rx) = channel(); + app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, cancelled: Default::default() }); + rx +} + +/// The desktop app's logic as eframe runs it for the main window, with each window's visibility +/// (`hidden`: minimised or covered). While the main window is hidden too this is all that runs. +fn logic(ctx: &egui::Context, app: &mut FigApp, hidden: &[ViewportId]) { + let mut input = RawInput::default(); + for id in std::iter::once(ViewportId::ROOT).chain(app.windows.list.iter().map(|w| crate::windows::viewport(w.id))) { + input.viewports.insert(id, ViewportInfo { minimized: Some(false), occluded: Some(hidden.contains(&id)), ..Default::default() }); + } + let _ = ctx.run_logic(&input, |ctx| crate::control::drain_hidden(app, ctx)); +} + fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } @@ -64,3 +84,43 @@ fn windows_hold_tabs_and_close_with_their_files() { assert!(!app.ui.quit, "the app keeps running"); assert_eq!(app.session.ds().unwrap().doc.name, "A"); } + +#[test] +fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + let rect = |size: u32| json!({"window": w, "command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": size, "height": size}}); + let dirty = |app: &FigApp, name: &str| app.session.docs.iter().find(|d| d.doc.name == name).unwrap().is_dirty(); + + // The main window is on screen, the other one covered: the request to it is answered in its + // file (A), and the main window keeps showing its own (B). + let edit = request(&mut app, "engine.execute", rect(10)); + logic(&ctx, &mut app, &[other]); + assert!(edit.try_recv().expect("answered without the other window's frame").result.is_ok()); + assert!(dirty(&app, "A") && !dirty(&app, "B"), "in the other window's file"); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + assert_eq!(app.windows.current, 0); + + // Both hidden: still answered, and inspection sees the other window's file. + let inspect = request(&mut app, "document.inspect", json!({"window": w, "depth": 0})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert_eq!(inspect.try_recv().expect("answered").result.unwrap()["name"], json!("A")); + assert_eq!(app.session.ds().unwrap().doc.name, "B"); + + // What needs a frame keeps waiting for the other window; an unknown window is an error at once. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + let unknown = request(&mut app, "window.list", json!({"window": 99})); + logic(&ctx, &mut app, &[ViewportId::ROOT, other]); + assert!(ui.try_recv().is_err(), "waits for the window to show"); + assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); + assert_eq!(app.control_queue.len(), 1); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f2c049ce..9cb5f6f5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,9 +35,10 @@ instead of dispatching the new request. These limits apply to the opt-in loopbac requests still queued after the UI timeout are discarded, including requests deferred to a hidden window. A command already running is not interrupted. -A minimised or covered main window draws no frames, but still answers `engine.execute`, -`engine.commands`, `document.inspect`, `node.get`, `window.list` and `ui.render`, so an attached -agent can edit, inspect and save while the app is out of sight. Screenshots, input (`ui.input`, +A minimised or covered window (the main one or another, see Windows below) draws no frames, but +still answers `engine.execute`, `engine.commands`, `document.inspect`, `node.get`, `window.list` +and `ui.render`, acting on the file that window shows, so an attached agent can edit, inspect and +save while the app is out of sight. Screenshots, input (`ui.input`, `ui.pointer`, `ui.key`, `ui.type`) and the interface's own state wait for the window to show, and time out after 60 s if it doesn't. From 30478894fd4b82919ec7878848476405b17248ea Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 21:17:42 +0200 Subject: [PATCH 2/2] M0.9: a window on screen draws requests for it while the main window is hidden (#269) Requests reach the UI through the main window's logic. A request for another window is answered in that window's frame, and the main window's frame (`drain`) asks that window to draw. While the main window is hidden, only its logic runs and nothing asked: an idle window on screen draws no frames, so the request waited until something else repainted the window, or timed out after 60 s. `drain_hidden` now asks a window on screen that a request waits for to draw. A hidden window isn't asked: it can't draw, and the main window's logic already answers what it can. --- crates/ui-egui/src/control.rs | 8 +++++++- crates/ui-egui/src/windows_tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e25100ce..99e8999f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -149,7 +149,8 @@ fn hidden(ctx: &Context, id: u64) -> bool { /// `logic` still runs, for the main window even while every window is hidden: it answers the /// requests for any hidden window that need no frame, in that window's file, so an attached agent /// can edit, inspect and save. The rest (screenshots, input, the interface's own state) wait for the -/// window to show. A request for a window that doesn't exist is an error at once. +/// window to show; another window on screen is asked to draw, which the main window's frame would +/// do but doesn't while it is hidden. A request for a window that doesn't exist is an error at once. pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { @@ -161,6 +162,11 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { continue; } if !hidden(ctx, target) || !NO_FRAME.contains(&r.method.as_str()) { + // Another window on screen draws only when asked: while the main window is hidden no + // frame of it does that (`drain`), so ask here. + if target != app.windows.current && !hidden(ctx, target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + } app.control_queue.push(r); continue; } diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 5d92881a..1a6e91bb 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -124,3 +124,29 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); assert_eq!(app.control_queue.len(), 1); } + +#[test] +fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { + let mut s = Session::empty(); + for n in ["A", "B"] { + s.execute("file.new", &json!({"name": n})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = FigApp::new(s, Box::new(NoServices)); + frame(&ctx, &mut app, false); + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + frame(&ctx, &mut app, false); + let other = crate::windows::viewport(w); + // Which windows are asked to draw from now on (an idle window draws no frames until asked). + let woken = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log = woken.clone(); + ctx.set_request_repaint_callback(move |info| log.lock().unwrap().push(info.viewport_id)); + + // Only the main window's logic runs while it is hidden: the request for the other window, + // which is on screen, waits for that window's frame, and that window is asked to draw it. + let ui = request(&mut app, "ui.inspect", json!({"window": w})); + logic(&ctx, &mut app, &[ViewportId::ROOT]); + assert!(ui.try_recv().is_err(), "answered in the other window's frame"); + assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); +}