From e9764f64462bb87fedecc9b4eefe018076302874 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:53:00 +0100 Subject: [PATCH] M0.37: fix RTL clipping and preserve screenshot budgets --- ATTRIBUTION.md | 5 +- NOTICE | 243 ++++++++++++- crates/ui-egui/build.rs | 12 +- crates/ui-egui/src/control.rs | 422 ++++++++++++++--------- crates/ui-egui/src/craft_fonts_policy.rs | 15 + crates/ui-egui/src/grid.rs | 6 +- crates/ui-egui/src/lib.rs | 275 +++++++++------ crates/ui-egui/src/theme.rs | 132 ++++--- crates/ui-egui/tests/it/formula_popup.rs | 46 +++ docs/control-protocol.md | 22 +- docs/egui-037-migration-plan.md | 3 + 11 files changed, 841 insertions(+), 340 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 36de2f2f9..a1e4d8fbc 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,7 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| -| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. | | `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | diff --git a/NOTICE b/NOTICE index 6ade4aad6..78c72b9ac 100644 --- a/NOTICE +++ b/NOTICE @@ -20,7 +20,7 @@ Font License 1.1. The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts and registered through egui's default font definitions. They derive from emoji-icon-font by John Slegers, under the MIT License. The subset and full face share the following upstream notice. -Source: https://github.com/storytold/egui/tree/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts Original: https://github.com/jslegers/emoji-icon-font The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: @@ -33,3 +33,244 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The +work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the +Bitstream Vera License. The DejaVu work it derives from was committed to the public domain. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://github.com/source-foundry/Hack +The following text is reproduced verbatim from Hack-Regular.txt: + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's +monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji +The following text is reproduced verbatim from OFL.txt: + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It +is distributed under the Ubuntu Font Licence, Version 1.0. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://design.ubuntu.com/font +The following text is reproduced verbatim from UFL.txt: + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f8f59a15f..d2711ae55 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -36,8 +36,9 @@ fn main() { /// The optional craft-fonts build input (, recipe from /// its `docs/integration.md`). With `CRAFT_FONTS_DIR=` the permitted fonts in /// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset, -/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1`, which -/// turns a missing checkout, a missing required face or a malformed row into a build error. +/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1` (exactly +/// `1`; any other value leaves required mode off), which turns a missing checkout, a missing +/// required face or a malformed row into a build error. /// /// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo /// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces @@ -48,13 +49,13 @@ fn main() { fn emit_craft_fonts() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); - let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let required = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref()); let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut src = String::new(); let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};"); src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) { - Some(dir) => match craft_fonts(&dir, wasm) { + Some(dir) => match craft_fonts(&dir, wasm, required) { Ok(entries) => src.push_str(&entries), Err(e) if required => { println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); @@ -74,8 +75,7 @@ fn emit_craft_fonts() { } /// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed. -fn craft_fonts(dir: &std::path::Path, wasm: bool) -> Result { - let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); +fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result { let manifest = dir.join("fonts/manifest.txt"); println!("cargo::rerun-if-changed={}", manifest.display()); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 55d0e23d7..b7fe784d4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -969,6 +969,39 @@ fn timeout_reply(message: &str, token: u64) -> Json { json!({"ok": false, "error": message, "request": token}) } +/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the +/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a +/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the +/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks +/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is +/// known. +fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { + let bytes = match expected { + Some(expected) => { + let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + )); + } + check_encoding_budget(expected, base64)?; + bytes + } + None => UNKNOWN_CAPTURE_BYTES, + }; + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + )); + } + if let Some(expected) = expected { + check_peak_budget(others.saturating_add(bytes), expected, base64)?; + } + Ok(bytes) +} + /// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the /// geometry and reserves the exact retained bytes, then sends one /// `ViewportCommand::Screenshot(callback)` per request. @@ -1025,38 +1058,13 @@ pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { app.shots.requests[i].expected = None; continue; }; - let Some(bytes) = current.retained_bytes() else { - failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); - continue; + let bytes = match capture_bytes(app, i, current, Some(expected), base64) { + Ok(bytes) => bytes, + Err(message) => { + failures.push((i, message)); + continue; + } }; - if bytes > SCREENSHOT_MAX_IMAGE_BYTES { - failures.push(( - i, - format!( - "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", - expected[0], expected[1] - ), - )); - continue; - } - if let Err(message) = check_encoding_budget(expected, base64) { - failures.push((i, message)); - continue; - } - let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); - if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { - failures.push(( - i, - format!( - "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" - ), - )); - continue; - } - if let Err(message) = check_peak_budget(others.saturating_add(bytes), expected, base64) { - failures.push((i, message)); - continue; - } // Update the geometry and its epoch together, so the drain accepts exactly the layout the // capture is issued against even when a hidden logic tick already saw the new viewport. let epoch = note_capture_geometry(app, current); @@ -1074,11 +1082,7 @@ pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { r.issued = true; } for (i, message) in failures.into_iter().rev() { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - reject_shot(request.reply, request.token, message); + reject_shot_at(app, i, message); } if app.shots.in_flight() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); @@ -1109,6 +1113,16 @@ fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), Ok(()) } +/// What [`drain_screenshots`] must do with one request after inspecting it. +enum DrainAction { + /// Still waiting; leave the request at the same index. + Keep, + /// Answer the request with this reply and remove it. + Remove(Json), + /// Re-arm the request for a fresh capture within its retry budget. + Restart, +} + /// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch /// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized /// or late image is dropped without writing a file. @@ -1120,109 +1134,12 @@ pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { let current_viewport = ctx.viewport_id(); let mut i = 0; while i < app.shots.requests.len() { - enum Action { - Keep, - Remove(Json), - Restart, - } - let action = { - let r = &mut app.shots.requests[i]; - // A document or viewport change is a definitive mismatch: cancel once, never retry and - // never write the pixels against the new identity. - if r.viewport_id != current_viewport { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - Action::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})) - } else if r.document != active_document { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - Action::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})) - } else if !r.issued { - if now >= r.deadline { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - let message = if r.expected.is_none() { - "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" - } else { - "no frame was presented before the screenshot timeout" - }; - Action::Remove(timeout_reply(message, r.token)) - } else { - Action::Keep - } - } else { - let taken = r.slot.as_ref().and_then(|slot| slot.take()); - match taken { - // A completion that arrives past the deadline is late even if the pixels are - // valid: the client was promised one answer within the deadline, so nothing is - // written for it. - Some(result) if now >= r.deadline => { - drop(result); - Action::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) - } - Some(Ok(image)) => { - let validated = match r.expected { - Some(expected) => validate_image(&image, expected), - None => Err("screenshot has no expected size".to_string()), - }; - match validated { - Err(_) if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline => { - drop(image); - Action::Restart - } - Err(message) => Action::Remove(json!({"ok": false, "error": message})), - Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { - if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline { - drop(image); - Action::Restart - } else { - Action::Remove(json!({ - "ok": false, - "error": "screenshot geometry changed while it was captured; no image was written" - })) - } - } - Ok(()) => Action::Remove(save_image(image, r.path.as_deref())), - } - } - // The slot refused the image before `validate_image` saw it: an image - // larger than the reservation is as much evidence of a resize as a smaller - // one. Retry within the same bounded budget instead of failing only when the - // window grew. A non-resize fault still exhausts the retries and reports the - // slot's error, so this stays fail-closed. - Some(Err(_)) if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline => Action::Restart, - Some(Err(e)) => Action::Remove(json!({"ok": false, "error": e.to_string()})), - None if now >= r.deadline => { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - Action::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) - } - None => Action::Keep, - } - } - }; - match action { - Action::Keep => i += 1, - Action::Remove(reply) => { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - let _ = request.reply.send(with_token(reply, request.token)); - } - Action::Restart => match retry_capture(app, i, current_geometry, epoch, now) { + match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { + DrainAction::Keep => i += 1, + DrainAction::Remove(reply) => finish_shot(app, i, reply), + DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { Ok(()) => i += 1, - Err(message) => { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - reject_shot(request.reply, request.token, message); - } + Err(message) => reject_shot_at(app, i, message), }, } } @@ -1231,6 +1148,130 @@ pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { } } +/// Validates one request and decides whether it stays queued, is answered or is re-armed. +fn drain_action( + app: &mut SheetApp, + i: usize, + now: f64, + current_geometry: CaptureGeometry, + epoch: u64, + active_document: Option, + current_viewport: egui::ViewportId, +) -> DrainAction { + let r = &mut app.shots.requests[i]; + // A document or viewport change is a definitive mismatch: cancel once, never retry and + // never write the pixels against the new identity. + if r.viewport_id != current_viewport { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); + } + if r.document != active_document { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); + } + if !r.issued { + return drain_unissued(r, now); + } + drain_completion(r, now, current_geometry, epoch) +} + +/// A request that never issued a capture: keep waiting, or report the timeout that fits whether +/// the window ever had a capturable size. +fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { + if now < r.deadline { + return DrainAction::Keep; + } + cancel_shot(r); + let message = if r.expected.is_none() { + "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" + } else { + "no frame was presented before the screenshot timeout" + }; + DrainAction::Remove(timeout_reply(message, r.token)) +} + +/// A request that issued a capture: inspect its slot once, then apply the deadline, validation +/// and geometry checks in the same order as issuance. +fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + match r.slot.as_ref().and_then(|slot| slot.take()) { + // A completion that arrives past the deadline is late even if the pixels are valid: the + // client was promised one answer within the deadline, so nothing is written for it. + Some(result) if now >= r.deadline => { + drop(result); + DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) + } + Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), + // The slot refused the image before `validate_image` saw it: an image larger than the + // reservation is as much evidence of a resize as a smaller one. Retry within the same + // bounded budget instead of failing only when the window grew. A non-resize fault still + // exhausts the retries and reports the slot's error, so this stays fail-closed. + Some(Err(_)) if retry_allowed(r, now) => DrainAction::Restart, + Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), + None if now >= r.deadline => { + cancel_shot(r); + DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) + } + None => DrainAction::Keep, + } +} + +/// Validates a completed image against the size recorded at issuance and the current +/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the +/// request's deadline and retry budget gets a fresh attempt. +fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + let validated = match r.expected { + Some(expected) => validate_image(&image, expected), + None => Err("screenshot has no expected size".to_string()), + }; + match validated { + Err(_) if retry_allowed(r, now) => { + drop(image); + DrainAction::Restart + } + Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), + Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { + if !retry_allowed(r, now) { + return DrainAction::Remove(json!({ + "ok": false, + "error": "screenshot geometry changed while it was captured; no image was written" + })); + } + drop(image); + DrainAction::Restart + } + Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), + } +} + +/// Whether this request may take another capture: both limits are fixed for the whole request. +fn retry_allowed(r: &Shot, now: f64) -> bool { + r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline +} + +fn cancel_shot(r: &mut Shot) { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } +} + +/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. +fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + let _ = request.reply.send(with_token(reply, request.token)); +} + +/// Remove request `i`, cancel its slot and reject it with `message`. +fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); +} + /// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum /// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. /// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by @@ -1238,29 +1279,7 @@ pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { let expected = geometry.expected_size(); let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); - let bytes = match expected { - Some(size) => { - let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; - if bytes > SCREENSHOT_MAX_IMAGE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", - size[0], size[1] - )); - } - check_encoding_budget(size, base64)?; - bytes - } - None => UNKNOWN_CAPTURE_BYTES, - }; - let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); - if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" - )); - } - if let Some(size) = expected { - check_peak_budget(others.saturating_add(bytes), size, base64)?; - } + let bytes = capture_bytes(app, i, geometry, expected, base64)?; let r = &mut app.shots.requests[i]; r.retries = r.retries.saturating_add(1); r.issued = false; @@ -1925,6 +1944,87 @@ mod tests { assert_eq!(app.shots.reserved(), 0); } + #[test] + fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let mut paths = Vec::new(); + let mut receivers = Vec::new(); + for n in 0..SCREENSHOT_MAX_REQUESTS { + let path = temp_png(&format!("minimize-retry-{n}")); + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + paths.push(path); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); + assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); + + // The window is minimized with every capture outstanding. The drain retries each one + // against the new surface generation, which has no capturable size, so every retry + // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate + // limit; the rest must be reported instead of inflating `reserved()` over the budget. + let mut minimized = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = None; + viewport.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); + + assert!( + app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, + "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", + app.shots.reserved() + ); + let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); + assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); + assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); + assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); + assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); + + // Restoring re-issues only the surviving requests at the real size; both fit because the + // over-reserved retries were rejected rather than kept. + let mut restored = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + let callbacks = issue_with(&ctx, &mut app, restored.clone()); + assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); + + let mut delivered = 0; + for rx in &mut receivers { + if let Ok(reply) = rx.try_recv() { + assert_eq!(reply["ok"], true, "{reply}"); + delivered += 1; + } + } + assert_eq!(delivered, 2, "the two surviving captures reply once each"); + let written = paths.iter().filter(|path| path.exists()).count(); + assert_eq!(written, 2, "only the surviving captures write their image"); + for path in &paths { + let _ = std::fs::remove_file(path); + } + assert_eq!(app.shots.reserved(), 0); + } + #[test] fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { let ctx = sized_context(egui::vec2(64.0, 48.0)); diff --git a/crates/ui-egui/src/craft_fonts_policy.rs b/crates/ui-egui/src/craft_fonts_policy.rs index 131aec2b4..01caed7b0 100644 --- a/crates/ui-egui/src/craft_fonts_policy.rs +++ b/crates/ui-egui/src/craft_fonts_policy.rs @@ -53,6 +53,12 @@ pub struct ManifestRow { pub sha256: String, } +/// Whether the `CRAFT_FONTS_REQUIRED` value enables required mode. Only exactly `1` does: +/// `0`, an empty value and other words are not accepted as an implicit yes. +pub fn required_mode(value: Option<&str>) -> bool { + value == Some("1") +} + /// Whether `family` is barred by GridCraft's asset policy. pub fn barred(family: &str) -> bool { BARRED_PREFIXES.iter().any(|prefix| family.starts_with(prefix)) @@ -184,6 +190,15 @@ mod tests { } } + #[test] + fn only_the_value_one_enables_required_mode() { + assert!(!required_mode(None)); + for value in ["", "0", "00", "01", "false", "true", "yes"] { + assert!(!required_mode(Some(value)), "{value:?} must not enable required mode"); + } + assert!(required_mode(Some("1"))); + } + #[test] fn a_complete_manifest_row_parses() { let rows = parse_manifest(GOOD).unwrap_or_default(); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index dbc60eb22..ed68653be 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3209,8 +3209,10 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but // Wrap Text can expand the editor below/right of it, which must not paint over the next line. let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; - let on_screen = cell_visible && erect.intersect(quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); - let clip = if on_screen { ui.clip_rect().intersect(quadrant) } else { Rect::NOTHING }; + // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. + let screen_quadrant = geo.mirror_rect(quadrant); + let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; let painter = ui.painter().with_clip_rect(clip); painter.rect_filled(erect, 0.0, fill); painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03f390f2e..70b5027cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1204,94 +1204,19 @@ impl SheetApp { /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { - if self.session.text_width.is_none() { - let ctx = ctx.clone(); - self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); - } + self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. self.ui.keytip_invoke = None; // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); - // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate - // before it handles input, so a font change requested now would consume the step without - // applying it. Delay the change one frame; the hidden-paint request keeps it coming. - let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); - if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { - self.fonts_ready = false; - self.fonts_set = false; - } - if !self.fonts_ready { - // New fonts apply from the next frame on: paint nothing until then. Startup always - // proceeds: without it a queued step would be re-offered forever. - if self.fonts_set { - self.fonts_ready = true; - } else { - if self.prepared_fonts { - ctx.set_fonts(theme::pinned_font_definitions()); - theme::apply(ctx, self.ui.dark); - } else { - SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); - } - self.fonts_set = true; - // The rebuilt set has no workbook fonts yet: bind them again once it is active. - self.fonts_bound.clear(); - self.fonts_seen = (0, 0); - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - ctx.request_repaint(); - } - } else if self.fonts_rebind { - // A font-set change made last frame is active now. - self.fonts_rebind = false; - } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { - // Bind the workbook's installed fonts once the background scan has finished. Adding - // fonts re-applies the font set, which egui activates at the next pass, so skip this - // frame's paint (same one-frame cost as startup). Only when the workbook changed. - let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); - if seen != self.fonts_seen { - let used = self.used_fonts(); - self.fonts_seen = seen; - if used != self.fonts_bound { - ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); - self.fonts_bound = used; - self.fonts_rebind = true; - ctx.request_repaint(); - } - } - } + self.apply_font_state(ctx); if let Some(answer) = self.desktop_theme.take_change() { self.desktop_dark = Some(answer); } - let preference = match ( - self.ui.system_theme, - ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), - ) { - (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, - (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, - _ => self.ui.theme_preference(), - }; - if ctx.options(|o| o.theme_preference) != preference { - ctx.set_theme(preference); - ctx.request_repaint(); - } - self.effective_dark = ctx.theme() == egui::Theme::Dark; - // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom - // keys stay off (they would also swallow GridCraft's shortcuts). - if ctx.options(|o| o.zoom_with_keyboard) { - ctx.options_mut(|o| o.zoom_with_keyboard = false); - } - // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, - // the app's Quit there. - if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { - ctx.options_mut(|o| o.quit_shortcuts.clear()); - } - let zoom = self.ui.zoom_factor(); - if (ctx.zoom_factor() - zoom).abs() > 0.001 { - ctx.set_zoom_factor(zoom); - ctx.request_repaint(); - } + self.sync_theme_preference(ctx); + self.sync_input_options(ctx); // The geometry captures are bound to. Native frames recorded it from their fresh // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is // monotonic, so a resize back to the original values is still a new epoch and old pixels @@ -1309,6 +1234,124 @@ impl SheetApp { self.poll_printing(ctx); self.guard_window_close(ctx); // Files read asynchronously (web file picker, dropped files). + self.poll_async_opens(); + self.autosave(ctx); + self.update_title(ctx); + self.request_hidden_paint(ctx); + if self.session.active().is_none() { + self.session.new_workbook(); + } + } + + /// Installs the text-width closure once, on the first frame. + fn ensure_text_width(&mut self, ctx: &egui::Context) { + if self.session.text_width.is_some() { + return; + } + let ctx = ctx.clone(); + self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); + } + + /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook + /// font binding. Every branch keeps its one-frame paint gate. + fn apply_font_state(&mut self, ctx: &egui::Context) { + // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate + // before it handles input, so a font change requested now would consume the step without + // applying it. Delay the change one frame; the hidden-paint request keeps it coming. + let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); + if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { + self.fonts_ready = false; + self.fonts_set = false; + } + if !self.fonts_ready { + self.start_font_setup(ctx); + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { + self.rebind_workbook_fonts(ctx); + } + } + + /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until + /// then. Startup always proceeds: without it a queued step would be re-offered forever. + fn start_font_setup(&mut self, ctx: &egui::Context) { + if self.fonts_set { + self.fonts_ready = true; + return; + } + if self.prepared_fonts { + ctx.set_fonts(theme::pinned_font_definitions()); + theme::apply(ctx, self.ui.dark); + } else { + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + } + self.fonts_set = true; + // The rebuilt set has no workbook fonts yet: bind them again once it is active. + self.fonts_bound.clear(); + self.fonts_seen = (0, 0); + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + ctx.request_repaint(); + } + + /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts + /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint + /// (same one-frame cost as startup). Only when the workbook changed. + fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { + let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + if seen == self.fonts_seen { + return; + } + let used = self.used_fonts(); + self.fonts_seen = seen; + if used == self.fonts_bound { + return; + } + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } + + /// Follows the desktop/system theme preference and records the effective theme. + fn sync_theme_preference(&mut self, ctx: &egui::Context) { + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); + } + self.effective_dark = ctx.theme() == egui::Theme::Dark; + } + + /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. + fn sync_input_options(&mut self, ctx: &egui::Context) { + // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom + // keys stay off (they would also swallow GridCraft's shortcuts). + if ctx.options(|o| o.zoom_with_keyboard) { + ctx.options_mut(|o| o.zoom_with_keyboard = false); + } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, + // the app's Quit there. + if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } + let zoom = self.ui.zoom_factor(); + if (ctx.zoom_factor() - zoom).abs() > 0.001 { + ctx.set_zoom_factor(zoom); + ctx.request_repaint(); + } + } + + /// Opens files that arrived asynchronously (web file picker, dropped files). + fn poll_async_opens(&mut self) { let arrived: Vec<(String, Vec)> = self .services .inbox @@ -1331,51 +1374,55 @@ impl SheetApp { } self.after_engine(); } - // AutoSave: save workbooks that have a file shortly after they change. This decision - // lives in `logic` so a hidden or logic-only frame still saves; the monotonic timestamp - // check keeps a forced hidden UI pass from saving the same change twice. + } + + /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in + /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a + /// forced hidden UI pass from saving the same change twice. + fn autosave(&mut self, ctx: &egui::Context) { + let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); let now = crate::monotonic_ms(); - let autosave_due = self.ui.autosave - && self.editor.is_none() - && self.text_box_editor.is_none() - && now - self.grid.last_autosave > 2000.0 - && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + let autosave_due = + self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; if autosave_due { self.grid.last_autosave = now; self.store_view_state(); let _ = self.session.run("file.save", json!({})); - } else if self.ui.autosave && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { + } else if self.ui.autosave && dirty_xlsx { // Keep checking even when no input arrives while the window is hidden. ctx.request_repaint_after(std::time::Duration::from_secs(2)); } - // Window title, next to the close/autosave state: `ViewportCommand::Title` is valid - // outside a pass and a control-channel rename while hidden updates it on return. - if let Some(d) = self.session.active() { - let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); - if self.grid.last_title.as_deref() != Some(title.as_str()) { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.grid.last_title = Some(title); - } + } + + /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a + /// pass and a control-channel rename while hidden updates it on return. + fn update_title(&mut self, ctx: &egui::Context) { + let Some(d) = self.session.active() else { return }; + let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); + if self.grid.last_title.as_deref() != Some(title.as_str()) { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.grid.last_title = Some(title); } - // A queued/settling capture with a capturable size and an unapplied synthetic step both - // need a real `ui` pass. Once a capture is issued, its `Screenshot` command's own - // `wants_paint` paints the hidden window; after that a full hidden pass every frame only - // wastes CPU. A minimized request has no size, so no hidden pass can reach issuance: the - // restore pass issues it instead. Native eframe honours `RequestPaintWhileHidden`; the - // web runner does not (hidden tabs only run `run_logic`), so a hidden web tab queues the - // work until it is shown again. This is a shared eframe web follow-up, not a - // GridCraft-local workaround, and the control protocol documents the limitation. - if self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty() { - // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an - // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps - // the work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). - #[cfg(not(target_arch = "wasm32"))] - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - ctx.request_repaint(); - } - if self.session.active().is_none() { - self.session.new_workbook(); + } + + /// A queued/settling capture with a capturable size and an unapplied synthetic step both need + /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` + /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A + /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues + /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden + /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. + /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control + /// protocol documents the limitation. + fn request_hidden_paint(&self, ctx: &egui::Context) { + if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { + return; } + // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an + // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the + // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); } pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c6a55b431..63f35e51d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -703,36 +703,7 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; let hack = fonts.font_data.contains_key(HACK).then_some(HACK); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let paths = candidates(role); - let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - let mut chain: Vec = Vec::new(); - let loaded = if system { try_load(&refs) } else { None }; - if let Some(mut fd) = loaded { - // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina - // screen (#1184). Core Text never runs them, so this draws it the way macOS does. - if cfg!(target_os = "macos") && role == UI_BOLD { - fd.tweak.hinting = Some(false); - } - let key = format!("sys-{role}"); - fonts.font_data.insert(key.clone(), Arc::new(fd)); - chain.push(key); - } else if role == UI_BOLD { - chain.push(format!("sys-{UI}")); - } else if role.starts_with("cell-") { - chain.push(format!("sys-{CELL}")); - } - chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); - // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but - // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as - // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it - // only catches characters those do not have. - if let Some(hack) = hack - && !chain.iter().any(|k| k.as_str() == hack) - { - chain.push(hack.to_string()); - } - chain.extend(fallback.iter().cloned()); - chain.retain(|k| fonts.font_data.contains_key(k)); + let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); fonts.families.insert(FontFamily::Name(role.into()), chain); bound.push(role.to_string()); } @@ -745,17 +716,7 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste // Interface text (formula bar, name box, dialogs) falls back to the cell font before the // script faces: whatever a cell can draw, the formula bar can draw too, even where no script // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). - let cell_key = format!("sys-{CELL}"); - if fonts.font_data.contains_key(&cell_key) { - for role in [UI, UI_BOLD, SERIF, MONO] { - if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) - && !chain.contains(&cell_key) - { - let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); - chain.insert(at, cell_key.clone()); - } - } - } + bind_cell_fallback(&mut fonts, &fallback); // Default proportional text in widgets uses the UI font. if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { fonts.families.insert(FontFamily::Proportional, ui); @@ -765,16 +726,7 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still // leads, and the barred Noto CJK collection never enters. - let craft = add_craft_fonts(&mut fonts); - if !craft.is_empty() { - for chain in fonts.families.values_mut() { - for key in &craft { - if !chain.contains(key) { - chain.push(key.clone()); - } - } - } - } + append_craft_faces(&mut fonts); drop_unparseable_fonts(&mut fonts); // Only families egui still has data for count as bound (a dropped face can empty a chain). bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); @@ -782,6 +734,84 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste fonts } +/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled +/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data +/// are dropped, as before. +fn build_role_chain( + fonts: &mut FontDefinitions, + role: &str, + base_prop: &[String], + base_mono: &[String], + fallback: &[String], + hack: Option<&str>, + system: bool, +) -> Vec { + let paths = candidates(role); + let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); + let mut chain: Vec = Vec::new(); + let loaded = if system { try_load(&refs) } else { None }; + if let Some(mut fd) = loaded { + // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina + // screen (#1184). Core Text never runs them, so this draws it the way macOS does. + if cfg!(target_os = "macos") && role == UI_BOLD { + fd.tweak.hinting = Some(false); + } + let key = format!("sys-{role}"); + fonts.font_data.insert(key.clone(), Arc::new(fd)); + chain.push(key); + } else if role == UI_BOLD { + chain.push(format!("sys-{UI}")); + } else if role.starts_with("cell-") { + chain.push(format!("sys-{CELL}")); + } + chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); + // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but + // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as + // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it + // only catches characters those do not have. + if let Some(hack) = hack + && !chain.iter().any(|k| k.as_str() == hack) + { + chain.push(hack.to_string()); + } + chain.extend(fallback.iter().cloned()); + chain.retain(|k| fonts.font_data.contains_key(k)); + chain +} + +/// Inserts the loaded cell face before the script fallbacks of the interface families, so the +/// formula bar and dialogs can draw whatever a cell can (#349). +fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { + let cell_key = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell_key) { + return; + } + for role in [UI, UI_BOLD, SERIF, MONO] { + if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) + && !chain.contains(&cell_key) + { + let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); + chain.insert(at, cell_key.clone()); + } + } +} + +/// Appends the permitted craft-fonts faces to every family, preserving the leading system or +/// bundled face's glyph shapes. +fn append_craft_faces(fonts: &mut FontDefinitions) { + let craft = add_craft_fonts(fonts); + if craft.is_empty() { + return; + } + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } +} + /// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so /// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so /// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text diff --git a/crates/ui-egui/tests/it/formula_popup.rs b/crates/ui-egui/tests/it/formula_popup.rs index ec726093c..dd342b574 100644 --- a/crates/ui-egui/tests/it/formula_popup.rs +++ b/crates/ui-egui/tests/it/formula_popup.rs @@ -1,6 +1,7 @@ //! Formula helpers must stay in the worksheet while its editor scrolls. use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2}; use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { @@ -193,6 +194,51 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper"); } +/// The editor is drawn in screen coordinates, so its frozen-pane quadrant must be mirrored +/// before the visibility test and clip on a right-to-left sheet. Both panes are checked: using +/// the logical quadrant hides the frozen editor, and lets the scrolling editor's clip reach into +/// the frozen pane. +#[test] +fn frozen_column_editor_paints_in_both_panes_on_a_right_to_left_sheet() { + for (cell, text) in [("A2", "frozen"), ("B2", "scrolling")] { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.run("view.rightToLeft", serde_json::json!({"on": true})).unwrap(); + session.run("view.freezeFirstColumn", serde_json::json!({})).unwrap(); + session.run("selection.set", serde_json::json!({"cell": cell})).unwrap(); + let mut app = crate::common::app(session); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let geo = app.grid.geo.as_ref().expect("the grid drew"); + assert!(geo.rtl && geo.fc == 1, "the test needs a right-to-left frozen column"); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let target = CellRef::parse(cell).unwrap(); + let screen_cell = geo.screen_cell_rect(sh, target); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if target.col >= geo.fc { split.x } else { geo.cells.left() }, if target.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if target.col >= geo.fc { geo.cells.right() } else { split.x }, if target.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + let screen_quadrant = geo.mirror_rect(quadrant); + let (clipped, _) = output + .shapes + .iter() + .find_map(|s| { + let egui::Shape::Text(t) = &s.shape else { return None }; + (t.galley.text() == text && s.clip_rect.intersect(t.visual_bounding_rect()).intersect(screen_cell).is_positive()).then_some((s, t)) + }) + .expect("the editor text is painted inside its screen cell"); + assert!( + screen_quadrant.contains_rect(clipped.clip_rect), + "the editor of {cell} must stay inside its screen pane {screen_quadrant:?}, not {:?}", + clipped.clip_rect + ); + } +} + #[test] fn argument_hint_shows_the_function_description() { // #363: inside DATEDIF( the hint lists the units the third argument takes. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aa218d7f1..4b886604b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -33,18 +33,32 @@ connection; nothing sent after it runs. Blank lines are skipped. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | -| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued | -| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit | -| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | +| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | +| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | +| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button | | `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | -| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests, 128 MiB per image and 256 MiB aggregate, reserving the largest image the ±1-pixel rounding rule can retain; a request that would exceed a budget is refused without evicting earlier ones. A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) | +### Screenshot budgets + +`ui.screenshot` keeps up to four requests outstanding. Each admitted request reserves the largest +image the ±1-pixel rounding rule can retain: 128 MiB per image and 256 MiB in total across +requests, and a request that would exceed a budget is refused without evicting earlier ones. +Encoding is bounded separately: one capture's transient working set may be 256 MiB (the larger of +the retained image plus its RGBA copy, or, for a base64 reply, the image plus the PNG stream plus +the encoded text), the base64 reply itself may be 64 MiB, and retained images plus one capture's +encoding working set may be 512 MiB. Base64 text counts toward the encoding working set, so a +large pathless capture can be refused there or by its base64 length even when its retained image +fits. `path` writes the PNG to a file instead of returning base64 text and so avoids those two +base64 costs, but it does not bypass the per-image, aggregate, encoding-working-set or peak +limits. + Any other method name that is a command id (e.g. `home.bold`) runs that command. Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index ddaca4057..2f59c863f 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -13,6 +13,9 @@ WASM, parity and keytips. Actual totals are 2,463 passed, none failed and one ig Independent review verified the merged main behavior and the final scroll/accessibility fixes. The local merge preserves both migration history and the reviewed upstream changes; final code and dependency bytes match the passing CI. Subsequent status edits are prose only. +Main advanced to `17b9f6ff0125209fe707efc919690632ff49cb0b` after that CI run; this tree does +not include it. The pass above therefore does not qualify the later integration, and PR CI must +pass on the combined tree before it is described as validated. Native/platform/performance and application E21b qualification remain open; canonical pins and full CI do not imply that every native contract is qualified. Human review and explicit publication approval are required. Earlier compiler logs,