//! Headless UI rendering: run the app's UI in an offscreen [`egui::Context`] and rasterize it on //! the CPU ([`crate::softpaint`]) — no window, no GPU, no compositor. //! //! Two users: //! - [`Headless`]: a complete windowless app session (`lightcraft-cli snapshot`, tests). It plays //! the role eframe plays for the desktop app: builds [`egui::RawInput`], runs `logic` + `ui`, //! keeps a CPU mirror of the textures, executes viewport commands (`Screenshot` is answered with //! a CPU-rendered frame, `InnerSize` resizes, `Close` quits). Control-protocol requests go //! through the very same handler as the desktop app's control server. //! - [`HeadlessView`] inside the desktop app: `ui.screenshot {"headless": true}` (and the //! automatic fallback when the compositor delivers no frame, e.g. while the display sleeps) //! draws the app's UI into a shadow context from `logic`, which keeps ticking when the window //! is occluded. use std::collections::HashMap; use std::sync::mpsc::Sender; use std::time::{Duration, Instant}; use egui::{Color32, ColorImage, RawInput, TextureId, ViewportCommand, ViewportId}; use serde_json::{Value, json}; use crate::softpaint::{self, CpuTexture, Layered, TextureStore}; use crate::{ControlRequest, LightcraftApp}; /// Background behind the panels (eframe's default clear colour, made opaque). const CLEAR: Color32 = Color32::from_rgb(12, 12, 12); /// Simulated frame interval (deterministic animation time). const FRAME_DT: f64 = 1.0 / 60.0; /// Validate the scaled screenshot dimensions before layout or pixel allocation. pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> { if size.iter().any(|edge| *edge < 1.0) { return Err("viewport dimensions must be at least one logical point".into()); } native_viewport_pixels(size, pixels_per_point) } /// Turn a logical viewport size into native pixels for the headless renderer. Both edges and /// the scale must be finite and positive; each scaled edge is rounded like egui's scissor and /// must be within `1..=MAX_IMAGE_EDGE`, and the product must fit both the raster and screenshot /// pixel budgets. Returns a user-facing error instead of clamping. fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> { let [width, height] = size; if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { return Err("viewport dimensions and scale must be finite and positive".into()); } let axis = |value: f32| { // Match egui/scissor rounding and the existing screenshot dimension contract. let pixels = (value * pixels_per_point).round(); if !(1.0..=softpaint::MAX_IMAGE_EDGE as f32).contains(&pixels) { return Err(format!("scaled viewport edges must be between 1 and {} pixels", softpaint::MAX_IMAGE_EDGE)); } Ok(pixels as usize) }; let (w, h) = (axis(width)?, axis(height)?); // The rasterizer's limit is not enough: a screenshot is also bounded by the RGBA budget it is // admitted against, so reject a size `ui.screenshot` would only refuse later. let max_pixels = softpaint::MAX_IMAGE_PIXELS.min(crate::MAX_SHOT_PIXELS); if w.checked_mul(h).is_none_or(|pixels| pixels > max_pixels) { return Err(format!("scaled viewport exceeds {max_pixels} pixels")); } Ok([w, h]) } /// Convert an effective-point resize to the native viewport before validating pixel bounds. /// Sub-point native edges after zooming out are valid when they round to at least one pixel. pub fn resized_viewport(size: [f32; 2], native_pixels_per_point: f32, zoom: f32) -> Result { if size.iter().any(|edge| !edge.is_finite() || *edge < 1.0) || !zoom.is_finite() || zoom <= 0.0 { return Err("resize dimensions must be at least one finite logical point with a positive zoom".into()); } let native_size = egui::Vec2::from(size) * zoom; native_viewport_pixels([native_size.x, native_size.y], native_pixels_per_point)?; Ok(native_size) } fn bounded_viewport(size: egui::Vec2, scale: f32) -> (egui::Vec2, f32) { if native_viewport_pixels([size.x, size.y], scale).is_ok() { (size, scale) } else { (egui::vec2(1600.0, 1000.0), 1.0) } } /// An offscreen egui context with our fonts and theme, and a CPU mirror of its textures. pub struct HeadlessView { pub ctx: egui::Context, pub textures: TextureStore, pub(crate) shapes: Vec, pixels_per_point: f32, size: [usize; 2], frames: u64, /// The pretend host's clipboard: what the UI last copied, and what a paste request pastes. pub clipboard: String, /// Events the host owes the next frame (a paste request's paste, as a desktop host sends it). owed: Vec, } impl Default for HeadlessView { fn default() -> Self { Self::new() } } impl HeadlessView { pub fn new() -> Self { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); crate::theme::apply(&ctx); HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: [1600, 1000], frames: 0, clipboard: String::new(), owed: vec![], } } /// Input for one frame of a `size` (points) viewport at `pixels_per_point`. pub fn raw_input(size: egui::Vec2, pixels_per_point: f32, time: f64, events: Vec) -> RawInput { let (size, pixels_per_point) = bounded_viewport(size, pixels_per_point); let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size); let mut raw = RawInput { screen_rect: Some(rect), time: Some(time), predicted_dt: FRAME_DT as f32, focused: true, events, max_texture_side: Some(16384), ..Default::default() }; let info = raw.viewports.entry(ViewportId::ROOT).or_default(); info.native_pixels_per_point = Some(pixels_per_point); info.inner_rect = Some(rect); info.outer_rect = Some(rect); info.focused = Some(true); raw } /// Run one frame; keeps the shapes for [`Self::paint`]. Returns the root viewport's commands. pub fn run(&mut self, mut raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec { if let Some(rect) = raw.screen_rect { let native_scale = raw.viewports.get(&ViewportId::ROOT).and_then(|info| info.native_pixels_per_point).unwrap_or(1.0); self.size = native_viewport_pixels([rect.width(), rect.height()], native_scale).unwrap_or([0, 0]); // The host viewport is measured before UI zoom. Egui lays out in zoomed points; // retain the host's physical size while allowing text and controls to grow. let zoom = self.ctx.zoom_factor(); if zoom.is_finite() && zoom > 0.0 { raw.screen_rect = Some(rect / zoom); if let Some(info) = raw.viewports.get_mut(&ViewportId::ROOT) { info.inner_rect = info.inner_rect.map(|rect| rect / zoom); info.outer_rect = info.outer_rect.map(|rect| rect / zoom); } } } raw.events.splice(0..0, std::mem::take(&mut self.owed)); let mut out = self.ctx.run_ui(raw, run_ui); self.frames += 1; self.textures.apply(std::mem::take(&mut out.textures_delta)); self.shapes = std::mem::take(&mut out.shapes); self.pixels_per_point = out.pixels_per_point; for c in &out.platform_output.commands { if let egui::OutputCommand::CopyText(text) = c { self.clipboard.clone_from(text); } } let mut commands = out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default(); // 0.37: `ViewportCommand::Screenshot` carries a one-shot callback. A headless host answers // it here, after the pass it was issued in, exactly once. let mut shots = Vec::new(); commands.retain(|command| match command { ViewportCommand::Screenshot(callback) => { shots.push(callback.clone()); false } _ => true, }); for callback in shots { callback.complete(std::sync::Arc::new(self.paint(&std::collections::HashMap::new()))); } // what a desktop host does with a cut, copy or paste request (egui-winit): the event, next frame for c in &commands { match c { ViewportCommand::RequestCut => self.owed.push(egui::Event::Cut), ViewportCommand::RequestCopy => self.owed.push(egui::Event::Copy), ViewportCommand::RequestPaste if !self.clipboard.is_empty() => self.owed.push(egui::Event::Paste(self.clipboard.clone())), _ => {} } } commands } /// Frames run so far. pub fn frames(&self) -> u64 { self.frames } /// Every piece of text the last frame painted (labels, tooltips, values), in paint order. /// Every shape of the last frame, with `Shape::Vec` flattened. pub fn painted_shapes(&self) -> Vec { fn flat(shape: &egui::epaint::Shape, out: &mut Vec) { match shape { egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| flat(shape, out)), other => out.push(other.clone()), } } let mut out = Vec::new(); self.shapes.iter().for_each(|s| flat(&s.shape, &mut out)); out } pub fn painted_text(&self) -> Vec { fn texts(shape: &egui::epaint::Shape, out: &mut Vec) { match shape { egui::epaint::Shape::Text(shape) => out.push(shape.galley.job.text.clone()), egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), _ => {} } } let mut out = Vec::new(); for shape in &self.shapes { texts(&shape.shape, &mut out); } out } /// Size in pixels of the last frame. pub fn size_px(&self) -> [usize; 2] { self.size } /// Rasterize the last frame. `extra` textures (by id) take precedence over the context's own. pub fn paint(&self, extra: &HashMap) -> ColorImage { if viewport_pixels([self.size[0] as f32, self.size[1] as f32], 1.0).is_err() || !self.pixels_per_point.is_finite() || self.pixels_per_point <= 0.0 { return ColorImage::new([0, 0], Vec::new()); } let prims = self.ctx.tessellate(self.shapes.clone(), self.pixels_per_point); softpaint::paint(&prims, &Layered { over: extra, base: &self.textures }, self.size_px(), self.pixels_per_point, CLEAR) } } /// A windowless app session: the app's `logic` + `ui` driven frame by frame into a /// [`HeadlessView`], with control-protocol requests answered by the shared handler. pub struct Headless { pub app: LightcraftApp, pub view: HeadlessView, /// The largest texture the pretend GPU takes (what a WebGL device may report: 2048). pub max_texture_side: usize, /// The limit egui was last told. Like a native host (egui-winit) it is reported once, not in /// every frame's input: code that reads the raw input sees `None` from then on (issue #652). reported_texture_side: Option, /// Logical size (points) and scale. pub size: egui::Vec2, pub pixels_per_point: f32, pub(crate) time: f64, frames: u64, pub(crate) events: Vec, control: Sender, quit: bool, /// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host). pub window_maximized: bool, /// Window-management and pointer commands the app sent (`StartDrag`, `Maximized`, /// `CursorPosition`, …), oldest first. pub window_commands: Vec, /// The pointer cursor the last frame asked for (what a real host would show). pub last_cursor: egui::CursorIcon, /// Test-only hook run after `raw_input_hook` and before `logic`/`ui`, as eframe does: it can /// create a prompt after the host input was already filtered for the previous frame. #[cfg(test)] after_raw_input_hook: Option>, } /// A headless run (`lightcraft-cli snapshot`, tests) is a one-shot process: however it ends, it /// first waits for an Apple Photos import its script left running (`export.addToPhotos {wait: /// false}`), bounded, so it never abandons osascript or the import's result. impl Drop for Headless { fn drop(&mut self) { #[cfg(not(target_arch = "wasm32"))] self.app.session.wait_for_photos_imports(); } } impl Headless { /// Wrap `app` (its control channel is replaced by the driver's). /// Invalid dimensions fall back to 1600×1000 at scale 1; external callers can use /// [`viewport_pixels`] to reject them before constructing the session. pub fn new(app: LightcraftApp, size: [f32; 2], pixels_per_point: f32) -> Self { let (size, pixels_per_point) = if viewport_pixels(size, pixels_per_point).is_ok() { (egui::Vec2::from(size), pixels_per_point) } else { (egui::vec2(1600.0, 1000.0), 1.0) }; let (tx, rx) = std::sync::mpsc::channel(); let mut app = app.with_control(rx); app.headless_host = true; Headless { app, view: HeadlessView::new(), max_texture_side: 16384, reported_texture_side: None, size, pixels_per_point, time: 0.0, frames: 0, events: vec![], control: tx, quit: false, window_maximized: false, window_commands: vec![], last_cursor: egui::CursorIcon::Default, #[cfg(test)] after_raw_input_hook: None, } } /// Run `hook` once, between the next frame's [`LightcraftApp::raw_input_hook`] and its /// `logic`/`ui` call. This is the production order in eframe: a prompt can be created there /// after the host input was already accepted for the frame. #[cfg(test)] pub(crate) fn set_after_raw_input_hook(&mut self, hook: impl FnMut(&mut LightcraftApp) + 'static) { self.after_raw_input_hook = Some(Box::new(hook)); } pub fn frames(&self) -> u64 { self.frames } /// The simulated clock (seconds) the next frame runs at. pub fn time(&self) -> f64 { self.time } /// Every piece of text the last frame painted ([`HeadlessView::painted_text`]). pub fn painted_text(&self) -> Vec { self.view.painted_text() } /// `app.quit` was requested. pub fn quit_requested(&self) -> bool { self.quit } /// Run one frame. pub fn step(&mut self) { (self.size, self.pixels_per_point) = bounded_viewport(self.size, self.pixels_per_point); let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events)); raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized); raw.max_texture_side = (self.reported_texture_side != Some(self.max_texture_side)).then_some(self.max_texture_side); self.reported_texture_side = Some(self.max_texture_side); // A headless run's timers follow its simulated clock, not the host's wall clock. self.app.set_simulated_clock(Some(self.time)); self.app.raw_input_hook(&self.view.ctx, &mut raw); #[cfg(test)] if let Some(mut hook) = self.after_raw_input_hook.take() { hook(&mut self.app); } let app = &mut self.app; let mut cursor = egui::CursorIcon::Default; let commands = self.view.run(raw, |ui| { app.logic(ui.ctx()); app.ui(ui); cursor = ui.ctx().output(|output| output.cursor_icon); }); self.last_cursor = cursor; self.time += FRAME_DT; self.frames += 1; for c in commands { // `ViewportCommand::Screenshot` never reaches here: `HeadlessView::run` answers the // one-shot callback after the pass it was issued in, so there is exactly one reply. match c { ViewportCommand::Screenshot(_) => {} ViewportCommand::InnerSize(size) => { // Viewport commands use current egui points, which include UI zoom. if let Ok(native_size) = resized_viewport([size.x, size.y], self.pixels_per_point, self.view.ctx.zoom_factor()) { self.size = native_size; } } ViewportCommand::Close => self.quit = true, ViewportCommand::Maximized(on) => { self.window_maximized = on; self.window_commands.push(c); } // recorded, not carried out: the simulated pointer moves only when a test moves it ViewportCommand::StartDrag | ViewportCommand::CursorPosition(_) | ViewportCommand::CursorGrab(_) => self.window_commands.push(c), _ => {} } } } /// Is anything still in progress (renders, file-system checks the sidebar waits for, queued /// input)? pub fn busy(&self) -> bool { self.app.renderer.in_flight() > 0 || crate::panels::left::fs_cached_running(&self.view.ctx) > 0 || self.app.merge.busy() || self.app.scan.is_some() || self.app.sync.is_some() || self.app.sync_run.is_some() || self.app.import.is_some() || self.app.export.is_some() || self.app.session.denoise_busy() || !self.app.tasks.is_empty() || !self.app.synthetic.is_empty() || !self.app.pending_gestures.is_empty() || !self.events.is_empty() } /// Run frames until nothing is pending (renders finished, input consumed) for a few frames in /// a row, or `timeout` passes. Returns whether it settled. pub fn settle(&mut self, timeout: Duration) -> bool { let t0 = Instant::now(); let mut quiet = 0; loop { self.step(); if self.busy() { quiet = 0; std::thread::sleep(Duration::from_millis(1)); } else { quiet += 1; } // a few quiet frames let layout and short UI animations finish if quiet >= 12 { return true; } if t0.elapsed() > timeout { return false; } } } /// Run frames until `done` holds or `timeout` passes (a render that finishes on a busy machine /// after a quiet spell, where [`Self::settle`] alone would stop too early). Returns `done`. pub fn step_until(&mut self, timeout: Duration, done: impl Fn(&Self) -> bool) -> bool { let t0 = Instant::now(); while !done(self) { if t0.elapsed() > timeout { return false; } self.step(); std::thread::sleep(Duration::from_millis(1)); } true } /// Rasterize the last frame (with the photo textures). pub fn paint(&self) -> ColorImage { self.view.paint(&HashMap::new()) } /// Settle, then render the current UI. pub fn snapshot(&mut self, timeout: Duration) -> ColorImage { self.settle(timeout); self.paint() } /// Tests that browse a folder in the temp directory expect it to be a Local location of its /// own. Where the temp directory lies inside the home folder (Windows), the sidebar lists it /// inside Home's tree instead, so hide Home for the test; elsewhere this does nothing. #[cfg(test)] pub(crate) fn hide_home_above(&mut self, path: &std::path::Path) { let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default(); if !home.is_empty() && lightcraft_catalog::query::folder_within(&path.to_string_lossy(), &home) { let r = self.request("engine.execute", json!({"command": "local.hide", "params": {"path": home}}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); } } /// Send a control-protocol request (see [`crate::control`]) and run frames until it is /// answered, then until its input has been consumed. Returns `{"ok": …, "result"|"error": …}`. pub fn request(&mut self, method: &str, params: Value, timeout: Duration) -> Value { let (req, rx) = ControlRequest::new(method, params); if self.control.send(req).is_err() { return json!({"ok": false, "error": "control channel closed"}); } let t0 = Instant::now(); let reply = loop { self.step(); if let Ok(v) = rx.try_recv() { break v; } if t0.elapsed() > timeout { return json!({"ok": false, "error": "timeout"}); } if self.busy() { std::thread::sleep(Duration::from_millis(1)); } }; // let injected input (clicks, keys, drags) play out before the next request let t1 = Instant::now(); while (!self.app.synthetic.is_empty() || !self.events.is_empty()) && t1.elapsed() < timeout { self.step(); } self.step(); reply } } /// For tests, a render offload: renders wait (stay queued) until `open` is set, then run; those /// whose slot `fails` picks come back failed, as an undecodable raw's would. Install it with /// `h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone())))`. #[cfg(test)] pub(crate) struct RenderGate { open: std::rc::Rc>, fails: fn(crate::render::Slot) -> bool, jobs: Vec<(crate::render::Slot, lightcraft_engine::media::RenderJob)>, } #[cfg(test)] impl RenderGate { pub(crate) fn new(open: std::rc::Rc>) -> Self { Self::failing(open, |_| false) } pub(crate) fn failing(open: std::rc::Rc>, fails: fn(crate::render::Slot) -> bool) -> Self { Self { open, fails, jobs: Vec::new() } } } #[cfg(test)] impl crate::render::RenderOffload for RenderGate { fn try_start(&mut self, slot: crate::render::Slot, job: lightcraft_engine::media::RenderJob) -> Option { if !self.open.get() { return Some(job); } self.jobs.push((slot, job)); None } fn finished(&mut self) -> Vec<(crate::render::Slot, lightcraft_engine::media::RenderResult, f64)> { let fails = self.fails; self.jobs .drain(..) .map(|(slot, job)| { let mut r = job.run(); if fails(slot) { r.rendered = Err("no decoder for this raw".into()); } (slot, r, 0.0) }) .collect() } } #[cfg(test)] mod tests;