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