Document migrated UI contracts and regression coverage

This commit is contained in:
skylar taylor-barrick committed 2026-10-25 14:26:08 +01:00
1 parent f8c6a3d51b
commit 253c425e2f
28 files changed
+416

No files matched your search

+7
View File
@@ -483,6 +483,13 @@ impl Draw<'_, '_> {
}
#[allow(clippy::too_many_arguments)]
/// Draw one key cap, modifier, Caps Lock or System cap at `rect`.
///
/// `Cap::Gap` is a no-op with no hit target. A key cap is colored by its holder class, selects
/// the application-wide holder as visible title when present, and sets `clicks.key` on click;
/// it exposes the selected state and a description naming the class. Modifier caps toggle into
/// `clicks.toggle`, while Caps Lock and System caps are only painted. Sizing derives from `u`;
/// a key's hover text lists every holder label.
fn cap(&mut self, ui: &mut Ui, rect: Rect, cap: Cap, u: f32, mods: Mods, index: &Index, clicks: &mut Clicks) {
if matches!(cap, Cap::Gap) {
return;
+8
View File
@@ -114,6 +114,14 @@ impl Draw<'_, '_> {
}
}
/// Draw the import review and return the chosen action.
///
/// The set name defaults to a translated "Imported"; it is only considered valid when no set
/// catalog exists or `set_name` accepts it, but the raw text is stored in `state.review.name`
/// even when invalid. Import is offered independently of the command count. The summary counts
/// changed commands plus unmatched, shortcutless, unreadable, approximated and truncated report
/// entries. Returns `Act::Import`, `Act::Close`, or `Act::Pick` when a suggestion is chosen; the
/// caller applies the action.
fn plan_view(&mut self, ui: &mut Ui, plan: &ImportPlan, mut name: String) -> Option<Act> {
let mut act = None;
let report = &plan.report;
+18
View File
@@ -620,6 +620,15 @@ impl Draw<'_, '_> {
(chips + ICON + CHIP_GAP).max(110.0).min(width * 0.4)
}
/// Draw the command table and own its keyboard focus and row navigation.
///
/// The table is a focusable non-interactive region. While focused and not recording, arrow,
/// Home/End, Enter and Delete are consumed by `table_keys`; while a shortcut cell is recording,
/// focus uses `FOCUS_LOCK` so the recording receives keys, and losing focus stops recording.
/// Rows are built from the cached lines, reveal scrolling centers the selected row when needed,
/// and the selected rendered row becomes the active accessibility descendant. Dispatched row
/// actions select, record, reset or toggle a group; group toggles keep focus, other actions
/// refocus the table.
fn table(&mut self, ui: &mut Ui) {
let rect = ui.max_rect();
let table_id = self.base.with("table");
@@ -828,6 +837,15 @@ impl Draw<'_, '_> {
clicked.then(|| RowAction::Toggle(name.to_string()))
}
/// Draw one command row and return at most one action.
///
/// The row always senses clicks and can be selected even when the dialog is read-only;
/// `editable` additionally enables shortcut-cell hit testing and the add/reset controls. The
/// accessibility label gains "(cycles)" and "(Changed)" suffixes, and the visual row uses
/// selected/focused and RTL-mirrored styling. A recording cell for this command shows the
/// recording text; clicking a chip records that binding, clicking an empty cell records the
/// first binding, and the plus button records an appended binding. The final fallback action is
/// selecting the clicked row.
fn row(&self, ui: &mut Ui, rect: Rect, r: &Row, shortcut_w: f32, focused: bool) -> Option<RowAction> {
let editable = self.env.options.editable;
let selected = self.state.selected.as_ref() == Some(&r.command);
+5
View File
@@ -17,6 +17,10 @@ const INPUTS: [(&str, &str, &str); 2] = [
),
];
/// Load the pinned craft-fonts inputs once for all dialog tests.
///
/// Requires an absolute `CRAFT_FONTS_DIR`, verifies the SHA-256 of both symbol faces, refuses any
/// installed-font fallback, and appends the two pinned faces plus egui's Hack to every family.
fn definitions() -> FontDefinitions {
static FONTS: OnceLock<FontDefinitions> = OnceLock::new();
FONTS
@@ -45,6 +49,7 @@ fn definitions() -> FontDefinitions {
.clone()
}
/// Install the deterministic fonts on `ctx` before its first layout pass.
pub fn configure(ctx: &egui::Context) {
ctx.set_fonts(definitions());
}
+4
View File
@@ -66,6 +66,10 @@ impl Fx {
}
}
/// Build an egui_kittest harness around `Fx`.
///
/// Installs and checks the pinned fonts every frame, selects window or inline dialog mode, records
/// emitted outcomes, and flags any key press still present after the dialog ran.
fn harness(fx: Fx, size: Vec2) -> Harness<'static, Fx> {
let mut h = Harness::builder().with_size(size).with_context_setup(test_fonts::configure).build_ui_state(
|ui, fx: &mut Fx| {
+5
View File
@@ -189,6 +189,11 @@ const CASES: &[Case] = &[
];
#[test]
/// Write review screenshots when `CRAFT_KEYMAP_SNAPSHOT_DIR` is set.
///
/// For every case whose name starts with `CRAFT_KEYMAP_SNAPSHOT_ONLY`, renders light and dark at
/// 1× and 2× with the pinned fonts, optional sets and long labels, after running the case action.
/// Without the directory variable the test does nothing; these are not golden comparisons.
fn dialog_screenshots() {
let Some(dir) = std::env::var_os("CRAFT_KEYMAP_SNAPSHOT_DIR").map(PathBuf::from) else { return };
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
+2
View File
@@ -24,6 +24,8 @@ struct App {
}
impl eframe::App for App {
/// Render the dock, apply every returned action with default permissions, print floating
/// window places, and forward residual native-window events to the host.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let style = DockStyle::from_ui(ui);
let out = DockArea::new(egui::Id::unique("dock")).show_content(ui, &self.layout, &style, &mut Panels);
+48
View File
@@ -52,6 +52,9 @@ struct Frames {
galleys_after_warmup: usize,
}
/// Run all micro and dock/integration cases at 1× and 2×, gate each case, and emit the schema-3
/// JSON report to `CRAFT_UI_PERF_OUT` (or stdout). Fails when no bundled or fixture font is
/// available.
fn main() -> Result<(), Box<dyn Error>> {
let (fonts, font_names) = font_definitions()?;
if font_names.is_empty() {
@@ -182,6 +185,9 @@ fn toolbar_widths(count: usize) -> Vec<f32> {
(0..count).map(|i| 24.0 + ((i * 13) % 48) as f32).collect()
}
/// Time `op` after at least 50 warmup calls and 3 seconds, record 50 batch-mean samples (2000
/// operations per batch for split cases, 16 otherwise), and also record diagnostic single-call
/// latencies. Returns the counts, samples and summaries without p95/p99 where noted.
fn micro_entry(name: &str, params: Value, mut op: impl FnMut()) -> Value {
let warmup_start = Instant::now();
let mut warmups = 0;
@@ -223,6 +229,7 @@ fn micro_entry(name: &str, params: Value, mut op: impl FnMut()) -> Value {
"individual_latency_note": "diagnostic only; timer overhead dominates single sub-microsecond calls"})
}
/// Sort a copy of `samples` and return count, median, p95, p99, min and max in nanoseconds.
fn summarize(samples: &[u64]) -> Value {
let mut sorted = samples.to_vec();
sorted.sort_unstable();
@@ -231,6 +238,8 @@ fn summarize(samples: &[u64]) -> Value {
"max_ns": sorted.last().copied().unwrap_or(0)})
}
/// Nearest-rank percentile of an ascending slice. Empty input returns 0; the index is clamped to
/// the last element.
fn percentile(sorted: &[u64], percent: usize) -> u64 {
if sorted.is_empty() {
return 0;
@@ -238,6 +247,8 @@ fn percentile(sorted: &[u64], percent: usize) -> u64 {
sorted[(sorted.len() * percent).div_ceil(100).saturating_sub(1).min(sorted.len() - 1)]
}
/// Use egui's bundled fonts, or an empty definition set with the `CRAFT_UI_PERF_FONT` file inserted
/// first in both proportional and monospace families. Returns the used font names.
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") {
@@ -256,6 +267,8 @@ 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)" }
}
/// A headless context at `scale` with the given fonts, dark visuals, zero animation time and no
/// cursor blinking.
fn context(scale: f32, fonts: &egui::FontDefinitions) -> Context {
let ctx = Context::default();
ctx.set_pixels_per_point(scale);
@@ -272,6 +285,7 @@ fn screen_rect(viewport: [f32; 2]) -> Rect {
Rect::from_min_size(pos2(0.0, 0.0), vec2(viewport[0], viewport[1]))
}
/// A focused 60 Hz input for `viewport` with a bounded texture side for headless frames.
fn raw_input(viewport: [f32; 2], frame: usize) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(screen_rect(viewport)),
@@ -282,6 +296,9 @@ fn raw_input(viewport: [f32; 2], frame: usize) -> egui::RawInput {
}
}
/// Check that tab fitting partitions every index exactly once, uses finite nonnegative widths,
/// stays within `available` (plus epsilon) including the overflow control, and keeps the selected
/// tab (clamped to the last tab).
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()];
@@ -309,6 +326,8 @@ fn check_tabs(natural: &[f32], selected: usize, available: f32, minimum: f32, ov
Ok(())
}
/// Check a split layout: both panes are positive and sum to the span minus the gap, do not overlap
/// or escape, `resized` stays finite, and an inverted rect is rejected. Returns the layout.
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 {
@@ -338,6 +357,9 @@ fn check_split(rect: Rect, axis: SplitAxis, size: SplitSize, gap: f32, minimum:
Ok(layout)
}
/// Check that toolbar fitting is an exact prefix partition: shown widths match the natural widths,
/// overflow is the remaining suffix, a nonempty overflow reserves a positive control, and a
/// zero-width group is rejected.
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;
@@ -359,10 +381,12 @@ fn check_toolbar(natural: &[f32], available: f32, spacing: f32, overflow: f32) -
Ok(())
}
/// A one-panel tabs node with the panel active.
fn leaf(panel: u32) -> Node<u32> {
Node::Tabs { panels: vec![panel], active: 0 }
}
/// A balanced recursive split fixture over `panels`, alternating the axis with `depth`.
fn tree(panels: &[u32], depth: usize) -> Node<u32> {
if panels.len() == 1 {
return leaf(panels[0]);
@@ -376,6 +400,8 @@ fn tree(panels: &[u32], depth: usize) -> Node<u32> {
}
}
/// The benchmark layout: with `with_floating`, a fixed 240 pt second column beside a vertical split
/// plus floating window id 1; otherwise a balanced tree over `panels`.
fn dock_layout(panels: u32, with_floating: bool) -> Layout<u32> {
if with_floating {
Layout {
@@ -402,6 +428,7 @@ fn panel_label(panel: &u32) -> String {
format!("Panel {panel}")
}
/// Count texture-set images, their color bytes, and freed images in `delta`.
fn texture_counts(delta: &egui::TexturesDelta) -> (usize, usize, usize) {
let mut images = 0;
let mut bytes = 0;
@@ -415,6 +442,8 @@ fn texture_counts(delta: &egui::TexturesDelta) -> (usize, usize, usize) {
(images, bytes, delta.free.len())
}
/// Sum mesh vertices and indices, rejecting renderer callbacks because benchmark fixtures must not
/// produce them.
fn count_mesh(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<(usize, usize), Box<dyn Error>> {
let (mut vertices, mut indices) = (0, 0);
for clipped in primitives {
@@ -432,6 +461,8 @@ fn count_mesh(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<(usize, u
// 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.
/// A 64-bit FNV-1a fingerprint over primitive count, clip rects, texture ids, vertex data and
/// indices. This is a diagnostic checksum, not exact mesh identity; callbacks are rejected.
fn mesh_hash(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<u64, Box<dyn Error>> {
let mut hash = 0xcbf29ce484222325u64;
let mut feed = |bytes: &[u8]| {
@@ -476,6 +507,10 @@ fn mesh_hash(primitives: &[egui::epaint::ClippedPrimitive]) -> Result<u64, Box<d
Ok(hash)
}
/// Run `FRAME_SAMPLES` measured frames after at least 50 warmup frames and 3 seconds. Times and
/// completed passes are recorded for every measured frame; the final measured frame also collects
/// bodies and the shapes, mesh counts and hash reported in the result. Texture and repaint counters
/// aggregate over measured frames, and texture deltas are cleared each frame.
fn measure_frames(
ctx: &Context,
viewport: [f32; 2],
@@ -524,6 +559,8 @@ fn measure_frames(
Ok(result)
}
/// Check every recorded body is positive, finite and inside the viewport; non-floating bodies may
/// not overlap by more than 0.5 pt. Returns the body count.
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() {
@@ -545,6 +582,8 @@ fn check_bodies(screen: Rect, bodies: &BTreeMap<u32, Rect>, floating: &BTreeSet<
Ok(bodies.len())
}
/// Check every interaction owned by the dock has a response with a positive hit rect inside the
/// viewport. Returns the interaction count.
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() {
@@ -562,6 +601,8 @@ fn check_interactions(ctx: &Context, dock_id: Id, screen: Rect) -> Result<usize,
Ok(owned.len())
}
/// Move the pointer to the first body's tab center in a separate unmeasured frame and require the
/// tab to hover; returns the probe JSON. The probe is excluded from timings and counters.
fn probe_tab_hover(
ctx: &Context,
viewport: [f32; 2],
@@ -594,6 +635,8 @@ struct Case<'a> {
scale: f32,
}
/// Render the fixture dock through `DockArea`, optionally recording each panel body's rect on the
/// collection frame, and return the dock error.
fn render_dock(
ui: &mut egui::Ui,
dock_id: Id,
@@ -629,6 +672,8 @@ struct Scene {
}
impl Scene {
/// Render the dock inside an optional 180 pt left panel and central panel, capturing panel geometry
/// and preserving the first dock error; otherwise render at the available rect.
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));
@@ -657,6 +702,9 @@ impl Scene {
}
}
/// Run one case end to end: validate the layout, measure frames, gate sample count, warmup, one
/// completed pass per frame and dock error, check body count, interactions and geometry, run the
/// hover probe, and return the case JSON.
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));
+23
View File
@@ -49,6 +49,9 @@ pub enum CaptureError {
}
impl core::fmt::Display for CaptureError {
/// Format a human-readable rejection reason. The wording is informative only and is not an
/// API contract; the variants keep checked-overflow, malformed-image and size-cap cases
/// distinguishable.
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::DimensionsOverflow { width, height } => {
@@ -136,33 +139,46 @@ impl CaptureSlot {
}
#[cfg(test)]
/// Nonblocking probe used by the wakeup test to prove that the slot mutex is free while the
/// repaint observer runs.
fn lock_is_free(&self) -> bool {
self.shared.state.try_lock().is_ok()
}
#[cfg(test)]
/// Count strong references to the shared slot state, proving callbacks retain none.
fn state_strong_count(&self) -> usize {
Arc::strong_count(&self.shared)
}
#[cfg(test)]
/// A weak handle to the shared state, used to inject a second completion directly when egui has
/// already consumed the public callback.
fn state_weak(&self) -> Weak<Shared> {
Arc::downgrade(&self.shared)
}
}
impl Drop for CaptureSlot {
/// Cancel the request as the slot goes away. A later completion upgrades no live state and
/// releases its image, a retained ready image is dropped, and no repaint is requested. Results
/// already taken by the caller are unaffected.
fn drop(&mut self) {
close(&self.shared);
}
}
impl core::fmt::Debug for CaptureSlot {
/// Format only the viewport and omit the remaining fields. The retained image and error payload
/// are deliberately hidden so logging cannot hold or dump screenshot contents.
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("CaptureSlot").field("viewport", &self.shared.viewport).finish_non_exhaustive()
}
}
/// Lock the slot state, recovering from poisoning instead of panicking. The guard must be
/// released before entering the egui context or a repaint observer; completion and close do so
/// before waking the viewport.
fn lock(state: &Mutex<SlotState>) -> MutexGuard<'_, SlotState> {
state.lock().unwrap_or_else(PoisonError::into_inner)
}
@@ -208,6 +224,13 @@ fn complete(weak: &Weak<Shared>, image: Arc<ColorImage>) {
}
}
/// Validate `image` against the slot's per-image byte bound before publishing it.
///
/// Zero width or height is malformed before any arithmetic; the pixel count and its byte size use
/// checked multiplication; the buffer length must equal `width * height`; and only then is the
/// byte count compared with `max_image_bytes`, rejecting a count greater than it (equality is
/// accepted). `max_image_bytes` bounds this one image, not an aggregate request budget. Returns the
/// same `Arc` without waking the viewport or requesting a repaint.
fn checked_image(image: Arc<ColorImage>, max_image_bytes: usize) -> Result<Arc<ColorImage>, CaptureError> {
let [width, height] = image.size;
if width == 0 || height == 0 {
+36
View File
@@ -3,10 +3,12 @@ use std::sync::{Arc, Barrier};
use super::*;
/// Allocate a filled `Arc<ColorImage>` fixture of `size` with every pixel set to `color`.
fn image(size: [usize; 2], color: egui::Color32) -> Arc<ColorImage> {
Arc::new(ColorImage::filled(size, color))
}
/// Install a repaint observer and return the viewport requests it records.
fn observe_repaints(ctx: &Context) -> Arc<Mutex<Vec<ViewportId>>> {
let requests = Arc::new(Mutex::new(Vec::new()));
let observed = requests.clone();
@@ -15,6 +17,7 @@ fn observe_repaints(ctx: &Context) -> Arc<Mutex<Vec<ViewportId>>> {
}
#[test]
/// Compile-time assertion that `CaptureSlot` and `ScreenshotCallback` are `Send + Sync`.
fn slot_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<CaptureSlot>();
@@ -22,6 +25,8 @@ fn slot_is_send_and_sync() {
}
#[test]
/// Two independent slots for different viewports may complete in reverse order; each `take`
/// returns exactly its own image and dimensions.
fn distinct_requests_complete_in_reverse_order() {
let ctx = Context::default();
let first_viewport = ViewportId::from_hash_of("first-window");
@@ -44,6 +49,9 @@ fn distinct_requests_complete_in_reverse_order() {
}
#[test]
/// Slots keep separate results and byte caps: a 2×2 image over the first slot's 4-byte cap is
/// rejected as too large while the second stays pending, then the second accepts its own image and
/// a taken slot remains empty.
fn slots_do_not_share_results_or_byte_bounds() {
let ctx = Context::default();
let first_viewport = ViewportId::from_hash_of("first-slot");
@@ -64,6 +72,8 @@ fn slots_do_not_share_results_or_byte_bounds() {
}
#[test]
/// Cloned callbacks share one completion: the first result wins, the losing image is released, and
/// `take` yields the result only once.
fn callback_clones_share_one_completion_and_take_is_single_use() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
@@ -82,6 +92,8 @@ fn callback_clones_share_one_completion_and_take_is_single_use() {
}
#[test]
/// After `take`, both a duplicate callback and a direct completion release their images without
/// storing a result or requesting a repaint.
fn duplicate_completion_after_take_neither_stores_nor_wakes() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -110,6 +122,7 @@ fn duplicate_completion_after_take_neither_stores_nor_wakes() {
}
#[test]
/// An early `take` returns `None` without closing the request; a later completion still delivers.
fn take_before_completion_is_empty_and_keeps_the_request_open() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
@@ -120,6 +133,8 @@ fn take_before_completion_is_empty_and_keeps_the_request_open() {
}
#[test]
/// Cancelling before completion releases a late image, leaves `take` empty, and suppresses the
/// repaint wakeup.
fn cancel_releases_late_images_without_waking() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -138,6 +153,8 @@ fn cancel_releases_late_images_without_waking() {
}
#[test]
/// A ready slot retains its image and has woken once; `cancel` releases the image and does not wake
/// again.
fn cancel_after_ready_releases_the_image() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -157,6 +174,7 @@ fn cancel_after_ready_releases_the_image() {
}
#[test]
/// Dropping the slot releases an image that is only retained by the completed slot.
fn dropping_the_slot_releases_a_completed_image() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
@@ -171,6 +189,7 @@ fn dropping_the_slot_releases_a_completed_image() {
}
#[test]
/// A completion after the slot is dropped releases the image and does not wake the viewport.
fn late_completion_after_drop_is_inert() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -188,6 +207,8 @@ fn late_completion_after_drop_is_inert() {
}
#[test]
/// Callbacks hold only a weak reference: the slot state has one strong owner, dropping the slot
/// releases the context and state, and a later completion is inert.
fn callback_retains_only_a_weak_reference_to_live_state() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -208,6 +229,8 @@ fn callback_retains_only_a_weak_reference_to_live_state() {
}
#[test]
/// A completion distinguishes the per-image size cap, a malformed pixel buffer, and overflow of
/// the pixel or byte count; a returned error is taken at most once.
fn rejects_oversized_malformed_and_overflowing_images() {
let ctx = Context::default();
@@ -232,6 +255,8 @@ fn rejects_oversized_malformed_and_overflowing_images() {
}
#[test]
/// Oversized and malformed images are dropped immediately and each rejection still wakes the error
/// consumer exactly once before `take` reports it.
fn rejected_oversized_and_malformed_images_are_released_before_take() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -254,6 +279,8 @@ fn rejected_oversized_and_malformed_images_are_released_before_take() {
}
#[test]
/// Any zero width or height is rejected as malformed before overflow or size-cap decisions, even
/// for extreme extents such as `usize::MAX × 0`.
fn zero_area_images_are_rejected_even_with_extreme_extents() {
let ctx = Context::default();
@@ -270,6 +297,7 @@ fn zero_area_images_are_rejected_even_with_extreme_extents() {
}
#[test]
/// A 2×2 image at exactly the 16-byte cap is accepted: the bound is inclusive.
fn accepts_an_image_exactly_at_the_byte_bound() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 16);
@@ -279,6 +307,8 @@ fn accepts_an_image_exactly_at_the_byte_bound() {
}
#[test]
/// A worker thread completes a child-window slot; every recorded wake belongs to that slot's
/// viewport and the expected image is delivered.
fn another_thread_completes_and_wakes_only_the_owning_viewport() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -298,6 +328,8 @@ fn another_thread_completes_and_wakes_only_the_owning_viewport() {
}
#[test]
/// The repaint observer can `take` the image re-entrantly: `try_lock` succeeds during the wakeup,
/// proving the slot lock is released before `request_repaint_of`.
fn wakeup_runs_after_the_slot_lock_is_released() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 1024);
@@ -386,6 +418,7 @@ fn complete_cancel_and_take_race_without_retaining_or_double_waking() {
}
#[test]
/// A zero byte budget rejects any nonempty image as too large and wakes once.
fn zero_budget_rejects_a_nonempty_image_and_wakes() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
@@ -396,6 +429,7 @@ fn zero_budget_rejects_a_nonempty_image_and_wakes() {
}
#[test]
/// Two threads racing on `take` produce exactly one result, and a subsequent take is empty.
fn concurrent_takers_receive_the_result_exactly_once() {
let ctx = Context::default();
let (slot, callback) = CaptureSlot::new(&ctx, ViewportId::ROOT, 4);
@@ -422,6 +456,8 @@ fn concurrent_takers_receive_the_result_exactly_once() {
}
#[test]
/// A renderer thread can complete a capture while a UI closure is blocked; the image is delivered
/// and the owning child viewport is woken exactly once.
fn renderer_completion_during_a_live_ui_pass_can_wake_and_deliver() {
let ctx = Context::default();
let requests = observe_repaints(&ctx);
+18
View File
@@ -175,6 +175,15 @@ impl<'a> FloatingFrame<'a> {
shown.inner
}
/// Draw the resize handles around `rect` and return `(resized, started, responses)`.
///
/// `resized` is set only for an enabled, uncancelled handle whose clamped next rect differs
/// from the current one. `started` names the handle whose drag began, which can remain set after
/// Escape or focus loss cancels that drag. Keyboard arrow steps move an edge by 8 pt; pointer
/// drags derive the next rect from the cursor relative to the press origin. Limits clamp the
/// proposal. Stored pointer anchors are removed on cancellation or once the primary button is
/// released. Handles are accessibility buttons; the diagonal one is painted while hovered,
/// dragged or focused.
fn handles(&self, ui: &mut Ui, rect: Rect, bounds: Rect, style: &ChromeStyle) -> (Option<Rect>, Option<Id>, Vec<Response>) {
let mut resized = None;
let mut started = None;
@@ -345,6 +354,15 @@ impl<'a> TitleBar<'a> {
self
}
/// Draw the title bar into `rect` and return its responses.
///
/// `close_at` rects are in logical points, already mirrored in a right-to-left layout; without it
/// the × is a square the bar's height centered 10 pt in from the trailing edge. Menu and close
/// buttons reserve space on the trailing side, the draggable bar excludes them, and a title is
/// truncated to the bar's remaining width. The bar senses clicks and drags, while double-click
/// behavior (for example minimizing or closing) is the caller's. A right-to-left style mirrors
/// the layout; `move_label` remains the accessible name of the drag surface. The bar's own id
/// is used, with `"menu"` and `"close"` child ids for the buttons.
pub fn show(self, ui: &mut Ui, rect: Rect, style: &ChromeStyle) -> TitleResponse {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, style.title_background);
+10
View File
@@ -108,6 +108,9 @@ impl Driver {
driver
}
/// One 800×600 frame with injected events and the driver's focus/right-to-left settings.
/// Renders the dock in a 600×600 scope, clears texture deltas, records shapes, and returns
/// `DockOutput`.
fn frame(&mut self, events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
self.time += 1.0 / 60.0;
@@ -432,6 +435,8 @@ fn a_column_takes_groups_anywhere_in_it_and_as_tabs() {
}
#[test]
/// A column of five groups with a 160 pt minimum is taller than the window; scrolling up over its
/// tab strip moves the last tab more than 100 pt into view.
fn a_column_too_tall_for_the_window_scrolls() {
let groups: [&[u32]; 5] = [&[2], &[3], &[4], &[5], &[6]];
let mut d = Driver::with(docked(Side::Start, &[&groups]), TestContent { min_height: 160.0 }, false);
@@ -483,6 +488,8 @@ fn column_bar_drag_moves_the_column_and_away_from_a_dock_floats_its_groups() {
}
#[test]
/// Escape, focus loss, pointer exit and `cancel_drag` each end a held column drag without emitting
/// actions or a group drop; the release changes nothing and the layout equals the original.
fn column_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
let layout = docked(Side::Start, &[&[&[2], &[3]], &[&[5]]]);
for case in ["escape", "focus", "exit", "cancel"] {
@@ -635,6 +642,9 @@ fn a_column_s_first_tab_dropped_on_its_own_column_edge_anchors_on_another_panel(
}
#[test]
/// A driver that never reported window focus can still drag tab 3 to a column edge (moving it after
/// panel 4). When focus arrives and is then lost mid-drag, releasing emits no action and restores the
/// original layout.
fn a_window_that_never_reported_focus_drags_tabs_and_losing_focus_cancels_a_drag() {
let mut layout = docked(Side::Start, &[&[&[2]], &[&[3, 4]]]);
if let Some(Node::Split { size, .. }) = &mut layout.root {
+23
View File
@@ -125,6 +125,8 @@ impl Driver {
driver
}
/// One 800×600 frame with an inserted modifier event, optional split-tree style/native
/// windows/maximized frame/hidden panels. Clears texture deltas and returns `DockOutput`.
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
events.insert(0, Event::ModifiersChanged(self.modifiers));
@@ -286,6 +288,8 @@ fn title_bar_drag_onto_dock_targets_docks_the_window() {
}
#[test]
/// A group dragged to another floating window's top band splits that window like a frame; the gap
/// between groups is not a target, and the window divider resizes both sides.
fn a_floating_window_is_a_tree_whose_groups_split_and_resize_like_frames() {
// A group dragged by its bar to another window's top band splits that window's group.
let mut d = Driver::new(workspace(), false);
@@ -315,6 +319,8 @@ fn a_floating_window_is_a_tree_whose_groups_split_and_resize_like_frames() {
}
#[test]
/// Double-clicking the window title toggles minimization of the whole window; the close box sits
/// 10 pt in from the trailing edge and emits `CloseWindow`, removing every panel in the window.
fn title_bar_double_click_minimizes_every_group_and_close_box_closes_the_window() {
let mut d = Driver::new(workspace(), false);
let title = d.title(5).center();
@@ -404,6 +410,9 @@ fn the_split_tree_outer_edge_band_is_8_pt() {
}
#[test]
/// Maximizing a frame makes it start at the area edge; another frame's tab is not rendered and the
/// hidden floating window's move-group response is absent, so dragging its title emits no move. A
/// floating panel cannot be maximized.
fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input() {
let mut layout = frames();
layout.floating.push(Floating::single(vec![5], 0, [400.0, 300.0, 200.0, 150.0]));
@@ -427,6 +436,8 @@ fn maximized_frame_fills_the_area_and_other_frames_do_not_render_or_take_input()
}
#[test]
/// The tab context menu exposes the expected group actions without Minimize; Close Other emits
/// `CloseOthers` leaving only the chosen tab, and Maximize Frame emits `ToggleMaximize`.
fn close_other_panels_keeps_only_the_chosen_tab() {
use egui_kittest::{Harness, kittest::Queryable};
type State = (Content, Vec<Action<u32>>, Vec<DockEvent<u32>>);
@@ -495,6 +506,8 @@ fn hide_panels_keeps_protected_panels_and_the_except_set() {
}
#[test]
/// A protected canvas that accepts splits is still kept when every panel is hidden, and draws
/// neither a tab nor a drag bar.
fn a_canvas_that_accepts_splits_is_still_protected() {
let mut d = Driver::new(workspace(), false);
d.content.beside = true;
@@ -522,6 +535,8 @@ fn native_window_falls_back_in_window_when_viewports_are_embedded() {
#[cfg(feature = "native-windows")]
#[test]
/// With real viewports, a close event from a native window's backend produces `CloseWindow` and
/// removes all of that window's panels.
fn native_window_close_closes_its_panels() {
use std::{cell::Cell, rc::Rc};
let close = Rc::new(Cell::new(false));
@@ -564,6 +579,8 @@ fn native_window_close_closes_its_panels() {
}
#[test]
/// `FloatingFrame` and `TitleBar` work without a dock area: close and menu clicks register, the bar
/// drag reports its delta, and an edge drag resizes the frame.
fn chrome_frame_and_title_bar_work_without_a_dock_area() {
use chrome::{ChromeStyle, FloatingFrame, TitleBar};
let ctx = Context::default();
@@ -612,6 +629,8 @@ fn chrome_frame_and_title_bar_work_without_a_dock_area() {
}
#[test]
/// Hidden panels give up their split space; the remaining split hit is named by its layout path and
/// dragging it emits `ResizeSplit` for exactly that path while the nested ratio changes.
fn hidden_panels_leave_their_split_and_resizing_names_the_layout_path() {
let layout = Layout {
root: Some(Node::Split {
@@ -661,6 +680,8 @@ fn a_hidden_floating_window_does_not_cover_dock_targets() {
}
#[test]
/// Each top resize edge sits directly above the title bar and keeps the opposite edges while
/// dragging; the title bar still moves the window, and a focused top edge responds to arrow keys.
fn top_edges_resize_from_the_band_above_the_title_bar() {
// W2 holds [8] at [560, 300, 200, 200]; each top handle keeps the opposite edges.
let area = Id::unique(AREA);
@@ -686,6 +707,8 @@ fn top_edges_resize_from_the_band_above_the_title_bar() {
}
#[test]
/// Undock Panel Group from one group of a stacked floating window emits `MoveGroup` to a float and
/// separates exactly that group; a window with only one group has no undock entry.
fn undock_panel_group_floats_one_group_of_a_floating_window() {
use egui_kittest::{Harness, kittest::Queryable};
let stacked = || Layout {
+19
View File
@@ -132,6 +132,8 @@ impl Driver {
driver
}
/// One 800×600 frame with inserted modifier events and optional transparent/tweaked style or
/// custom area. Captures shapes, clears texture deltas, and returns `DockOutput`.
fn frame(&mut self, mut events: Vec<Event>) -> DockOutput<u32> {
let mut output = DockOutput::default();
events.insert(0, Event::ModifiersChanged(self.modifiers));
@@ -255,6 +257,9 @@ fn docked_two_tab_group_drags_by_its_bar_and_keeps_order_and_active_tab() {
}
#[test]
/// In a 300 pt strip with eight tabs and a 24 pt header, the drag bar keeps at least 40 pt and ends
/// at the header; tabs shrink and overflow before it is narrowed, and dragging it floats all eight
/// panels as one group.
fn drag_bar_survives_a_strip_full_of_tabs() {
let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6, 7, 8], 0)), floating: Vec::new() };
let content = TestContent { header: 24.0, ..Default::default() };
@@ -392,6 +397,8 @@ fn ctrl_or_cmd_held_at_release_always_floats() {
}
#[test]
/// Escape, focus loss, pointer exit and `cancel_drag` each end a held group drag with no actions or
/// drop; releasing changes nothing and the layout equals the original.
fn group_drag_escape_focus_loss_pointer_exit_and_cancel_drag_leave_the_layout_unchanged() {
for case in ["escape", "focus", "exit", "cancel"] {
let mut d = Driver::new(frames());
@@ -547,6 +554,8 @@ fn docked_header_drag_reaches_the_host_hook_and_preserves_the_group() {
}
#[test]
/// A lone protected panel renders neither a tab nor a drag bar, while another group still has its
/// bar.
fn a_lone_protected_panel_draws_no_strip_and_no_bar() {
struct Bare;
impl DockContent<u32> for Bare {
@@ -569,6 +578,8 @@ fn a_lone_protected_panel_draws_no_strip_and_no_bar() {
}
#[test]
/// The drag bar's context menu contains Close Tab Group, Minimize, Float Tab Group and Move Group
/// To; clicking Close emits `CloseGroup`, and Enter on the focused bar opens the menu.
fn drag_bar_menu_has_the_group_items_and_opens_from_the_keyboard() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
@@ -617,6 +628,9 @@ fn a_group_dragged_out_of_a_floating_stack_tears_out_alone() {
}
#[test]
/// A docked panel floats at the title bar plus strip plus recorded content height, with a 120 pt
/// minimum and the 320 pt docked cap; a host floating size supplies the width and caps the height,
/// and a group uses its active panel's content height.
fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() {
let title = crate::docking::chrome::TITLE_HEIGHT;
let float = |body: f32, floating_size: Option<Vec2>| {
@@ -678,6 +692,8 @@ fn a_panel_that_fills_its_frame_floats_at_its_floating_size_or_320() {
}
#[test]
/// Keyboard focus on a tab is kept across a user drag, but a layout reset that moves the tab clears
/// it and reports no new focus; closing the panel also gives focus up.
fn tab_focus_follows_only_the_users_input() {
let mut d = Driver::new(docked());
let tab = Id::unique(AREA).with(("tab", &4u32));
@@ -715,6 +731,9 @@ fn tab_focus_follows_only_the_users_input() {
}
#[test]
/// Wheel selection is off by default. With the floating frame style, wheel up selects the previous
/// tab and stops at the first, trackpad points accumulate into a notch, huge deltas clamp at both
/// ends, and middle-click closes the tab.
fn frame_tabs_follow_the_wheel_and_close_on_a_middle_click() {
let second = |d: &Driver| match &d.layout.root {
Some(Node::Split { second, .. }) => (**second).clone(),
+2
View File
@@ -160,6 +160,8 @@ fn icon_click_opens_a_flyout_on_the_canvas_side_and_auto_collapse_closes_it() {
}
#[test]
/// Choosing Auto-Collapse Iconic Panels from the iconic column's menu emits a
/// `DockEvent::ToggleAutoCollapse` event rather than mutating the layout directly.
fn auto_collapse_toggle_is_a_column_menu_event() {
use egui_kittest::{Harness, kittest::Queryable};
let layout = docked(&[&[1, 2]], true);
+9
View File
@@ -75,6 +75,9 @@ enum Marks {
}
impl DockContent<u32> for Content {
/// Test body that probes native input: records events/raw events, text focus and typing, button
/// focus, a click counter, drag source state, drop sites and payloads, file hover/drop, and
/// primary/secondary presses inside the body.
fn body(&mut self, ui: &mut egui::Ui, panel: &u32) {
ui.label(format!("panel {panel}"));
if self.input_probe == Some(*panel) {
@@ -1518,6 +1521,9 @@ fn resize_bands_are_a_few_points_deep_and_leave_out_the_bar_buttons() {
}
#[test]
/// On macOS the close glyph sits 8 pt from the trailing corner in both directions; its hit area
/// extends 10 pt away from the corner, reaches the bar bottom, leaves the drag area clear, and stays
/// reachable outside AppKit's corner resize region.
fn on_macos_the_close_glyph_sits_by_the_corner_and_its_hit_area_reaches_away_from_it() {
use crate::docking::chrome::{ChromeStyle, TitleBar};
use crate::docking::placement::BAR_HEIGHT;
@@ -1672,6 +1678,9 @@ fn a_native_window_takes_no_input_while_the_app_blocks_it() {
}
#[test]
/// A blocked native window rejects typing but preserves native control-event order: IME reset
/// completion and focus loss reach both event and raw-event readers, while a shortcut release is
/// still forwarded to the host.
fn a_blocked_native_window_preserves_ordered_ime_reset_completions() {
let mut d = blocked_driver();
let id = id_of(&d);
+20
View File
@@ -36,6 +36,15 @@ const OVERFLOW_WIDTH: f32 = 22.0;
const WHEEL_NOTCH: f32 = 40.0;
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// Draw one tab group at `rect`.
///
/// An `active` index outside `panels` is a no-op. A lone protected panel is drawn stripless:
/// its whole rect is the body and its only tab has `Rect::NOTHING`. Side splits are offered
/// only for a `Frame` or `Floating` group whose selected panel accepts splits. Tab labels
/// shrink widest-first to the minimum width, then the remaining tabs move to the overflow menu;
/// hidden overflow tabs still appear in the drop target with `Rect::NOTHING`. `minimized` hides
/// the body but keeps the strip. Tabs, strip, header and drag bar are mirrored in right-to-left
/// layout.
pub(super) fn group(&mut self, ui: &mut Ui, panels: &[P], active: usize, minimized: bool, rect: Rect, kind: GroupKind) {
let Some(selected) = panels.get(active) else { return };
self.record_float(panels, rect.size(), rect.size());
@@ -329,6 +338,9 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
anchors
}
/// A stock menu button for `panel` whose visible text comes from
/// [`crate::docking::DockContent::label`] while its accessible name stays the logical `label_of`
/// value. The function only returns the response; the caller performs the menu action.
pub(super) fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response {
let logical = self.label_of(panel);
let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text);
@@ -354,6 +366,14 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.ctx().data(|d| d.get_temp::<bool>(key)).unwrap_or(false)
}
/// Draw one tab inside `rect` and return its response.
///
/// Clicking activates and focuses the panel. In a minimized group the first click expands and
/// records the time so that same click is not also interpreted as a double-click; a genuine
/// double-click toggles minimized only when the owner minimizes. Middle-click closes only when
/// the style enables it and the panel is closable. The context menu emits `Action`s, with float,
/// move and close entries gated by the panel's permissions. `owner: None` removes minimized
/// semantics and undock latching. An elided label exposes its full text as hover text.
pub(super) fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect, owner: Option<&TabGroup<'_, P>>) {
let minimized = owner.filter(|owner| self.minimizes(owner)).map(|owner| owner.minimized);
let policy = self.permissions(panel);
+7
View File
@@ -396,6 +396,13 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
/// Draw a panel body into `rect`.
///
/// An unusable rect returns without painting. Otherwise the body runs in a clipped child scope,
/// and its minimum height is recorded against `rect.height()` for floating-size policy. Any
/// pointer press inside the body focuses the panel, including secondary and middle presses; with
/// native windows that press origin is stored for body-drag routing and the panel is recorded as
/// the pointed body. The panel's content map keeps the sizing entry for later frames.
pub(super) fn contents(&mut self, ui: &mut Ui, panel: &P, rect: Rect) {
if !crate::docking::targets::usable(rect) {
return;
+14
View File
@@ -836,6 +836,11 @@ pub(super) fn body_input(ctx: &Context) -> Arc<egui::mutex::Mutex<NativeBodyInpu
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<egui::mutex::Mutex<NativeBodyInput>>>(body_input_key()).clone())
}
/// The temp-data key for the context's native body-routing state.
///
/// All dock areas in a context deliberately use this one container: its `routes` map is keyed by
/// dock-area `Id`, and queued and held events by target viewport. The key is unique only so this
/// state does not collide with unrelated temporary data.
fn body_input_key() -> Id {
Id::unique("craft-ui docking native body input")
}
@@ -847,6 +852,15 @@ impl egui::Plugin for NativeInput {
"craft-ui docking native input"
}
/// Route this viewport's input without deadlocking.
///
/// Only the root viewport advances the frame pass; queued and held entries older than the
/// current pass are pruned. Events waiting for this viewport are taken before routing, so events
/// enqueued by routing wait for its next input. A held release is only released once a later
/// root pass exists. Earlier routed events are spliced before the window's own events, and any
/// accepted route requests a root repaint. The dock area's application hook is called only
/// after the routing mutex is dropped because it may cancel a drag and re-enter the shared
/// body state.
fn input_hook(&mut self, ctx: &Context, input: &mut RawInput) {
let viewport = input.viewport_id;
let body = body_input(ctx);
+56
View File
@@ -487,6 +487,8 @@ fn old_hidden_locations_accept_panel_ids_without_a_default_identity() {
assert_eq!(location.anchor, Some(Panel::Layers));
}
/// One 800×600 `DockArea::show` frame with actual body labels. Returns `DockOutput` plus the body
/// scope ids and records the final cursor icon in temp data; clears texture deltas.
fn frame(ctx: &Context, layout: &Layout<u32>, events: Vec<Event>, focused: bool) -> (DockOutput<u32>, Vec<Id>) {
let mut output = DockOutput::default();
let mut ids = Vec::new();
@@ -525,6 +527,8 @@ fn renderer_keeps_content_identity_when_panel_moves_into_a_float() {
}
#[test]
/// Escape and window focus loss each discard an in-progress drag; a subsequent release emits no
/// actions.
fn cancelling_or_losing_focus_during_a_drag_does_not_move_panels() {
let ctx = Context::default();
let layout = sample();
@@ -565,6 +569,8 @@ fn pointer_button(pos: egui::Pos2, pressed: bool) -> Event {
}
#[test]
/// Real pointer input drags a floating panel's tab into the main tab strip, emits a `Move`, removes
/// it from the float, preserves the panel count, and activates it in the target group.
fn actual_pointer_drag_docks_a_floating_panel_into_the_main_tab_strip() {
let ctx = Context::default();
let mut layout = sample();
@@ -610,6 +616,9 @@ fn tab_dragged_out_of_the_window_floats_where_it_left() {
}
#[test]
/// At wide, narrow and wide-again sizes, every rendered body rect stays horizontally inside the
/// viewport and, at the wide sizes, panel min/max limits bound its width, without mutating the
/// layout's stored preferred widths.
fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() {
let layout = Layout {
root: Some(Node::Split {
@@ -668,6 +677,8 @@ fn panel_size_limits_clamp_geometry_without_overwriting_preferred_widths() {
}
#[test]
/// A focused bottom-right floating resize grip moves width and height with Right/Down arrows and
/// then loses focus to Tab normally.
fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() {
let ctx = Context::default();
let mut layout = sample();
@@ -687,6 +698,8 @@ fn focused_floating_resize_grip_accepts_both_arrow_axes_and_releases_tab() {
}
#[test]
/// A stack divider dragged by pointer grows the open body above and shrinks the adjacent open body
/// while closed entries and unrelated bodies keep their heights; keyboard arrows continue resizing.
fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() {
let ctx = Context::default();
let mut layout = Layout {
@@ -738,6 +751,8 @@ fn stack_dividers_resize_adjacent_open_bodies_by_pointer_and_keyboard() {
}
#[test]
/// A horizontal split's inner edge inside a scrollable dock remains hit-testable and draggable next
/// to a scrollbar.
fn split_inner_edge_remains_draggable_beside_a_scrollbar() {
let ctx = Context::default();
let mut layout = Layout {
@@ -820,6 +835,7 @@ fn restoring_a_panel_beside_a_nested_subtree_preserves_the_complete_workspace()
}
#[test]
/// A layout with only floating windows leaves the caller's available document rect unchanged.
fn floating_only_surface_does_not_allocate_the_callers_document_region() {
let ctx = Context::default();
let layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [30.0, 40.0, 200.0, 200.0])] };
@@ -843,6 +859,8 @@ fn floating_only_surface_does_not_allocate_the_callers_document_region() {
}
#[test]
/// Dragging the floating group grip emits `MoveFloating` and changes the window rect while the
/// group's panels and active panel stay the same; dragging a tab detaches only that panel.
fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() {
let ctx = Context::default();
let mut layout = sample();
@@ -983,6 +1001,8 @@ struct CustomContent {
}
#[test]
/// `cancel_drag` discards a floating group's move before the caller replaces the layout, so later
/// pointer movement and release emit nothing and the replacement stays intact.
fn host_cancel_discards_group_drag_before_workspace_replacement() {
let ctx = Context::default();
let mut layout = sample();
@@ -1025,6 +1045,8 @@ impl DockContent<u32> for CustomContent {
fn group_header_width(&self, _: &[u32], _: &u32, _: bool, _: f32) -> f32 {
40.0
}
/// Custom header: records the call, asserts it receives at most the declared 40 pt width, and
/// draws its own glyph.
fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) {
self.headers += 1;
assert!(ui.max_rect().width() <= 40.0);
@@ -1089,6 +1111,7 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
}
#[test]
/// An overlay appearing mid-drag blocks the release's float fallback; later frames emit no action.
fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() {
let ctx = Context::default();
let layout = sample();
@@ -1125,6 +1148,8 @@ fn group_move_escape_restores_original_geometry() {
}
#[test]
/// Custom headers and labels render at 0.75×, 1×, 1.5× and 2×: headers and bodies are produced and
/// panel 6's label is rendered with its logical text.
fn custom_headers_and_labels_render_at_small_and_large_scales() {
for scale in [0.75, 1.0, 1.5, 2.0] {
let ctx = Context::default();
@@ -1153,6 +1178,8 @@ fn custom_headers_and_labels_render_at_small_and_large_scales() {
}
#[test]
/// A protected member blocks whole-group gestures and host drops: drag/drop state stays empty and
/// no `MoveFloating` action is emitted.
fn protected_group_member_blocks_whole_group_gesture_and_host_drop() {
let ctx = Context::default();
let layout = sample();
@@ -1228,6 +1255,8 @@ fn protected_group_member_blocks_floating_group_resize() {
}
#[test]
/// Chrome focus recognizes a rendered tab but rejects a focused body text field, an invalid layout,
/// an empty layout and an unrendered id.
fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid_layout() {
let ctx = Context::default();
let layout = sample();
@@ -1267,6 +1296,8 @@ fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid
}
#[test]
/// Chrome focus recognizes rendered tabs, collapse buttons, stack resizers, split dividers and
/// floating resizers.
fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() {
let ctx = Context::default();
let area = Id::unique("focus-chrome-controls");
@@ -1304,6 +1335,9 @@ fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers()
}
#[test]
/// `cancel_drag` ends an owned split/stack/floating resize until a new press: held movement stops
/// producing actions and the layout stays at the cancellation snapshot, then a fresh press resizes
/// again.
fn workspace_cancel_stops_owned_resize_controls_until_new_press() {
let area = Id::unique("test-area");
let fixtures = [
@@ -1350,6 +1384,8 @@ fn workspace_cancel_stops_owned_resize_controls_until_new_press() {
}
#[test]
/// Cancelling one area's drag leaves another area's split divider or a body-owned drag in control,
/// and that gesture continues producing its effects.
fn workspace_cancel_preserves_other_area_and_body_drag_ownership() {
for body_drag in [false, true] {
let ctx = Context::default();
@@ -1415,6 +1451,8 @@ fn workspace_cancel_preserves_other_area_and_body_drag_ownership() {
}
#[test]
/// Custom action labels paint their visual substitution while the popup buttons keep their own
/// logical accessibility names; the visual text is not exposed as an accessibility label.
fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() {
use egui_kittest::{Harness, kittest::Queryable};
fn paints(shape: &egui::Shape, text: &str) -> bool {
@@ -1465,6 +1503,9 @@ fn popup_action_labels_use_custom_visual_text_and_logical_accessibility() {
}
#[test]
/// At the minimum tab width, only the tabs that fit beside the drag bar and overflow control are
/// shown, the active last tab replaces the last fitting tab, and the overflow menu lists exactly the
/// hidden panels.
fn a_strip_too_narrow_for_its_tabs_at_their_minimum_width_lists_the_others_in_the_overflow_menu() {
use egui::accesskit::Role;
use egui_kittest::{Harness, kittest::Queryable};
@@ -1499,6 +1540,8 @@ fn a_strip_too_narrow_for_its_tabs_at_their_minimum_width_lists_the_others_in_th
}
#[test]
/// Long tab labels are truncated with an ellipsis inside their tab and expose the full label in a
/// tooltip on hover.
fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tooltip() {
let ctx = Context::default();
ctx.all_styles_mut(|style| style.interaction.tooltip_delay = 0.0);
@@ -1551,6 +1594,8 @@ fn tab_labels_that_do_not_fit_are_truncated_inside_their_tab_and_named_in_a_tool
// Ported from storytold/craft-libs#110 by @Zenodeon.
#[test]
/// A floating resize keeps drag ownership when another host control rewrites the owned-interaction
/// list; `cancel_drag` then ends it and further movement is ignored.
fn resize_cancellation_retains_ownership_across_other_host_rendering() {
let ctx = Context::default();
let area = Id::unique("test-area");
@@ -1572,6 +1617,8 @@ fn resize_cancellation_retains_ownership_across_other_host_rendering() {
}
#[test]
/// A collapsed 26 pt floating window still resizes its width with the Right arrow under a height
/// cap, growing to the minimum height instead of ignoring the gesture.
fn collapsed_floating_height_cap_does_not_disable_keyboard_width_resizing() {
let ctx = Context::default();
let mut layout = Layout { root: None, floating: vec![Floating::single(vec![1u32], 0, [80.0, 70.0, 300.0, 26.0])] };
@@ -1632,6 +1679,11 @@ fn returning_to_a_singleton_or_floating_source_body_is_an_explicit_noop() {
}
#[test]
/// Dragging each floating resize grip with the pointer past a viewport edge leaves every asserted
/// edge unchanged when the pointer moves partway back while still outside, and returning to the
/// start point restores the original geometry. `resize-right` and `resize` assert the clamped
/// right edge and fixed left edge; `resize-left` and `resize-bottom-left` assert only their fixed
/// right edge. `resize-bottom` asserts the clamped bottom edge and fixed top edge.
fn pointer_floating_resize_stops_at_edges_and_waits_for_cursor_to_return() {
for name in ["resize-right", "resize-left", "resize-bottom", "resize", "resize-bottom-left"] {
let ctx = Context::default();
@@ -1700,6 +1752,8 @@ fn unchanged_tab_slots_are_explicit_noops_with_bounded_insertion_markers() {
}
#[test]
/// A host drag handler that accepts the release as a no-op consumes it, so no fallback float is
/// emitted and the host observes one release.
fn host_owned_noop_consumes_release_without_a_fallback_float() {
struct Content(usize);
impl DockContent<u32> for Content {
@@ -1810,6 +1864,7 @@ fn insertion_marker_handles_subpixel_strip_crossing_zero_without_panicking() {
}
#[test]
/// A strip narrower than its tabs paints at least one ellipsis overflow symbol.
fn overflowing_strip_paints_a_single_ellipsis_symbol() {
use egui_kittest::Harness;
fn paints(shape: &egui::Shape, text: &str) -> bool {
@@ -1853,6 +1908,7 @@ fn swapped() -> Layout<u32> {
}
#[test]
/// Replacing the layout during a tab drag ends the drag, so the new layout is not modified.
fn replacing_the_layout_ends_a_drag_in_progress() {
use super::testing::DockDriver;
let area = Id::unique("replaced");
+10
View File
@@ -79,6 +79,8 @@ impl DockContent<u32> for GalleyLabels {
}
#[test]
/// Galley-backed labels in shrunk tabs end in an ellipsis and stay wholly inside their tab, both
/// for the active tab and for inactive tabs, at wide and narrow dock widths.
fn galley_labels_in_shrunk_tabs_end_with_an_ellipsis_inside_the_tab_active_or_not() {
let panels = [1, 2, 3, 4, 5, 6];
let layout = Layout { root: Some(tabs(&panels, 3)), floating: Vec::new() };
@@ -189,6 +191,8 @@ fn double_click_on_the_active_tab_of_a_minimized_group_or_window_ends_expanded()
}
#[test]
/// A pointer click on a tab shows no focus outline even if the platform then focuses it, while
/// keyboard (Tab) focus paints the accent outline.
fn a_clicked_tab_shows_no_focus_outline_but_tab_key_focus_does() {
let mut d = Driver::new(frames());
let accent = d.ctx.global_style().visuals.selection.stroke.color;
@@ -295,6 +299,8 @@ fn right_to_left_mirrors_the_tab_strip_and_its_insertion_slots() {
}
#[test]
/// In a right-to-left title bar the close and menu buttons sit on the left, the drag surface ends at
/// the bar's right edge, and a galley title is truncated with an ellipsis inside the bar.
fn a_right_to_left_title_bar_puts_its_buttons_left_and_cuts_a_galley_title() {
let ctx = Context::default();
let mut shapes = Vec::new();
@@ -327,6 +333,8 @@ fn a_right_to_left_title_bar_puts_its_buttons_left_and_cuts_a_galley_title() {
}
#[test]
/// With close buttons and close menu items disabled, no close control renders and tab menus contain
/// neither Close Panel nor Close Tab Group; the host can still apply its own CloseWindow action.
fn hidden_close_controls_leave_closing_to_the_host() {
let layout = Layout { root: Some(tabs(&[1, 2], 0)), floating: vec![Floating::single(vec![5, 6], 0, [80.0, 70.0, 300.0, 220.0])] };
let close = Id::unique(AREA).with(("move-group", Some(&5u32))).with("close");
@@ -361,6 +369,8 @@ fn apply_all_returns_every_panel_its_actions_closed() {
}
#[test]
/// The column bar button and the column menu item expose distinct accessible names
/// (`Collapse column to icons` versus `Collapse to Icons`).
fn the_column_button_and_the_column_menu_item_have_distinct_names() {
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state(
+8
View File
@@ -72,6 +72,14 @@ impl<'a> ResizeHandle<'a> {
self
}
/// Show the handle and return a resize proposal for the caller to apply.
///
/// Invalid geometry or values make the handle inert. Pointer drags and arrow keys produce a
/// physical `delta` along the split axis; `reversed` flips only accessibility
/// increment/decrement/SetValue changes, not the pointer or arrow movement. A finite nonzero
/// delta marks the response changed. The accessibility role is `Splitter`, oriented
/// perpendicular to the movement axis, with Increment/Decrement and optional SetValue actions
/// and numeric value when configured.
pub fn show(self, ui: &mut Ui) -> ResizeResponse {
let hit = self.rect.expand2(match self.axis {
SplitAxis::Horizontal => Vec2::new(self.hit_margin, 0.0),
+2
View File
@@ -120,6 +120,8 @@ impl HandleTest {
}
}
/// Run one enabled `ResizeHandle` in a 400×200 frame and return its response plus the frame
/// output; texture deltas are cleared.
fn frame(&mut self, events: Vec<Event>) -> (ResizeResponse, FullOutput) {
let ctx = self.ctx.clone();
let mut result = None;
+6
View File
@@ -57,6 +57,9 @@ impl App {
Self { open: open.to_vec(), states: HashMap::new(), log: Vec::new(), focused: None, to_document: 0, rtl: false, policy: SidePolicy::Columns }
}
/// Render `PaneHost` with the app's open panes, policy and RTL flag, then a central document
/// panel. Applies pane requests to stored state and records requests, focused pane and
/// document-focus transitions.
fn frame(&mut self, ui: &mut Ui) {
let states = self.states.clone();
let out = PaneHost::new(Id::unique("panes")).rtl(self.rtl).policy(self.policy).show(ui, &self.open, |p| states.get(p).copied(), &mut Content);
@@ -89,6 +92,7 @@ fn harness(app: App) -> Harness<'static, App> {
h
}
/// The stable response id for `pane` under the `panes` host scope.
fn pane_id(pane: Pane) -> Id {
Id::unique("panes").with(("pane", &pane))
}
@@ -327,6 +331,8 @@ fn pane_state_round_trips_through_json() {
}
#[test]
/// A pane body may request 400 pt inside its 250 pt side pane; the document region still begins at
/// the pane's inner edge on both sides.
fn a_body_wider_than_its_pane_is_clipped_and_the_document_starts_at_the_pane_edge() {
struct Wide;
impl PaneContent<Pane> for Wide {
+4
View File
@@ -175,6 +175,8 @@ fn active_jobs_keep_their_receiver_and_report_receipt_refusal_or_worker_failure(
}
#[test]
/// Discovery and rendering localized controls populate the settings (printer, copies, duplex,
/// grayscale) and become ready, without starting or submitting a print job.
fn discovery_and_controls_do_not_submit_a_job() {
let mut state = State::default();
let discover: Discover = Box::new(|| {
@@ -206,6 +208,8 @@ fn discovery_and_controls_do_not_submit_a_job() {
}
#[test]
/// At 220×360 with long localized labels, the grayscale checkbox stays inside the pane and works;
/// starting a job disables it, and clicking it while pending changes nothing.
fn localized_controls_fit_narrow_ui_and_disable_while_submitting() {
let ctx = egui::Context::default();
ctx.enable_accesskit();
+2
View File
@@ -144,6 +144,8 @@ impl TabTest {
}
}
/// Render the two tabs at fixed 90×26 rects with injected events. Clears texture deltas, counts
/// body paints, and updates the clicked/selected fixtures before returning the responses.
fn frame(&mut self, events: Vec<Event>) -> (Vec<Response>, FullOutput) {
self.time += 0.05;
let ctx = self.ctx.clone();
+46
View File
@@ -127,10 +127,13 @@ pub fn align_galley_rows(mut galley: Arc<Galley>, width: f32, mut align: impl Fn
mod tests {
use super::*;
/// The three-row fixture (`Short` / `A longer line` / `End`) laid out at 1.0 ppp and left aligned.
fn layout() -> Arc<Galley> {
layout_text("Short\nA longer line\nEnd", 1.0, Align::Min)
}
/// A fresh context at `ppp` with one settling pass, then a simple job of `text` with the requested
/// halign and infinite width.
fn layout_with_context(text: &str, ppp: f32, halign: Align) -> (egui::Context, Arc<Galley>) {
let ctx = egui::Context::default();
ctx.set_pixels_per_point(ppp);
@@ -141,10 +144,13 @@ mod tests {
(ctx, galley)
}
/// Convenience wrapper returning only the galley from [`layout_with_context`].
fn layout_text(text: &str, ppp: f32, halign: Align) -> Arc<Galley> {
layout_with_context(text, ppp, halign).1
}
/// Destructure an aligned result into galley, paint offset and reading rect; a valid fixture must
/// not become `InvalidWidth`.
fn parts(aligned: &AlignedGalley) -> (&Arc<Galley>, Vec2, Rect) {
match aligned {
AlignedGalley::Aligned { galley, paint_offset, reading_rect } => (galley, *paint_offset, *reading_rect),
@@ -152,6 +158,7 @@ mod tests {
}
}
/// Compare two rects componentwise within `epsilon`.
fn close_rect(actual: Rect, expected: Rect, epsilon: f32) {
assert!((actual.min - expected.min).length() <= epsilon, "{actual:?} != {expected:?}");
assert!((actual.max - expected.max).length() <= epsilon, "{actual:?} != {expected:?}");
@@ -159,6 +166,10 @@ mod tests {
// The pre-normalization row placement, independent of the new wrapper/offset code. This is
// the old physical-pixel floor formula, applied to the original shared glyph rows.
/// Reconstruct the previous physical-pixel floor placement as an independent oracle. The
/// reference starts from a rect of the requested width, recomputes each row's x from the alignment
/// factor, and unions each row rect and translated ink bound into its `rect` and `mesh_bounds`;
/// overwide rows therefore extend the rect past the requested width.
fn reading_space_reference(original: &Galley, width: f32, alignments: &[Align]) -> Arc<Galley> {
let mut reference = original.clone();
reference.rect = Rect::from_min_size(Pos2::ZERO, vec2(width, original.rect.height()));
@@ -174,6 +185,8 @@ mod tests {
}
#[test]
/// Per-row alignment at width 400 keeps row Y positions, the shared job and row arcs, and vertex
/// and index counts; the returned logical width is 400 and every translated ink bound is contained.
fn independent_row_alignment_preserves_shaping_and_cached_geometry() {
let original = layout();
let positions: Vec<_> = original.rows.iter().map(|row| row.pos).collect();
@@ -213,6 +226,9 @@ mod tests {
}
#[test]
/// NaN, infinite and negative widths return `InvalidWidth` without calling the alignment callback
/// or changing the galley and halign; there is no reading rect, and painting still uses the original
/// galley at the given origin.
fn invalid_widths_preserve_the_input() {
for halign in [Align::Min, Align::Center, Align::Max] {
let original = layout_text("Short", 1.0, halign);
@@ -231,6 +247,9 @@ mod tests {
}
#[test]
/// At 2.0 ppp, an unbreakably wide row at width 23.3 keeps the requested reading edge through the
/// paint offset for Min/Center/Max: physical x is pixel-aligned and within half a pixel of the
/// expected alignment, and all logical and ink bounds contain the row.
fn overwide_rows_keep_the_requested_reading_edge() {
let original = layout_text("unbreakable-long-token", 2.0, Align::Min);
let width = 23.3;
@@ -258,6 +277,8 @@ mod tests {
}
#[test]
/// A right-aligned overwide row extends left of zero while its right edge stays at the requested
/// reading edge, and the public logical rect contains the actual row.
fn overwide_right_aligned_rows_have_complete_logical_bounds() {
let original = layout_text("unbreakable-long-token", 2.0, Align::Min);
let width = 23.3;
@@ -272,6 +293,9 @@ mod tests {
}
#[test]
/// Fractional width 163.3 with any original halign normalizes the result to a left anchor: halign
/// becomes Min, offset is zero, the logical rect is 0..163.3, and rows keep pixel-snapped absolute
/// positions.
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);
@@ -291,6 +315,8 @@ mod tests {
}
#[test]
/// Empty, whitespace-only and trailing-newline text produce a valid container rect of the requested
/// width and original height; a cleared row list gives zero height and `Rect::NOTHING` ink.
fn empty_and_whitespace_rows_have_valid_logical_bounds() {
for text in ["", " ", "Short\n"] {
let original = layout_text(text, 1.0, Align::Min);
@@ -315,6 +341,9 @@ mod tests {
}
#[test]
/// At fractional scales, zero and fractional widths, aligning directly and re-aligning after a
/// different first width produce identical reading rects, offsets, logical/ink bounds and row
/// positions; row x positions are pixel-snapped in physical space.
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);
@@ -343,6 +372,10 @@ mod tests {
}
#[test]
/// A synthetic cached quad with binary32 rounding differences keeps its glyph arcs shared. The
/// normalized offset is exactly the whitespace shift; `mesh_bounds` contains every translated vertex
/// and the complete legacy rect, equals the union of both rounding directions, survives repetition,
/// and remains `Rect::NOTHING` when all ink is empty.
fn fractional_ink_bounds_contain_translated_rectangles_and_vertices() {
// A synthetic cached quad isolates the two directions of binary32 rounding. The
// whitespace row supplies the normalization offset without contributing any ink.
@@ -456,6 +489,8 @@ mod tests {
}
#[test]
/// The logical container width follows the reading width while ink bounds stay the glyph extent;
/// whitespace has a container but no ink bounds.
fn logical_container_and_ink_bounds_remain_distinct() {
let result = align_galley_rows(layout_text("Short", 1.0, Align::Min), 400.0, |_| Align::Min);
let (aligned, _, _) = parts(&result);
@@ -468,6 +503,7 @@ mod tests {
}
#[test]
/// A uniquely owned galley is reused at the same allocation instead of being cloned.
fn uniquely_owned_galley_reuses_its_allocation() {
let original = Arc::new((*layout()).clone());
assert_eq!(Arc::strong_count(&original), 1);
@@ -479,6 +515,8 @@ mod tests {
const MIXED: &str = "First extraordinarily long reading row\nSecond independently aligned reading row\nThird right-aligned overwide reading row";
const ALIGNMENTS: [Align; 3] = [Align::Min, Align::Center, Align::Max];
/// Assert the mixed fixture's three rows each overflow `width`, then apply Min/Center/Max in that
/// order through `align_galley_rows`.
fn mixed(original: Arc<Galley>, width: f32) -> AlignedGalley {
assert_eq!(original.rows.len(), ALIGNMENTS.len());
assert!(original.rows.iter().all(|row| row.size.x > width), "each fixture row must overflow");
@@ -493,6 +531,9 @@ mod tests {
}
#[test]
/// Across fractional scales and widths 0/23.3, mixed overflow matches the independent oracle after
/// translating by the offset, shares row arcs, preserves absolute Y positions, and keeps the
/// reading rect at the requested width.
fn mixed_overflow_normalizes_bounds_without_changing_absolute_rows_or_ink() {
for ppp in [1.25, 1.5, 2.0] {
let original = layout_text(MIXED, ppp, Align::Center);
@@ -523,6 +564,8 @@ mod tests {
}
#[test]
/// `text_shape` anchors rotation at the complete logical bound rather than the reading rect; row
/// corners and the visual ink bounds match an independently rotated oracle for several anchors.
fn public_text_shape_uses_the_complete_logical_bound_for_rotation_anchors() {
let original = layout_text(MIXED, 1.5, Align::Min);
let expected = reading_space_reference(&original, 23.3, &ALIGNMENTS);
@@ -555,6 +598,9 @@ mod tests {
}
#[test]
/// At fractional DPI and origins, including points straddling a physical pixel, `paint` emits one
/// text shape whose tessellated vertices, indices, UVs and colors match the reading-space reference
/// at the same physical pixels, with text rounding both on and off.
fn painter_preserves_visible_vertices_at_fractional_origins_and_dpi() {
for ppp in [1.25, 1.5, 2.0] {
let (ctx, original) = layout_with_context(MIXED, ppp, Align::Min);
+4
View File
@@ -77,6 +77,9 @@ impl Harness {
harness
}
/// One 520 pt-high frame through the real wgpu test renderer. Applies returned actions with
/// canvas protection (round-tripping them through JSON when `serde` is enabled), validates the
/// layout, and records body ids and applied actions.
fn step(&mut self, events: Vec<Event>) {
self.frame += 1;
let input = RawInput {
@@ -180,6 +183,7 @@ impl Harness {
}
}
/// The response rect of `panel`'s tab in the workflow dock.
fn tab(&self, panel: u32) -> Rect {
self.ctx.read_response(Id::unique("workflow").with(("tab", &panel))).unwrap().rect
}