diff --git a/Cargo.lock b/Cargo.lock index 48f04b90..e7b6efb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 45ff1948..39f58e79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/ui/README.md b/crates/ui/README.md index f093a549..f7a7705d 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -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::()` 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 diff --git a/crates/ui/examples/ui_hotpaths.rs b/crates/ui/examples/ui_hotpaths.rs new file mode 100644 index 00000000..46692744 --- /dev/null +++ b/crates/ui/examples/ui_hotpaths.rs @@ -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, + warmups: usize, + warmup_elapsed_ns: u64, + next_frame: usize, + mesh_hash: u64, + bodies: BTreeMap, + shapes: usize, + vertices: usize, + indices: usize, + set_images: usize, + set_bytes: usize, + free_images: usize, + repaint_frames: usize, + passes: Vec, + galleys_after_warmup: usize, +} + +fn main() -> Result<(), Box> { + 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::>()}, + "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 { + (0..count).map(|i| 40.0 + ((i * 17) % 80) as f32).collect() +} + +fn toolbar_widths(count: usize) -> Vec { + (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), Box> { + 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> { + 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> { + 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> { + 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 { + Node::Tabs { panels: vec![panel], active: 0 } +} + +fn tree(panels: &[u32], depth: usize) -> Node { + 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 { + 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 = (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> { + 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> { + 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, bool), +) -> Result> { + 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, floating: &BTreeSet) -> Result> { + 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> { + let owned = ctx.data(|data| data.get_temp::>(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, + frame: usize, + paint: &mut impl FnMut(&mut egui::Ui, &mut BTreeMap, bool), +) -> Result> { + 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, + bodies: &mut BTreeMap, + style: &DockStyle, + collect: bool, +) -> Option { + 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, + error: Option, + dock_rect: Rect, + left_rect: Option, + central_rect: Option, +} + +impl Scene { + fn render(&mut self, layout: &Layout, ui: &mut egui::Ui, bodies: &mut BTreeMap, 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> { + 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 = 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, 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, 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}, + })) +} diff --git a/crates/ui/src/capture.rs b/crates/ui/src/capture.rs new file mode 100644 index 00000000..009bac94 --- /dev/null +++ b/crates/ui/src/capture.rs @@ -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::()` + /// 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, CaptureError>), + Taken, + Cancelled, +} + +struct Shared { + state: Mutex, + /// 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, +} + +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, 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 { + 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) -> 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, image: Arc) { + 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, max_image_bytes: usize) -> Result, 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::()).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; diff --git a/crates/ui/src/capture/tests.rs b/crates/ui/src/capture/tests.rs new file mode 100644 index 00000000..84459913 --- /dev/null +++ b/crates/ui/src/capture/tests.rs @@ -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 { + Arc::new(ColorImage::filled(size, color)) +} + +fn observe_repaints(ctx: &Context) -> Arc>> { + 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() {} + assert_send_sync::(); + assert_send_sync::(); +} + +#[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); +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index fcb60829..03e667a0 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -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; diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs new file mode 100644 index 00000000..22db5592 --- /dev/null +++ b/crates/ui/src/text.rs @@ -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, width: f32, mut align: impl FnMut(&PlacedRow) -> Align) -> Arc { + 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 { + layout_text("Short\nA longer line\nEnd", 1.0, Align::Min) + } + + fn layout_text(text: &str, ppp: f32, halign: Align) -> Arc { + 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::>(), 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)); + } +} diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 00000000..4e72a2ab --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1185 @@ +# egui 0.37 migration plan for craft-libs + +Status: local implementation and immutable dependency preparation, 2026-10-24. Reconciled on merged +PR 199 (`3832c47ef68a0e6214f2c1e85f3526b6adcb686e`). Screenshot completion and read-only text +alignment helpers, regression coverage and the headless performance harness are implemented. +The root manifest and lock select the reviewed egui fork +`173cc7375d35dc5fbc5b5d0994fdf787f547c5d1` and winit fork +`ae48c756a3b81125eb796c0cc6dccf60990d1181` using canonical GitHub URLs. + +Current checks: canonical lock/source audit passed; all 118 craft-ui tests passed (112 unit, +1 docking and 5 visual tests), strict craft-ui all-target clippy passed, and workspace formatting +passed after applying rustfmt. These are local mirror and package checks. Full workspace tests +and clippy, platform/native acceptance, accepted comparative performance measurements and a clean +remote fetch after approved publication remain separate gates. The harness is not a performance +acceptance result. + +No publication approval exists. Every future push or new pull request needs explicit human review +of the exact commit range and validation first. The baseline tables and detailed work plan below +retain the 2026-10-22 planning snapshot for provenance; this current-status record supersedes their +statements about pending implementation and the old PR 199 conflict. + +This plan covers only `storytold/craft-libs`. It records the shared-library work needed before the +Craft applications move to the egui 0.37 family. Application-specific UI migrations, their document +models, command routing, persistence policy and native window ownership remain in those applications. + +The [suite rollout plan](egui-037-suite-rollout.md) supplies the shared publication order, +measurement protocol and cross-application acceptance contract. Stricter repository gates apply. + +## 1. Baseline and provenance + +### 1.1 Exact source baseline + +| Item | Revision / value | +| --- | --- | +| craft-libs checkout | `49190884278f16c9a9f5823d7698a8ffb5deb062` (`Merge pull request #238 ...`, 2026-10-22) | +| older local comparison point | `b1401e3` (stale local `origin/main`; not the current remote baseline) | +| lockfile | `Cargo.lock`, format version 4 | +| current egui family | `egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, `egui_extras`, `egui_kittest` all `0.36.2` | +| current winit | `0.30.13`, crates.io checksum `a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d` | +| current accesskit | `0.24.1`; `accesskit_consumer` `0.35.0`; `kittest` `0.4.0` | +| target upstream release | egui annotated tag `0.37.0`, peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`, released 2026-10-15; **not** `main` | +| egui source candidate | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916`; integrate/review into storytold before production pinning | +| winit source candidate | `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; integrate/review into storytold before production pinning | +| storytold winit base | `craft/0.30.13` at `ffae5198770d7730e718043f035a772b7dbc3b1b`, itself based on winit tag `v0.30.13` (`e9809ef5`) | +| craft-libs PR under review | `storytold/craft-libs` PR 199, OPEN / CONFLICTING at head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; its merge into local audit branch is `ec8840af8c5fda695cf195cdd05e5ae078415c07` | + +The audit merge `ec8840a` is **not** a usable target: its conflicted `Cargo.lock` still resolves egui +0.36.2 even though the manifest and source use the 0.37 API. The PR branch itself has a 0.37.0 +registry lock. The historical audit log at +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/craft-libs.log` shows a successful +`cargo check` after the audit harness patched 11 crates to the fork path; it is a useful lead, not +fresh evidence and not a test run. Fresh validation uses `maintenance/task.py run/status/finish`: retain the owning resource, source +and evidence paths across sequential commands and use a fresh job ID for each intentional command. + +### 1.2 Dependents and feature targets in this workspace + +The root workspace has no application binaries or examples. Excluded vendored PDF +crates retain their own examples/workspaces; keep them unchanged. Relevant root targets are +libraries and their tests: + +| Crate | egui use | Features / targets | +| --- | --- | --- | +| `craft-ui` (`crates/ui`) | public components and tests | production `egui` with `default-features = false`; optional `serde`; dev `egui` with `default_fonts`, `egui_extras/svg`, `egui_kittest/wgpu`, `image/png` | +| `craft-window` (`crates/window`) | optional `decorate(egui::ViewportBuilder)` | `egui` feature; Wayland probe only on Linux/BSD; pure logic without egui | +| `craft-quit` (`crates/quit`) | optional close handling and app menu | `egui`; `menu` implies `egui`; menu/terminate code only on macOS | +| `craft-platform` (`crates/platform`) | optional deferred close and native theme hook | `egui` with `dialogs` and/or `appearance`; `appearance` only on unix non-macOS non-wasm; `browser` only on wasm | +| other crates | none, except references in documentation | no egui dependency | + +`Cargo.toml:34` is the workspace pin; `crates/ui/Cargo.toml:20-24` is the only egui test toolchain. +`docs/ui-components-audit.md:5` and `crates/ui/README.md:237` still say 0.36; both need updating after +validation, but historical source citations in the audit must not be silently rewritten. + +### 1.3 Current tests and CI + +Source-declared `#[test]` occurrences at the baseline: 728 workspace-wide, 91 in `crates/ui` +(85 in `src`, 6 integration). These counts are not proof of execution. The UI CI matrix is Ubuntu, +Windows and macOS for `cargo fmt`, clippy, `cargo test -p craft-ui` (default and all features), plus a +wasm32 `cargo check` (`.github/workflows/ui.yml:20-58`). The visual suite declares 372 control pairs, +36 deliberate negative pairs, 18 toolbar pairs, 12 palette pairs and 12 SVG pairs; every positive +pair requires exact RGBA and mesh equality (`crates/ui/tests/visual_regression.rs:185-394`). + +The repository AGENTS.md additionally requires before merge: +`cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, and +`cargo test --workspace`. Those are the minimum gates; the package-specific CI matrix and the fork +contract below add coverage rather than replacing them. + +## 2. What actually changes in egui 0.37 + +The following are released 0.37 behavior, not fork behavior. They are the only reasons `craft-ui` +needs production edits for the upstream release. + +| Change | Effect in craft-libs | Source anchor | +| --- | --- | --- | +| `WidgetType` is replaced by `accesskit::Role` (`egui::Role`); compatibility alias is deprecated | Every `WidgetInfo` constructor must use `Role`; `SelectableLabel` becomes `Button`, `Other` becomes `Unknown`, `Checkbox` becomes `CheckBox` | `egui/src/lib.rs` 0.37.0 lines ~640-655; PR #8555 | +| `Id::new` and `AsId` are deprecated; `Id::unique`, `IdSalt`, `IdSaltSet/Map` are the explicit APIs | Test IDs and any caller-provided IDs should use `Id::unique`; use `IdSalt` for scope-local salts | `egui/src/id.rs`; PRs #8534, #8586, #8590 | +| `Ui::id` is renamed `Ui::scope_id`; `UiBuilder::id` is renamed `scope_id` | Child body/header UIs must pass a globally unique scope id; tests and README examples must use `scope_id` | `egui/src/ui.rs` 0.37.0 lines ~359-383; `ui_builder.rs` lines ~63-82; PR #8542 | +| `Panel` constructors accept `impl AsIdSalt`; `CentralPanel` stays last; root `Ui` is the normal app surface | Application panel code must keep panel order and use a stable salt; craft-ui's `DockArea` is a child of that root/panel UI, not a `Context` replacement | `egui/src/containers/panel.rs` 0.37.0; PR #8592; `Context::run_ui` / `root_ui` / `run_pass` | +| `default_fonts` is no longer a default feature; eframe still enables it by default, but integrations must choose | `craft-ui` production already disables defaults; test dev-dependency must keep `default_fonts`; applications with custom fonts must verify coverage | `egui/Cargo.toml` 0.37.0; PR #8583 | +| egui 0.37 adds missing-glyph policy and system font providers; kittest panics on missing glyph by default | Tests that deliberately draw arbitrary script text must opt into `MissingGlyphPolicy::Tofu`; production should keep tofu rather than panic; custom app fonts need explicit coverage checks | `epaint/src/text/fonts.rs`; `egui_kittest/src/builder.rs` (`missing_glyph_policy`); PRs #8601, #8512 | +| `ViewportCommand::Screenshot` takes a one-shot `ScreenshotCallback`; `Context::request_screenshot` is the convenience API | Any app screenshot path, not craft-ui itself, must move from `UserData`/`Event::Screenshot` correlation to callbacks | `egui/src/data/screenshot_callback.rs`; `viewport.rs:1202-1205`; PR #8489 | +| `Response::accessible_name`, `Ui::label_inputs_by` / `name_inputs`, `Area::role`, better popup/menu trees | Accessibility labels/roles must be explicit for input widgets and icon-only controls; kittest now fails unnamed inputs | PRs #8604, #8608, #8612, #8581, #8589 | +| Style defaults changed (roomier buttons, larger corner radius); `Style` theme plugin is experimental | `DockStyle::from_ui` inherits changed defaults; visual fixtures must keep explicit geometry/colors instead of accepting a new golden | PRs #8617, #8445, #8153 | +| `Context::run_ui`, `run_pass`, `root_ui` and `Context::run_logic` define root-UI and hidden-window semantics | Tests must use the correct entry point; logic-only work must not require a painted `Ui`; `request_discard` may call a UI closure more than once | `egui/src/context.rs` 0.37.0; PR #8535; eframe `App::logic`/`App::ui` | +| PRIMARY selection support was added upstream | App clipboard behavior changes; E4 changes the backend on Wayland; PRIMARY must be tested separately from the regular clipboard | PR #8428; `egui-winit/src/clipboard.rs` | +| `egui_extras` and `egui_kittest` have their own 0.37 releases and features; `egui_plot` is not part of this repo | Do not assume every egui-family crate shares one version number; pin what is used and verify its manifest | crate manifests at tag `0.37.0` | + +The upstream 0.37.0 tag itself has no `PATCHES.md`; fork-only material is in the suite branch. The +0.37.0 tag and the fork revision must never be confused: a registry-only build gets upstream +behavior, while a suite build gets E1-E21 and the winit patches. + +## 3. Fork and winit provenance + +craft-libs #198 is a broader language-support issue/proposal, not a PR or merged +dependency. It does not authorize a wholesale locale/font roadmap in this port. +PR 199 is open/conflicting at the recorded head; its stale merge tree must not +replace current main. Current upstream report status is dated research, not proof +of fixed behavior or fresh task validation. + + +### 3.1 The suite fork + +`aiongg/egui` `craft/0.37` is based on tag `0.37.0` and merges the `craft/*` topic branches in the +order listed in its `PATCHES.md`. The final inspected revision is +`a4f9a17f64a6bae61350fb85804c9df0d9865916`. The branch and its `origin/ai-0.37` alias are movable; +all manifests and CI must use the full SHA. The older `origin/craft/0.37.0-trial` +(`59163e291c56ebe0199e191c23bf58ed157c9862`) is not the target because it lacks later language +work and is not the published suite reference. + +The fork's workspace manifest pins winit by **branch**: + +```toml +[patch.crates-io] +winit = { git = "https://github.com/aiongg/winit", branch = "craft/0.30.13-lang" } +``` + +That patch belongs to the egui workspace. Cargo does **not** propagate a dependency's patch section +into a consumer root. A consumer that only depends on `aiongg/egui` will resolve registry winit, and +E4/E19/E21 will fail to compile or silently use the wrong API. `craft-libs` and every app must add its +own `winit` patch with the immutable commit. + +### 3.2 Winit patch ledger + +Base: winit `v0.30.13` (`e9809ef5`), then storytold/winit `craft/0.30.13` (`ffae5198`) with W1-W15, +then `aiongg/winit` `craft/0.30.13-lang` (`8b35388b`) with W17-W27. W16 (Wayland pinch/rotation) is +not in this cut. + +| Patch | Commit(s) | Behavior relevant to this plan | Removal / test condition | +| --- | --- | --- | --- | +| W1 | `b357a332` | Windows mixed-DPI `WM_DPICHANGED` rect | Remove when a winit release in use has #4341; verify DPI geometry | +| W2 | `13ca693b` | Wayland text input dies only with its seat | Remove with upstream #4747; test seat removal | +| W3 | `fd104a4c` | Windows IME caret from `GCS_CURSORPOS` only when reported | Test Korean caret; remove on release | +| W4 | `3fffb7ce` | Hidden maximized windows stay hidden | Test restore; remove on release | +| W5 | `283370ce` | X11 focus replacement IME context | Superseded by W20; remove together | +| W6 | `a82b90fb` | macOS NSTextInputClient commit flow | Test composition; remove on release | +| W7 | `9b90d5bd` | macOS discard marked text when IME disabled | Test disable; remove on release | +| W8 | `6a87addd` | macOS emoji picker text as commit | Test picker; remove on release | +| W9 | `9500f25d` | macOS no duplicate Korean space | Test Korean space; remove on upstream fix | +| W10 | `4a7dd791` | macOS first Apple Korean key retry | Test first key; remove on release | +| W11 | `4df8554b` | X11 scale factor from server DPI before physical DPI | Test mixed-DPI X11; remove when upstream equivalent lands | +| W12 | `7facf001` | macOS title-bar drag reaches app | Test drag; remove when upstream equivalent lands | +| W13 | `5506b1a9` | Wayland pixel wheel/tilt/continuous reports `Moved` | Test Ctrl zoom not latched; remove on upstream fix | +| W14 | `c488d2e9` | macOS opt-in cancelable termination -> close request | Test close/quit; keep until upstream release | +| W15 | `cf73a29c`, `b05ce129` | Wayland file drops and clipboard on one per-seat data device | Required by E4; remove only with a winit release carrying #4729/#4571 and equivalent regular-clipboard/data-device tests | +| W15P | New; no commit yet | Per-seat SCTK PRIMARY on the existing Wayland connection/event queue; `platform/wayland.rs`, `wayland/clipboard.rs`, `wayland/state.rs` and seat handlers | Require bounded asynchronous read/write transfers, total deadlines, byte/request/worker caps, cancellation and focused-owner completion; pointer/keyboard triggering serials, seats/offers, empty/absent/self-paste tests on wlroots/KWin/Mutter. Remove only with an equivalent released native path | +| W17 | `2be27382` | Regression guards for W2/W3/W5-W10/W12/W13 | Keep as tests while their patches remain | +| W18 | `894bf2d7` | Windows hidden system caret at IME area | Test Sogou/Japanese IME; remove on upstream fix | +| W19 | `fa2e1b3d` | Windows `VK_PROCESSKEY` not sent as a key press | Test IME key ownership; remove on upstream fix | +| W20 | `019c95f1` | X11 one input context per window; disable resets | Test Vietnamese Telex; replaces W5, remove together | +| W21 | `23bb19a7` | X11 IME spot after 100 ms quiet | Test forwarded keys not refiltered; remove on upstream fix | +| W22 | `0816871e` | macOS caret, replacement ranges, un-composed commits | Test press-and-hold/dictation; remove on release | +| W23 | `c8cd8aa1` | macOS layout's Command table for shortcut keys | Test Dvorak-Command/V; remove on upstream fix | +| W24 | `4b5bc5a0` | `Window::reset_ime()` for all four desktop backends | Required by IME reset tests; remove on release with equivalent | +| W25 | `eb4007da` | X11/Wayland `KeyEventExtLatinLayout` | Required by E19; remove on release with equivalent | +| W26 | `2b9cd1cf` | macOS IME delete-surrounding by UTF-8 bytes | Required by E21; remove after the egui -> winit 0.31 move | +| W27 | `f40c76a2` | Wayland text-input-v3 surrounding text and delete-surrounding | Required by E21; remove after the egui -> winit 0.31 move | + +Provenance concerns: + +- Integrate and review W17-W27 plus W15P and the W27 lifecycle revision in `storytold/winit` before pinning production consumers. Retain + aiongg provenance; use the resulting full storytold commit, never a moving branch. +- W15's Wayland clipboard API and E4 are a pair. Do not take one without the other. +- W26/W27 use a fork-only delete-surrounding API shape. When winit gains core + `Ime::DeleteSurrounding`, E21 and the winit patches must be removed together; otherwise the + compiler sees two incompatible IME APIs. +- W11 changes `pixels_per_point`/DPR input. It affects tab fit, visual fixtures, screenshot pixel + sizes and any performance comparison. Record the exact winit commit in those runs. + +## 4. Audit of craft-libs PR 199 + +PR 199 is the useful starting point, not a branch to merge blindly. + +### 4.1 What it gets right + +- `Cargo.toml:34` changes the workspace egui pin to `0.37` and `crates/ui/Cargo.toml:23-24` changes + `egui_extras` and `egui_kittest` to `0.37`. +- `crates/ui/src/buttons/mod.rs`, `crates/ui/src/docking/surface.rs`, + `crates/ui/src/layout/resize.rs`, and `crates/ui/src/tabs/mod.rs` map the deprecated + `WidgetType` values to the 0.37 roles: `SelectableLabel` -> `Button`, `Checkbox` -> `CheckBox`, + `Other` -> `Unknown`. This matches egui's own mapping table. +- Test IDs move from `Id::new` to `Id::unique`; child-body IDs move from `Ui::id`/`UiBuilder::id` to + `scope_id`. These are semantic renames, not a new ID scheme. +- `crates/ui/src/docking/tests.rs` replaces the `⋮` fixture label with `…`. A read-only cmap check of the + bundled 0.36.2 and 0.37.0 Hack, Ubuntu, NotoEmoji and emoji-icon fonts finds no U+22EE in either + release; the 0.36 test drew tofu and the 0.37 kittest harness instead panics on a missing glyph. +- The branch's own lock resolves `egui`/`eframe`/`egui-*` 0.37.0 from crates.io and raises accesskit + to 0.25.1 and kittest to 0.5.0. The lock churn is large but coherent for a registry-only build. + +### 4.2 Findings and required corrections before reuse + +1. **Both the PR merge tree and audit merge are unsafe bases.** `b68de8c` + has a coherent registry lock but a stale tree relative to `4919088`: it lacks + the entire `crates/platform` crate, drops PDF/media bounds and secure font-fetch + changes, reverts settings merge behavior, docking permission guards and the + linear printer-name collision algorithm. `ec8840a` additionally has a stale + 0.36 lock. Never merge/cherry-pick either tree as the migration result. +2. **Reuse only the intended port hunks.** Apply the API changes from `8614e4a` + and README change from `1764bca` onto recorded current main. Preserve provenance, + resolve against present source, and regenerate the lock with the final reviewed + storytold fork/winit set. Include those pins atomically with source adaptation; + no stock-only intermediate consumer release is planned. +3. **README code still uses `ui.id()`.** `crates/ui/README.md:115` is documentation, not compiled, but + it is a public example and will fail under `-D warnings` if copied. Change it to + `ui.scope_id()` in the same documentation pass. The version statement at line 237 must also be + updated. +4. **The ellipsis change is not a production fix.** The production source still paints `…` and `−`; + both are present in the bundled Hack/Ubuntu fonts. The old `⋮` was only a test fixture and was never + covered by the bundled fonts; 0.36 silently painted tofu. Add an explicit glyph-coverage test so + the next font/default change is caught deliberately, instead of relying on one changed string. +5. **AccessKit role mapping needs contract tests, not just compilation.** The PR changes enum names; + it does not prove that `Role::Tab` (manually set after `WidgetInfo::selected(Role::Button)`), + `Role::CheckBox` for stack collapse, `Role::Splitter`, labels, enabled state and selection still + reach the tree. The 0.37 tree changes from #8581/#8589 make this a real regression surface. +6. **The PR does not test a hidden/minimized `App::logic`.** `craft-ui` has one UI-driven poll path + (`printing::State::show` calls `poll`) and no logic-only test. Eframe 0.37 calls `App::logic` but no + `App::ui` while hidden. The API is already public, but the contract is untested. +7. **The PR does not add a publication-compatible check.** Root `[patch.crates-io]` entries will not + travel to crates.io consumers. The packaged crate must be tested against registry egui 0.37, not + only against the suite fork. + +Decision: reuse only reviewed port hunks from `8614e4a` and `1764bca`. CL-1 uses +an explicit changed-file allow-list: `Cargo.toml`, `Cargo.lock`, `crates/ui/Cargo.toml`, +`crates/ui/README.md`, `crates/ui/src/buttons/mod.rs`, `crates/ui/src/docking/surface.rs`, +`crates/ui/src/docking/tests.rs`, `crates/ui/src/layout/resize.rs`, +`crates/ui/src/layout/tests.rs`, `crates/ui/src/printing/tests.rs`, +`crates/ui/src/tabs/mod.rs`, `crates/ui/src/tabs/tests.rs`, +`crates/ui/tests/docking_workflows.rs` and `crates/ui/tests/visual/fixtures.rs`. +Compare `git diff --name-only` against this list and review the actual allowed-file +hunks: being on the list never permits unrelated reversions. Extra files required +by a proven compatibility fix need a named reason and review before extending it. +In particular, `.github/workflows/**`, `NOTICE`, root `README.md`, +`crates/ui/src/docking/model.rs`, `crates/platform/**`, vendors and non-UI libraries +stay unchanged. Preserve linear `shown_names`, floating-group permission guards, +secure font fetches and all intervening hardening. Run +`moving_a_floating_group_needs_every_member_movable`, +`protected_group_member_blocks_floating_group_resize`, +`collisions_resolve_like_the_pass_by_pass_rule` and `a_long_collision_cascade_stays_fast`. +Root workspace/lock membership must still contain craft-platform. + +## 5. Dependency and publication topology + +### 5.1 Intended suite graph + +For suite validation, `craft-libs` should resolve one and only one copy of each egui-family crate and +one winit: + +```text +craft-ui / craft-quit / craft-platform / craft-window + -> egui 0.37.0 (reviewed published storytold SHA, derived from a4f9a17f input) + -> egui-winit 0.37.0, eframe 0.37.0, egui-wgpu 0.37.0, + egui_extras 0.37.0, egui_kittest 0.37.0 (test/consumer) + -> epaint, epaint_default_fonts, ecolor, emath (same git revision) + -> winit 0.30.13 (reviewed published storytold SHA including W15P) +``` + +The root `[patch.crates-io]` must name every used package at the same full SHA. At minimum: +`egui`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_extras`, `egui_kittest`, `epaint`, +`epaint_default_fonts`, `ecolor`, `emath`, and `winit`. Include `egui_glow` +when selected by tests/optional features; metadata determines the complete set. If `egui_system_font_provider` is enabled, +patch it too. Do not patch `egui_plot` or `egui_dock` unless a consumer actually uses them; do not +assume their 0.37 version numbers. + +Required checks: + +```sh +cargo tree -d +cargo tree -i egui@0.37.0 +cargo tree -i winit@0.30.13 +``` + +Acceptance is exactly one egui-family source and exactly one winit source, with no registry +`egui`/`epaint`/`egui-winit` copy beside the git copy. A duplicate is a failure, not a warning. + +### 5.2 Publication reachability + +`craft-ui`'s production manifest must remain compatible with **stock** egui 0.37.0. The fork-only +behaviors are in dependencies, not in craft-libs source. This is deliberate: + +- it keeps `cargo package` usable; +- it lets a consumer choose stock egui 0.37 or the suite fork; +- it prevents a fork-only API (for example `Style::right_to_left` or + `RawInput::latin_layout_chars`) from leaking into a published shared crate. + +Add an isolated `ci/registry-egui` workspace (its own `[workspace]`, not a member of the root +workspace) that depends on `crates/ui` by path without any patch. Run its clippy/tests against +registry egui 0.37.0. Then run `cargo package -p craft-ui` and inspect the generated manifest and +`.crate` contents. A machine-local path patch, an aiongg branch name, or a hidden winit dependency in +the packaged manifest blocks release. + +Do not put path overrides into the published manifest. A consumer's `[patch.crates-io]` is the +supported place for the suite fork and winit; document that in the README compatibility section. +The suite's `Cargo.lock` is a lock, not a publication promise. + +### 5.3 Versioning + +`craft-libs` currently versions every crate `0.1.0` from the workspace. This migration ships as a +new immutable git revision with explicit compatibility notes: egui-facing public types change +identity, so every consumer must move coherently. Do not bump every unrelated workspace crate. +Publishing a crates.io release is a separate release operation requiring a semver/version plan; +it is not authorized or required by the git-based suite migration. + +### 5.4 Vendors and nested workspaces + +The only vendored packages are PDF readers/writer, not egui: + +- `vendor/krilla` (0.8.2), `vendor/hayro` (0.7.1), `vendor/hayro-interpret` (0.7.0), + `vendor/hayro-syntax` (0.7.2) and `vendor/hayro-jbig2` (0.3.0). +- Provenance and patch ledger: `vendor/README.craft-reader.md`, transferred from PdfCraft + `7437bde378f43856eada40af25b983812e615e8a`; each source manifest records blob and SHA-256. +- Equivalence decision: keep the 0.7 backend and its documented compatibility patches. They are not + replaced by the egui migration. Do not update, rebase, delete or re-vendor them as part of this + work. +- Removal/test condition: only a separate PDF backend migration may change them; their existing + `cargo test -p craft-pdf --all-features` and `scripts/test-pdf-backends.sh` evidence remain the + gate. The root `[patch.crates-io]` must keep these entries while `craft-pdf` uses them. + +`crates/pdf/read-only-contract` is a nested workspace with its own `Cargo.lock` and a single +`hayro-syntax` patch. It does not depend on egui and is unaffected by the workspace egui lock; do not +edit its lock as part of this migration. There are no vendored egui, eframe, egui-winit or winit +sources in this repository. + +## 6. Root-`Ui` and lifecycle design + +### 6.1 Boundaries + +craft-libs must not grow a blanket `Context` compatibility layer. The correct boundaries are: + +- **Application lifecycle:** eframe owns `App::logic` and `App::ui`. `logic` runs before each + UI pass, possibly repeatedly in one frame, and in hidden mode when a repaint + is requested; `ui` may not run at all while hidden. Code that must + drain queues, service print jobs, autosave, or decide a close must live in `logic`. +- **Root UI:** `App::ui` receives the root `Ui`. `craft_ui::docking::DockArea` and every other component + take `&mut Ui`. They must never call `Context::root_ui` from inside their own rendering; that would + panic when the root UI is already borrowed (`Context::root_ui` docs at 0.37.0). +- **Painted output:** `DockArea::show` returns `DockOutput` actions and does not mutate the layout. + The app applies them after rendering. This remains the command/undo/persistence boundary. +- **Hidden/minimized:** no component may rely on a paint to make progress. `printing::State::poll` + already takes `&Context` and can run in `logic`; `show` is a fallback. Background receivers, + autosave timers and playback must be app-owned and continue in `logic`. Pending + work must arrange a wakeup with `request_repaint`/`request_repaint_after`; hidden + polling is not a continuous scheduler. Test repaint-driven delivery explicitly. +- **Multiple passes:** `Context::run_ui` can call its closure more than once after `request_discard`. + Both hosts call logic/UI inside that closure. Queues/actions must be consumed + once and deadlines use monotonic time. Count observable effects across all passes; + do not hide duplicates by counting only the final pass or altering the test host. + After a completed run_ui the pass index is zero, but events/time remain stale in + run_logic. Fresh input belongs to the known UI phase, including forced hidden paint. + +### 6.2 Preserve lazy printing and hidden result delivery + +Current behavior: + +- `printing::State::show` calls `refresh` on first display and `poll` every displayed frame + (`crates/ui/src/printing.rs:205-209`). +- `poll` drains discovery/submission receivers and calls `request_repaint_after(100 ms)` while work is + pending (`printing.rs:162-201`). +- WordCraft and GridCraft already call `poll`/`poll_printing` from `logic`, and GridCraft also calls it + from `ui`; repeated polling is currently harmless because `try_recv` consumes once. A window + that has never opened print should not start printer discovery merely because `logic` runs. + +Keep `refresh` as the explicit discovery trigger and `poll` as the idempotent receiver drain. +Retain `show`'s lazy first-display behavior. Apps may trigger discovery from the print-open action +before the page is drawn, then poll pending work in `logic` while hidden. Do not add an eager +`tick` API or new startup process/IPC work as part of this migration. Change production code only +if a regression demonstrates a missing transition. `Settings`, `Feedback`, `begin`, `ready`, +selection and job ownership stay unchanged. + +Contract tests: + +1. `print_poll_is_lazy_without_open`: run repeated `poll` calls through `Context::run_logic` before + any print action; assert no discovery worker starts and no periodic wakeup is requested. +2. `print_result_arrives_without_ui`: trigger `refresh` once, send a fake printer list and run + `poll` through `run_logic`; assert printers/feedback update without a `Ui`. Subsequent `show` + and discarded UI passes must not start another worker or consume a result twice. +3. `completed_work_requests_no_more_repaints`: after a submission result is delivered, run 60 + `run_logic` passes and assert no `request_repaint_after`/immediate repaint is requested by + `State`. +4. `queue_default_and_selection_survive_logic_only_poll`: assert a still-present queue remains + selected and a removed queue cannot be submitted. + +### 6.3 Autosave, undo and side effects + +No autosave, playback or undo engine lives in craft-libs. The migration contract for consumers is +therefore explicit: + +- queue drains, autosave, audio/playback polling and save-file completion callbacks go in + `App::logic`, not `App::ui`; +- painting only presents state; it must not be the sole trigger for a save/undo transaction; +- `DockArea` action output is applied once, after rendering, through the app command path; +- any `Context::request_discard` path must be idempotent; tests use a call counter to prove exactly + one committed action or one undo step. + +These are app acceptance criteria, not claims that this repository's tests can prove application +behavior. + +## 7. Component-by-component migration + +### 7.0 Shared-helper policy + +The 0.37 API renames are direct calls, not a reason to build a compatibility layer. Apply this rule: + +- **No wrappers** for `Id::unique`, `Role`, `Ui::scope_id`, `ScreenshotCallback`, + `Context::set_language`, `Response::accessible_name` or `Context::run_logic`. Applications call the + egui API; craft-ui exposes only component-specific semantics. +- **Justified mechanism:** a bounded-lifetime capture completion slot, reviewed with two concrete + existing craft-ui consumers; section 7.8 keeps request IDs and application policy outside it. +- **Existing print API:** retain lazy `refresh` and idempotent `poll`. No eager discovery helper. +- **Docking direction:** retain the persisted first/second split and tab model. Use an explicit + opt-in DockArea direction policy from section 7.7, with matching hit coordinates; + no hidden global mirroring mode. +- **Not shared:** fonts, themes, localization catalogs, autosave, undo history and native pickers. + They remain application-owned, as the existing component audit requires. + +### 7.1 IDs and scopes + +Anchors: `crates/ui/src/tabs/mod.rs:117` (`Tab::new`), `:141` (`ui.interact`), +`crates/ui/src/docking/surface.rs:539` (body child), `:600` (header child), `:761` (floating `Area`), +`crates/ui/src/layout/resize.rs:82` (handle), `crates/ui/src/printing.rs:210` (`push_id`). + +Rules: + +- Caller-supplied widget IDs stay caller-owned and global. Use `Id::unique(("app", panel))` or an + app-owned `IdSalt`; never derive an ID from translated text. +- `Tab::new` and `ResizeHandle::new` keep taking `Id`. The public signatures do not need a new wrapper. +- `Ui::new_child(UiBuilder::new().scope_id(...))` must use a scope id that is globally unique. The + body scope is `self.id.with(("body", panel))`; the header scope is + `self.id.with(("header", selected))`; they are distinct. Do not replace them with `id_salt` unless + the child is intended to move with its parent. +- `printing::State::show` continues to use `ui.push_id(id, ...)`; the caller's `id` remains the + stable identity of the print block. +- `Id::unique` is the same hash as the old `Id::new`, so test behavior is intended to be identical. + Add a test that the same source yields the same `Id` across frames and that two calls with + different panel payloads differ. + +### 7.2 Accessibility roles and names + +Anchors: `buttons/mod.rs:84`, `tabs/mod.rs:143-148`, `docking/surface.rs:419-421,569,628,769`, +`layout/resize.rs:157-173`, `printing.rs:213-254`. + +Required mappings and assertions: + +| Control | Role / metadata | Assertion | +| --- | --- | --- | +| Icon button | `Role::Button`, `selected`, label, enabled | AccessKit role Button; `Toggled::True/False`; exact label; disabled flag | +| Tab | widget info starts Button, then node role `Tab`; `clear_toggled`; selected | Role Tab, selected, no toggled flag, label exact; duplicate labels have distinct IDs | +| Stack collapse | `Role::CheckBox`, selected = open | Role CheckBox, toggled/selected matches open, label exact | +| Resize handle | `Role::Splitter`, orientation, numeric value/range/step, Increment/Decrement/SetValue | orientation perpendicular to movement; actions; SetValue delta; NaN ignored | +| Floating group grip | `Role::Button`, move label | Role Button, label; not focusable as a splitter | +| Overflow | `Role::Button`, panels label | Role Button, label, menu opens and entries keep logical labels | +| Tab menu actions | `Role::Button`, logical labels | custom visual text does not replace AccessKit label | +| Print printer ComboBox | labelled by visible printer label | `labelled_by` present; `get_by_label("Printer")` finds it | +| Print copies DragValue | labelled by visible copies label | label present; `check_accessibility` passes | +| Print grayscale checkbox | own label | Role CheckBox, label present | + +Do not disable `Harness::check_accessibility` globally. If a fixture deliberately uses an unnamed +input, scope the exception to that fixture and document why. `egui_kittest` 0.37 fails unnamed input +widgets by default (PR #8589); that is a feature, not a migration nuisance. + +### 7.3 Panels, docking, order, clip and floating layers + +`DockArea` does not use `egui::Panel`; it renders a snapshot into the caller's `Ui` and uses +`Area::new(id).order(Order::Middle)` for floating groups +(`crates/ui/src/docking/surface.rs:761`). The app's panel structure remains outside. + +Required integration tests: + +1. **Panel order and clip:** in a root `Ui`, add `Panel::left("left").exact_size(200).show(...)`, then + `CentralPanel::default().show(ui, |ui| DockArea::show(...))`; assert the dock gets the remaining + width, the left panel is not overdrawn, and body `Ui` clip is a subset of the parent clip. +2. **Nested panels:** a top panel containing a horizontal `Panel` and a central panel; assert order + and no double allocation. This checks 0.37's `Panel`/`CentralPanel` last rule. +3. **Floating layer order:** two floating groups, `raise_panel` on the second, assert the top layer + and tab focus; assert the serialized `Layout` is unchanged by `raise_panel`. +4. **IDs after move:** move a panel into a floating group and back; assert the body `scope_id` is the + same before/after and that a focused text field retains its edit buffer. +5. **No document allocation in floating-only mode:** existing test + `floating_only_surface_does_not_allocate_the_callers_document_region` remains mandatory. +6. **Clip and popup:** open the overflow `Popup` and tab context menu near the viewport edge; assert + popup rect stays inside the screen, menu labels are logical, and the parent clip does not clip the + popup away. 0.37's popup tree changes (#8581) make this a real check. + +Do not turn `DockArea` into a native multi-window manager. Native viewport creation, reparenting and +cross-monitor coordinates remain host-owned. `Action::Float` means an in-viewport floating group. + +### 7.4 Tabs, split geometry, resize handles, toolbar and theme + +- `tabs::fit` is direction-agnostic and pure. Keep its exact width/overflow assertions. Add a + 64-tab and a 4096-tab bounded-work test; the latter must still be linear and must not allocate + unbounded storage. +- `layout::split_rect` and `SplitLayout::resized` stay pure and f32/f64 bounded. Add property tests + with extreme finite values and non-finite rejection; do not accept a `None` as a silent panel + disappearance in app adapters. +- `ResizeHandle` focus lock, arrow keys, AccessKit actions and pointer drag deltas stay. Add a test + that a focused handle consumes only its axis arrows and leaves Tab traversal intact. +- `fit_toolbar` stays ordered-prefix only; add a test that every input index is either shown or in + overflow, and that the overflow control is reachable when the width is exactly the overflow width. +- `WidgetPalette` keeps preserving borders/expansion. Add a test that a 0.37 default-style change + does not overwrite an explicit app border or expansion. +- `DockStyle::from_ui` should document that 0.37's default radius/spacing values are inherited; apps + with exact designs must pass explicit values. + +### 7.5 Fonts, glyphs and custom catalogs + +Anchors: `docking/surface.rs:425` (`−`/`+`), `:629` (`…`), `printing.rs` labels, +`crates/ui/tests/visual_regression.rs` (font-free fixtures and SVG raster checks). + +- `craft-ui` production still disables egui default features. This is correct: the app chooses fonts. +- The `crates/ui` dev-dependency keeps `default_fonts` so component tests can shape labels; keep it. +- 0.37's `default_fonts` no longer includes monochrome emoji fonts, and the 0.37 kittest harness + panics when nothing can draw a glyph. That is why the `⋮` fixture had to change even though 0.36 + had silently drawn tofu. `…` and `−` are present in Hack/Ubuntu; verify with: + `ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(12.0), "…−+"))` after one `run_ui` pass. +- Add a test using `MissingGlyphPolicy::Panic` (or `Harness` default) with `⋮` as a deliberate + negative fixture: it must fail. Then use `…` in the actual component and assert `has_glyphs`. +- Do not call `allow_missing_glyphs()` on a whole suite just to make a test green. If arbitrary + script fixtures are intentional, scope the policy to that fixture. +- SVG catalogs remain trusted compiled-in bytes and are not a font fallback. `SvgIconSet` still + requires a unique URI prefix per catalog/style; egui caches by URI. Test SVG at 12/24/48 logical + pixels and exact RGBA. + +### 7.6 Quit, platform and window helpers + +- `craft-quit::on_close_request` already expects `egui::Context`, `ViewportCommand::CancelClose` and + the root viewport. It compiles against 0.37. Add a test with `App::logic`-style ordering: app + cancels close first, then `on_close_request` sees the cancellation and does not quit. +- `craft-platform::DeferredClose::update` scans `ViewportCommand::Close`/`CancelClose`; keep its test + that a pending picker holds close and releases exactly once. The 0.37 `ViewportCommand::Screenshot` + callback does not change this, but apps must not treat a pending screenshot as a pending close. +- `craft-platform::sync_native_window_theme` is an `egui::Plugin::output_hook`. In 0.37 the plugin + API still calls `output_hook` after the pass; keep the test that a manual `SetTheme` is not + overwritten and that child viewport outputs receive the hint. +- `craft-window::decorate` only touches `ViewportBuilder`; no 0.37 API change. Keep the Linux/BSD + Wayland probe and the `WAYLAND_SOCKET` protection. Do not call it more than once at startup. +- `craft-quit` menu code uses `muda`; 0.37 does not change muda. Do not add a second native menu + abstraction. + +### 7.7 Input, IME, RTL, clipboard and PRIMARY + +craft-libs itself does not implement text input, IME, clipboards or RTL shaping; it consumes egui +widgets and labels. The fork changes that matter: + +- **E5/E12/E13/E15/E16/E20:** bidi shaping, visual order, grapheme editing and language-aware layout + affect every `Label`/`TextEdit` inside a `DockArea` body. Test Arabic/Hebrew mixed labels through + the fork fixture, not stock 0.37. Keep ASCII geometry tests unchanged as the no-regression baseline. +- **E9 and docking direction:** built-in egui chrome follows the fork's direction, + but custom docking uses explicit geometry. Add an opt-in `DockArea::direction` + builder with a stock-compatible `DockDirection::{LeftToRight, RightToLeft}`; + default remains LTR. Applications pass their UI direction; craft-ui does not + read fork-only style fields or infer language from labels. + - Preserve serialized split first/second, axes, ratios, floating rectangles and + logical tab order. Changing UI direction must not rearrange a saved workspace. + - Mirror horizontal tab-strip visual order and leading/trailing controls. Painting, + clipping, hit tests, accessibility order and drag insertion use the same computed + rectangles. Convert visual insertion index back to logical model index once. + - Split-pane geometry and physical splitter drag directions stay unchanged. + Floating resize grips use bottom-trailing placement (right in LTR, left in RTL); + RTL resize keeps the right/top edges fixed: for pointer delta `(dx, dy)`, + clamp `(width - dx, height + dy)` to the same limits, then emit + `Action::MoveFloating` with `[old_right - new_width, old_top, new_width, new_height]`. + LTR keeps left/top fixed and clamps `(width + dx, height + dy)`. Apply the existing + screen clamp afterwards. Horizontal splitter motion to the right increases the + physical left pane and decreases the right pane in both directions; vertical + motion downward increases the top pane. Assert these rectangles/ratios and + keyboard-generated deltas under both policies and at min/max/screen limits. For a focused + floating resize grip, ArrowLeft supplies negative physical dx and ArrowRight positive dx: + RTL ArrowLeft increases width and moves the left edge left while keeping right fixed; + RTL ArrowRight decreases width and moves left right. LTR uses the opposite width + effect with left fixed. ArrowDown grows height; ArrowUp shrinks it in both directions. + - Physical left/right keyboard traversal follows visual tab order; panel IDs and + focus stay stable across direction changes. Vertical behavior is unchanged. + - CL-3 tests LTR/RTL click/drag/reorder/resize, keyboard focus, protected floating + groups and unchanged serialized workspace. Deliberately mirror paint without + hit coordinates to prove failure detection. This is a visual policy, not a + schema migration or wholesale locale redesign. +- **E17/E18:** `Context::set_language(Some("ar".to_owned()))` sets the AccessKit root language; `UiBuilder::language` + marks content language; eframe's `Frame::set_ui_language` is the convenient host hook. Craft-ui + should not cache a language; pass localized text and let the app set language once per change. +- **E19:** Latin layout characters affect app shortcuts, not craft-ui geometry. App tests must cover + Ctrl+Q on Russian/Arabic/Hebrew and Dvorak Command layouts; do not infer shortcut behavior from + `Key` alone. +- **E21:** TextEdit deletes the exact UTF-8 bytes an IME replaces and groups the deletion with the + commit. App undo tests must assert one undo step for a press-and-hold/dictation replacement. +- **E21b/P7b (new prerequisite):** the candidate does not send outgoing surrounding text to W27. + The reviewed fork must add the bounded transient IME payload, built-in TextEdit producer and + Wayland adapter before CL-1's fork contract can pass. Check deletion-only and delete/commit + batches, selection/focus changes, password clearing, two windows, UTF-8 boundaries, preedit + exclusion and the 4,000-byte window. Stock-compatible craft-ui production code uses ordinary + TextEdit and needs no fork-only payload API; the fork contract fixture exercises it explicitly. + The eight custom-editor consumers need their own adapters as specified in the suite rollout. + CL-1 requires the final reviewed E21b/W27 revisions with an explicit input-owner/context + generation present for both `Available` (including an empty document) and `Unavailable`, and clear/invalidate + operations. Clear both caches and protocol state through a committed disable/enable boundary + when the owner changes or text becomes unavailable; ordinary updates preserve composition. + Cancel old pending preedit/commit/delete state on leave/reset/disable. Within the current focus + epoch, text-input-v3 `done` with a mismatched serial still applies incoming edits once but defers + outgoing state until a matching serial; old-epoch events cannot edit the new owner. The latest + completed UI pass is authoritative, including `IMEOutput = None`; do not retain the earlier + payload through `ime.or(self.ime)`. Test password/unavailable/over-limit transitions, queued + deletion then refocus, reset, two windows and text-to-non-text discarded-pass output. + Final SHAs are execution prerequisites. `8b35388b` remains a comparison input, not an interim + production pin: CL-1 waits for the completed reviewed/published dependency series. +- **E4 + upstream #8428:** the existing candidate's arboard PRIMARY fallback is + replaced by the selected new winit W15P path on the same connection/event queue, + using SCTK's per-seat PRIMARY objects. This must land before egui P2b and CL-1. + Test pointer- and keyboard-triggered ownership, including keyboard-only focus, with + fresh triggering serials scoped to their seat/origin (no arbitrary first-seat fallback), + owner/seat changes, empty/absent offers, + self-paste and canceled/slow transfers on wlroots/KWin/Mutter. Keep password and + unchanged-selection protections; no second regular data device. An isolated + PRIMARY-only fallback needs measured necessity and review; ordinary paste is + not PRIMARY acceptance. W15 and W15P must use bounded asynchronous transfer handles, + a total monotonic deadline, byte/count limits and bounded workers. The existing two-second + per-`WouldBlock` timeout is not a total bound. Egui consumes completion only for the still-live + paste request/owner/input generation; cancellation and late completion cannot paste elsewhere. + Verify responsiveness during slow trickles and oversized/concurrent offers. + +### 7.8 Screenshot callback correlation (downstream contract) + +craft-libs has no screenshot code; multiple suite applications do. The migration must replace +`ViewportCommand::Screenshot(UserData)` plus `Event::Screenshot` matching with one-shot callbacks: + +```rust +// Proposed ownership, not an existing API: +// app request record -> one result slot -> optional image + owning-viewport wakeup +// screenshot callback -> weak reference to that slot +// app logic -> take the image once, validate request, encode/write/reply +``` + +Requirements: + +- `ScreenshotCallback::new` / `Context::request_screenshot` is one-shot. Clones share one callback; + complete it at most once. +- The request record owns one completion slot and the callback holds a weak reference + to that exact slot. Correlation comes from ownership, not callback order or a second + global token map. Drop/cancel releases the slot and rejects late results. +- If the window is hidden, send `ViewportCommand::RequestPaintWhileHidden` or the screenshot command + itself (a pending screenshot already requests a paint). A hidden `App::ui` is not run otherwise. +- In a custom/headless renderer (for example LightCraft's headless path), retain the + `ScreenshotCallback` and call `complete(image)` with the next rendered image; do not reconstruct a + fake `Event`. +- Test two outstanding requests with distinct tags, callback-once behavior, no callback without a + request, and a hidden-window request. Test E2 surface fitting: the returned image may be smaller + than the window when surface fitting reduced it. The integration render_scale is + private: derive x/y image scale from returned image dimensions and the recorded + logical viewport rectangle for that capture, including pixels-per-point metadata. + Verify expected aspect/rounding. A resize/DPI generation mismatch retries within + the deadline or returns an error; never combine old image pixels with current geometry. + +Add a narrow completion-slot helper for existing craft-ui consumers after reviewing two concrete +adapters together (DesignCraft and GridCraft are the selected first consumers). Their tokens, paths, settle +delays, deadlines and replies remain app-owned. The callback holds only a weak slot reference; +avoid a strong Context/callback cycle, release locks before wakeup, and drop late results after +cancellation. The app enforces aggregate request/image limits. A bounded nonblocking channel is +the measured fallback if slot stress tests expose a problem. Apps without craft-ui need not add +it solely for capture. See the suite rollout plan for the selected ownership contract. + +Admission accounts for in-flight captures as well as completed slots: reserve a checked byte +budget before issuing a capture, using the allowed surface dimensions, and cap request count +independently. Defaults: 4 outstanding requests, 128 MiB/image, 256 MiB aggregate reserved +in-flight/completed image bytes; stricter app budgets are allowed. Record known issuance UI +logical bounds and a geometry epoch. Reject/retry any resize/DPI/zoom/surface epoch change +before save, even a change back; the callback contains pixels only, not frame metadata. +Revalidate dimensions before issuance after resize/DPI changes. On completion, +reject an oversized or stale image and release both its memory and reservation; account for the +renderer/readback's transient allocation separately in peak RSS. A post-callback size check alone +does not prove that peak capture memory was bounded. Stress concurrent 4K captures, resize races, +timeouts and canceled records; retained image bytes must return to zero after drain/shutdown. + +## 8. Fork-specific patch plan: E1-E21 + +The fork's `PATCHES.md` is the source for the table below. The upstream column reflects the inspected +tag `0.37.0` and open PRs, not a claim about `main` after 2026-10-15. Removal is always conditional on +a released egui version in use containing equivalent behavior and on the full suite tests passing +without the patch. + +| Patch | Commit(s) | Behavior | Upstream status at 0.37.0 | craft-libs relevance and required test | +| --- | --- | --- | --- | --- | +| E1 | `8f3abf06` | eframe `NativeOptions::viewport_hooks`: every viewport's native window on create/destroy and every viewport's `RawInput` before egui, including immediate viewports | not proposed | No direct craft-ui use. Compile-only fork contract; native window identity is app/craft-tablet. Test hook installed/replaced and one call per viewport in egui repo. | +| E2+E3 | `9dd799ab` | egui-wgpu fits a surface to `max_texture_dimension_2d` and returns `ConfigureSurface`/`CreateRenderer` errors instead of panicking | not proposed; addresses open #8361 | Required for large/multi-monitor windows. Test `surface_fit` edge cases and an oversized surface; report errors rather than abort. Screenshot pixel geometry must account for reduced surface. | +| E4 | `66bacf23` | egui-winit uses winit's Wayland data device for regular clipboard, falling back to arboard; smithay not started | emilk/egui#8657 draft; winit#4729 open | Required on Hyprland/KWin; needs W15 plus new W15P for PRIMARY. Test regular/image/drop and PRIMARY separately, serial/seat/ownership/cancel cases, and no second regular data device. | +| E5 | `0e27d87e`, `777977b7`, `f7e89e23`, `5b2b8e19` | Bidi shaping, visual row order, marks/end caret/selection, no-default-fonts tests | emilk/egui#8577 open, plus #8698 review fixes | Affects all app text and DockArea labels. Test Arabic/Hebrew mixed rows, selection across boundaries, marks, end caret, ASCII unchanged; run with default_fonts off and on. | +| E6 | `0abfabcf` | `request_focus` on the already-focused widget does not interrupt IME composition | not proposed | App TextEdit behavior. Test focus to same widget preserves composition; focus to another widget commits/cancels as intended. | +| E7 | `0670c58a` | Windows letter keys from virtual-key code on layouts without A-Z | not proposed | App shortcuts. Test Russian/Arabic/Hebrew layout key names and no duplicate text. | +| E8 | `1b03c10f` | TextEdit does not clip glyphs that overshoot the row | not proposed | App text clipping. Test Thai/Vietnamese/Arabic marks in single-line and wrapped fields. | +| E9 | `b2f5cafc`, `e1341a3d`, `5105330d`, `75a5b3d3` | `Style::right_to_left` mirrors menus, scroll bars, headers, title bars, combo boxes, checkboxes, popups, resize grips | not proposed | Required for RTL UI. Test built-in chrome in both directions; `DockArea` manual tabs/splits remain explicit. Do not let this alter persisted layout semantics. | +| E10 | `dc90c13e`, `c8e573f7`, `34b2c0d4` | eframe web: named keys not typed as text; letter keys from `KeyboardEvent.code`; `lang` on hidden input and speech | not proposed | Web apps. Test browser key layout, IME, screen-reader language; wasm check is compile-only. | +| E11 | `92e61afd` | epaint rasterizes each glyph on a single-threaded vello context, avoiding vello multithreading panic | emilk/egui#8641 open draft | Required when any app enables `vello_cpu/multithreading` (VectorCraft). Test rasterization with the feature on and no thread-per-glyph panic. | +| E12 | `e955bb18`, `485c0b89`, `0185887c`, `7015ac23`, `4067b2c4`, `620e070e` | `LayoutJob::direction`, RTL paragraph alignment, visual-end ellipsis, UAX #9 L1, linear reorder, bidi controls keep levels | not proposed; follows #8577 | Test forced LTR/RTL, neutral paragraphs, wrapped RTL, elision, long mixed paragraph linearity. Affects tab label widths. | +| E13 | `5e4da7f4` | Cursor movement/hit-test/Delete/Backspace by grapheme cluster | not proposed | App text editing. Test combining marks, emoji ZWJ/flags/keycaps, Devanagari, Thai, Hangul, CRLF; hit test every x in LTR/RTL. | +| E14 | `cbd77549`, `b3722ac0` | Fallback fonts share baseline and grow the row; `FontTweak::fallback_align` default unchanged | not proposed | CJK/Indic fallback. Test row height, DockArea `tab_height` clipping, and default center behavior unchanged. | +| E15 | `f1cae609` | Pluggable `LineBreaker`, grapheme-safe emergency breaks, `LayoutJob::language` | not proposed | App line breaking. Test `SimpleLineBreaker` old rules, custom breaker receives language, hard breaks and emergency cluster breaks. | +| E16 | `b3ef9771`, `bed6dfe3` | Visual cursor movement with affinity, Home/End reading order, per-cluster password mask, neutral paragraph inherits direction | not proposed | App text editing. Test bidi arrow behavior, Home/End, selection, password masking; no craft-ui API change. | +| E17 | `520435df` | `Context::set_language`, `UiBuilder::language`, `Response::accessible_language`; AccessKit root language; one text run per bidi level | not proposed | Craft-ui labels/accessibility. Test root language, per-widget language, mixed bidi runs, screen-reader names. | +| E18 | `89de8f3c` | eframe `Frame::set_ui_language`; web speech/input language fallback | fork-only | App host hook. Test native and web language propagation; no craft-ui cache. | +| E19 | `d64047bf`, `642b74f7`, `baeb4515` | One Latin-letter shortcut rule on Windows/macOS/Linux; `RawInput::keyboard_layout_is_latin`; per-key Latin layout chars | fork-only; needs W25 | App shortcuts. Test non-Latin layouts and Dvorak-Command; `Key` alone is insufficient. | +| E20 | `23955535` | Widget text laid out in the language of its `Ui` | fork-only (with E15/E17) | Tab/menu/label shaping language. Test Arabic/Hebrew labels inside DockArea and menu; ASCII unchanged. | +| E21 | `a70470b0` | `ImeEvent::DeleteSurroundingBytes`; TextEdit deletes exact bytes in the commit's undo step | not proposed; needs W26/W27 | App IME. Test exact-byte deletion, one undo step, combining accent/flag/ZWJ replacement, and no over-deletion. | +| E21b/P7b | New, no commit yet | Bounded outgoing surrounding-text payload, TextEdit producer and egui-winit W27 adapter | Confirmed missing from candidate | Fork-only contract tests cover logical text, UTF-8/preedit mapping, 4,000-byte limits and focus/password clearing; custom app editors supply their own adapters. | + +Decision: keep E1-E21 and implement E21b before the target is frozen; remove none during this migration. The removal +condition for every row is a released egui version in use with equivalent behavior, the fork test +suite passing without the patch, and the app workflows passing. E5 is the dependency root for E12, +E13, E16, E17 (language runs), E18 and E20; E19 and E21 are independent but require specific winit +patches. A removal decision for one patch must consider its +dependents, its tests and the winit side. The fork's own tests are numerous: epaint text layout tests +include bidi order, mixed Hebrew/Latin, cursor hit-testing, selection boundaries, grapheme clusters, +fallback baseline, line breaking and RTL wrap; egui integration tests include `ime_focus`, +`text_edit_overshoot`, `layout_language` and `ime_delete_surrounding`. Those tests are the first +acceptance layer, not the only one. + +## 9. Test plan and regression assertions + +### 9.1 Commands and evidence + +All commands run through `maintenance/task.py run/status/finish`, never directly, with a fresh task, +exact source, durable evidence and a unique job ID. Use the allocated `CARGO_TARGET_DIR`; do not use a +shared target. + +Minimum gates, in order: + +```sh +cargo fmt --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +cargo clippy -p craft-ui --all-targets --all-features --locked -- -D warnings +cargo test -p craft-ui --all-features --locked +cargo check -p craft-ui --all-features --locked --target wasm32-unknown-unknown +``` + +Then the fork/registry contract and package checks described in section 5. Record actual executed +test counts, exit codes, `rustc -Vv`, `cargo -V`, lock hashes, feature list and the full command line +for each run. Do not call a historical audit log a fresh run. + +### 9.2 Stable fixtures and exact assertions + +Use fixed `RawInput.screen_rect`, fixed `time`, `pixels_per_point` 1.0/1.5/2.0, both themes, and +`animation_time = 0.0`. No test may update a golden automatically. + +Required assertions: + +- **Pixel/mesh:** 372 positive visual pairs exact RGBA and exact vertices/indices/texture IDs/clips; + 36 deliberate color/spacing/stroke changes must differ in both. If a 0.37 default changes a fixture, + diagnose the component or deliberately change the independent reference; never accept a new golden + to hide a visual regression. +- **Geometry:** exact `TabFit` widths/overflow for the existing fixtures; split first/divider/second + rectangles exact; 64-tab and 4096-tab work bounded; `fit_toolbar` every index reachable. +- **IDs/focus:** tab/body IDs equal across frames and moves; duplicate labels distinct; focus survives + a panel move; `scope_id` children do not collide. +- **Actions:** `DockArea` returns the expected single `Move`/`Float`/`Close`/`Resize` sequence; the + layout snapshot is unchanged until the caller applies it; 800 deterministic moves never lose or + duplicate a panel. +- **Persistence:** `Layout` and `Action` serialize to a fixed JSON fixture and back without changing + panel identity, active index, fixed sizes, accordion state or floating rect. Unknown fields are a + deliberate schema decision, not an accident. +- **Accessibility:** exact roles/names/selected/enabled as in section 7.2; `Harness::check_accessibility` + passes; unnamed input fixture (deliberate) fails when the name is removed. +- **Lifecycle:** no print worker starts before print is opened; once requested, results are + delivered via `run_logic` without `run_ui`; completed work requests no further repaint and + repeated logic/UI calls do not start a second worker. +- **Glyphs:** component-painted glyph set (`…`, `−`, `+`) passes `has_glyphs` with bundled test fonts; + missing `⋮` under panic policy demonstrates the test detects a font regression. +- **RTL/fork:** in the fork contract workspace only, Arabic/Hebrew mixed labels keep logical order, + tab widths and hit rects remain finite, built-in menus mirror, and an ASCII fixture is byte-identical + to the stock 0.37 result (where the API is shared). +- **Screenshot:** two tokens, callback once, hidden-window request, surface-fit scaling; app fixtures + live in app repositories, not craft-libs. +- **Panic policy:** no new `unwrap`, `expect`, `panic`, `todo`, `unimplemented` or `unreachable` in + production code; crate `lib.rs` deny headers stay. + +### 9.3 Deliberate failure probes + +The existing 36 visual mutation comparisons are the primary pixel/mesh probes. Add these targeted +negative checks: + +1. Render an icon button with the label omitted or role changed to `Label`; the accessibility test + must fail. +2. Remove `labelled_by` from the print `DragValue`; `Harness::check_accessibility` or the label query + must fail. +3. Render `⋮` under `MissingGlyphPolicy::Panic`; the fixture must fail. +4. Feed the same body `scope_id` to two simultaneously rendered panels; a duplicate-ID test must fail. +5. Change a tab-strip width by one point or reverse the overflow order; exact geometry must fail. +6. Call the print discover closure twice; the one-worker counter test must fail. +7. Deliberately misroute a capture slot to another request; correlation must fail. Completing + clones twice is a positive one-shot test and must still yield exactly one result. + +Document the expected failure for each probe and keep it out of release acceptance. + +## 10. Performance protocol and thresholds + +### 10.1 Baseline capture (before production edits) + +The benchmark harness is test-only work and must exist in both baseline and candidate trees. Stage +the harness in a separate test-only commit, cherry-pick it into a read-only baseline worktree at +`49190884278f16c9a9f5823d7698a8ffb5deb062`, and run the baseline first. + +Environment to record exactly: + +- host CPU: Intel Xeon E-2176G, 12 threads (6 cores), 3.70 GHz base, 125 GiB RAM (the observed host); +- current host inspection finds i915 PCI `8086:3E96` and an accessible render node; no display + session is inherited. Qualify the actual selected adapter/driver/backend with a managed job. + Historical llvmpipe results are software-renderer evidence only, not current hardware discovery; +- Windows row (if used): the Windows VM's Microsoft Basic Render Driver (CPU), not a physical GPU; +- no real-GPU measurement has been performed for this task. Hardware availability alone does not + establish renderer, compositor or presentation acceptance. + +Benchmark target: `crates/ui/benches/ui_hotpaths.rs`, with Criterion as a +craft-ui dev-dependency and a separate individual-frame timing mode: + +- `tabs_fit`: 1, 8, 64 and 4096 tabs; `available` at tight/medium/wide; 1x and 2x. +- `split_rect` and `resized`: 100k iterations, normal and tiny panes. +- `fit_toolbar`: 8 and 4096 groups. +- `dock_area`: 4-panel split+floating and 32-panel tree at 800x600 and 1440x900, 1x and 2x; + call `DockArea::show` with a no-op body, tessellate `FullOutput`, discard textures. +- `printing`: lazy `refresh` plus `poll` with empty, pending and completed receivers; `show` at 220x360. +- `panel_integration`: left panel + central `DockArea`. +- `text_tessellation`: 120 fixed ASCII labels plus a mixed/color-glyph control, + time Context::tessellate separately against old fork, stock 0.37 and candidate. +- `font_startup`: fresh-process font discovery and first-missing-glyph timing using + the explicit app/provider fixture; no installed-font-dependent pixel goldens. + +Named cross-repository acceptance owners: egui fork section 10 owns fresh-process +DX12 renderer/pipeline construction (#8736), and SoundCraft/PdfCraft Windows rows +exercise their real options. Its selection-then-toolbar test owns #8763/#8764; +craft-ui adds select-text-then-dock/button-action integration coverage. Its hidden +parent/deferred-child fixture owns #8748; DesignCraft's native child-window scenario +provides the consumer evidence. Link actual results to CL-4, rather than claiming +craft-libs' library harness independently validates native DX12 or child windows. + +Measure with Criterion, fixed sample size and warmup: + +```sh +# through task.py, with the allocated target and evidence paths +cargo bench -p craft-ui --bench ui_hotpaths --locked -- \ + --warm-up-time 3 --measurement-time 10 --sample-size 100 --save-baseline base +``` + +Then run the candidate with `--baseline base`. Keep raw estimates JSON/CSV, not just the summary. +Record Criterion estimates/confidence intervals and sample count, CPU affinity, `RUSTFLAGS`, profile, feature set, +renderer, driver, DPI, viewport, font definitions and fixture fixture hash. +For latency percentiles, collect individual operation/frame durations separately: Criterion's +batch means are not a frame-tail distribution. Use at least 1,000 measured frames for p99. + +Additional counters from `FullOutput` and `Context`: +`shapes.len`, total vertices/indices, `textures_delta.set/free` count and bytes, +`font atlas` growth, `ctx.fonts(|f| f.num_galleys_in_cache())`, and `requested_repaint_last_pass`. +Peak RSS from `/usr/bin/time -v` or `/proc/self/status`. For allocation counts, use a test-only +counting allocator around the closure; report both count and bytes. + +### 10.2 Thresholds + +These are acceptance thresholds for the migration, not automatically generated pass/fail golden +values. Correctness gates are hard. Performance thresholds apply after excluding correctness +failures. + +| Metric | Threshold | Action on failure | +| --- | --- | --- | +| Exact visual pairs | 0 changed pixels, 0 changed mesh elements | Fix component or make a reviewed, justified reference change | +| Exact geometry fixtures | exact values | Fix component | +| Accessibility names/roles | exact | Fix component or scoped fixture policy | +| Idle repaint | 0 paints in 2 s after settle, no `request_repaint` from completed print state | Fix lifecycle | +| Dock frame median | <= +5% candidate vs paired baseline | Profile; rerun; no acceptance on one noisy run | +| Dock frame p95 | <= +10% | Profile; rerun; report | +| Pooled dock frame p99 (>=1,000 individual frames) | <= +15% | Profile; rerun; part of acceptance; max is diagnostic only | +| Layout pure functions median | <= +3% | Profile; rerun | +| Layout pure functions p95 | <= +5%, from individual latency samples | Profile; rerun | +| 800-action dock sequence median | <= +5%; inspect growth across larger trees | Inspect algorithmic regression | +| Texture upload bytes / 100 frames | <= +5%, no repeated upload of the same image | Inspect cache keys/URI | +| Peak RSS | investigate > +8 MiB or +5%, whichever is reached first | Report both absolute and percentage | +| Galley cache size | bounded, no monotonic growth over 10k frames | Inspect cache key/language/viewport | + +Noise policy: + +- Run baseline and candidate on the same host, same OS image, same Rust toolchain, same feature set, + recorded lock identities and same viewport/DPI. Review the intended dependency delta rather + than requiring identical old/new lock contents. Alternate baseline/candidate sessions; do not compare a + laptop to the server or llvmpipe to a GPU. +- Warm up for at least 3 s or 50 iterations, whichever is longer. Discard warmup. +- Use 5 independent sessions of at least 200 individual frame samples for frame percentiles, + plus the Criterion runs for microbenchmarks. If the coefficient of variation of medians is > 3%, or + any session has a build/test/profiling process competing for CPU, discard and rerun the pair. +- Accept only when the median-of-medians, pooled p95 and pooled p99 meet the table. + Report p99 only from the pooled >=1,000 individual samples, not each 200-frame + session; if tail variability prevents a repeatable verdict, expand to 10,000 frames. Report the raw session + values even when they pass. +- A changed visual golden is never auto-accepted because of a performance change. A compile-time + measurement is not UI performance and must not be reported as such. + +The Windows row is a separate performance table (Basic Render Driver, guest CPU, guest memory). +The macOS row requires real CI or hardware evidence; without it the row is unresolved. The real-GPU +row requires fresh adapter-qualified measurements. The suite protocol's startup counts, small +operation floor and stricter applicable thresholds also apply. + +## 11. Implementation PR stages + +Dependency DAG: + +```text +all reviewed plans land + -> CL-0 baseline harness and measurements (before production change) +winit completed integration including W15P + W27 lifecycle revision -> approved publication + -> egui final P8 incl. P2b, P7, P7b/E21b and P7c -> approved publication + -> CL-1 atomic API/fork graph + -> CL-2 capture prototype + lazy-print tests + -> approved prototype revision for integration consumers + -> DesignCraft + GridCraft adapter PRs/reviews + -> CL-2F final helper API freeze and docs + -> CL-3 accessibility/glyph/persistence/direction tests + -> CL-4 stock/fork fixtures and linked native acceptance + -> CL-5 final compatibility documentation +``` + +CL-0 is a separate baseline harness stage. CL-1 cannot start until CL-0 baseline records are complete. CL-2 and CL-3 may proceed after CL-1 +on disjoint code; their publication still requires approval. CL-2's initial API is +explicitly provisional until the two named adapters are reviewed, preventing a +dependency cycle between helper availability and API freeze. + +### CL-1: egui 0.37 API and coherent suite lock + +Files: + +- `Cargo.toml` (workspace egui pin). +- `crates/ui/Cargo.toml` (dev dependency versions). +- `crates/ui/src/buttons/mod.rs`, `crates/ui/src/docking/surface.rs`, + `crates/ui/src/layout/resize.rs`, `crates/ui/src/tabs/mod.rs` (role mapping). +- The exact closed test-file allow-list in §4.2 (`Id::unique`, `scope_id`, ellipsis fixture); + additional files need a named reviewed exception, not a broad glob. +- `crates/ui/README.md` (version + `scope_id` example). +- Root patches and `Cargo.lock` regenerated against the reviewed immutable storytold egui/winit + commits; retained PDF backend patches remain intact. + +Use a local stock-0.37 experiment to isolate API fallout, then land the coherent suite graph in +one compatibility PR after the fork dependencies are published with approval. Stock compatibility +is verified independently; no intermediate registry-only consumer graph is a suite handoff. +Rollback: revert the coherent commit. The old coherent set is preserved at +`49190884278f16c9a9f5823d7698a8ffb5deb062`: egui family 0.36.2, accesskit 0.24.1, kittest 0.4.0, +winit 0.30.13 and the baseline `Cargo.lock`. Do not partially revert only one crate and do not leave a +0.37 manifest with a 0.36 lock. + +Prerequisite: the completed reviewed/published P8 fork includes P2b, P7, P7b/E21b and +P7c, with W15P and the W27 lifecycle revision. Intermediate pins are not accepted. +Acceptance: all section 9.1 gates against the suite fork and focused stock-0.37 compatibility +checks; `cargo tree` has one egui family; visual +suite still exact; no deprecation warnings under clippy `-D warnings`. + +### CL-2: Capture completion and existing lifecycle contracts + +Files: + +- `crates/ui/src/lib.rs` declares public `capture`; the lightweight slot has no new feature + gate or renderer dependency. Public types are available under `craft_ui::capture`. +- `crates/ui/src/capture.rs` completion-slot prototype and tests. Publish only after + approval as a provisional git API; DesignCraft and GridCraft then implement their + adapters against that exact revision. CL-2F freezes the public API after joint review. +- `crates/ui/src/printing.rs` only if a demonstrated regression needs a fix; preserve lazy discovery. +- `crates/ui/src/printing/tests.rs` (logic-only and one-worker tests). +- `crates/ui/README.md` lifecycle paragraph. + +No change to `Settings`, `Feedback`, `Discover`, `Submit`, `begin`, `ready`, or job ownership. +Rollback: revert the helper together with its adapters, preserving pending-request cleanup and +the previously coherent app dependency set. Print behavior changes require their own regression. + +Acceptance: tests from sections 6.2 and 7.8; cancellation, out-of-order result and shutdown stress; +no worker before print-open, no duplicate workers or repaint after completion. + +### CL-3: Accessibility, glyph, persistence and docking direction contracts + +Files: + +- `crates/ui/src/docking/tests.rs`, `tabs/tests.rs`, `layout/tests.rs`, `buttons/tests.rs`, + `printing/tests.rs`. +- `crates/ui/src/docking/surface.rs` implements `DockArea::direction`/`DockDirection`; + `crates/ui/src/docking/mod.rs` re-exports the public direction type. +- New `crates/ui/tests/panel_integration.rs` and `serde_contract` test module; + opt-in docking direction implementation and the exact geometry/hit/drag tests in §7.7. +- Beyond the explicit direction policy, no production change unless an assertion exposes a real component bug; then fix the component in + the same PR and document the exact role/name change. + +Acceptance: exact roles/names as section 7.2; `Harness::check_accessibility`; glyph coverage; fixed +JSON round-trip; panel order/clip/float layer; targeted failure probes. Rollback: revert the +production direction API and its app adapters as a coherent set; other fixtures are test-only. +Any additional component fix is identified and reverted separately. + +### CL-4: Suite fork, winit pin and dual-compatibility checks + +Files: + +- Verify the root patches and lock introduced atomically in CL-1, with one git source per family. +- `ci/registry-egui` isolated workspace plus `cargo package -p craft-ui` smoke check. + This preserves library portability; it does not authorize a crates.io release. +- `ci/egui-fork-contract` isolated workspace that depends on the four UI-facing craft crates and + compiles against the fork with `eframe` wgpu/accesskit/x11/wayland and winit. +- Documentation of the consumer patch requirement (README or docs). + +This PR does not publish anything and does not change the published manifest's registry requirement. +Rollback: retain CL-1's coherent dependency set and revert only this stage's fixture changes. + +Acceptance: + +- `cargo tree -d` empty (or only pre-existing unrelated duplicates documented); +- exactly one `egui`/`epaint`/`egui-winit` and one `winit` source; +- fork contract compiles on Linux (x11+wayland), Windows GNU check, and wasm check for `craft-ui`; +- packaged `craft-ui` still compiles/tests against registry 0.37.0; +- E1/E4/E19/E21 symbol smoke checks compile only where the API exists; +- no branch names in locks or manifests. + +### CL-5: Documentation and release checklist + +Files: + +- `crates/ui/README.md` compatibility paragraph: registry 0.37 vs suite fork, consumer patch + requirement, `scope_id` example. +- `docs/ui-components-audit.md` version statement and a dated migration note; do not rewrite its + historical pinned hashes. +- `docs/egui-037-migration-plan.md` status update only after the plan is approved. +- Release notes in the user's voice, no agent/model/tool attribution. + +Acceptance: documentation links to immutable commits/tags; no machine-local path; no claim of a +performance or platform result that was not measured. Rollback: documentation-only. + +## 12. Risks and migration traps + +1. **Lock conflict regression.** The audit merge proves how easily a 0.37 manifest gets a 0.36 lock. + Always inspect the lock, not only the manifest, and run `cargo tree`. +2. **Fork patch not propagating.** A consumer that only patches `egui` will get registry `egui-winit` + or registry winit. The root patch must name every family member and winit. +3. **Movable branch pins.** `craft/0.37`, `craft/0.30.13-lang` and `origin/ai-0.37` can move. Use full + SHAs in all locks, fixtures and docs. +4. **Fork vs upstream API leak.** The shared library deliberately preserves stock 0.37 compatibility, + independently of its git-only suite delivery. Keep fork-only APIs in app/fork + fixtures: `Context::set_language`, `UiBuilder::language`, `Response::accessible_language`, + `Frame::set_ui_language`, `LayoutJob::{language,direction}`, `LineBreaker`, + `FontTweak::fallback_align`, `Style::right_to_left`, `RawInput::latin_layout_chars`, + `RawInput::keyboard_layout_is_latin`, E21b surrounding-text/owner payload and clear APIs, + `ImeEvent::DeleteSurroundingBytes` and `ViewportHooks`. The independent stock + compile/test fixture is authoritative, not just a symbol-name grep. +5. **Default fonts changed twice.** `default_fonts` became opt-in and monochrome emoji fonts became + separate. The old `⋮` tofu case became a hard test failure under the new panic policy. Custom app + fonts, not craft-ui production defaults, must prove glyph coverage. +6. **Kittest panic policy.** `MissingGlyphPolicy::Panic` and `check_accessibility` are deliberate bug + detectors. Scoped opt-outs, never global. +7. **Screenshot callback ownership.** Callbacks are one-shot, `Send + 'static`, and execute outside + the UI pass. A callback that captures `&mut App` will not compile; a callback that never wakes the + app will strand a hidden-window result. +8. **Surface fitting changes screenshot pixels.** E2 may render a window at a smaller surface and + scale it. Use returned image dimensions and capture-time logical viewport/DPI + metadata; the private integration render_scale is not a public app API. +9. **Manual DockArea layout is not mirrored by `Style::right_to_left`.** Tabs, split first/second and + floating grip placement follow the explicit opt-in visual policy in section 7.7; + E9 does not implement it automatically. +10. **Bidi changes text widths.** A tab label with Arabic/Hebrew can change `tabs::fit` results. Keep + ASCII fixtures exact and add fork-specific mixed-script geometry tests; do not update ASCII + references for an unrelated reason. +11. **IME and undo.** E21's exact-byte deletion is only correct if the app keeps the commit and + deletion in one undo transaction. Do not reimplement deletion in app code. +12. **Clipboard PRIMARY.** Regular copy/paste and middle-click PRIMARY are separate paths and separate + compositor behaviors. Test both. +13. **GPU/resource tests.** `WgpuTestRenderer` is offscreen and prefers CPU adapters but may fall back to + a physical GPU. Record adapter identity; enforce a CPU adapter for deterministic + goldens and keep physical-GPU performance rows separate. It is not native + focus/IME/picker evidence and not a real-GPU claim. +14. **Panel/root UI borrow.** Calling `Context::root_ui` from inside `DockArea` or a component panics. + Components take `&mut Ui`. +15. **Multiple passes.** `request_discard` can repeat a `run_ui` closure. One-shot side effects in + tests or app callbacks must be guarded. + +## 13. Resolved design and blocking acceptance + +1. **Dependency policy.** Integrate the reviewed patches into storytold egui/winit first; pin + their final immutable commits at the suite root. The aiongg SHAs are reviewed inputs, not + promised production tips. A separate registry fixture verifies stock API compatibility. +2. **Versioning.** Ship a new git revision and compatibility note; no blanket workspace version + bump or crates.io publication is part of this task. +3. **Printing.** Keep user-triggered/lazy `refresh` and idempotent `poll`; hidden polling must + deliver requested results without starting unrelated printer discovery. +4. **Docking direction.** Preserve serialized panel IDs, split axes, tab order and positions. + Adopt the explicit opt-in DockArea direction policy in §7.7; test RTL painting, + hit coordinates, visual-to-logical drag insertion and unchanged serialization. Do not silently mirror the persisted model or add a locale redesign. +5. **Capture.** Use the narrow weak completion-slot design from section 7.8; review two concrete + callers and stress cancellation/memory before freezing the API. Keep app policy outside it. +6. **Platform evidence.** Qualify the accessible Linux i915 render node and software backends + separately. Windows Basic Render Driver is software evidence. macOS needs native evidence, + and wasm compilation does not replace a real browser interaction workflow. + +Blocking acceptance criteria for craft-libs: + +- CL-1 through CL-4 gates pass with fresh task/evidence records; no direct cargo run. +- One egui family and one winit source in the fork lock; packaged crate still resolves stock 0.37. +- Fresh reports reproduce or rule out the applicable upstream #8731 text-tessellation and #8512 + font-startup regressions; renderer fixtures include #8736 DX12 setup, #8763 selection/action and + hidden/deferred viewport behavior. #8763 is the open selection issue; #8764 its open, unmerged + proposed fix. #8748 is a closed, unmerged PR. #8512 is a closed feature PR whose comments supply + the startup-cost discussion. None is evidence of a released regression fix. +- Exact visual/geometry/accessibility/persistence tests and the deliberate failure probes pass. +- `cargo tree -d` has no new duplicates; no branch refs; no machine-local paths. +- The performance table meets the thresholds, or a regression is profiled and explicitly reviewed + with a documented exception. A compile-time number, a changed golden or a single noisy sample is + not acceptance. +- Every push/new PR has explicit human approval after the exact diff and validation are shown. + Commits and PR text use the user's voice and username-only contributor credit (`@skylartaylor`); + no agent/model/tool attribution. + +## 14. Source index used for this plan + +Primary upstream: + +- egui 0.37.0 changelog and release commit `841a328b`: `CHANGELOG.md` at tag `0.37.0`. +- `crates/egui/src/context.rs` (`run_ui`, `run_pass`, `root_ui`, `run_logic`, `request_screenshot`). +- `crates/egui/src/id.rs` (`Id::unique`, `IdSalt`), `ui.rs` (`scope_id`), `ui_builder.rs`. +- `crates/egui/src/containers/panel.rs` (`Panel`, `CentralPanel`). +- `crates/egui/src/data/screenshot_callback.rs`, `viewport.rs` (`Screenshot`). +- `crates/egui_kittest/src/builder.rs`, `accessibility.rs`; `crates/epaint/src/text/fonts.rs` + (`MissingGlyphPolicy`, `has_glyphs`). +- Upstream PRs: #8361, #8428, #8489, #8534, #8535, #8542, #8555, #8577, #8581, #8583, #8589, #8592, + #8601, #8617, #8657, #8641, #4729 (winit). + +Suite fork: + +- `/home/codex/work/CraftApps/egui037-migration-20261022/egui`: + `aiongg/craft/0.37` = `a4f9a17f64a6bae61350fb85804c9df0d9865916`; + `PATCHES.md` E1-E21; `0.37.0` = `841a328b`. +- `/home/codex/work/CraftApps/craft-fork-20261020/winit-ai-lang`: + `craft/0.30.13-lang` = `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; + `craft/0.30.13` = `ffae5198`; PATCHES.md W1-W27. +- craft-libs PR 199 ref `refs/review/pr199` = `b68de8c`; audit merge `ec8840a` is not a target. +- Historical evidence, not fresh validation: + `/home/codex/work/ssd2/task-evidence/craft-fork-plan-20261020/PLAN.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-ai-lines-20261022/SURVEY.md`, + `/home/codex/work/ssd2/task-evidence/craft-fork-egui037-20261021/RESULTS.md`, + `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/craft-libs.log`. + +craft-libs source anchors: + +- `Cargo.toml:34`; `crates/ui/Cargo.toml:13-24`; `.github/workflows/ui.yml:20-58`. +- `crates/ui/src/lib.rs`; `crates/ui/src/buttons/mod.rs:70-102`. +- `crates/ui/src/tabs/mod.rs:40-163`. +- `crates/ui/src/layout/geometry.rs:1-135`; `layout/resize.rs:1-198`; `layout/toolbar.rs:1-152`. +- `crates/ui/src/docking/model.rs:1-798`; `docking/surface.rs:1-919`; `docking/targets.rs:1-99`. +- `crates/ui/src/printing.rs:1-261`; `printing/tests.rs:1-257`. +- `crates/ui/src/theme/mod.rs:1-97`; `icons/mod.rs:1-78`. +- `crates/ui/tests/visual_regression.rs:1-394`; `tests/visual/fixtures.rs:1-327`; + `tests/docking_workflows.rs:1-252`. +- `crates/platform/src/appearance.rs:439-525`; `platform/src/dialogs.rs:220-251`; + `crates/quit/src/close.rs:1-190`; `crates/window/src/lib.rs:255`. +- `crates/ui/README.md:115,237`; `docs/ui-components-audit.md:5`; + `docs/ui-visual-regressions.md`; `docs/ui-docking.md`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/craft-libs` `main` now resolves to `f180fc0d35d521c5d18e118c881d3efde1ec1eee`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve new hayro/krilla hostile-input bounds, updated SOURCE manifests/nested lock, and chrono std-only workspace policy. PR199 advanced to1dcde7ed and now incorporates49190884 security fixes; its14-file delta can be reviewed against currentmain rather than treating newhead as the old stale tree. Regenerate lock against final dependencies. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +### Local row-alignment component, 2026-10-23 + +`craft_ui::text::align_galley_rows` positions already-shaped rows within an explicitly allocated width. It accepts a row alignment callback and uses stock egui 0.37 types, so it does not make the shared UI crate depend on the fork's bidi extensions. It is useful for comment cards, captions and message panels. It retains shared row glyphs/meshes and logical text, updates row positions and logical/mesh bounds, and never mutates the cached source galley. Oversized left-aligned rows overflow right, right-aligned rows overflow left, and centered rows overflow on both sides; nonfinite or negative widths are refused without changing the input. + +WordCraft supplies alignment from the fork's `rtl_paragraph` field. This preserves native shaping, mixed-direction runs, mirrored bracket glyphs and neutral-paragraph inheritance. The fork's `LayoutJob::halign_min_is_start` aligns RTL rows to the widest text row, which is insufficient for a short comment in a wider card. No new Unicode reordering implementation is introduced. Unicode's logical-order and mirroring rules are described in [UAX #9](https://www.unicode.org/reports/tr9/); the fork source documents its row placement in `epaint/src/text/text_layout_types.rs`. + +Prepared helper tests check independent row alignment, unchanged cached row data, mesh bounds, invalid widths and oversized rows. WordCraft's real Arabic/mixed/neutral-paragraph test passes in `egui037-wordcraft-candidate-20261023-targeted-07`, including atlas-bitmap bracket orientation. Stock shared-library tests, performance and independent component review are pending; the prior 110-test result predates this helper. + +The second independent component review found no blocking helper defect. Word now clips balloon ink to the card and disables the fork-only start-edge override before custom row alignment. Additional source tests cover fractional scale, zero width, idempotence and separate ink bounds. Execution remains pending. diff --git a/docs/egui-037-suite-rollout.md b/docs/egui-037-suite-rollout.md new file mode 100644 index 00000000..d3e529be --- /dev/null +++ b/docs/egui-037-suite-rollout.md @@ -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` 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.