diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index db61cbc4..48758b37 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -139,27 +139,45 @@ 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; 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) { - 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()) { + // 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; } - // Outside a frame, so outside the frame's guard: a panic still ends in recovery copies. - let mut res = Err("internal error".to_string()); - crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); + // Outside a frame, so outside the frame's guard: within the window's swap, a panic still + // ends in recovery copies and leaves every window its own state. + let res = crate::windows::as_window(app, target, |app| { + let mut res = Err("internal error".to_string()); + crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); + res + }); + 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..1a6e91bb 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,69 @@ 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); +} + +#[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()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c1d4ae54..2351a9df 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.