M0.37: fix RTL clipping and preserve screenshot budgets

This commit is contained in:
skylar taylor-barrick
2026-10-25 08:53:00 +01:00
parent 7a65d373c1
commit e9764f6446
11 changed files with 841 additions and 340 deletions
+4 -1
View File
@@ -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 |
+242 -1
View File
@@ -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.
+6 -6
View File
@@ -36,8 +36,9 @@ fn main() {
/// The optional craft-fonts build input (<https://github.com/storytold/craft-fonts>, recipe from
/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=<absolute checkout>` 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<String, String> {
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
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()))?;
+261 -161
View File
@@ -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<usize, String> {
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<u64>,
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<egui::ColorImage>, 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<Json> = 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));
+15
View File
@@ -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();
+4 -2
View File
@@ -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);
+161 -114
View File
@@ -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<u64> = 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<u8>)> = 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) {
+81 -51
View File
@@ -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<String> = 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<String> {
let paths = candidates(role);
let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect();
let mut chain: Vec<String> = 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
+46
View File
@@ -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.
+18 -4
View File
@@ -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"}}`
+3
View File
@@ -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,