egui037: add shared screenshot and text helpers

This commit is contained in:
skylartaylor committed 2026-10-24 05:25:10 +02:00
1 parent 3832c47ef6
commit c488caa9fb
10 files changed
+3390 -23

No files matched your search

Generated
+32 -22
View File
@@ -1051,8 +1051,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecolor"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"emath",
@@ -1061,8 +1060,7 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1094,8 +1092,7 @@ dependencies = [
[[package]]
name = "egui"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"accesskit",
"ahash",
@@ -1114,8 +1111,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1134,8 +1130,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"block2 0.6.2",
"egui",
@@ -1154,8 +1149,7 @@ dependencies = [
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "def286cf47194f3fe45b8e37c503ada7a20a64b59a903b4bc704baec943b1827"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"egui",
@@ -1169,8 +1163,7 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"eframe",
"egui",
@@ -1193,8 +1186,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
]
@@ -1263,8 +1255,7 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1280,6 +1271,7 @@ dependencies = [
"self_cell",
"skrifa 0.44.0",
"smallvec",
"unicode-bidi",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
@@ -1289,8 +1281,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[package]]
name = "equivalent"
@@ -5709,8 +5700,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
dependencies = [
"android-activity",
"atomic-waker",
@@ -6134,3 +6124,23 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
+15
View File
@@ -54,3 +54,18 @@ hayro = { path = "vendor/hayro" }
hayro-interpret = { path = "vendor/hayro-interpret" }
hayro-syntax = { path = "vendor/hayro-syntax" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
+80 -1
View File
@@ -112,7 +112,7 @@ The work is linear in tab count with at most 32 fitting passes; the caller caps
```rust
fn tab(ui: &mut egui::Ui, rect: egui::Rect, selected: bool) -> egui::Response {
craft_ui::tabs::Tab::new(ui.id().with("layers-tab"), "Layers", selected)
craft_ui::tabs::Tab::new(ui.scope_id().with("layers-tab"), "Layers", selected)
.sense(egui::Sense::click_and_drag())
.show_at(ui, rect, |ui, response| {
let fill = if response.hovered() { egui::Color32::GRAY } else { egui::Color32::DARK_GRAY };
@@ -232,6 +232,85 @@ represented by `None`, independently of explicit one-sided, long-edge or short-e
Receipts and backend errors remain unmodified data for the app to display. These controls work
on WASM too; browser PDF printing remains an app-owned service.
## Screenshot completion
`capture::CaptureSlot` is a one-result slot for egui 0.37's one-shot screenshot callback. It is not a
capture scheduler: request IDs, output paths, deadlines, settling frames, geometry epochs and the
aggregate admission reservation stay in the application. The slot owns strong `Context` and
`ViewportId` handles only in the live request; the callback holds a weak reference, so closing the
request cannot leave a callback-owned context cycle. Keep the slot in the application's request
record, outside egui-owned storage: a slot stored in `egui::Memory::data` or a registered plugin
would form a `Context` → slot → `Context` cycle.
```rust
use craft_ui::capture::{CaptureError, CaptureSlot};
use egui::{Context, ViewportCommand, ViewportId};
fn issue_capture(ctx: &Context, viewport: ViewportId) -> CaptureSlot {
// A per-image bound, not the application's aggregate admission budget.
let (slot, callback) = CaptureSlot::new(ctx, viewport, 128 * 1024 * 1024);
// A pending screenshot command requests the paint that hidden windows need.
ctx.send_viewport_cmd_to(viewport, ViewportCommand::Screenshot(callback));
slot
}
fn report_capture(slot: &CaptureSlot) {
if let Some(result) = slot.take() {
match result {
Ok(image) => {
// Validate the app-owned request/geometry epoch, then encode and reply.
let _ = image;
}
Err(error) => {
// Report the completion error through the app's request record.
let _: CaptureError = error;
}
}
}
}
```
`take` returns the image or completion error at most once; `None` means pending, cancelled, already
taken or dropped at that moment. The slot cannot distinguish those closed states, so the
application's request record owns the pending/closed distinction and the associated timeout or
error reply.
Completion checks a non-zero width and height, `width * height * size_of::<Color32>()` with checked
arithmetic, a pixel buffer that matches the reported dimensions, and the per-image byte bound. A
zero width or height is `MalformedImage`; an overflowing pixel or byte count is
`DimensionsOverflow`. `CaptureError` is `#[non_exhaustive]`, so match the known variants with a
wildcard arm. The slot releases its mutex before requesting a repaint, and `cancel`/`Drop` replace
the state and drop any retained image only after unlocking, so a large image is not freed while
another thread is completing.
Both accepted images and rejection errors wake the owning viewport so the application can take
the result and reply promptly.
Every retry needs a fresh `CaptureSlot` and a fresh callback from `CaptureSlot::new`. A callback is
one-shot and a slot never accepts a second completion, even after `take` removed the first result.
Issue an explicit screenshot command only once per slot, after the application has settled the
frame it wants. The first delivered frame wins; later frames delivered to the same callback are
discarded. A completion is published before the wake: a completion racing `cancel` or `Drop`
may already have published its result, so it can still deliver one harmless extra repaint after
cancellation. If the cancellation won, the image is released and a later `take` returns `None`; if
`take` won first, the caller already owns the result. A completion that observes the cancelled or
taken state stores no image and does not wake. Cancellation and wakeup are therefore not one atomic
step, and the helper never blocks a completing thread under its lock.
`complete` calls `Context::request_repaint_of`, which takes egui's context lock. Completing from an
ordinary `Ui` closure, an egui 0.37 plugin hook or a renderer/worker thread is fine: these run outside
the context lock. Repaint observers and request-repaint callbacks run under that lock, so completing
there would deadlock. An observer can take or cancel a slot because those operations only use the
slot lock, but further `Context` access must happen outside the observer.
Correlation is based on callback ownership. The helper cannot detect a callback sent to the wrong
viewport if the returned image otherwise fits its bound. The application must route each callback
to its recorded viewport and validate its request and geometry; native multi-window tests must
verify the actual pixels as well as callback routing.
## Text reading areas
`text::align_galley_rows` positions already shaped rows inside a fixed-width reading area without changing glyphs or logical text. Supply a per-row alignment, for example the paragraph direction from the application shaper. The helper uses stock egui APIs and keeps cached glyph meshes shared. Its logical rectangle is the requested width; ink may overflow, so clip the painter when drawing cards. Use it for read-only painting, keeping the original galley for editable hit testing.
## Compatibility and checks
The crate uses egui 0.37 and Rust 1.95 or later. Its production dependency disables egui's default
+730
View File
@@ -0,0 +1,730 @@
//! Test-only headless performance harness for craft-ui hot paths.
//!
//! Run through the server task workflow, e.g. `cargo run -p craft-ui --example ui_hotpaths --locked`.
//! Set `CRAFT_UI_PERF_OUT` to write the raw JSON report (otherwise stdout); `CRAFT_UI_PERF_LABEL`
//! is copied into the report. Frames are `Context::run_ui` plus `Context::tessellate`; texture
//! deltas are counted but never uploaded, and no GPU, window, compositor or native input path is
//! exercised. Fonts are egui's bundled `default_fonts` unless `CRAFT_UI_PERF_FONT` points at a
//! pinned fixture file (recorded by the parent with its hash). Bodies, geometry, interactions and
//! the mesh fingerprint are collected on the final measured frame; the hover probe is a separate
//! unmeasured frame and never contributes to timings or counters.
use std::{
collections::{BTreeMap, BTreeSet},
env,
error::Error,
hint::black_box,
sync::Arc,
time::{Duration, Instant},
};
use craft_ui::{
docking::{DockArea, DockError, DockStyle, Floating, Layout, Node, Permissions},
layout::{SplitAxis, SplitSize, fit_toolbar, split_rect},
tabs,
};
use egui::{Context, Id, Rect, pos2, vec2};
use serde_json::{Value, json};
const WARMUPS: usize = 50;
const MIN_WARMUP: Duration = Duration::from_secs(3);
const MICRO_SAMPLES: usize = 50;
const MICRO_BATCH: usize = 16;
const FRAME_SAMPLES: usize = 1000;
const EPS: f32 = 0.1;
#[derive(Default)]
struct Frames {
times_ns: Vec<u64>,
warmups: usize,
warmup_elapsed_ns: u64,
next_frame: usize,
mesh_hash: u64,
bodies: BTreeMap<u32, Rect>,
shapes: usize,
vertices: usize,
indices: usize,
set_images: usize,
set_bytes: usize,
free_images: usize,
repaint_frames: usize,
passes: Vec<usize>,
galleys_after_warmup: usize,
}
fn main() -> Result<(), Box<dyn Error>> {
let (fonts, font_names) = font_definitions()?;
if font_names.is_empty() {
return Err("the example needs egui's bundled default_fonts or CRAFT_UI_PERF_FONT".into());
}
let mut micro = Vec::new();
for scale in [1.0f32, 2.0] {
for count in [1usize, 8, 64, 4096] {
let natural: Vec<_> = tab_widths(count).into_iter().map(|width| width * scale).collect();
let selected = count / 2;
for available in [96.0f32, 640.0, 1920.0] {
let (available, minimum, overflow) = (available * scale, 48.0 * scale, 64.0 * scale);
check_tabs(&natural, selected, available, minimum, overflow)?;
micro.push(micro_entry(
"tabs_fit",
json!({"tabs": count, "available": available, "selected": selected, "minimum_width": minimum,
"overflow_width": overflow, "size_scale": scale}),
|| {
let natural = black_box(&natural);
black_box(tabs::fit(natural, black_box(selected), black_box(available), black_box(minimum), black_box(overflow)));
},
));
}
}
for (shape, rect, axis) in [
("normal", Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0 * scale, 600.0 * scale)), SplitAxis::Horizontal),
("tiny", Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0 * scale, 80.0 * scale)), SplitAxis::Vertical),
] {
let (gap, minimum) = (4.0 * scale, 24.0 * scale);
let span = match axis {
SplitAxis::Horizontal => rect.width(),
SplitAxis::Vertical => rect.height(),
};
// Dock layout uses `SplitSize::FixedFirst` (`docking/surface.rs`), so both fixed modes are
// measured next to the ratio case for every shape and scale.
for size in [SplitSize::Ratio(0.31), SplitSize::FixedFirst(span * 0.3), SplitSize::FixedSecond(span * 0.3)] {
let layout = check_split(rect, axis, size, gap, minimum)?;
micro.push(micro_entry(
"split_rect",
json!({"shape": shape, "axis": format!("{axis:?}"), "size": format!("{size:?}"), "gap": gap, "minimum": minimum,
"size_scale": scale}),
|| {
black_box(split_rect(black_box(rect), black_box(axis), black_box(size), black_box(gap), black_box(minimum)));
},
));
micro.push(micro_entry(
"split_resized",
json!({"shape": shape, "axis": format!("{axis:?}"), "size": format!("{size:?}"), "delta": 12.0 * scale,
"minimum": minimum, "size_scale": scale}),
|| {
black_box(black_box(layout).resized(black_box(size), black_box(12.0 * scale), black_box(minimum)));
},
));
}
}
for groups in [8usize, 4096] {
let natural: Vec<_> = toolbar_widths(groups).into_iter().map(|width| width * scale).collect();
let available = 1920.0 * scale;
check_toolbar(&natural, available, 6.0 * scale, 40.0 * scale)?;
micro.push(micro_entry(
"fit_toolbar",
json!({"groups": groups, "available": available, "spacing": 6.0 * scale, "overflow_width": 40.0 * scale,
"size_scale": scale}),
|| {
let natural = black_box(&natural);
black_box(fit_toolbar(natural, black_box(available), black_box(6.0 * scale), black_box(40.0 * scale)));
},
));
}
}
let mut dock = Vec::new();
for (tag, panels, floating) in [("dock4", 4u32, true), ("dock32", 32u32, false)] {
for viewport in [[800.0f32, 600.0], [1440.0, 900.0]] {
for scale in [1.0f32, 2.0] {
dock.push(run_case(Case { label: tag, kind: "dock", panels, floating, side_panel: false, viewport, scale }, &fonts)?);
}
}
}
let mut integration = Vec::new();
for viewport in [[800.0f32, 600.0], [1440.0, 900.0]] {
for scale in [1.0f32, 2.0] {
integration.push(run_case(
Case { label: "panel_integration", kind: "panel_integration", panels: 6, floating: false, side_panel: true, viewport, scale },
&fonts,
)?);
}
}
let case_counts = json!({"micro": micro.len(), "dock": dock.len(), "panel_integration": integration.len()});
let report = json!({
"schema": 3, "harness": "craft-ui/examples/ui_hotpaths",
"label": env::var("CRAFT_UI_PERF_LABEL").unwrap_or_default(),
"os": env::consts::OS, "arch": env::consts::ARCH,
"package_version": env!("CARGO_PKG_VERSION"), "debug_assertions": cfg!(debug_assertions),
"build_profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"theme": "dark",
"render_config": {"visuals": "dark", "animation_time": 0.0, "text_cursor_blink": false, "max_texture_side": 4096,
"window_focused": true, "clock": "frame index / 60 Hz", "pixels_per_point": [1.0, 2.0]},
"notes": [
"CPU-only headless measurement; no GPU renderer, native window, compositor or input.",
"Dock frame scope is Context::run_ui + Context::tessellate; texture deltas are counted, not uploaded.",
"Fonts are egui default_fonts or the CRAFT_UI_PERF_FONT fixture; no installed system fonts.",
"Micro samples are mean-of-batch nanoseconds; frame samples are individual frame nanoseconds.",
"Every micro operation black-boxes its inputs and result so loop-invariant hoisting cannot erase the measured work.",
"Individual micro latencies are diagnostic only: below about one microsecond the single-call timer overhead dominates.",
"Bodies, geometry, interactions and the mesh fingerprint come from the final measured frame; the hover probe is a separate unmeasured frame.",
],
"minimum_warmups": WARMUPS, "minimum_warmup_ns": MIN_WARMUP.as_nanos(), "micro_samples": MICRO_SAMPLES, "micro_batch": MICRO_BATCH, "frame_samples": FRAME_SAMPLES,
"fonts": {"source": font_source(), "font_file": env::var_os("CRAFT_UI_PERF_FONT"), "font_names": font_names,
"families": fonts.families.iter().map(|(family, names)| (format!("{family:?}"), names)).collect::<BTreeMap<_, _>>()},
"micro": micro, "dock": dock, "panel_integration": integration,
"case_counts": case_counts,
});
let text = serde_json::to_string_pretty(&report)?;
if let Ok(path) = env::var("CRAFT_UI_PERF_OUT") {
std::fs::write(&path, &text)?;
eprintln!("ui_hotpaths: wrote {path} ({} bytes)", text.len());
} else {
println!("{text}");
}
Ok(())
}
fn tab_widths(count: usize) -> Vec<f32> {
(0..count).map(|i| 40.0 + ((i * 17) % 80) as f32).collect()
}
fn toolbar_widths(count: usize) -> Vec<f32> {
(0..count).map(|i| 24.0 + ((i * 13) % 48) as f32).collect()
}
fn micro_entry(name: &str, params: Value, mut op: impl FnMut()) -> Value {
let warmup_start = Instant::now();
let mut warmups = 0;
while warmups < WARMUPS || warmup_start.elapsed() < MIN_WARMUP {
op();
warmups += 1;
}
let warmup_elapsed_ns = warmup_start.elapsed().as_nanos() as u64;
let mut samples = Vec::with_capacity(MICRO_SAMPLES);
let batch = if name.starts_with("split_") { 2000 } else { MICRO_BATCH };
for _ in 0..MICRO_SAMPLES {
let start = Instant::now();
for _ in 0..batch {
op();
}
samples.push((start.elapsed().as_nanos() as u64) / batch as u64);
}
let mut summary = summarize(&samples);
if let Some(stats) = summary.as_object_mut() {
stats.remove("p95_ns");
stats.remove("p99_ns");
}
let mut individual = Vec::with_capacity(MICRO_SAMPLES);
for _ in 0..MICRO_SAMPLES {
let start = Instant::now();
op();
individual.push(start.elapsed().as_nanos() as u64);
}
let mut latency = summarize(&individual);
if let Some(stats) = latency.as_object_mut() {
stats.remove("p99_ns");
}
let (sample_count, individual_count) = (samples.len(), individual.len());
json!({"name": name, "params": params, "sample_kind": "mean_of_batch_ns", "iterations_per_sample": batch,
"operations": warmups + MICRO_SAMPLES * batch + MICRO_SAMPLES,
"warmups": warmups, "warmup_elapsed_ns": warmup_elapsed_ns, "samples": sample_count, "samples_ns": samples,
"stats": summary, "individual_samples": individual_count, "individual_latency_ns": individual,
"individual_latency_stats": latency,
"individual_latency_note": "diagnostic only; timer overhead dominates single sub-microsecond calls"})
}
fn summarize(samples: &[u64]) -> Value {
let mut sorted = samples.to_vec();
sorted.sort_unstable();
json!({"count": sorted.len(), "median_ns": percentile(&sorted, 50), "p95_ns": percentile(&sorted, 95),
"p99_ns": percentile(&sorted, 99), "min_ns": sorted.first().copied().unwrap_or(0),
"max_ns": sorted.last().copied().unwrap_or(0)})
}
fn percentile(sorted: &[u64], percent: usize) -> u64 {
if sorted.is_empty() {
return 0;
}
sorted[(sorted.len() * percent).div_ceil(100).saturating_sub(1).min(sorted.len() - 1)]
}
fn font_definitions() -> Result<(egui::FontDefinitions, Vec<String>), Box<dyn Error>> {
let mut fonts = egui::FontDefinitions::default();
if let Some(path) = env::var_os("CRAFT_UI_PERF_FONT") {
fonts = egui::FontDefinitions::empty();
let name = "perf-fixture".to_owned();
fonts.font_data.insert(name.clone(), Arc::new(egui::FontData::from_owned(std::fs::read(&path)?)));
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(family).or_default().insert(0, name.clone());
}
}
let names = fonts.font_data.keys().cloned().collect();
Ok((fonts, names))
}
fn font_source() -> &'static str {
if env::var_os("CRAFT_UI_PERF_FONT").is_some() { "CRAFT_UI_PERF_FONT (pinned fixture file)" } else { "egui default_fonts (bundled)" }
}
fn context(scale: f32, fonts: &egui::FontDefinitions) -> Context {
let ctx = Context::default();
ctx.set_pixels_per_point(scale);
ctx.set_fonts(fonts.clone());
ctx.set_visuals(egui::Visuals::dark());
ctx.all_styles_mut(|style| {
style.animation_time = 0.0;
style.visuals.text_cursor.blink = false;
});
ctx
}
fn screen_rect(viewport: [f32; 2]) -> Rect {
Rect::from_min_size(pos2(0.0, 0.0), vec2(viewport[0], viewport[1]))
}
fn raw_input(viewport: [f32; 2], frame: usize) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(screen_rect(viewport)),
time: Some(frame as f64 / 60.0),
focused: true,
max_texture_side: Some(4096),
..Default::default()
}
}
fn check_tabs(natural: &[f32], selected: usize, available: f32, minimum: f32, overflow: f32) -> Result<(), Box<dyn Error>> {
let fit = tabs::fit(natural, selected, available, minimum, overflow);
let mut seen = vec![false; natural.len()];
let mut sum = 0.0f32;
for (index, width) in &fit.shown {
if *index >= natural.len() || seen[*index] || !width.is_finite() || *width < 0.0 {
return Err(format!("tabs_fit invalid shown entry ({index}, {width})").into());
}
seen[*index] = true;
sum += *width;
}
for index in &fit.overflow {
if *index >= natural.len() || seen[*index] {
return Err(format!("tabs_fit invalid overflow index {index}").into());
}
seen[*index] = true;
}
if seen.iter().any(|shown| !*shown) || sum + fit.overflow_width > available + EPS {
return Err(format!("tabs_fit did not partition {available}: sum={sum} overflow={}", fit.overflow_width).into());
}
let selected = selected.min(natural.len().saturating_sub(1));
if !natural.is_empty() && !fit.shown.iter().any(|(index, _)| *index == selected) {
return Err("tabs_fit dropped the selected tab".into());
}
Ok(())
}
fn check_split(rect: Rect, axis: SplitAxis, size: SplitSize, gap: f32, minimum: f32) -> Result<craft_ui::layout::SplitLayout, Box<dyn Error>> {
let layout = split_rect(rect, axis, size, gap, minimum).ok_or("split_rect returned None")?;
let (span, first, second) = match axis {
SplitAxis::Horizontal => (rect.width(), layout.first.width(), layout.second.width()),
SplitAxis::Vertical => (rect.height(), layout.first.height(), layout.second.height()),
};
let (divider_end, second_start) = match axis {
SplitAxis::Horizontal => (layout.divider.max.x, layout.second.min.x),
SplitAxis::Vertical => (layout.divider.max.y, layout.second.min.y),
};
if !(first > 0.0 && second > 0.0) || (first + second + gap.min(span) - span).abs() > EPS {
return Err(format!("split_rect geometry: first={first} second={second} span={span}").into());
}
if divider_end > second_start + EPS || !rect.expand(EPS).contains_rect(layout.first) || !rect.expand(EPS).contains_rect(layout.second) {
return Err("split_rect panes overlap or escape their rect".into());
}
let next = layout.resized(size, 12.0, minimum).ok_or("split_resized returned None")?;
let finite = match next {
SplitSize::Ratio(v) | SplitSize::FixedFirst(v) | SplitSize::FixedSecond(v) => v.is_finite(),
};
if !finite {
return Err("split_resized returned a non-finite size".into());
}
if split_rect(Rect::from_min_max(pos2(10.0, 10.0), pos2(0.0, 0.0)), axis, size, gap, minimum).is_some() {
return Err("split_rect accepted an inverted rect".into());
}
Ok(layout)
}
fn check_toolbar(natural: &[f32], available: f32, spacing: f32, overflow: f32) -> Result<(), Box<dyn Error>> {
let fit = fit_toolbar(natural, available, spacing, overflow).ok_or("fit_toolbar returned None")?;
let mut expected = 0;
for (index, width) in &fit.shown {
if *index >= natural.len() || *index != expected || (*width - natural[*index]).abs() > EPS {
return Err(format!("fit_toolbar broken prefix at {index}").into());
}
expected += 1;
}
if fit.overflow.iter().any(|index| *index < expected) || expected + fit.overflow.len() != natural.len() {
return Err("fit_toolbar did not partition the groups".into());
}
if !fit.overflow.is_empty() && fit.overflow_width <= 0.0 {
return Err("fit_toolbar overflowed without reserving a control".into());
}
if fit_toolbar(&[0.0], available, spacing, overflow).is_some() {
return Err("fit_toolbar accepted a zero-width group".into());
}
Ok(())
}
fn leaf(panel: u32) -> Node<u32> {
Node::Tabs { panels: vec![panel], active: 0 }
}
fn tree(panels: &[u32], depth: usize) -> Node<u32> {
if panels.len() == 1 {
return leaf(panels[0]);
}
let mid = panels.len() / 2;
Node::Split {
axis: if depth.is_multiple_of(2) { SplitAxis::Horizontal } else { SplitAxis::Vertical },
size: SplitSize::Ratio(0.5),
first: Box::new(tree(&panels[..mid], depth + 1)),
second: Box::new(tree(&panels[mid..], depth + 1)),
}
}
fn dock_layout(panels: u32, with_floating: bool) -> Layout<u32> {
if with_floating {
Layout {
root: Some(Node::Split {
axis: SplitAxis::Horizontal,
size: SplitSize::FixedSecond(240.0),
first: Box::new(Node::Split {
axis: SplitAxis::Vertical,
size: SplitSize::Ratio(0.55),
first: Box::new(leaf(0)),
second: Box::new(leaf(1)),
}),
second: Box::new(leaf(2)),
}),
floating: vec![Floating { panels: vec![3], active: 0, rect: [80.0, 70.0, 300.0, 220.0] }],
}
} else {
let all: Vec<u32> = (0..panels).collect();
Layout { root: Some(tree(&all, 0)), floating: Vec::new() }
}
}
fn panel_label(panel: &u32) -> String {
format!("Panel {panel}")
}
fn texture_counts(delta: &egui::TexturesDelta) -> (usize, usize, usize) {
let mut images = 0;
let mut bytes = 0;
for deltas in delta.set.values() {
for image in deltas {
images += 1;
let egui::ImageData::Color(data) = &image.image;
bytes += data.as_raw().len();
}
}
(images, bytes, delta.free.len())
}
fn count_mesh(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<(usize, usize), Box<dyn Error>> {
let (mut vertices, mut indices) = (0, 0);
for clipped in primitives {
match &clipped.primitive {
egui::epaint::Primitive::Mesh(mesh) => {
vertices += mesh.vertices.len();
indices += mesh.indices.len();
}
egui::epaint::Primitive::Callback(_) => return Err("the benchmark fixtures must not produce renderer callbacks".into()),
}
}
Ok((vertices, indices))
}
// Fingerprint of one frame's tessellated primitives, beyond equal vertex/index counts. Texture IDs and
// clip bounds are included. This is a 64-bit FNV-1a checksum, not an exact mesh identity. Timings stop
// before this evidence collection.
fn mesh_hash(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<u64, Box<dyn Error>> {
let mut hash = 0xcbf29ce484222325u64;
let mut feed = |bytes: &[u8]| {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
};
feed(&(primitives.len() as u64).to_le_bytes());
for clipped in primitives {
for value in [clipped.clip_rect.min.x, clipped.clip_rect.min.y, clipped.clip_rect.max.x, clipped.clip_rect.max.y] {
feed(&value.to_bits().to_le_bytes());
}
match &clipped.primitive {
egui::epaint::Primitive::Mesh(mesh) => {
feed(&[0]);
match mesh.texture_id {
egui::TextureId::Managed(id) => {
feed(&[0]);
feed(&id.to_le_bytes());
}
egui::TextureId::User(id) => {
feed(&[1]);
feed(&id.to_le_bytes());
}
}
feed(&(mesh.vertices.len() as u64).to_le_bytes());
feed(&(mesh.indices.len() as u64).to_le_bytes());
for vertex in &mesh.vertices {
for value in [vertex.pos.x, vertex.pos.y, vertex.uv.x, vertex.uv.y] {
feed(&value.to_bits().to_le_bytes());
}
feed(&vertex.color.to_array());
}
for index in &mesh.indices {
feed(&index.to_le_bytes());
}
}
egui::epaint::Primitive::Callback(_) => return Err("the benchmark fixtures must not produce renderer callbacks".into()),
}
}
Ok(hash)
}
fn measure_frames(
ctx: &Context,
viewport: [f32; 2],
paint: &mut impl FnMut(&mut egui::Ui, &mut BTreeMap<u32, Rect>, bool),
) -> Result<Frames, Box<dyn Error>> {
let mut result = Frames::default();
result.times_ns.reserve(FRAME_SAMPLES);
result.passes.reserve(FRAME_SAMPLES);
let warmup_start = Instant::now();
let mut frame = 0;
while result.times_ns.len() < FRAME_SAMPLES {
let warming = frame < WARMUPS || warmup_start.elapsed() < MIN_WARMUP;
if !warming && result.times_ns.is_empty() {
result.galleys_after_warmup = ctx.fonts(|fonts| fonts.num_galleys_in_cache());
result.warmup_elapsed_ns = warmup_start.elapsed().as_nanos() as u64;
}
// The last measured frame also collects panel bodies, so bodies, geometry and mesh evidence all
// describe the same rendered frame as the reported timings.
let collect = !warming && result.times_ns.len() + 1 == FRAME_SAMPLES;
result.bodies.clear();
let start = Instant::now();
let mut output = ctx.run_ui(raw_input(viewport, frame), |ui| paint(ui, &mut result.bodies, collect));
let ppp = output.pixels_per_point;
let shapes = output.shapes.len();
let primitives = ctx.tessellate(output.shapes, ppp);
if !warming {
result.times_ns.push(start.elapsed().as_nanos() as u64);
result.passes.push(output.platform_output.num_completed_passes);
result.shapes = shapes;
(result.vertices, result.indices) = count_mesh(&primitives)?;
result.mesh_hash = mesh_hash(&primitives)?;
let (sets, bytes, frees) = texture_counts(&output.textures_delta);
result.set_images += sets;
result.set_bytes += bytes;
result.free_images += frees;
if output.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|output| output.repaint_delay != std::time::Duration::MAX) {
result.repaint_frames += 1;
}
} else {
result.warmups += 1;
}
output.textures_delta.clear();
frame += 1;
}
result.next_frame = frame;
Ok(result)
}
fn check_bodies(screen: Rect, bodies: &BTreeMap<u32, Rect>, floating: &BTreeSet<u32>) -> Result<usize, Box<dyn Error>> {
for (panel, rect) in bodies {
if !rect.is_positive() || !rect.min.is_finite() || !rect.max.is_finite() {
return Err(format!("panel {panel} has empty or invalid body geometry {rect:?}").into());
}
if !screen.expand(EPS * 4.0).contains_rect(*rect) {
return Err(format!("panel {panel} body {rect:?} escapes the viewport {screen:?}").into());
}
}
let static_rects: Vec<(u32, Rect)> = bodies.iter().filter(|(panel, _)| !floating.contains(*panel)).map(|(panel, rect)| (*panel, *rect)).collect();
for (i, (a, first)) in static_rects.iter().enumerate() {
for (b, second) in static_rects.iter().skip(i + 1) {
let overlap = first.intersect(*second);
if overlap.width() > 0.5 && overlap.height() > 0.5 {
return Err(format!("static panels {a} and {b} overlap: {overlap:?}").into());
}
}
}
Ok(bodies.len())
}
fn check_interactions(ctx: &Context, dock_id: Id, screen: Rect) -> Result<usize, Box<dyn Error>> {
let owned = ctx.data(|data| data.get_temp::<Vec<Id>>(dock_id.with("owned-interactions"))).unwrap_or_default();
if owned.is_empty() {
return Err("dock rendered no owned interactions".into());
}
for id in &owned {
let response = ctx.read_response(*id).ok_or_else(|| format!("missing response for dock interaction {id:?}"))?;
if !response.interact_rect.is_positive() {
return Err(format!("dock interaction {id:?} has empty hit rect {:?}", response.interact_rect).into());
}
if !screen.expand(EPS * 4.0).contains_rect(response.interact_rect) {
return Err(format!("dock interaction {id:?} hit rect {:?} escapes the viewport", response.interact_rect).into());
}
}
Ok(owned.len())
}
fn probe_tab_hover(
ctx: &Context,
viewport: [f32; 2],
dock_id: Id,
bodies: &BTreeMap<u32, Rect>,
frame: usize,
paint: &mut impl FnMut(&mut egui::Ui, &mut BTreeMap<u32, Rect>, bool),
) -> Result<Value, Box<dyn Error>> {
let panel = bodies.keys().next().copied().ok_or("no panel body for hover probe")?;
let tab_id = dock_id.with(("tab", panel));
let rect = ctx.read_response(tab_id).ok_or("no tab response for hover probe")?.rect;
let mut input = raw_input(viewport, frame);
input.events.push(egui::Event::PointerMoved(rect.center()));
let mut probe_bodies = BTreeMap::new();
ctx.run_ui(input, |ui| paint(ui, &mut probe_bodies, true)).textures_delta.clear();
if !ctx.read_response(tab_id).is_some_and(|response| response.hovered()) {
return Err(format!("pointer at {panel}'s tab center did not hover its tab").into());
}
Ok(json!({"panel": panel, "tab_center": [rect.center().x, rect.center().y], "hovered": true,
"scope": "separate unmeasured frame after the samples; excluded from timings and counters"}))
}
struct Case<'a> {
label: &'a str,
kind: &'a str,
panels: u32,
floating: bool,
side_panel: bool,
viewport: [f32; 2],
scale: f32,
}
fn render_dock(
ui: &mut egui::Ui,
dock_id: Id,
layout: &Layout<u32>,
bodies: &mut BTreeMap<u32, Rect>,
style: &DockStyle,
collect: bool,
) -> Option<DockError> {
DockArea::new(dock_id)
.show(
ui,
layout,
style,
panel_label,
|_| Permissions::default(),
|ui, panel| {
if collect {
bodies.insert(*panel, ui.max_rect());
}
},
)
.error
}
struct Scene {
dock_id: Id,
side_panel: bool,
style: Option<DockStyle>,
error: Option<DockError>,
dock_rect: Rect,
left_rect: Option<Rect>,
central_rect: Option<Rect>,
}
impl Scene {
fn render(&mut self, layout: &Layout<u32>, ui: &mut egui::Ui, bodies: &mut BTreeMap<u32, Rect>, collect: bool) {
if self.style.is_none() {
self.style = Some(DockStyle::from_ui(ui));
}
let Self { dock_id, side_panel, style, error, dock_rect, left_rect, central_rect } = self;
let Some(style) = style.as_ref() else { return };
if *side_panel {
let _ = egui::Panel::left("ui-perf-left").resizable(false).exact_size(180.0).show(ui, |ui| {
*left_rect = Some(ui.max_rect());
ui.label("Left panel");
});
let _ = egui::CentralPanel::default().show(ui, |ui| {
*central_rect = Some(ui.max_rect());
let next = render_dock(ui, *dock_id, layout, bodies, style, collect);
if error.is_none() {
*error = next;
}
});
} else {
*dock_rect = ui.available_rect_before_wrap();
let next = render_dock(ui, *dock_id, layout, bodies, style, collect);
if error.is_none() {
*error = next;
}
}
}
}
fn run_case(case: Case<'_>, fonts: &egui::FontDefinitions) -> Result<Value, Box<dyn Error>> {
let ctx = context(case.scale, fonts);
let dock_id = Id::unique(("ui-perf", case.label));
let layout = dock_layout(case.panels, case.floating);
layout.validate()?;
let floating: BTreeSet<u32> = layout.floating.iter().flat_map(|group| group.panels.iter().copied()).collect();
let mut scene =
Scene { dock_id, side_panel: case.side_panel, style: None, error: None, dock_rect: Rect::NOTHING, left_rect: None, central_rect: None };
let frames = {
let mut paint = |ui: &mut egui::Ui, bodies: &mut BTreeMap<u32, Rect>, collect: bool| scene.render(&layout, ui, bodies, collect);
measure_frames(&ctx, case.viewport, &mut paint)?
};
if frames.times_ns.len() != FRAME_SAMPLES {
return Err(format!("{} recorded {} of {FRAME_SAMPLES} frame samples", case.label, frames.times_ns.len()).into());
}
if frames.warmups < WARMUPS || frames.warmup_elapsed_ns < MIN_WARMUP.as_nanos() as u64 {
return Err(format!("{} failed its warmup gate: warmups={} elapsed_ns={}", case.label, frames.warmups, frames.warmup_elapsed_ns).into());
}
if frames.passes.len() != frames.times_ns.len() || frames.passes.iter().any(|passes| *passes != 1) {
return Err(format!("{} has measured frames without exactly one completed pass: {:?}", case.label, frames.passes).into());
}
if let Some(error) = scene.error {
return Err(format!("{} reported {error}", case.label).into());
}
let screen = screen_rect(case.viewport);
let body_count = check_bodies(screen, &frames.bodies, &floating)?;
let interaction_count = check_interactions(&ctx, dock_id, screen)?;
if body_count != case.panels as usize {
return Err(format!("{} rendered {body_count} of {} panel bodies", case.label, case.panels).into());
}
let geometry = if case.side_panel {
let left = scene.left_rect.ok_or("left panel did not render")?;
let central = scene.central_rect.ok_or("central panel did not render")?;
if !left.is_positive() || !central.is_positive() || left.right() > central.left() + EPS {
return Err(format!("left/central panel geometry is invalid: left={left:?} central={central:?}").into());
}
json!({"left_rect": [left.min.x, left.min.y, left.width(), left.height()],
"central_rect": [central.min.x, central.min.y, central.width(), central.height()]})
} else {
let dock_rect = scene.dock_rect;
if !dock_rect.is_positive() {
return Err(format!("{} had an empty available rect", case.label).into());
}
json!({"dock_rect": [dock_rect.min.x, dock_rect.min.y, dock_rect.width(), dock_rect.height()]})
};
let hover_probe = {
let mut probe = |ui: &mut egui::Ui, bodies: &mut BTreeMap<u32, Rect>, collect: bool| scene.render(&layout, ui, bodies, collect);
probe_tab_hover(&ctx, case.viewport, dock_id, &frames.bodies, frames.next_frame, &mut probe)?
};
let frame_stats = summarize(&frames.times_ns);
Ok(json!({
"case": case.label, "kind": case.kind, "panels": case.panels, "floating": case.floating,
"viewport_points": case.viewport, "viewport_physical": [case.viewport[0] * case.scale, case.viewport[1] * case.scale],
"pixels_per_point": case.scale, "frames": frames.times_ns.len(), "warmups": frames.warmups,
"warmup_elapsed_ns": frames.warmup_elapsed_ns,
"last_measured_mesh_fnv64": format!("{:016x}", frames.mesh_hash),
"last_measured_mesh_scope": "64-bit FNV-1a fingerprint of the final measured frame's primitives; not an exact identity",
"frame_scope": "Context::run_ui + Context::tessellate",
"frame_times_ns": frames.times_ns, "frame_stats": frame_stats,
"passes_per_frame": frames.passes,
"counter_scope": "shapes, vertices, indices and the mesh fingerprint describe the final measured frame, which also collects bodies and geometry; texture and repaint counters are summed over all measured frames",
"checks_scope": "final measured frame",
"galleys_after_warmup": frames.galleys_after_warmup,
"shapes": frames.shapes, "vertices": frames.vertices, "indices": frames.indices,
"texture_set_images": frames.set_images, "texture_set_bytes": frames.set_bytes,
"texture_free_images": frames.free_images, "repaint_frames": frames.repaint_frames,
"galleys": ctx.fonts(|fonts| fonts.num_galleys_in_cache()),
"hover_probe": hover_probe,
"checks": {"bodies": body_count, "interactions": interaction_count, "geometry": geometry},
}))
}
+228
View File
@@ -0,0 +1,228 @@
//! One-shot completion slots for screenshot requests.
//!
//! Applications retain request IDs, output paths, deadlines, settling policy, geometry epochs and
//! aggregate admission budgets. This module only correlates one capture with one slot, checks the
//! retained image against a per-image byte bound, and wakes the owning viewport.
//!
//! [`CaptureSlot::new`] does not issue a screenshot command. Send the returned [`egui::ScreenshotCallback`]
//! with [`egui::ViewportCommand::Screenshot`] when the application decides the capture is due, or
//! complete it directly from a headless renderer. The callback holds only a weak reference to the
//! slot, so dropping or cancelling the request releases its image and makes late results inert.
//!
//! Every retry needs a fresh slot and a fresh callback from [`CaptureSlot::new`]; a callback is
//! one-shot, and a slot ignores completions after its result was taken, cancelled or dropped. The
//! slot does not track whether a request is still open: keep that pending/closed distinction in the
//! application's request record.
//!
//! A completion that publishes its result just before a concurrent [`CaptureSlot::cancel`] or drop
//! can still deliver its single wakeup afterwards. That extra repaint is harmless: if the
//! cancellation won, the image is released and a later [`CaptureSlot::take`] returns `None`; if
//! `take` won first, the caller already owns the result and nothing else changes. A completion that
//! observes a cancelled or taken slot stores no image and does not wake. Cancellation and completion
//! are therefore not atomic with respect to the wakeup, and this module never waits for another
//! thread while holding the slot lock.
//!
//! [`CaptureSlot`] holds a strong [`egui::Context`]. Keep it in the application's request record;
//! storing it in `Context`-owned data (`egui::Memory::data` or a registered plugin) forms a
//! `Context` → slot → `Context` cycle that keeps both alive.
use std::mem::size_of;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak};
use egui::{Color32, ColorImage, Context, ScreenshotCallback, ViewportId};
/// Why a completed screenshot was rejected and released.
///
/// The enum is `#[non_exhaustive]`: consumers should match the known variants and use a wildcard
/// arm for a future rejection reason.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CaptureError {
/// The checked `width * height` pixel count, or its `width * height * size_of::<Color32>()`
/// byte count, overflowed `usize`.
DimensionsOverflow { width: usize, height: usize },
/// The pixel buffer does not match the reported image dimensions, or the image has zero width
/// or height.
MalformedImage { width: usize, height: usize, pixels: usize },
/// The checked image size exceeds the slot's per-image byte bound.
ImageTooLarge { bytes: usize, max_bytes: usize },
}
impl core::fmt::Display for CaptureError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::DimensionsOverflow { width, height } => {
write!(f, "screenshot dimensions {width}×{height} overflow the checked pixel or byte count")
}
Self::MalformedImage { width, height, pixels } => write!(f, "screenshot has {pixels} pixels for its {width}×{height} dimensions"),
Self::ImageTooLarge { bytes, max_bytes } => write!(f, "screenshot uses {bytes} bytes, over the {max_bytes}-byte per-image limit"),
}
}
}
impl std::error::Error for CaptureError {}
enum SlotState {
Pending,
Ready(Result<Arc<ColorImage>, CaptureError>),
Taken,
Cancelled,
}
struct Shared {
state: Mutex<SlotState>,
/// The live request owns the strong context; the callback only upgrades it briefly to wake.
context: Context,
viewport: ViewportId,
max_image_bytes: usize,
}
/// The live request side of a capture. Keep it alive until the result is taken or the request is
/// cancelled; dropping it cancels the completion and releases any retained image.
///
/// This type is `Send + Sync`; the application may hold it in its request record and poll
/// [`Self::take`] from its logic pass. Do not store it in `Context`-owned storage: the slot holds a
/// strong [`egui::Context`], so `egui::Memory::data` or a registered plugin would create a cycle.
#[must_use = "the slot must be kept alive to receive and take the screenshot"]
pub struct CaptureSlot {
shared: Arc<Shared>,
}
impl CaptureSlot {
/// Create a completion slot and its one-shot callback.
///
/// `max_image_bytes` bounds the single retained image only. The application must separately
/// reserve its aggregate in-flight/completed budget before issuing the request.
///
/// This function does not send a viewport command. If the request is issued in a UI pass, send
/// the callback with [`egui::ViewportCommand::Screenshot`]; a pending screenshot command itself
/// requests the paint needed while the window is hidden. Issue that command at most once per
/// slot, after the application has settled the frame it wants; every retry needs a fresh slot
/// and callback.
pub fn new(context: &Context, viewport: ViewportId, max_image_bytes: usize) -> (Self, ScreenshotCallback) {
let shared = Arc::new(Shared { state: Mutex::new(SlotState::Pending), context: context.clone(), viewport, max_image_bytes });
let weak = Arc::downgrade(&shared);
let callback = ScreenshotCallback::new(move |image| complete(&weak, image));
(Self { shared }, callback)
}
/// Take the completed result, at most once.
///
/// Returns `None` while the capture is pending, after it was cancelled or dropped, and after a
/// previous call already took the image or error. The slot cannot distinguish those states;
/// the application's request record owns the pending/closed distinction. Invalid images are
/// returned as an error so the application can report completion failure with its own request
/// identity.
pub fn take(&self) -> Option<Result<Arc<ColorImage>, CaptureError>> {
let mut state = lock(&self.shared.state);
if !matches!(&*state, SlotState::Ready(_)) {
return None;
}
match core::mem::replace(&mut *state, SlotState::Taken) {
SlotState::Ready(result) => Some(result),
_ => None,
}
}
/// Cancel the request and release any completed image.
///
/// A completion that published its result before this call may still deliver one harmless
/// repaint afterwards; the slot retains no image and a later [`Self::take`] returns `None`. A
/// completion that observes the cancellation stores no image and does not wake. Calling `cancel`
/// after [`Self::take`] is harmless. Any retained image is dropped after the slot mutex is
/// unlocked.
pub fn cancel(&self) {
close(&self.shared);
}
#[cfg(test)]
fn lock_is_free(&self) -> bool {
self.shared.state.try_lock().is_ok()
}
#[cfg(test)]
fn state_strong_count(&self) -> usize {
Arc::strong_count(&self.shared)
}
#[cfg(test)]
fn state_weak(&self) -> Weak<Shared> {
Arc::downgrade(&self.shared)
}
}
impl Drop for CaptureSlot {
fn drop(&mut self) {
close(&self.shared);
}
}
impl core::fmt::Debug for CaptureSlot {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CaptureSlot").field("viewport", &self.shared.viewport).finish_non_exhaustive()
}
}
fn lock(state: &Mutex<SlotState>) -> MutexGuard<'_, SlotState> {
state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Mark the slot closed and drop any retained image after releasing the mutex.
fn close(shared: &Shared) {
let previous = {
let mut state = lock(&shared.state);
core::mem::replace(&mut *state, SlotState::Cancelled)
};
drop(previous);
}
/// Validate a completed image, store it, and wake the owning viewport.
///
/// The slot lock is released before calling into the context, so a repaint observer may call
/// [`CaptureSlot::take`] or [`CaptureSlot::cancel`] re-entrantly. Inside an observer, only consume or
/// release that value; any further `Context` access must happen outside the observer.
/// [`egui::Context::request_repaint_of`] takes egui's context lock. Ordinary `Ui` closures and egui
/// 0.37 plugin hooks run outside that lock, as do renderer/worker callbacks. Repaint observers and
/// request-repaint callbacks run under it: completing from either would deadlock when requesting
/// the repaint. This function never waits under the slot lock.
fn complete(weak: &Weak<Shared>, image: Arc<ColorImage>) {
let Some(shared) = weak.upgrade() else {
// The request was dropped; the image is released here without a reply.
return;
};
let result = checked_image(image, shared.max_image_bytes);
let should_wake = {
let mut state = lock(&shared.state);
if matches!(&*state, SlotState::Pending) {
*state = SlotState::Ready(result);
true
} else {
// Cancelled, already taken, or (defensively) completed twice: drop the image.
false
}
};
// The slot lock is released before calling into the context, so a repaint observer may take
// the result or cancel the request re-entrantly without deadlocking.
if should_wake {
shared.context.request_repaint_of(shared.viewport);
}
}
fn checked_image(image: Arc<ColorImage>, max_image_bytes: usize) -> Result<Arc<ColorImage>, CaptureError> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return Err(CaptureError::MalformedImage { width, height, pixels: image.pixels.len() });
}
let pixels = width.checked_mul(height).ok_or(CaptureError::DimensionsOverflow { width, height })?;
let bytes = pixels.checked_mul(size_of::<Color32>()).ok_or(CaptureError::DimensionsOverflow { width, height })?;
if image.pixels.len() != pixels {
return Err(CaptureError::MalformedImage { width, height, pixels: image.pixels.len() });
}
if bytes > max_image_bytes {
return Err(CaptureError::ImageTooLarge { bytes, max_bytes: max_image_bytes });
}
Ok(image)
}
#[cfg(test)]
mod tests;
+453
View File
@@ -0,0 +1,453 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Barrier};
use super::*;
fn image(size: [usize; 2], color: egui::Color32) -> Arc<ColorImage> {
Arc::new(ColorImage::filled(size, color))
}
fn observe_repaints(ctx: &Context) -> Arc<Mutex<Vec<ViewportId>>> {
let requests = Arc::new(Mutex::new(Vec::new()));
let observed = requests.clone();
ctx.set_repaint_observer(move |info| observed.lock().unwrap().push(info.viewport_id));
requests
}
#[test]
fn slot_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CaptureSlot>();
assert_send_sync::<ScreenshotCallback>();
}
#[test]
fn distinct_requests_complete_in_reverse_order() {
let ctx = Context::default();
let first_viewport = ViewportId::from_hash_of("first-window");
let second_viewport = ViewportId::from_hash_of("second-window");
let (first, first_callback) = CaptureSlot::new(&ctx, first_viewport, 1024);
let (second, second_callback) = CaptureSlot::new(&ctx, second_viewport, 1024);
let first_image = image([2, 1], egui::Color32::RED);
let second_image = image([1, 3], egui::Color32::BLUE);
// The renderer may answer the later request first.
second_callback.complete(second_image.clone());
first_callback.complete(first_image.clone());
let taken_first = first.take().unwrap().unwrap();
let taken_second = second.take().unwrap().unwrap();
assert!(Arc::ptr_eq(&taken_first, &first_image));
assert!(Arc::ptr_eq(&taken_second, &second_image));
assert_eq!(taken_first.size, [2, 1]);
assert_eq!(taken_second.size, [1, 3]);
}
#[test]
fn slots_do_not_share_results_or_byte_bounds() {
let ctx = Context::default();
let first_viewport = ViewportId::from_hash_of("first-slot");
let second_viewport = ViewportId::from_hash_of("second-slot");
let (first, first_callback) = CaptureSlot::new(&ctx, first_viewport, 4);
let (second, second_callback) = CaptureSlot::new(&ctx, second_viewport, 16);
let rejected_for_first = image([2, 2], egui::Color32::RED);
first_callback.complete(rejected_for_first);
assert!(second.take().is_none(), "completing one slot leaves the other pending");
assert!(matches!(first.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 16, max_bytes: 4 }))));
let accepted_for_second = image([2, 2], egui::Color32::BLUE);
second_callback.complete(accepted_for_second.clone());
assert!(Arc::ptr_eq(&second.take().unwrap().unwrap(), &accepted_for_second));
assert!(first.take().is_none(), "a taken slot stays empty");
}
#[test]
fn callback_clones_share_one_completion_and_take_is_single_use() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let duplicate = callback.clone();
let first_image = image([1, 1], egui::Color32::RED);
let second_image = image([1, 1], egui::Color32::BLUE);
let weak_loser = Arc::downgrade(&second_image);
callback.complete(first_image.clone());
duplicate.complete(second_image);
assert!(weak_loser.upgrade().is_none(), "the losing completion releases its image");
let taken = slot.take().unwrap().unwrap();
assert!(Arc::ptr_eq(&taken, &first_image), "the first completion wins");
assert!(slot.take().is_none(), "the result is taken once");
}
#[test]
fn duplicate_completion_after_take_neither_stores_nor_wakes() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let duplicate_callback = callback.clone();
let first_image = image([1, 1], egui::Color32::RED);
callback.complete(first_image.clone());
assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &first_image));
assert_eq!(requests.lock().unwrap().len(), 1, "the first completion wakes");
let duplicate_image = image([1, 1], egui::Color32::BLUE);
let weak_duplicate = Arc::downgrade(&duplicate_image);
duplicate_callback.complete(duplicate_image);
assert!(weak_duplicate.upgrade().is_none(), "a duplicate after take is released");
assert!(slot.take().is_none());
assert_eq!(requests.lock().unwrap().len(), 1, "a duplicate after take does not wake");
// Deliver a second completion straight to the slot; egui already consumed its callback.
let direct_image = image([1, 1], egui::Color32::GREEN);
let weak_direct = Arc::downgrade(&direct_image);
complete(&slot.state_weak(), direct_image);
assert!(weak_direct.upgrade().is_none(), "a direct second completion after take is released");
assert!(slot.take().is_none());
assert_eq!(requests.lock().unwrap().len(), 1, "a direct second completion after take does not wake");
}
#[test]
fn take_before_completion_is_empty_and_keeps_the_request_open() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
assert!(slot.take().is_none());
let expected = image([1, 1], egui::Color32::RED);
callback.complete(expected.clone());
assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected));
}
#[test]
fn cancel_releases_late_images_without_waking() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let viewport = ViewportId::from_hash_of("cancelled-window");
let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024);
slot.cancel();
let late_image = image([2, 2], egui::Color32::RED);
let weak_image = Arc::downgrade(&late_image);
callback.complete(late_image.clone());
drop(late_image);
assert!(weak_image.upgrade().is_none(), "a cancelled slot releases the late image");
assert!(slot.take().is_none());
assert!(requests.lock().unwrap().is_empty(), "cancellation suppresses the wakeup");
}
#[test]
fn cancel_after_ready_releases_the_image() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let completed = image([2, 2], egui::Color32::RED);
let weak_image = Arc::downgrade(&completed);
callback.complete(completed.clone());
drop(completed);
assert!(weak_image.upgrade().is_some(), "the ready slot retains the image");
assert_eq!(requests.lock().unwrap().len(), 1, "the completion woke the viewport");
slot.cancel();
assert!(weak_image.upgrade().is_none(), "cancel releases a ready image");
assert!(slot.take().is_none());
assert_eq!(requests.lock().unwrap().len(), 1, "cancellation itself does not wake");
}
#[test]
fn dropping_the_slot_releases_a_completed_image() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let completed = image([2, 2], egui::Color32::RED);
callback.complete(completed.clone());
let weak_image = Arc::downgrade(&completed);
drop(completed);
assert!(weak_image.upgrade().is_some(), "the slot retains the completed image");
drop(slot);
assert!(weak_image.upgrade().is_none(), "dropping the slot releases its image");
}
#[test]
fn late_completion_after_drop_is_inert() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let viewport = ViewportId::ROOT;
let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024);
drop(slot);
let late_image = image([1, 1], egui::Color32::RED);
let weak_image = Arc::downgrade(&late_image);
callback.complete(late_image.clone());
drop(late_image);
assert!(weak_image.upgrade().is_none());
assert!(requests.lock().unwrap().is_empty(), "a late result does not wake the viewport");
}
#[test]
fn callback_retains_only_a_weak_reference_to_live_state() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let callback_clone = callback.clone();
assert_eq!(slot.state_strong_count(), 1, "the callback must not own the slot state");
let weak_state = slot.state_weak();
drop(slot);
assert!(weak_state.upgrade().is_none(), "dropping the slot releases its context and state");
let late_image = image([1, 1], egui::Color32::RED);
let weak_image = Arc::downgrade(&late_image);
callback_clone.complete(late_image.clone());
drop(late_image);
assert!(weak_image.upgrade().is_none());
assert!(requests.lock().unwrap().is_empty());
}
#[test]
fn rejects_oversized_malformed_and_overflowing_images() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16);
callback.complete(image([3, 3], egui::Color32::RED));
assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 36, max_bytes: 16 }))));
assert!(slot.take().is_none(), "a completion error is reported once");
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
callback.complete(Arc::new(ColorImage { size: [2, 2], source_size: egui::Vec2::ZERO, pixels: vec![egui::Color32::RED; 3] }));
assert!(matches!(slot.take(), Some(Err(CaptureError::MalformedImage { width: 2, height: 2, pixels: 3 }))));
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
callback.complete(Arc::new(ColorImage { size: [usize::MAX, 2], source_size: egui::Vec2::ZERO, pixels: Vec::new() }));
assert!(matches!(slot.take(), Some(Err(CaptureError::DimensionsOverflow { width, height })) if width == usize::MAX && height == 2));
// The pixel count fits but the byte count does not.
let wide = usize::MAX / 2;
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
callback.complete(Arc::new(ColorImage { size: [wide, 1], source_size: egui::Vec2::ZERO, pixels: Vec::new() }));
assert!(matches!(slot.take(), Some(Err(CaptureError::DimensionsOverflow { width, height })) if width == wide && height == 1));
}
#[test]
fn rejected_oversized_and_malformed_images_are_released_before_take() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16);
let oversized = image([3, 3], egui::Color32::RED);
let weak_oversized = Arc::downgrade(&oversized);
callback.complete(oversized);
assert!(weak_oversized.upgrade().is_none(), "an oversized image is released");
assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT], "rejection wakes the error consumer");
assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 36, max_bytes: 16 }))));
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let malformed = Arc::new(ColorImage { size: [2, 2], source_size: egui::Vec2::ZERO, pixels: vec![egui::Color32::RED; 3] });
let weak_malformed = Arc::downgrade(&malformed);
callback.complete(malformed);
assert!(weak_malformed.upgrade().is_none(), "a malformed image is released");
assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT, ViewportId::ROOT]);
assert!(matches!(slot.take(), Some(Err(CaptureError::MalformedImage { width: 2, height: 2, pixels: 3 }))));
}
#[test]
fn zero_area_images_are_rejected_even_with_extreme_extents() {
let ctx = Context::default();
for size in [[0, 4], [4, 0], [0, 0], [usize::MAX, 0], [0, usize::MAX]] {
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
callback.complete(Arc::new(ColorImage { size, source_size: egui::Vec2::ZERO, pixels: Vec::new() }));
let rejected = slot.take();
assert!(
matches!(rejected, Some(Err(CaptureError::MalformedImage { width, height, pixels }))
if width == size[0] && height == size[1] && pixels == 0),
"zero-area image {size:?} must be rejected"
);
}
}
#[test]
fn accepts_an_image_exactly_at_the_byte_bound() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16);
let exact = image([2, 2], egui::Color32::GREEN);
callback.complete(exact.clone());
assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &exact));
}
#[test]
fn another_thread_completes_and_wakes_only_the_owning_viewport() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let viewport = ViewportId::from_hash_of("child-window");
let (slot, callback) = CaptureSlot::new(&ctx, viewport, 1024);
let expected = image([1, 1], egui::Color32::RED);
let sent = expected.clone();
let handle = std::thread::spawn(move || callback.complete(sent));
handle.join().unwrap();
let requested = requests.lock().unwrap();
assert!(!requested.is_empty(), "completion wakes the owning viewport");
assert!(requested.iter().all(|requested_viewport| *requested_viewport == viewport), "completion wakes only the owning viewport: {requested:?}");
drop(requested);
assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected));
}
#[test]
fn wakeup_runs_after_the_slot_lock_is_released() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
let slot = Arc::new(slot);
let weak_slot = Arc::downgrade(&slot);
let observed = Arc::new(AtomicBool::new(false));
let lock_free = Arc::new(AtomicBool::new(true));
let taken = Arc::new(AtomicBool::new(false));
let observer_observed = observed.clone();
let observer_lock_free = lock_free.clone();
let observer_taken = taken.clone();
ctx.set_repaint_observer(move |info| {
if info.viewport_id == ViewportId::ROOT {
observer_observed.store(true, Ordering::SeqCst);
if let Some(slot) = weak_slot.upgrade() {
if slot.lock_is_free() {
observer_taken.store(slot.take().is_some(), Ordering::SeqCst);
} else {
observer_lock_free.store(false, Ordering::SeqCst);
}
}
}
});
callback.complete(image([1, 1], egui::Color32::RED));
assert!(observed.load(Ordering::SeqCst), "completion wakes the owning viewport");
assert!(lock_free.load(Ordering::SeqCst), "the slot lock is released before the wakeup");
assert!(taken.load(Ordering::SeqCst), "the observer can consume the image reentrantly");
assert!(slot.take().is_none());
}
/// Complete, cancel and take all racing on one slot. Every interleaving must leave at most one
/// wake and at most one taken result, and every image must be released once the slot and result
/// are dropped.
#[test]
fn complete_cancel_and_take_race_without_retaining_or_double_waking() {
const RACE_TRIALS: usize = 100;
for trial in 0..RACE_TRIALS {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let viewport = ViewportId::from_hash_of("race-window");
let (slot, callback) = CaptureSlot::new(&ctx, viewport, 4096);
let slot = Arc::new(slot);
let completed = image([1, 1], egui::Color32::RED);
let weak_image = Arc::downgrade(&completed);
let barrier = Arc::new(Barrier::new(3));
let taken = std::thread::scope(|scope| {
let completion_barrier = barrier.clone();
let completion_image = completed.clone();
scope.spawn(move || {
completion_barrier.wait();
callback.complete(completion_image);
});
let cancel_barrier = barrier.clone();
let cancel_slot = slot.clone();
scope.spawn(move || {
cancel_barrier.wait();
cancel_slot.cancel();
});
barrier.wait();
slot.take()
});
let wake_count = requests.lock().unwrap().len();
assert!(wake_count <= 1, "trial {trial}: one completion wakes at most once, got {wake_count}");
if taken.is_some() {
assert_eq!(wake_count, 1, "trial {trial}: a published result wakes exactly once");
}
if let Some(result) = &taken {
match result {
Ok(taken_image) => assert!(Arc::ptr_eq(taken_image, &completed), "trial {trial}: only the completed image is returned"),
Err(error) => panic!("trial {trial}: a valid image was rejected: {error:?}"),
}
}
drop(taken);
drop(slot);
drop(completed);
assert!(weak_image.upgrade().is_none(), "trial {trial}: the slot and any taken result release the image");
}
}
#[test]
fn zero_budget_rejects_a_nonempty_image_and_wakes() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 0);
callback.complete(image([1, 1], egui::Color32::GREEN));
assert!(matches!(slot.take(), Some(Err(CaptureError::ImageTooLarge { bytes: 4, max_bytes: 0 }))));
assert_eq!(*requests.lock().unwrap(), [ViewportId::ROOT]);
}
#[test]
fn concurrent_takers_receive_the_result_exactly_once() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 4);
let expected = image([1, 1], egui::Color32::GREEN);
callback.complete(expected.clone());
let barrier = Barrier::new(3);
let results = std::thread::scope(|scope| {
let first = scope.spawn(|| {
barrier.wait();
slot.take()
});
let second = scope.spawn(|| {
barrier.wait();
slot.take()
});
barrier.wait();
[first.join().unwrap(), second.join().unwrap()]
});
assert_eq!(results.iter().filter(|result| result.is_some()).count(), 1);
for result in results.into_iter().flatten() {
assert!(Arc::ptr_eq(&result.unwrap(), &expected));
}
assert!(slot.take().is_none());
}
#[test]
fn renderer_completion_during_a_live_ui_pass_can_wake_and_deliver() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
let viewport = ViewportId::from_hash_of("render-readback");
let (slot, callback) = CaptureSlot::new(&ctx, viewport, 4);
let expected = image([1, 1], egui::Color32::GREEN);
let sent = expected.clone();
let (start_tx, start_rx) = std::sync::mpsc::channel();
let (done_tx, done_rx) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
scope.spawn(move || {
start_rx.recv().unwrap();
callback.complete(sent);
done_tx.send(()).unwrap();
});
let mut start_tx = Some(start_tx);
let mut output = ctx.run_ui(Default::default(), |ui| {
ui.label("A live frame while readback completes");
if let Some(tx) = start_tx.take() {
tx.send(()).unwrap();
done_rx.recv_timeout(std::time::Duration::from_secs(3)).expect("readback must complete while the UI closure is active");
}
});
// This synchronization test has no renderer to apply the atlas upload.
output.textures_delta.clear();
});
assert!(Arc::ptr_eq(&slot.take().unwrap().unwrap(), &expected));
assert_eq!(requests.lock().unwrap().iter().filter(|id| **id == viewport).count(), 1);
}
+2
View File
@@ -6,9 +6,11 @@
//! accept focused style values and return egui responses; no application engine is required.
pub mod buttons;
pub mod capture;
pub mod docking;
pub mod icons;
pub mod layout;
pub mod printing;
pub mod tabs;
pub mod text;
pub mod theme;
+204
View File
@@ -0,0 +1,204 @@
//! Position already shaped text within a fixed-width reading area.
use std::sync::Arc;
use egui::{Align, Galley, Rect, epaint::text::PlacedRow, pos2, vec2};
/// Align each row inside `width`, without changing its glyphs, wrapping, or logical text.
///
/// This is useful for read-only comments, captions and messages whose text must reach the
/// container's edges. `LayoutJob` alignment ordinarily uses the widest text row instead.
/// The caller supplies the row alignment, for example from paragraph direction in its shaper.
/// This helper uses only stock egui APIs and does not implement bidirectional shaping.
///
/// The returned galley uses a left-edge anchor (`job.halign = Align::Min`) and its logical
/// rectangle is exactly the requested reading area. Ink can extend outside that rectangle:
/// oversized right-aligned rows overflow to the left, and centered rows overflow on both sides.
/// `mesh_bounds` describes the complete ink, including that overflow. Apply clipping separately
/// when required. Positions snap down to physical pixels so right-aligned rows do not cross the
/// right edge through nearest-pixel rounding.
///
/// Use the returned galley for painting; per-row alignment is not represented by its single
/// `LayoutJob` alignment, so editable text should retain its original galley for hit testing.
/// Zero width is valid and may place ink entirely outside the logical rectangle.
/// Invalid widths leave the input untouched. Row positions are copy-on-write; the original
/// shared glyph meshes remain shared, and a cached galley is never modified.
#[must_use]
pub fn align_galley_rows(mut galley: Arc<Galley>, width: f32, mut align: impl FnMut(&PlacedRow) -> Align) -> Arc<Galley> {
if !width.is_finite() || width < 0.0 {
return galley;
}
let positioned = Arc::make_mut(&mut galley);
if positioned.job.halign != Align::Min {
Arc::make_mut(&mut positioned.job).halign = Align::Min;
}
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, positioned.rect.height()));
let mut mesh_bounds = Rect::NOTHING;
for row in &mut positioned.rows {
let spare = f64::from(width) - f64::from(row.size.x);
let ppp = f64::from(positioned.pixels_per_point);
row.pos.x = ((f64::from(align(row).to_factor()) * spare * ppp).floor() / ppp) as f32;
mesh_bounds |= row.visuals.mesh_bounds.translate(row.pos.to_vec2());
}
positioned.rect = rect;
positioned.mesh_bounds = mesh_bounds;
galley
}
#[cfg(test)]
mod tests {
use super::*;
fn layout() -> Arc<Galley> {
layout_text("Short\nA longer line\nEnd", 1.0, Align::Min)
}
fn layout_text(text: &str, ppp: f32, halign: Align) -> Arc<Galley> {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(ppp);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
let mut job = egui::text::LayoutJob::simple(text.into(), egui::FontId::proportional(14.0), egui::Color32::BLACK, f32::INFINITY);
job.halign = halign;
ctx.fonts_mut(|fonts| fonts.layout_job(job))
}
#[test]
fn independent_row_alignment_preserves_shaping_and_cached_geometry() {
let original = layout();
let positions: Vec<_> = original.rows.iter().map(|row| row.pos).collect();
let mut i = 0;
let aligned = align_galley_rows(original.clone(), 400.0, |_| {
let align = match i {
0 => Align::Max,
1 => Align::Min,
_ => Align::Center,
};
i += 1;
align
});
assert_eq!(i, 3);
assert_eq!(original.rows.iter().map(|row| row.pos).collect::<Vec<_>>(), positions);
assert!(Arc::ptr_eq(&original.job, &aligned.job));
assert_eq!(aligned.num_vertices, original.num_vertices);
assert_eq!(aligned.num_indices, original.num_indices);
for (index, (old, new)) in original.rows.iter().zip(&aligned.rows).enumerate() {
assert!(Arc::ptr_eq(&old.row, &new.row), "glyphs and meshes must remain shared");
assert_eq!(old.pos.y, new.pos.y);
let factor = match index {
0 => 1.0,
1 => 0.0,
_ => 0.5,
};
assert!((new.pos.x - (400.0 - new.size.x) * factor).abs() < 1.0);
let bounds = new.visuals.mesh_bounds.translate(new.pos.to_vec2());
assert!(aligned.mesh_bounds.contains_rect(bounds));
}
assert_eq!(aligned.size().x, 400.0);
assert_eq!(aligned.size().y, original.size().y);
}
#[test]
fn invalid_widths_preserve_the_input() {
let original = layout();
for width in [f32::NAN, f32::INFINITY, -1.0] {
let aligned = align_galley_rows(original.clone(), width, |_| Align::Max);
assert!(Arc::ptr_eq(&original, &aligned));
}
}
#[test]
fn overwide_rows_keep_the_requested_reading_edge() {
let original = layout_text("unbreakable-long-token", 2.0, Align::Min);
let width = 23.3;
assert!(original.rows[0].size.x > width);
for alignment in [Align::Min, Align::Center, Align::Max] {
let aligned = align_galley_rows(original.clone(), width, |_| alignment);
let row = &aligned.rows[0];
let expected = (width - row.size.x) * alignment.to_factor();
assert!(row.pos.x <= expected + 0.0001);
assert!(expected - row.pos.x < 0.5);
assert_eq!(row.pos.x * 2.0, (row.pos.x * 2.0).floor());
if alignment == Align::Max {
assert!(row.pos.x < 0.0);
assert!(row.rect().right() <= width + 0.0001);
}
assert_eq!(aligned.rect.left(), 0.0);
assert_eq!(aligned.rect.width(), width);
assert!(aligned.mesh_bounds.contains_rect(row.visuals.mesh_bounds.translate(row.pos.to_vec2())));
}
}
#[test]
fn fractional_width_and_nonleft_input_normalize_the_anchor() {
for original_align in [Align::Min, Align::Center, Align::Max] {
let original = layout_text("Short", 2.0, original_align);
let aligned = align_galley_rows(original.clone(), 163.3, |_| Align::Max);
assert_eq!(original.job.halign, original_align);
assert_eq!(aligned.job.halign, Align::Min);
assert_eq!(aligned.rect.left(), 0.0);
assert_eq!(aligned.rect.right(), 163.3);
for row in &aligned.rows {
assert_eq!(row.pos.x * 2.0, (row.pos.x * 2.0).floor());
assert!(row.rect().right() <= 163.3);
assert!(163.3 - row.rect().right() < 0.5);
}
}
}
#[test]
fn empty_and_whitespace_rows_have_valid_logical_bounds() {
for text in ["", " ", "Short\n"] {
let original = layout_text(text, 1.0, Align::Min);
let height = original.rect.height();
let aligned = align_galley_rows(original, 80.25, |_| Align::Max);
assert_eq!(aligned.rect, Rect::from_min_size(pos2(0.0, 0.0), vec2(80.25, height)));
}
let original = layout();
let mut empty = (*original).clone();
empty.rows.clear();
empty.rect = Rect::ZERO;
empty.mesh_bounds = Rect::NOTHING;
let aligned = align_galley_rows(Arc::new(empty), 80.25, |_| Align::Max);
assert_eq!(aligned.rect, Rect::from_min_size(pos2(0.0, 0.0), vec2(80.25, 0.0)));
assert_eq!(aligned.mesh_bounds, Rect::NOTHING);
}
#[test]
fn fractional_scale_zero_width_and_repeated_alignment_keep_absolute_positions() {
for ppp in [1.25, 1.5] {
let original = layout_text("Short", ppp, Align::Center);
for width in [0.0, 163.3] {
for alignment in [Align::Min, Align::Center, Align::Max] {
let direct = align_galley_rows(original.clone(), width, |_| alignment);
let first = align_galley_rows(original.clone(), 230.5, |_| Align::Max);
let repeated = align_galley_rows(first, width, |_| alignment);
assert_eq!(direct.rect.width(), width);
for (a, b) in direct.rows.iter().zip(&repeated.rows) {
assert_eq!(a.pos, b.pos);
let pixel = a.pos.x * ppp;
assert!((pixel - pixel.round()).abs() < 1e-4);
}
}
}
}
}
#[test]
fn logical_container_and_ink_bounds_remain_distinct() {
let aligned = align_galley_rows(layout_text("Short", 1.0, Align::Min), 400.0, |_| Align::Min);
assert_eq!(aligned.rect.width(), 400.0);
assert!(aligned.mesh_bounds.right() < aligned.rect.right());
let whitespace = align_galley_rows(layout_text(" ", 1.0, Align::Min), 400.0, |_| Align::Max);
assert_eq!(whitespace.rect.width(), 400.0);
assert_eq!(whitespace.mesh_bounds, Rect::NOTHING);
}
#[test]
fn uniquely_owned_galley_reuses_its_allocation() {
let original = Arc::new((*layout()).clone());
assert_eq!(Arc::strong_count(&original), 1);
let before = Arc::as_ptr(&original);
let aligned = align_galley_rows(original, 400.0, |_| Align::Max);
assert_eq!(before, Arc::as_ptr(&aligned));
}
}
File diff suppressed because it is too large. Load diff
+461
View File
@@ -0,0 +1,461 @@
# Craft suite: egui 0.37 rollout and acceptance plan
Status: local implementation underway, updated 2026-10-24. The reviewed local egui fork is
`173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`; the reviewed local winit fork is
`ae48c756a3b81125eb796c0cc6dccf60990d1181`. craft-libs is reconciled on merged PR 199 and now
records those immutable canonical sources in its manifest and lock. Its 118 craft-ui tests, strict
package clippy, canonical source audit and formatting checks pass. Full workspace, platform/native,
accepted comparative performance and clean post-publication remote-fetch gates are not implied.
No push or new pull request is authorized.
The inventory below retains the 2026-10-22 planning snapshot and source references for provenance;
the current repository status supersedes its old implementation/PR-conflict statements. Every
repository has a separate `docs/egui-037-migration-plan.md`. This document defines the shared
dependency order and acceptance contract; each repository plan supplies its actual source anchors,
commands, fixtures and behavior-specific assertions.
## Scope and current dependency state
The inventory covers 15 applications: CADCraft, DeckCraft, DesignCraft, EffectCraft, FigCraft,
FilmCraft, GridCraft, LightCraft, PdfCraft, PhotoCraft, SoundCraft, VectorCraft, WordCraft,
ArtCraft Launcher (`craft-launcher`) and ArtCraft Native (`artcraft/artcraft-native`).
It also covers `craft-libs`, `craft-ai-integrations`, `storytold/egui` and the required
`storytold/winit` companion fork. CLI, web, headless, examples, benchmarks and nested workspaces
that consume these libraries belong to their repository's migration.
The first 14 applications were present in the earlier suite audit; ArtCraft Native and
`craft-ai-integrations/crates/ai-egui` must not be lost from the rollout. `craft-fonts` and
`craft-i18n` are relevant data/API inputs, not egui rendering consumers in the inspected sources.
The older `storyteller-bevy` studio and `bevy-mocap` examples use Bevy-specific integrations
(`bevy-inspector-egui` 0.23 and 0.22); they are outside this Craft suite/fork migration. A
future expansion to those projects needs their own Bevy compatibility plan, not a root egui override.
The [launcher's pinned catalog](https://github.com/storytold/craft-launcher/blob/9419d8977bc70f80373da8558356e4ba7387c7b2/crates/core/src/catalog.rs)
enumerates the 13 creative apps above. Repository discovery also covered all 68 repositories
returned for `storytold` (the next page was empty), with a separate code search excluding the
19 migration repositories so one large app could not crowd out smaller consumers. Search is a
discovery aid, not proof about unindexed code. Manifest/source inspection confirmed the additional
ArtCraft Native and AI UI consumers. ArtCraft-X is a Tauri/Vite frontend, the repository-status
app uses a TypeScript UI, and ArtCraft Services' inspected workspace contains service/CLI code;
the latter's old Docker comments mentioning egui are not a dependency declaration. Asset/data
repositories and the empty AniCraft/CraftHub repositories do not require an egui port. Re-run
this inventory before final integration to catch newly added consumers or branches.
Most inspected suite roots still resolve egui 0.36.2. CADCraft at
`1977a493c7d8340249185126744c5143f096dc86` and SoundCraft at
`52471b37e957feaea56dfd48932ef16d314cc74a` have already bumped direct dependencies to 0.37,
while their pinned `craft-ui` still requires 0.36. Both lockfiles therefore contain two egui versions.
Their first deliverable is a coherent graph and preserved behavior, not another version bump.
The existing application code already uses the root-Ui lifecycle on 0.36.2; some web shells
implement only `App::ui` and inherit default `logic`. Do not attribute that split to 0.37
or rewrite it mechanically. Inspect the remaining incompatibilities and
revalidate the lifecycle, especially hidden windows, child viewports and repeated UI passes.
## Pinned starting points and existing work
| Source | Exact reference | Use |
|---|---|---|
| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | Release commit, not moving main or the annotated tag object |
| Upstream egui 0.36.2 | `49682f8baa058bf49e011035cfbd6e825f88a5ef` | Stock comparison, where useful |
| Published storytold egui | `767b1cad294120689a514bb4bd68c077e46679b9` | Existing `craft/0.36.2` patch inventory |
| aiongg egui candidate | `a4f9a17f64a6bae61350fb85804c9df0d9865916` | `craft/0.37`; review E1–E21 before integration |
| Published storytold winit | `ffae5198770d7730e718043f035a772b7dbc3b1b` | Existing `craft/0.30.13` behavior |
| aiongg winit candidate | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | `craft/0.30.13-lang`; review companion patches and downstream vendors |
| craft-libs plan baseline | `49190884278f16c9a9f5823d7698a8ffb5deb062` | Current shared-library tree used by this plan |
| craft-libs PR 199 | `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` | Existing aiongg API port; open and conflicting at inspection |
[PR 199](https://github.com/storytold/craft-libs/pull/199) supplies reusable API-port hunks,
not a mergeable replacement tree. Its head `b68de8c` and historical audit tree `ec8840a`
omit current `craft-platform` and revert security, settings, docking and printing fixes.
Apply the port from `8614e4a04fae516fba16c22b31ff1008cb6be10b` and relevant README
changes from `1764bca` onto current `4919088`, preserving intervening fixes and provenance.
Re-run actual gates; neither the PR nor historical checks qualify the resulting tree.
[Issue 198](https://github.com/storytold/craft-libs/issues/198) is a broader language-support
proposal, not a merged dependency. Its locale/formatting roadmap is not silently included in
this upgrade. Changes required to consume the selected fork correctly are included: remove
double bidi processing, maintain logical text at OS/accessibility boundaries, preserve IME and
shortcut behavior, and ensure installed/bundled font coverage under each app's asset policy.
Do not import prohibited font assets from the proposal; existing licensing rules still apply.
The candidate also has a confirmed IME integration gap: winit W27 exposes outgoing
surrounding-text support but egui-winit never sends it. Egui E21b/P7b adds the bounded
transient payload, TextEdit producer and Wayland adapter. Word/Vector/Fig/Effect/Film/
Design/Deck/Photo custom editors require model/preedit/byte-index adapters; this is
part of their atomic compatibility work. Outgoing state, focus/password clearing,
long selections and native IME evidence are explicit acceptance gates. The input-owner generation
exists even for `Unavailable`; an empty available document is distinct. W27 must clear cached and
protocol surrounding state at an owner/unavailable transition using a committed disable/enable
boundary and cancel old pending preedit/commit/delete batches. Ordinary updates retain composition.
Within a current focus epoch, mismatched text-input-v3 `done` serials still apply incoming edits
once while deferring outgoing state until a match; old epochs cannot edit a new owner. Egui's
latest completed pass, including `IMEOutput = None`, replaces earlier IME output. Fork P7b/W27
own these fixes and tests before downstream compatibility PRs; see their detailed contracts.
P7b also separates the web TextAgent DOM-position equality key from transient IME payload
equality, testing browser composition and no extra style writes for unchanged caret geometry.
The winit plan must pin the candidate's full SHA and compare its tree against the published
`craft/0.30.13`. The selected egui language APIs and winit language APIs form one compatibility
unit. winit remains 0.30.13 and wgpu remains 30 unless a separately justified change is needed.
## Implementation and publication dependency order
1. Finish each plan, then obtain a fresh independent adversarial review of that exact document.
Resolve concrete findings, retain the review and final document hash, and prepare documentation
PRs. Obtain publication approval after showing the proposed branches, files and checks.
Implementation begins after the plans land; local drafts are not described as landed.
2. Add regression and benchmark harnesses on the recorded baseline revisions before changing
production behavior. Record pre-existing failures separately. For CADCraft and SoundCraft,
locate the last coherent pre-bump revision; keep both the current broken graph and the
coherent performance baseline identified. No fabricated baseline from a failing binary.
3. Integrate the reviewed winit patch series, new W15P PRIMARY support and tests. Integrate egui
E1–E21 plus E21b outgoing IME state and E4/P2b PRIMARY integration in dependency order,
including P7 held-key pairing and P7c selection-action/hidden-child fixes, then final P8;
include only justified performance fixes. Preserve patch authorship, source links, test mappings
and removal conditions. Publish approved dependency commits before consumers reference them.
4. Port craft-libs using the selected PR 199 hunks, including `craft-ui`, egui-enabled
`craft-quit`/`craft-window` and all relevant optional features. The current shared workspace
has no binary/example targets; excluded vendor examples retain their separate disposition.
Add narrowly justified shared helpers and their
regressions. Port `craft-ai-egui` in parallel once its dependency contracts are fixed.
5. Port each application in its own reviewable PR series. Atomic compatibility changes include
manifests, lockfiles, source adaptations and harness changes; never land an intermediate
dependency bump that leaves main unbuildable. Eleven craft-ui consumers adopt the capture
helper from CL-1 + CL-2F in that atomic stage; DesignCraft/GridCraft explicitly pilot the
reviewed CL-2 prototype before CL-2F. Only Deck/Fig/Art Native use local capture adapters.
Non-capture helpers may follow separately when compatible. Required IME/bidi adapters
belong in the first fork pin; later language-policy and expanded coverage PRs stay focused.
6. Run per-app functional, visual, platform and performance acceptance, then a fresh independent
adversarial implementation review at the exact candidate revision. Fix findings and re-run
affected checks. Perform a final suite-wide dependency and capability audit before rollout.
Consumers can be implemented in parallel after the shared contracts stabilize, using separate
source checkouts, build resources and test VMs. Start integration with ArtCraft Launcher plus
CADCraft/SoundCraft to expose graph/API problems, then the office apps and complex canvas/media
apps. This order is risk management, not permission to skip a later app or its platform checks.
FilmCraft/EffectCraft cross-repository pins require an explicit reverse-dependency check.
Each push and each new PR still requires the user's explicit approval for that publication.
Implementation authorization does not authorize pushing, merging, releasing or closing other
contributors' issues. Public descriptions contain the problem, change and validation, without
agent/model/tool signatures. Contributor metadata remains username-only.
## Dependency and vendoring acceptance
- Record manifests and lockfile package IDs, sources, versions, revisions and features for every
root and nested workspace. Inspect target-specific, dev and build dependencies, not just the
default executable. Parse `cargo metadata` and `cargo tree` under native and wasm feature sets.
- Every selected egui family crate, including tests, renderers, `epaint_default_fonts` and
`egui_system_font_provider` when used, must resolve from the intended immutable fork revision.
A package version match alone is insufficient: registry and git packages have different identities.
- Root `[patch]` sections must exist where resolution occurs; a dependency's patch section is
ignored by its consumer. Verify fork-specific APIs under real consumer roots. Align craft-libs
revisions wherever shared public types cross crate boundaries; do not churn unrelated pins blindly.
- Resolve features for every accepted native/wasm/package-test root with `cargo tree -e features`.
Egui 0.37 has no default `default_fonts`; eframe's default feature set is different. Each root
declares its bundled/provider policy and passes a no-tofu fixture without relying on another
workspace member's feature unification. Check clipboard, accessibility, color-font and renderer
features explicitly. All-family source audits include optional lock-only crates such as `egui_glow`.
- Record the full coherent version/source set in a canonical manifest and check all consumer
roots against it in CI. Tooling must catch a missing patch, duplicate egui/winit, an old transitive
`craft-ui`, a moving branch dependency, and a machine-local path. Test these failure cases.
- Compare PdfCraft's vendored winit and egui-wgpu fixes against the selected fork semantically,
including fixes added since the old audit. Transfer regression coverage before deleting only
superseded vendors. Preserve krilla, hayro (including hayro-cmap), lopdf, vello and VectorCraft's windows-link patches unless a
separate comparison proves a migration-related replacement. Review open Android/IME/Wayland
vendor work as evidence; do not merge unrelated changes or close its PRs as a side effect.
- A clean checkout must fetch every approved revision and pass `--locked` without local overrides.
Before merge, recheck remote heads for drift and rebase/revalidate affected source and evidence.
The initial root-patch disposition is exhaustive for the 19 recorded roots. Nested workspaces,
vendored manifests and command-line overrides remain separately enumerated in the static
inventory and must be checked under their actual resolution roots:
| Root | Existing override | Migration disposition |
|---|---|---|
| DesignCraft | winit `storytold/winit@3d51077fd1a966f84df9ef287839dc84fd5b4ca7` | Replace with final reviewed W14/W15P/W27-compatible storytold revision; preserve cancelable-quit regression |
| PdfCraft | `vendor/winit`, `vendor/egui-wgpu` 0.36.2 | Transfer all window/GPU fixes and guards centrally, prove equivalence, then remove these overrides/vendors atomically |
| craft-libs | local krilla, hayro, hayro-interpret, hayro-syntax, hayro-jbig2 | Retain PDF/printing fixes, provenance and tests |
| DesignCraft | krilla and four hayro packages at craft-libs `89dcbb3060377a26c99a6d80caa16084366704c4` | Retain independently of the UI pin |
| DeckCraft, WordCraft | krilla at craft-libs `c348732…` | Retain exact existing revision from each manifest |
| EffectCraft | hayro-syntax at craft-libs `ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d` | Retain |
| VectorCraft | hayro-syntax, hayro-interpret, hayro-jbig2, krilla at the same `ca50d63…` | Retain; also preserve Windows 7 packaging's command-line windows-link path override and its dedicated test |
| PdfCraft | local hayro, hayro-interpret, hayro-syntax, hayro-cmap, hayro-jbig2, lopdf, vello_common | Retain unrelated PDF fixes |
| Other 12 roots | No root patch at the recorded source | Add required immutable family/winit overrides at the actual consuming roots |
Candidate fork branch dependencies are research inputs; final manifests use approved reachable
storytold revisions containing the completed series. CI checks actual package IDs, not textual
version matches. Negative fixtures inject DesignCraft's old winit override, PdfCraft's old path
override, a missing family patch and an unapproved moving branch; each must fail the graph gate.
Inspect inverse trees for winit and egui-wgpu under every selected root/target/feature set.
The canonical inventory contains all 19 sources, including ArtCraft Native, AI UI and winit;
gate inventory/ledger source-set equality and reject missing or unreadable tracked manifests.
The synchronized `dependency-inventory.json` at this planning workspace root and in the task's
`ssd2/task-evidence/egui037-migration-20261022` directory contain the same 19-source inventory;
record its hash in each gate, compare against Git's tracked manifests, and reject stale copies.
## Shared components: justified boundaries
Screenshot completion has 14 consumers: CADCraft, DeckCraft, DesignCraft, EffectCraft, FigCraft,
FilmCraft, GridCraft, LightCraft, PdfCraft, PhotoCraft, SoundCraft, VectorCraft, WordCraft and
ArtCraft Native. Eleven creative apps already depend on craft-ui and will adopt its new helper after CL-2F
(the DesignCraft/GridCraft pilots first use the reviewed CL-2 prototype).
DeckCraft, FigCraft and ArtCraft Native do not depend on it and keep small local adapters
with the same tested contract. Exact manifest-key inspection distinguishes `craft-ui`
from each app's own `*-ui-egui` crate name. The launcher has no screenshot consumer.
egui 0.37 replaces screenshot input events with a one-shot callback.
The screenshot command itself requests a hidden paint; callback arrival does not. A request
queued in app logic but issued only in UI, including every remaining settling frame, must
send `RequestPaintWhileHidden` to reach issuance. A wake alone may run hidden logic only.
The callback receives only `Arc<ColorImage>` and does not schedule another repaint itself.
See the [release callback implementation](https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/data/screenshot_callback.rs).
Keep app request IDs, output paths, serialization, timeouts, settling policy and reply schemas
in each app. A shared mechanism may provide a bounded, cancelable per-request completion slot,
capture correlation and viewport-aware wakeup. The callback must not encode PNGs, write files,
run commands, hold a long lock or borrow mutable app state. After timeout/close, a late callback
must release its image without sending another reply. Limits must bound pending images and
requests; counter wrap and a reused viewport must not bind an old capture to a new document.
The selected starting design is one completion slot per admitted request, with the callback holding
only a weak reference to that slot. Several app plans need the same one-result lifecycle, while
their request records already own IDs, deadlines and reply routing. Keeping the slot inside that
record gives correlation without a second global token map. Dropping/canceling the record releases
its image; a late callback cannot retain the app or produce a file/reply. Keep any context/wakeup
handle in the live slot, not strongly captured by a callback that may itself be queued in that
context. Release the slot lock before waking the owning viewport. The app retains admission limits
and must bound aggregate pending image memory, not merely the number of results in each slot.
The default capture admission policy is 4 pending requests, 128 MiB image bytes per request
and 256 MiB aggregate reserved in-flight/completed bytes; apps may select stricter explicit
limits (CADCraft uses its documented smaller budget). Use checked dimensions × 4 arithmetic,
reject excess admission without evicting earlier requests, and measure transient readback/encoder
RSS separately. Callback arguments contain pixels only. The default adapter records target-UI
logical content/screen bounds and a geometry epoch at issuance, then rejects/retries any intervening
resize/DPI/zoom/surface epoch change before save, even a change back. With an unchanged epoch,
derive pixel scale from returned image dimensions and verify aspect/rounding. Do not assume
Wayland `inner_rect` exists or that callback arrival supplies capture metadata. A renderer-specific
frame annotation is an alternative only after an actual tested API is established. Distinct-image
correlation tests manually complete callbacks in reverse order; kittest's ordinary renderer shares
one image across callbacks, so real two-window pixels require native evidence. Before
baselines, reconcile every app admission table to these defaults or stricter limits; no
app silently retains 8/16 requests or larger per-image/aggregate budgets.
Stress concurrent captures, cancellation, shutdown, delayed GPU readback, out-of-order completion,
memory and hidden-window wakeup. Compare a bounded nonblocking channel prototype with explicit
overflow/error handling if these tests or measurements expose a weakness in the slot design.
Do not encode/save images on the callback thread or add a generic UI compatibility framework.
Applications without craft-ui need not adopt the entire crate solely for this helper. Prototype
the shared API in CL-2, then integrate DesignCraft's multiple viewports and GridCraft's single
window against an approved provisional revision. Review those adapters before CL-2F freezes the
API. This is an initial topology check, not a reduction of scope: all eleven shared
consumers and the three local adapters must pass their capture/cancel/close/DPI tests.
The DockArea direction policy is explicit and opt-in: custom RTL tab/control chrome,
hit tests, drag insertion and keyboard traversal share one geometry calculation;
serialized physical splits, rectangles and logical tab order are preserved. The
shared-library plan defines the builder and tests. No app may reverse saved panel
order to simulate RTL.
Other candidates are a tested wheel-event adapter that preserves `MouseWheelSource`, and focused
font/direction/IME helpers with multiple named callers. Retain native widget roles/names rather
than hiding all role changes behind `Unknown`. Existing app themes, units, document models,
canvas engines and undo stacks remain owned by the applications.
## Shared regression matrix
| Risk | Required observable assertion |
|---|---|
| Callback screenshot routing | Distinct requests for two viewports receive the right pixels and exactly one reply; timeout/close/cancel prevents a late file write or reply; pending queue drains |
| UI passes and hidden logic | A forced second UI pass does not duplicate a command/undo step; background results, autosave and media continue under logic-only iterations without replaying stale input |
| Hidden parent and child viewports | With the parent occluded or overdue, open, update and close a child; the visible child receives content and close acknowledgement without restoring the parent |
| Root UI and panels | Existing Ui is passed through; no recursive `root_ui` borrow; panels reserve space in order; canvas geometry, clip and hit positions agree after resizing |
| Stable widget identity | Changing language, hiding a panel or inserting/reordering a row does not transfer focus/edit/drag state to another item; persisted workspace restores |
| Accessibility | Named inputs expose correct roles, values, selected/disabled/focus states; hidden controls disappear; tab/arrow/Enter/Space actions trigger the same commands |
| Fonts and text | Controlled licensed fonts cover fixture glyphs; no tofu, clipped combining marks or doubled bidi; logical clipboard/save/accessibility strings remain unchanged |
| Selection regression | Select text, click a toolbar/context action and verify the intended selected range is used; pointer and keyboard paths agree |
| IME/graphemes | Preedit, replacement, commit and cancel produce one intended edit/undo step; surrounding byte ranges cannot split UTF-8; emoji clusters and RTL caret/selection remain consistent |
| Scroll and zoom | Wheel, trackpad and momentum preserve source, phase and modifiers; overscroll uses the correct clamped/unclamped coordinate for painting and hit tests; zoom stays anchored |
| Input/native platform | Child focus/close/reopen, mixed DPI, native pickers, clipboard/PRIMARY, file drop and pen/tablet behavior are observed in real native workflows |
| Wayland PRIMARY | Password selection is never offered; unchanged selection does not reclaim ownership; pointer- and keyboard-triggered ownership (including keyboard-only focus), fresh triggering serials per seat/origin, owner/seat changes, empty/absent offers, self-paste and slow/canceled transfers work under wlroots, KWin and Mutter with supported capabilities recorded |
| GPU/resources | Surface limits/configuration errors do not panic; textures are updated/freed correctly after DPI/resize/device errors; color emoji and nearest/linear/wrap sampling stay correct |
| Persistence | Edit, save, close and reopen a synthetic document plus layout; compare document meaning, selection/workspace expectations and undo semantics |
Use deterministic synthetic fixtures and explicitly pinned fonts/DPI/theme/viewport. Test small
windows and 1x/1.5x/2x scaling in both themes; add RTL/CJK/combining/emoji cases where the app's
UI or document text uses them. Keep geometry and semantic assertions alongside rendered images.
Review each intended image change; never bulk-refresh goldens to make CI pass. Demonstrate detection
with bounded negative controls (wrong callback/viewport association, missing accessible name, wrong clip/offset,
dropped callback or forced duplicate command), then remove the deliberate defect.
Rerun's [testing guide](https://github.com/rerun-io/rerun/blob/42677ef9b096abbce742f0929dea58bb75f740ad/TESTING.md)
provides relevant prior art: drive viewer interactions through the harness and examine image diffs.
Apply that principle to Craft workflows; direct state setup is for fixtures, not a substitute for
clicking the changed control. Use egui_kittest's strict missing-name and missing-glyph behavior.
Native IME, focus, system dialogs and compositor behavior still need native evidence.
W15/W15P use bounded asynchronous read/write transfer handles, a total monotonic deadline,
byte/request/worker limits and cancellation. The candidate's two-second inactivity poll timeout
is not a total deadline. Egui E4/P2b routes completions only to a live paste request with unchanged
input owner/generation; focus changes cancel it. Test slow trickles, oversized offers, cancellation
and concurrent peers while measuring event-loop responsiveness. Serial selection uses the actual
triggering pointer/keyboard event and seat; no arbitrary first-seat fallback.
Text conformance fixtures must match the data in the selected dependencies: the candidate locks
`unicode-bidi` 0.3.18 (Unicode 16) and `unicode-segmentation` 1.13.3 (Unicode 17). Test the fork's
layout/editing adapters against versioned [bidi](https://www.unicode.org/reports/tr9/) and
[grapheme](https://www.unicode.org/reports/tr29/) cases; do not silently use the latest data as the
locked contract. For [Wayland text-input-v3](https://cgit.freedesktop.org/wayland/wayland-protocols/tree/unstable/text-input/text-input-unstable-v3.xml),
assert the full preedit/deletion/commit sequence, valid UTF-8 byte boundaries and freshly updated
outgoing surrounding text. Cover deletion-only batches, focus/reset/serial changes and two windows.
A [similar Mozilla report](https://bugzilla.mozilla.org/show_bug.cgi?id=2080478) motivates the
stale-surrounding-text case; it is not evidence that this fork has that bug.
## Performance protocol and stop conditions
Capture baseline before production edits. Rebuild baseline and candidate with the same compiler,
profile, features, fonts, fixture, renderer/backend, driver, viewport and DPI. Record full Git and
lockfile identities, CPU/GPU/OS, raw samples and any shared-host load. Use separate allocations,
then alternate execution order to reduce thermal/cache bias. Never benchmark concurrently with
builds or another noisy workload and claim a small improvement.
Use at least 10 warmups and 30 timed iterations for steady operations, and at least 20 fresh
processes for startup. For frame distributions capture at least 1,000 frames and report p50/p95/p99;
do not claim a reliable p99 from 30 samples. Report median/p95 for discrete operations with the
sample count, a paired confidence interval where suitable, peak RSS, allocations/texture uploads,
idle CPU/repaint count, and cold versus warm caches separately. Preserve startup/font-provider
timings and raw/compressed WASM sizes. Software renderers and real GPUs are separate results.
Preliminary gates, to be frozen in baseline reports before candidates are measured: investigate
a repeatable >5% median or >10% p95 latency regression, >5% peak-RSS increase, any new idle
busy-loop or audio underrun, and any loss of the app's existing absolute performance budget.
For sub-millisecond measurements require the relative increase also exceed 0.05 ms or corroborate
it with stable instruction/allocation counts. Reject noisy comparisons as inconclusive and rerun;
do not expand tolerances after seeing a failure. Any exception needs a documented tradeoff and
review before acceptance. Existing stricter repository budgets win.
These sample counts are minimums and the investigation thresholds are maximum tolerances across
the suite: an app may tighten them, never silently loosen them. Any proposed exception must be
documented and approved before candidate measurements; an exploratory small sample is not tail
acceptance. Reconcile every app's table against this contract before baseline capture. In
particular, the earlier FilmCraft five-warmup, EffectCraft three-warmup/relaxed startup and winit
three-warmup proposals are superseded: use the shared floor and investigate at the shared latency
thresholds unless an explicit pre-measurement exception has been approved. FilmCraft’s old
absolute +20 ms startup allowance is also superseded by the shared relative thresholds.
The first baseline report and every investigated regression include an equivalent stock-0.37
control alongside 0.36 and the selected fork. Fork-only app code need not compile unchanged on
stock: use the same operation in a pinned minimal adapter/harness and explain any remaining
semantic differences. Record all three source/lock identities; unavailable equivalence remains
an attribution limitation, not evidence that an upstream regression is unavoidable.
App plans select meaningful workloads: canvas pan/zoom/selection and text editing; spreadsheet
scroll/recalculation; timeline scrub/playback and waveform updates; slide transition/presenter
windows; thumbnail grids and RAW previews; PDF page navigation/annotations/search; launcher catalog
and download progress; AI job history/progress. Include idle and first-use paths, not just engine
CLI throughput. Rendering hashes and document state must remain correct during measurement.
Three upstream reports require targeted reproduction on our own fixtures and environments:
- [#8731](https://github.com/emilk/egui/issues/8731): plain-text tessellation regression associated
with color-glyph handling. Compare unchanged 0.36, stock 0.37 and selected fork. If confirmed,
evaluate a row-level fast path and cached color-glyph metadata while testing mixed/color rows.
- [#8736](https://github.com/emilk/egui/issues/8736): DX12 FXC shader compilation cost even when
predictable filtering is disabled. Measure renderer construction separately from first frame.
Compare an override-constant design with semantically equivalent wrap arithmetic only if needed;
clamp-only output is not a valid fix for Repeat/MirroredRepeat.
- [#8512](https://github.com/emilk/egui/pull/8512): a system-font startup-cost report in the
discussion. Measure discovery separately, preserve glyph coverage, and compare current app
loaders with explicitly configured system providers before enabling a new default everywhere.
These are external reports, not reproduced measurements or promises of a speedup.
Known functional reports also require explicit decisions before the fork is frozen:
- [#8763](https://github.com/emilk/egui/issues/8763) and unmerged
[#8764](https://github.com/emilk/egui/pull/8764): reproduce selection loss when clicking an
outside toolbar/context action. The fork owns the selected fix, with pointer and keyboard
regressions proving that the intended range reaches the action in that pass. A necessary app
workaround must preserve the range and undo semantics and have its own removal condition.
- [#8748](https://github.com/emilk/egui/pull/8748) is closed and unmerged, not behavior in the
selected release. The fork owns a tested hidden-parent/deferred-child solution; exercise
overdue redraw with unknown visibility as well as actual occlusion/minimization. Do not infer
phase from `visible()`, or describe ordinary logic-only calls as full UI passes.
- [#8657](https://github.com/emilk/egui/issues/8657) and
[#8428](https://github.com/emilk/egui/pull/8428) inform the clipboard/PRIMARY matrix;
[#8577](https://github.com/emilk/egui/pull/8577) informs logical-text/bidi regressions.
Their status is evidence to inspect, not permission to assume a reported behavior is fixed.
For a reproduced required workflow failure, a reviewed fork fix or tested app workaround is
required. An explicit deferral records an unresolved rollout gate; it is not a passing result.
## Platform evidence, review and rollback
Each app's required gates include its existing fmt/lints/tests/assets/layers/wasm commands plus
selected feature checks that default CI misses. Record actual selected test counts and failures.
Linux covers native X11 and Wayland with compositor identity; Windows uses a uniquely owned
baseline clone and visible edited/saved/reopened workflows. Browser workflows must run, not merely
compile to wasm. macOS IME, Metal/GL, keyboard layouts and native termination need real macOS
evidence; cross-compilation is not acceptance. FreeBSD/Android rows follow the app's support claims.
Unavailable environments remain explicit rollout blockers, not implicit passes.
Current readiness and allocation plan (recheck immediately before each wave):
| Wave/environment | Current evidence | Required preparation and capacity gate |
|---|---|---|
| Static planning | 19 owned checkouts; no tests/builds run | No build reservation needed; preserve review artifacts |
| Shared/fork CPU and headless regressions | No qualified runtime yet | Estimate one baseline/candidate build's growth from dependencies, reserve through task.py, execute sequentially and preserve paired binaries; defaults do not fit current capacity |
| Linux X11 | No inherited DISPLAY; Xvfb/window manager absent at inspection | Provision an isolated display/window manager, record versions and qualify native focus/clipboard; label virtual-display/software results accurately |
| Linux Wayland | No inherited WAYLAND_DISPLAY; Weston/Sway/KWin absent | Provision the compositor/input-method environments required by the matrix; qualify keyboard/IME/PRIMARY protocol behavior and identify unsupported capabilities |
| Linux GPU | Parent can access i915 8086:3E96 renderD128; worker sandbox may hide it | Managed renderer/device probe must identify actual driver/backend; access alone is not GPU acceptance |
| Windows | Sealed baseline uses Basic Render Driver; 128 GiB default clone cannot currently fit | Estimate guest source/build/evidence growth; allocate a unique full clone if capacity permits or a baseline-backed overlay with an adequate reservation (at least 10 GiB); otherwise block this wave |
| Browser | Wasm compile is not runtime evidence | Provision/pin browser and WebGPU/WebGL backend; run interactive fixtures and record browser/adapter identity |
| macOS and physical Windows/audio | No qualified environment in this task | Obtain real platform access and owned execution/evidence before those gates pass; software VM results do not qualify these rows |
The allocator snapshot had about 23.6 GiB and 37.7 GiB allocatable on the two separate drives;
they cannot be combined for one target. Overlay creation is already authorized by the standing
workflow; its budget must cover expected growth, not merely the minimum. Per-wave reservations
are set from that estimate and current capacity before submission. Do not invent a fixed small
budget now to claim all platform gates are ready.
All new host tests/builds and owned-VM commands use the managed run/status/finish workflow.
Keep exact source, evidence and resource identities; export logs, screenshots, fixtures, hashes,
source patches and deliverables before cleanup. No existing job/VM is adopted or stopped.
Schedule builds and VMs from current allocator capacity, not from the number of available review
workers. At planning inspection, both drives together had less than one default 64 GiB host-build
reservation available after existing reservations. Before each validation wave, inspect capacity
and budget each owned task from its expected growth; do not lower an estimate simply to pass the
guard. Start only the workloads that fit, retain paired baseline/candidate executables outside
disposable targets, and finish a resource only after its required reviews and evidence are complete.
Use a smaller reservation only for a demonstrably smaller workload. Resize before substantial
growth. If a required build or clone cannot fit, preserve its records and report the capacity gate;
do not reclaim another task, move an active allocation or bypass the allocator. Keep performance
measurement sequential even when correctness builds can safely run concurrently.
Every plan and every implementation gets a new independent reviewer with a bounded assignment.
Reviewers inspect source, tests, dependency identities and negative cases; a passing build or an
author's own checklist is insufficient. Track finding, severity, evidence, resolution, recheck and
reviewed hash. No unresolved correctness finding is silently downgraded to a future task.
The authoritative ledger records all 19 repository plans plus this suite document separately:
current hash, review-request ID, reviewed hash, verdict, finding resolutions and fresh closure
review. Preserve earlier drafts and reports. A changed document invalidates its earlier approval;
implementation remains blocked until all 20 current documents have accepted reviews and their
documentation PRs have landed. The evidence copy of the ledger is a synchronized snapshot.
Rollback restores the entire previous coherent manifests/lockfiles/source set, not just an egui
version string. Keep prior dependency revisions reachable. Avoid persistence format changes for
this upgrade; if one is necessary, specify backward compatibility and preserve unreadable data.
Do not release a mixed suite dependency graph or delete old source/evidence during cache cleanup.
At each dependency/app integration stage, rehearse restoration of the previous coherent source,
lockfiles and settings using preserved source refs and the baseline executable. Reopen the saved
fixture and verify the old behavior before accepting the stage. If rebuilding is necessary, use
a separate managed allocation. Keep the old executable/source identities reachable through the
rollback window; restoring only an old lockfile does not constitute the rehearsal.
## Local implementation clarification — 2026-10-23
The user explicitly corrected the publication-first workflow: all reviewed plans stay local while implementation proceeds. Published dependency reachability remains a release requirement, not a condition for local prototypes, source ports, tests or reviews. Local development may use isolated candidate worktrees and explicit local Cargo patches; no machine-local path enters a publication draft.
Canonical integration sources were refreshed for all19 repositories; sixteen advanced by220 commits in total. See the task evidence `REMOTE-REFRESH-SUMMARY.json`, canonical ref/diff snapshots and the per-repository local-update appendices. Keep the original review source refs for historical comparison. Current-main paired measurements must use refreshed integration sources so unrelated security/performance work is not attributed to the toolkit migration.