refactor(ui-egui): split control.rs into control/

This commit is contained in:
aiongg
2026-10-26 21:16:48 +00:00
parent 531d474781
commit 13e8dacda7
4 changed files with 1066 additions and 1042 deletions
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
use super::screenshots::working_set;
/// The surface reported by one `RawInput`, before it is tied to a document. Captured in the host
/// input hook because `InputState::viewport_rect` and the last painted screen rect are stale on a
/// hidden logic tick; RawInput is the host's answer for this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SurfaceGeometry {
viewport: egui::Vec2,
/// Native pixels per point times egui's zoom, so a zoom change is its own epoch.
pixels_per_point: f32,
max_texture_side: usize,
minimized: bool,
occluded: bool,
}
impl SurfaceGeometry {
/// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite
/// or zero-sized visible surface and recording minimized or occluded state.
pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option<Self> {
Self::from_viewport(raw.viewports.get(&raw.viewport_id)?, raw.screen_rect, raw.max_texture_side, ctx)
}
/// [`Self::from_raw`] for one viewport's info, content rectangle and texture limit.
pub(crate) fn from_viewport(
viewport: &egui::ViewportInfo,
screen_rect: Option<egui::Rect>,
max_texture_side: Option<usize>,
ctx: &egui::Context,
) -> Option<Self> {
let minimized = viewport.minimized == Some(true);
let occluded = viewport.occluded == Some(true);
// A minimized/occluded surface may report no rectangle at all. Its stale size is harmless
// because such a surface is never capturable; a visible surface without a rectangle is not
// fresh enough to use.
let rect = match viewport.inner_rect.or(screen_rect) {
Some(rect) => rect,
None if minimized || occluded => ctx.content_rect(),
None => return None,
};
let zoom = ctx.zoom_factor();
let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom);
let max_texture_side = max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side));
if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 {
return None;
}
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
return None;
}
Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded })
}
/// Pairs the surface geometry with the window's viewport and the document id a capture
/// belongs to.
pub(crate) fn with_target(self, viewport: egui::ViewportId, document: u64) -> CaptureGeometry {
CaptureGeometry { surface: self, viewport, document }
}
}
/// The viewport geometry and document identity a capture is admitted, issued and completed
/// against. `WordApp` keeps a monotonic epoch: any change, including a change back, makes an
/// older image stale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CaptureGeometry {
pub(super) surface: SurfaceGeometry,
/// The active window's viewport, which the capture command goes to.
pub(super) viewport: egui::ViewportId,
pub(super) document: u64,
}
impl CaptureGeometry {
/// Returns the document id the capture was admitted for.
pub(crate) fn document(self) -> u64 {
self.document
}
/// Why a capture admitted for `viewport` and `document` can't go on against this geometry:
/// another window was brought forward, or the window shows another document.
pub(super) fn moved_reason(self, viewport: egui::ViewportId, document: u64) -> Option<&'static str> {
if self.viewport != viewport {
Some("screenshot failed: another window was brought forward while the capture was queued")
} else if self.document != document {
Some("screenshot failed: the document changed while the capture was queued")
} else {
None
}
}
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
/// returns `None` when it can.
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
if self.surface.minimized {
Some("the window is minimized")
} else if self.surface.occluded {
Some("the window is fully occluded")
} else {
None
}
}
/// The screenshot's physical size, rounded to the nearest pixel.
///
/// The renderer's maximum texture side is deliberately not applied here. It is a texture
/// limit, not the size of the surface the callback reports; capping one axis would distort
/// the expected aspect and reject the real image. [`Self::oversize_reason`] rejects a window
/// that cannot have a surface texture at all, before admission.
pub(crate) fn expected_size(self) -> Option<[usize; 2]> {
if self.unavailable_reason().is_some() {
return None;
}
let axis = |points: f32| -> Option<usize> {
let px = (f64::from(points) * f64::from(self.surface.pixels_per_point)).round();
if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 {
return None;
}
Some(px as usize)
};
Some([axis(self.surface.viewport.x)?, axis(self.surface.viewport.y)?])
}
/// Why this surface is too large to capture at all: a window larger than the renderer's
/// maximum texture side has no surface texture to copy. Reject it explicitly rather than
/// silently capping the expected size and failing exact comparisons later.
pub(crate) fn oversize_reason(self) -> Option<String> {
let [width, height] = self.expected_size()?;
let max = self.surface.max_texture_side;
(max > 0 && (width > max || height > max))
.then(|| format!("screenshot failed: the window is {width}×{height} pixels, over the renderer's {max}-pixel texture limit"))
}
/// Checked `width * height * 4` bytes for the expected image.
pub(crate) fn expected_bytes(self) -> Option<usize> {
let [width, height] = self.expected_size()?;
width.checked_mul(height)?.checked_mul(4)
}
/// The admission reservation for the complete capture working set (image plus encoding).
/// `inline_reply` includes the base64 string a reply without a path produces.
pub(crate) fn reserved_bytes(self, inline_reply: bool) -> Option<usize> {
working_set(self.expected_bytes()?, inline_reply)
}
}
+358
View File
@@ -0,0 +1,358 @@
use super::screenshots::render_png;
use super::*;
pub enum Outcome {
Done(Value),
Screenshot { path: Option<String> },
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
let r = ctx.input_for(app.active_viewport(), |i| i.content_rect());
let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h}));
json!({
"ui": app.prefs(),
"view": app.session.view,
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
"window": [r.width(), r.height()],
"canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]),
"pages": app.canvas.page_rects.iter().map(|r| [r.left(), r.top(), r.width(), r.height()]).collect::<Vec<_>>(),
"scale": app.canvas.scale,
"caret": caret,
"balloons": app.canvas.balloon_rects.iter().map(|(id, r)| json!({"id": id, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect::<Vec<_>>(),
"balloon": app.canvas.balloon,
"focused": app.canvas.focused,
"title": app.title_stem(),
"dirty": app.session.dirty,
"document": app.session.document_id(),
"documents": app.session.documents(),
"perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms},
})
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" => Some(egui::Key::Delete),
"backspace" => Some(egui::Key::Backspace),
"left" => Some(egui::Key::ArrowLeft),
"right" => Some(egui::Key::ArrowRight),
"up" => Some(egui::Key::ArrowUp),
"down" => Some(egui::Key::ArrowDown),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
"alt" | "altleft" | "altgr" => Some(egui::Key::AltLeft),
"altright" => Some(egui::Key::AltRight),
"home" => Some(egui::Key::Home),
"end" => Some(egui::Key::End),
_ => None,
})
}
fn mods(p: &Value) -> egui::Modifiers {
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
let cmd = b("cmd") || b("command") || b("ctrl");
egui::Modifiers {
alt: b("alt"),
ctrl: cmd && !cfg!(target_os = "macos"),
shift: b("shift"),
mac_cmd: cmd && cfg!(target_os = "macos"),
command: cmd,
}
}
fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, count: u64, m: egui::Modifiers) {
app.synthetic.push(egui::Event::PointerMoved(pos));
for _ in 0..count.clamp(1, 3) {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
}
/// Programmatic calls never open the Save prompt, so closing unsaved work must be explicit:
/// save first, or pass `"force": true` (only a real boolean true counts) to discard it (#1268).
pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<&'static str> {
let force = params.get("force").and_then(Value::as_bool) == Some(true);
if force {
return None;
}
if id == "file.close" && app.session.dirty {
return Some(UNSAVED);
}
// Comment text still being typed is unsaved work too (#1348): replacing the document would drop it.
(crate::discards_document(id, params) && crate::panes::has_comment_drafts(app))
.then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it")
}
const UNSAVED: &str = "the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close";
/// The reply to a request that needs a document while none is open (#1129).
const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first";
/// The methods that take `doc`.
const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render"];
/// The open document other than the active one that a request's `doc` names; `None` for the
/// active document (no `doc`, null, or the active document's id). An id that names no open
/// document is an error.
fn addressed(app: &WordApp, p: &Value) -> Result<Option<u64>, String> {
let id = match p.get("doc") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v.as_u64().ok_or("`doc` must be the id of an open document (view.switchWindows lists them)")?,
};
if !app.session.is_open(id) {
return Err(format!("no open document has id {id}"));
}
Ok((id != app.session.document_id()).then_some(id))
}
/// The reply to work run on another document: its result, or why it couldn't run or failed.
fn reply<E: std::fmt::Display, F: std::fmt::Display>(r: Result<Result<Value, E>, F>) -> Outcome {
match r {
Ok(Ok(v)) => ok(v),
Ok(Err(e)) => err(e),
Err(e) => err(e),
}
}
/// A document method addressed to open document `doc`, which isn't the active one: it runs on
/// that document, and the window keeps showing the active one.
fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome {
if app.no_document == Some(doc) {
return err(NO_DOCUMENT);
}
match method {
"document.inspect" => reply(app.session.with_document(doc, |s| s.run("document.inspect", p))),
"ui.render" => {
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1);
let scale = p.get("scale").and_then(Value::as_f64).map_or(2.0, |v| v as f32);
let path = p.get("path").and_then(Value::as_str);
let r = app.session.with_document(doc, |s| match path {
Some(path) => s.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})).map_err(|e| e.to_string()),
None => render_png(s, page, scale),
});
reply(r)
}
_ => {
let Some(id) = p.get("command").or(p.get("id")).and_then(Value::as_str) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
if id.starts_with("ui.") {
return err(format!("`{id}` works on the window: make the document active first (view.switchWindows {{\"id\": {doc}}})"));
}
// Close, with the same guard as the window's: unsaved changes need `force`.
if id == "file.close" {
let force = params.get("force").and_then(Value::as_bool) == Some(true);
if !force && app.session.documents().iter().any(|d| d.id == doc && d.dirty) {
return err(UNSAVED);
}
return match app.session.close_document(doc) {
Ok(()) => ok(json!({"closed": true, "id": doc, "documents": app.session.documents()})),
Err(e) => err(e),
};
}
let r = app.session.with_document(doc, |s| s.run(id, &params));
// UI requests the command made carry `document`, so the window can tell they aren't
// about the document it shows.
for req in std::mem::take(&mut app.session.ui_requests) {
app.handle_request(&req);
}
reply(r)
}
}
}
/// Dispatches one JSON-lines control request: commands and inspection, parity, synthetic click,
/// drag, key, wheel and text input, screenshots and renders, resize, focus and quit, and unknown
/// ids as commands. It refuses closing unsaved work and document operations without a document.
pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
if DOC_METHODS.contains(&req.method.as_str()) {
match addressed(app, p) {
Ok(Some(doc)) => return handle_in(app, doc, &req.method, p),
Ok(None) => {}
Err(e) => return err(e),
}
}
let s = |k: &str| p.get(k).and_then(Value::as_str);
let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32);
match req.method.as_str() {
"engine.execute" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
if let Some(e) = close_refused(app, id, &params) {
return err(e);
}
match app.execute(id, params) {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
"engine.commands" => {
let reg = app.session.registry.clone();
let mut commands: Vec<Value> = reg.all().iter()
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": app.session.why_disabled(c.id).is_none()}))
.collect();
commands.extend(crate::printing::commands(app));
let search = s("search").unwrap_or("").to_ascii_lowercase();
if !search.is_empty() {
commands.retain(|command| {
["id", "label", "location", "params"]
.iter()
.any(|field| command.get(field).and_then(Value::as_str).is_some_and(|text| text.to_ascii_lowercase().contains(&search)))
});
}
ok(Value::Array(commands))
}
// The Start screen's empty window has no document to inspect or render (#1129).
"document.inspect" | "ui.render" if !app.has_document() => err(NO_DOCUMENT),
"document.inspect" => match app.session.run("document.inspect", p) {
Ok(v) => ok(v),
Err(e) => err(e),
},
"ui.inspect" => ok(inspect(app, ctx)),
"ui.parity" => ok(wordcraft_engine::catalog::parity(&app.session.registry)),
"ui.click" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let button = match s("button") {
Some("right") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
click_events(app, egui::pos2(x, y), button, count, mods(p));
ok(json!({"queued": true}))
}
"ui.clickText" => {
let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let Some(pos) = crate::canvas::page_to_screen(app, page, x, y) else { return err("page not on screen") };
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
click_events(app, pos, egui::PointerButton::Primary, count, mods(p));
ok(json!({"screen": [pos.x, pos.y]}))
}
"ui.move" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
ok(json!({"queued": true}))
}
"ui.press" | "ui.release" => {
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let pos = egui::pos2(x, y);
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: req.method == "ui.press",
modifiers: mods(p),
});
ok(json!({"queued": true}))
}
"ui.drag" => {
let (Some(x), Some(y), Some(tx), Some(ty)) = (f("x"), f("y"), f("toX"), f("toY")) else { return err("missing x/y/toX/toY") };
let m = mods(p);
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 100);
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
app.synthetic.push(egui::Event::PointerButton {
pos: egui::pos2(x, y),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: m,
});
for i in 1..=steps {
let k = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * k, y + (ty - y) * k)));
}
app.synthetic.push(egui::Event::PointerButton {
pos: egui::pos2(tx, ty),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: m,
});
ok(json!({"queued": true}))
}
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown key") };
let m = mods(p);
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
ok(json!({"queued": true}))
}
"ui.wheel" => {
// Mouse-wheel or trackpad scroll at (x, y): `dy` points (positive scrolls up), or
// lines with `"unit": "line"`; `phase` "start"/"move"/"end" for a trackpad gesture.
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
let unit = match s("unit") {
Some("line") => egui::MouseWheelUnit::Line,
Some("page") => egui::MouseWheelUnit::Page,
_ => egui::MouseWheelUnit::Point,
};
let phase = match s("phase") {
Some("start") => egui::TouchPhase::Start,
Some("end") => egui::TouchPhase::End,
_ => egui::TouchPhase::Move,
};
let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0));
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p), source: egui::MouseWheelSource::Unknown });
ok(json!({"queued": true}))
}
"ui.text" => {
let Some(t) = s("text") else { return err("missing text") };
app.synthetic.push(egui::Event::Text(t.to_string()));
ok(json!({"queued": true}))
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
"ui.render" => {
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1);
let scale = f("scale").unwrap_or(2.0);
match s("path") {
Some(path) => match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})) {
Ok(v) => ok(v),
Err(e) => err(e),
},
// No path: the PNG travels in the reply. Never a fixed name in the shared
// temporary folder, where another user could have planted a link to a file.
None => match render_png(&app.session, page, scale) {
Ok(v) => ok(v),
Err(e) => err(e),
},
}
}
"ui.resize" => {
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
// The window's surface is a GPU texture: no side may pass the largest one (#3734).
let max_side = ctx.input(|i| i.max_texture_side) as f32 / ctx.pixels_per_point();
let Some(size) = crate::window_geometry::requested_size(egui::vec2(w, h), max_side) else {
return err("width and height must be positive numbers no larger than 3.4e38");
};
ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(size));
ok(json!({"width": size.x, "height": size.y}))
}
"ui.focus" => {
ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::Focus);
app.canvas.want_focus = true;
ok(json!({}))
}
"app.quit" => {
app.quit_requested = true;
ok(json!({}))
}
other => {
// Anything else: a command id.
if let Some(e) = close_refused(app, other, p) {
return err(e);
}
match app.execute(other, p.clone()) {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
}
}
+553
View File
@@ -0,0 +1,553 @@
use super::geometry::CaptureGeometry;
use super::*;
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
/// MCP server's `ui.render`).
pub(super) fn render_png(session: &wordcraft_engine::Session, page: u64, scale: f32) -> Result<Value, String> {
let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1);
let l = session.export_layout();
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?;
let img = wordcraft_render::render_page(&session.doc, pg, scale.clamp(0.1, 8.0), &Default::default());
let png = img.to_png();
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
}
/// The bounded admission policy for `ui.screenshot` captures. This is the screenshot queue;
/// the engine's 64-entry UI-intent queue is unrelated. The per-image and aggregate limits are
/// working-set limits, not just raw RGBA: encoding a PNG and base64-encoding it for a reply can
/// briefly need several times the raw pixel buffer.
pub(crate) const SCREENSHOT_MAX_REQUESTS: usize = 4;
pub(crate) const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_RETRIES: u32 = 2;
/// An incompressible PNG of `raw` RGBA bytes. Deflate's stored-block worst case adds five bytes
/// per 64 KiB block; zlib/PNG framing and the encoder's small internal buffers get 64 KiB more.
/// Checked, because every byte count here comes from a hostile window size.
pub(super) fn png_upper_bound(raw: usize) -> Option<usize> {
let blocks = raw.div_ceil(65_535);
raw.checked_add(blocks.checked_mul(5)?)?.checked_add(64 * 1024)
}
/// The length of the base64 text for `bytes`, rounded up, as `base64_encode` produces it.
pub(super) fn base64_upper_bound(bytes: usize) -> Option<usize> {
bytes.div_ceil(3).checked_mul(4)
}
/// Peak memory of one screenshot, in bytes: the completed `ColorImage`, the one straight-alpha
/// RGBA copy handed to the PNG encoder, the PNG buffer, and (for a reply without a path) the
/// base64 string. All of these can be live at once, so admission reserves their sum rather than
/// only the raw pixels or a PNG-only estimate. Checked throughout: a hostile surface size must
/// result in an error, never a wrapped budget.
pub(super) fn working_set(raw: usize, inline_reply: bool) -> Option<usize> {
let png = png_upper_bound(raw)?;
let mut peak = raw.checked_add(raw)?.checked_add(png)?;
if inline_reply {
peak = peak.checked_add(base64_upper_bound(png)?)?;
}
Some(peak)
}
/// One admitted capture. The request owns its token, path, deadline, immutable document identity,
/// geometry and reservation; the shared slot owns the image and its weak callback.
pub(crate) struct Shot {
pub(crate) token: u64,
pub(crate) path: Option<String>,
pub(crate) reply: Sender<Value>,
/// One monotonic deadline for the whole request, including retries.
pub(crate) deadline: f64,
pub(crate) ready_at: f64,
pub(crate) frames: u32,
pub(crate) issued: bool,
pub(crate) retries: u32,
/// The document the request was admitted for. It never changes; a document switch cancels.
pub(crate) document: u64,
/// The viewport of the window the request was admitted for. It never changes; bringing
/// another window forward cancels.
pub(crate) viewport: egui::ViewportId,
pub(crate) geometry: CaptureGeometry,
pub(crate) epoch: u64,
pub(crate) expected: [usize; 2],
/// Admission reservation, including the encoding working set.
pub(crate) bytes: usize,
pub(crate) slot: Option<CaptureSlot>,
pub(crate) callback: Option<egui::ScreenshotCallback>,
}
/// All outstanding `ui.screenshot` requests.
#[derive(Default)]
pub(crate) struct Shots {
pub(crate) token: u64,
pub(crate) requests: Vec<Shot>,
}
impl Shots {
/// Whether any screenshot request is outstanding.
pub(crate) fn in_flight(&self) -> bool {
!self.requests.is_empty()
}
/// Sums, saturating, the admission bytes reserved by outstanding screenshot requests.
fn reserved(&self) -> usize {
self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.bytes))
}
}
/// Builds a screenshot error reply carrying the request token.
fn error_reply(token: u64, message: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": message.to_string(), "request": token})
}
/// Sends one screenshot error reply, ignoring a disconnected receiver.
fn reject_shot(reply: Sender<Value>, token: u64, message: impl std::fmt::Display) {
let _ = reply.send(error_reply(token, message));
}
/// Advances the screenshot token, wrapping and skipping zero so every request has a non-zero
/// token.
fn next_token(app: &mut WordApp) -> u64 {
let mut token = app.shots.token.wrapping_add(1);
if token == 0 {
token = 1;
}
app.shots.token = token;
token
}
/// Admit one screenshot request against the count and byte budgets **before** a capture is
/// issued. A rejected request gets one error reply, with its request token, and never evicts an
/// earlier request.
pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>) {
let token = next_token(app);
// An earlier request in this batch may have brought another window forward.
app.refresh_capture_geometry(ctx);
let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
return;
};
if let Some(reason) = geometry.unavailable_reason() {
reject_shot(reply, token, format!("screenshot failed: {reason}"));
return;
}
let Some(expected) = geometry.expected_size() else {
reject_shot(reply, token, "screenshot failed: the window has no capturable size");
return;
};
if let Some(reason) = geometry.oversize_reason() {
reject_shot(reply, token, reason);
return;
}
let inline_reply = path.is_none();
let Some(bytes) = geometry.reserved_bytes(inline_reply) else {
reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget");
return;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
reject_shot(
reply,
token,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit",
expected[0], expected[1]
),
);
return;
}
if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()),
);
return;
}
let reserved = app.shots.reserved().saturating_add(bytes);
if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {reserved} bytes reserved, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit"),
);
return;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let now = crate::now_ms();
app.shots.requests.push(Shot {
token,
path,
reply,
deadline: now + 8000.0,
ready_at: now + 120.0,
frames: 0,
issued: false,
retries: 0,
document: geometry.document(),
viewport: geometry.viewport,
geometry,
epoch: app.capture_epoch,
expected,
bytes,
slot: Some(slot),
callback: Some(callback),
});
// The capture command is issued from `ui`; while hidden, logic keeps asking for a painted
// frame until it gets there.
ctx.request_repaint();
}
/// Issued only from `ui`, after the layout has settled. Counts one frame per host frame on the
/// first pass, revalidates geometry and the byte reservation, and sends one callback command.
/// `current_pass_index() == 0` is meaningful here because this function is already in a UI pass.
pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let first_pass = ctx.current_pass_index() == 0;
let mut failures: Vec<(usize, String)> = Vec::new();
for i in 0..app.shots.requests.len() {
let (issued, ready_at, frames) = {
let shot = &app.shots.requests[i];
(shot.issued, shot.ready_at, shot.frames)
};
// Each window issues its own captures, from a pass that drew its layout.
if issued || app.shots.requests[i].viewport != ctx.viewport_id() {
continue;
}
let frames = if first_pass { frames.saturating_add(1) } else { frames };
app.shots.requests[i].frames = frames;
if now < ready_at || frames < 3 {
continue;
}
let Some(current) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
failures.push((i, "screenshot failed: the window geometry is not known yet".into()));
continue;
};
if let Some(reason) = current.unavailable_reason() {
failures.push((i, format!("screenshot failed: {reason} while the capture was queued")));
continue;
}
if let Some(reason) = current.moved_reason(app.shots.requests[i].viewport, app.shots.requests[i].document) {
failures.push((i, reason.into()));
continue;
}
let Some(expected) = current.expected_size() else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if let Some(reason) = current.oversize_reason() {
failures.push((i, reason));
continue;
}
let inline_reply = app.shots.requests[i].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
failures.push((
i,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"
),
));
continue;
}
let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
failures.push((i, format!("screenshot budget exceeded: outstanding requests would reserve over {SCREENSHOT_MAX_AGGREGATE_BYTES} bytes")));
continue;
}
let epoch = app.capture_epoch;
let shot = &mut app.shots.requests[i];
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
if let Some(callback) = shot.callback.take() {
ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback));
shot.issued = true;
}
}
for (i, message) in failures.into_iter().rev() {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, message);
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// A completed image is either stale because the surface changed (safe to retry once within the
/// deadline) or invalid for this request (report it at once; retrying cannot help).
pub(super) enum ImageCheck {
Stale(String),
Invalid(String),
}
/// The callback's physical size is the host surface texture's extent, rounded independently of
/// the expected `round(logical points × pixels_per_point)`. One physical pixel on each axis is
/// therefore the same surface seen through a different rounding boundary, not a resize.
pub(super) fn dimensions_are_a_rounded_variant(expected: [usize; 2], actual: [usize; 2]) -> bool {
// Both axes are derived from the same logical viewport and the same pixels-per-point; each
// may round at a boundary one pixel away, but a larger change on either axis is a resize.
expected[0].abs_diff(actual[0]) <= 1 && expected[1].abs_diff(actual[1]) <= 1
}
/// Rejects an empty, overflowing, underfilled or oversized image. A size one pixel away from the
/// expected surface is accepted as a rounding variant; a larger difference is stale.
pub(super) fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageCheck> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return Err(ImageCheck::Invalid("the screenshot has no pixels".to_string()));
}
// A screenshot is issued for one exact logical viewport. Exact equality would reject a valid
// fractional-DPI capture whose callback rounds by one pixel; a larger difference, or a
// different aspect, is a different surface.
if !dimensions_are_a_rounded_variant(expected, [width, height]) {
return Err(ImageCheck::Stale(format!(
"screenshot is {width}×{height} pixels, expected {}×{}: the window changed size while it was captured",
expected[0], expected[1]
)));
}
let pixels = width.checked_mul(height).ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if image.pixels.len() != pixels {
return Err(ImageCheck::Invalid(format!("screenshot has {} pixels for its {width}×{height} dimensions", image.pixels.len())));
}
// The largest image allowed by the one-pixel rounding allowance. The reservation was made for
// the expected size; a sibling one pixel wider or taller is still the same surface.
let largest = expected[0]
.checked_add(1)
.and_then(|w| expected[1].checked_add(1).and_then(|h| w.checked_mul(h)))
.ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if pixels > largest {
return Err(ImageCheck::Invalid(format!("screenshot has {pixels} pixels, over the {largest} allowed by its reservation")));
}
Ok(())
}
/// What one pass over an outstanding screenshot request decides.
enum ShotAction {
Keep,
Remove(Value),
Restart,
}
/// Classify one pending request: deadline first, then admission state, then the completed slot.
fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Option<CaptureGeometry>, epoch: u64) -> ShotAction {
let completed = {
let shot = &mut app.shots.requests[index];
if now >= shot.deadline {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout"));
}
// Before the issue check: a window that closed never draws to issue its capture.
if let Some(reason) = current.and_then(|geometry| geometry.moved_reason(shot.viewport, shot.document)) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, reason));
}
if !shot.issued {
return ShotAction::Keep;
}
if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending")));
}
shot.slot.as_ref().and_then(|slot| slot.take())
};
match completed {
None => ShotAction::Keep,
Some(Err(error)) => ShotAction::Remove(error_reply(app.shots.requests[index].token, error)),
Some(Ok(image)) => completed_screenshot_action(app, index, image, current, epoch),
}
}
/// Judge one taken image: its geometry and epoch identity first, then the image's own validity.
fn completed_screenshot_action(
app: &mut WordApp,
index: usize,
image: std::sync::Arc<egui::ColorImage>,
current: Option<CaptureGeometry>,
epoch: u64,
) -> ShotAction {
let shot = &app.shots.requests[index];
let stale = current != Some(shot.geometry) || epoch != shot.epoch;
if stale {
if shot.retries < SCREENSHOT_MAX_RETRIES {
return ShotAction::Restart;
}
return ShotAction::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written"));
}
match validate_image(&image, shot.expected) {
Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => ShotAction::Restart,
Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => ShotAction::Remove(error_reply(shot.token, message)),
Ok(()) => ShotAction::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)),
}
}
/// Drop one request at `index` with its reply, cancelling any slot still held.
fn remove_shot(app: &mut WordApp, index: usize, value: Value) {
let shot = app.shots.requests.remove(index);
if let Some(slot) = shot.slot {
slot.cancel();
}
let _ = shot.reply.send(value);
}
/// Drop one request at `index` and send an error reply for it.
fn reject_removed_shot(app: &mut WordApp, index: usize, message: impl std::fmt::Display) {
let shot = app.shots.requests.remove(index);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, message);
}
/// Reissue the request at `index` against the current geometry. Returns whether it was reissued:
/// a request removed for a refusal keeps `index` for the request that slides into its place.
fn restart_shot(app: &mut WordApp, ctx: &egui::Context, index: usize, now: f64, current: Option<CaptureGeometry>, epoch: u64) -> bool {
let Some(current) = current else {
reject_removed_shot(app, index, "screenshot failed: the window geometry is not known");
return false;
};
let Some(expected) = current.expected_size() else {
reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget");
return false;
};
if let Some(reason) = current.oversize_reason() {
reject_removed_shot(app, index, reason);
return false;
}
let inline_reply = app.shots.requests[index].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget");
return false;
};
let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_removed_shot(app, index, "screenshot budget exceeded while retrying after a resize");
return false;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let shot = &mut app.shots.requests[index];
shot.slot = Some(slot);
shot.callback = Some(callback);
shot.issued = false;
shot.ready_at = now + 120.0;
shot.frames = 0;
shot.retries = shot.retries.saturating_add(1);
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
true
}
/// Called from `logic`: take each completed slot once, check the deadline **before** writing,
/// validate request/document/geometry identity, and only then encode and reply. A late, stale or
/// oversized image is dropped without a file; a document switch cancels instead of rebinding.
pub(crate) fn drain_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let current = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g });
let epoch = app.capture_epoch;
let mut i = 0;
while i < app.shots.requests.len() {
match take_screenshot_action(app, i, now, current, epoch) {
ShotAction::Keep => i += 1,
ShotAction::Remove(value) => remove_shot(app, i, value),
ShotAction::Restart => {
if restart_shot(app, ctx, i, now, current, epoch) {
i += 1;
}
}
}
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// The screenshot as a straight-alpha PNG, using one RGBA copy rather than the extra full-image
/// copy [`wordcraft_render::Rendered::to_png`] makes for its premultiplied input. The callback's
/// `ColorImage` is premultiplied; PNG stores straight alpha.
fn screenshot_png(image: &egui::ColorImage) -> Option<Vec<u8>> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return None;
}
let pixels = width.checked_mul(height)?;
if image.pixels.len() != pixels {
return None;
}
let mut rgba = Vec::with_capacity(pixels.checked_mul(4)?);
for color in &image.pixels {
let [r, g, b, a] = color.to_array();
if a == 0 || a == 255 {
rgba.extend_from_slice(&[r, g, b, a]);
} else {
let straight = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]);
}
}
let img = image::RgbaImage::from_raw(u32::try_from(width).ok()?, u32::try_from(height).ok()?, rgba)?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// The window screenshot: written to `path` when one was given, else base64 PNG in the reply
/// (never a fixed name in the shared temporary folder). Every reply carries the request token so
/// a client can correlate it with the admission, timeout and error replies.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value {
let [w, h] = image.size;
let Some(raw) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else {
return error_reply(token, "screenshot dimensions overflow the byte count");
};
let inline_reply = path.is_none();
let Some(peak) = working_set(raw, inline_reply) else {
return error_reply(token, "screenshot working set overflows the byte count");
};
if peak > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot needs {peak} bytes of working set, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"),
);
}
let Some(png) = screenshot_png(image) else {
return error_reply(token, "screenshot PNG encoding failed");
};
if png.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(token, format!("screenshot PNG uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", png.len()));
}
let Some(path) = path else {
let encoded = wordcraft_engine::cmd::insert::base64_encode(&png);
if encoded.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot base64 reply uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", encoded.len()),
);
}
return json!({"ok": true, "request": token, "result": {"png": encoded, "width": w, "height": h}});
};
#[cfg(target_arch = "wasm32")]
let _ = (&png, path);
#[cfg(not(target_arch = "wasm32"))]
{
if !path.to_ascii_lowercase().ends_with(".png") {
return error_reply(token, format!("{path}: a screenshot path must end in .png"));
}
if let Err(e) = wordcraft_engine::io::write_file(path, &png) {
return error_reply(token, e);
}
}
json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}})
}