UI.6: add precise visual regressions and toolbar overflow fitting

This commit is contained in:
skylar taylor-barrick committed 2026-10-09 19:15:10 -06:00
1 parent 5de0356334
commit 67ce2e9a82
8 files changed
+3384 -7

No files matched your search

Generated
+2433 -6
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -15,3 +15,5 @@ egui = { workspace = true }
[dev-dependencies]
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
egui_kittest = { version = "0.36", features = ["wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
+23 -1
View File
@@ -2,7 +2,7 @@
`craft-ui` provides small egui components with application-supplied styles. `icons` prepares
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
interaction; `layout` provides split geometry and resize handles; `theme` shares widget-state styling.
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
Adapters cover all twelve Rust Craft apps; the [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
records their scope and acceptance limits. Assets, complete themes, localization and command handling
remain in each application. No icons, fonts or application engines are bundled.
@@ -153,6 +153,20 @@ or use an application-specific adapter. The library does not persist geometry, d
or create panes. Apps keep their existing dock trees, stacked panels, layout files, pane contents,
cameras, document focus and native windows. Shared controls do not constitute a dock-tree migration.
## Toolbar overflow
`layout::fit_toolbar(natural_widths, available, spacing, overflow_width)` keeps an ordered
prefix of indivisible groups at their natural widths. When groups do not fit, it reserves the
full overflow-control width and the preceding gap. `ToolbarFit` contains visible `(index, width)`
pairs, overflow indices and the reserved width. It does not skip wide groups or compress controls.
The application paints an enabled, focusable overflow control and makes every overflow index
available in its menu through existing commands. Group identity, labels, selection and command
dispatch remain local. An empty input produces an empty fit. Invalid dimensions, more than 4096
groups, or an available width too small for the overflow control return `None`; the caller must
then provide a scrollable row, wrapping or an existing application menu. Never turn that result
into silently missing commands. See the API documentation for the accepted numeric bounds.
## Theme mechanisms
`theme::WidgetPalette` applies background colors to egui's five widget states, with a supplied
@@ -190,3 +204,11 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo check --target wasm32-unknown-unknown -p craft-ui
```
The [visual regression suite](../../docs/ui-visual-regressions.md) compares the actual controls
against independent reference painters, including exact mesh and RGBA checks, and proves that
color, spacing and stroke changes fail those comparisons. To retain reviewable PNG pairs:
```sh
CRAFT_UI_VISUAL_DIR=/absolute/path/to/visuals cargo test -p craft-ui --test visual_regression
```
+2
View File
@@ -6,9 +6,11 @@
mod geometry;
mod resize;
mod toolbar;
pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect};
pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse};
pub use toolbar::{ToolbarFit, fit_toolbar};
#[cfg(test)]
mod tests;
+152
View File
@@ -0,0 +1,152 @@
/// Visible toolbar groups and the groups to present in an application-owned overflow menu.
///
/// Groups stay at their natural widths, in their original order. This helper does not paint,
/// hide commands, create a menu, or change widget identity. The caller must make every index in
/// `overflow` available through an enabled, focusable overflow control and its menu.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ToolbarFit {
/// A leading sequence of `(input index, natural width)` pairs. Groups are never compressed.
pub shown: Vec<(usize, f32)>,
/// Remaining input indices, in order. Empty when the whole toolbar fits.
pub overflow: Vec<usize>,
/// Full requested overflow-control width when needed, otherwise zero.
pub overflow_width: f32,
}
const MAX_GROUPS: usize = 4096;
const MAX_EXTENT: f32 = 1_000_000.0;
fn nonnegative_extent(value: f32) -> bool {
value.is_finite() && (0.0..=MAX_EXTENT).contains(&value)
}
/// Fit indivisible toolbar groups and, when needed, a full-size overflow control.
///
/// `spacing` is included between adjacent visible groups and before the overflow control. The
/// result preserves a leading sequence instead of skipping a wide group to show later commands.
/// An empty input returns an empty fit. At most 4096 groups and dimensions at most one million
/// logical points are accepted; all dimensions must be finite, widths strictly positive, and
/// `available` and `spacing` nonnegative. Work and storage are bounded and linear in group count.
///
/// Returns `None` for invalid input or if overflow is needed but `available` is smaller than
/// `overflow_width`. The caller must then use an explicit alternative such as a scrollable row,
/// wrapping, or an existing application menu; do not silently hide the commands. A positive
/// overflow-control width is required even when the current groups happen to fit.
pub fn fit_toolbar(natural: &[f32], available: f32, spacing: f32, overflow_width: f32) -> Option<ToolbarFit> {
if natural.len() > MAX_GROUPS
|| !nonnegative_extent(available)
|| !nonnegative_extent(spacing)
|| !nonnegative_extent(overflow_width)
|| overflow_width == 0.0
|| natural.iter().any(|&width| !nonnegative_extent(width) || width == 0.0)
{
return None;
}
let gaps = natural.len().saturating_sub(1);
let total = natural.iter().map(|&width| f64::from(width)).sum::<f64>() + f64::from(spacing) * gaps as f64;
if total <= f64::from(available) {
return Some(ToolbarFit { shown: natural.iter().copied().enumerate().collect(), ..Default::default() });
}
if available < overflow_width {
return None;
}
let budget = f64::from(available) - f64::from(overflow_width);
let mut used = 0.0;
let shown_count = natural
.iter()
.take_while(|&&width| {
// Every shown group has a following gap: to the next group or to the overflow.
let next = used + f64::from(width) + f64::from(spacing);
if next <= budget {
used = next;
true
} else {
false
}
})
.count();
Some(ToolbarFit {
shown: natural.iter().copied().take(shown_count).enumerate().collect(),
overflow: (shown_count..natural.len()).collect(),
overflow_width,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_fit_keeps_every_natural_width_and_needs_no_overflow() {
assert_eq!(
fit_toolbar(&[24.0, 36.0, 48.0], 120.0, 6.0, 20.0),
Some(ToolbarFit { shown: vec![(0, 24.0), (1, 36.0), (2, 48.0)], overflow: vec![], overflow_width: 0.0 })
);
assert_eq!(fit_toolbar(&[], 0.0, 6.0, 20.0), Some(ToolbarFit::default()));
}
#[test]
fn overflow_reserves_its_control_and_the_preceding_spacing() {
assert_eq!(
fit_toolbar(&[24.0, 36.0, 48.0], 96.0, 4.0, 20.0),
Some(ToolbarFit { shown: vec![(0, 24.0), (1, 36.0)], overflow: vec![2], overflow_width: 20.0 })
);
// At 87 points the second group and its gap would overlap the overflow control.
assert_eq!(
fit_toolbar(&[24.0, 36.0, 48.0], 87.0, 4.0, 20.0),
Some(ToolbarFit { shown: vec![(0, 24.0)], overflow: vec![1, 2], overflow_width: 20.0 })
);
}
#[test]
fn a_wide_first_group_is_not_compressed_or_skipped() {
assert_eq!(
fit_toolbar(&[200.0, 24.0, 24.0], 120.0, 4.0, 20.0),
Some(ToolbarFit { shown: vec![], overflow: vec![0, 1, 2], overflow_width: 20.0 })
);
assert_eq!(fit_toolbar(&[200.0], 60.0, 4.0, 20.0), Some(ToolbarFit { shown: vec![], overflow: vec![0], overflow_width: 20.0 }));
}
#[test]
fn tiny_rows_require_a_real_fallback_instead_of_an_inaccessible_button() {
for available in [0.0, 1.0, 19.999] {
assert_eq!(fit_toolbar(&[30.0, 30.0], available, 4.0, 20.0), None);
}
assert_eq!(fit_toolbar(&[30.0, 30.0], 20.0, 4.0, 20.0), Some(ToolbarFit { shown: vec![], overflow: vec![0, 1], overflow_width: 20.0 }));
assert!(fit_toolbar(&[10.0], 10.0, 4.0, 20.0).unwrap().overflow.is_empty());
}
#[test]
fn malformed_dimensions_and_excessive_group_counts_are_rejected() {
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, MAX_EXTENT + 1.0, f32::MAX] {
assert_eq!(fit_toolbar(&[24.0], value, 4.0, 20.0), None);
assert_eq!(fit_toolbar(&[24.0], 100.0, value, 20.0), None);
assert_eq!(fit_toolbar(&[24.0], 100.0, 4.0, value), None);
assert_eq!(fit_toolbar(&[value], 100.0, 4.0, 20.0), None);
}
assert_eq!(fit_toolbar(&[0.0], 100.0, 4.0, 20.0), None);
assert_eq!(fit_toolbar(&[24.0], 100.0, 4.0, 0.0), None);
assert!(fit_toolbar(&vec![24.0; MAX_GROUPS], MAX_EXTENT, 4.0, 20.0).is_some());
assert_eq!(fit_toolbar(&vec![24.0; MAX_GROUPS + 1], MAX_EXTENT, 4.0, 20.0), None);
}
#[test]
fn every_group_remains_reachable_and_every_accepted_fit_stays_within_budget() {
let widths = [12.5, 28.0, 24.25, 58.0, 32.0];
for available in [20.0, 31.5, 37.0, 55.0, 93.5, 156.0, 500.0, MAX_EXTENT] {
for spacing in [0.0, 0.5, 4.0, 12.0] {
let fit = fit_toolbar(&widths, available, spacing, 20.0).unwrap();
let indices: Vec<_> = fit.shown.iter().map(|&(i, _)| i).chain(fit.overflow.iter().copied()).collect();
assert_eq!(indices, [0, 1, 2, 3, 4]);
for &(index, width) in &fit.shown {
assert_eq!(width.to_bits(), widths[index].to_bits());
}
let count = fit.shown.len() + usize::from(!fit.overflow.is_empty());
let used = fit.shown.iter().map(|&(_, width)| f64::from(width)).sum::<f64>()
+ f64::from(fit.overflow_width)
+ count.saturating_sub(1) as f64 * f64::from(spacing);
assert!(used <= f64::from(available), "{fit:?} uses {used} of {available}");
}
}
}
}
+327
View File
@@ -0,0 +1,327 @@
//! Independent, font-free reference painting for the shared controls' public visual contracts.
//! Reference paths never call craft-ui or derive their expected rectangles from its responses.
use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize, split_rect};
use craft_ui::tabs::Tab;
use egui::{Align, Color32, CornerRadius, Id, Layout, Painter, Rect, Response, Stroke, StrokeKind, Ui, UiBuilder, Vec2, pos2, vec2};
pub const CANVAS: Vec2 = vec2(128.0, 72.0);
pub const GAP: f32 = 8.0;
#[derive(Clone, Copy, Debug)]
pub enum Theme {
Light,
Dark,
}
impl Theme {
pub fn name(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
}
}
pub fn egui(self) -> egui::Theme {
match self {
Self::Light => egui::Theme::Light,
Self::Dark => egui::Theme::Dark,
}
}
pub fn surface(self) -> Color32 {
match self {
Self::Light => Color32::from_rgb(248, 250, 252),
Self::Dark => Color32::from_rgb(17, 24, 39),
}
}
pub fn palette(self) -> Palette {
let rgb = |[r, g, b]: [u8; 3]| Color32::from_rgb(r, g, b);
let colors = match self {
Self::Light => [
[226, 232, 240],
[186, 230, 253],
[125, 211, 252],
[37, 99, 235],
[148, 163, 184],
[15, 23, 42],
[12, 74, 110],
[8, 47, 73],
[220, 38, 38],
],
Self::Dark => [
[31, 41, 55],
[55, 65, 81],
[75, 85, 99],
[67, 56, 202],
[100, 116, 139],
[226, 232, 240],
[243, 244, 246],
[255, 255, 255],
[56, 189, 248],
],
};
Palette {
normal: rgb(colors[0]),
hovered: rgb(colors[1]),
pressed: rgb(colors[2]),
selected: rgb(colors[3]),
border: rgb(colors[4]),
icon: rgb(colors[5]),
hover_icon: rgb(colors[6]),
press_icon: rgb(colors[7]),
focus: rgb(colors[8]),
}
}
}
#[derive(Clone, Copy)]
pub struct Palette {
pub normal: Color32,
pub hovered: Color32,
pub pressed: Color32,
pub selected: Color32,
pub border: Color32,
pub icon: Color32,
pub hover_icon: Color32,
pub press_icon: Color32,
pub focus: Color32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Normal,
Hover,
Press,
Selected,
SelectedPress,
Disabled,
Focus,
}
impl State {
pub const ALL: [Self; 7] = [Self::Normal, Self::Hover, Self::Press, Self::Selected, Self::SelectedPress, Self::Disabled, Self::Focus];
pub fn selected(self) -> bool {
matches!(self, Self::Selected | Self::SelectedPress)
}
pub fn pressed(self) -> bool {
matches!(self, Self::Press | Self::SelectedPress)
}
pub fn pointer(self) -> bool {
matches!(self, Self::Hover | Self::Press | Self::SelectedPress | Self::Disabled)
}
pub fn enabled(self) -> bool {
self != Self::Disabled
}
}
#[derive(Clone, Copy, Debug)]
pub enum Component {
Button,
SingleShapeButton,
Tab,
HorizontalSplit,
VerticalSplit,
}
impl Component {
pub const ALL: [Self; 5] = [Self::Button, Self::SingleShapeButton, Self::Tab, Self::HorizontalSplit, Self::VerticalSplit];
pub fn is_split(self) -> bool {
matches!(self, Self::HorizontalSplit | Self::VerticalSplit)
}
}
#[derive(Clone, Copy, Debug)]
pub struct Case {
pub component: Component,
pub state: State,
pub theme: Theme,
pub scale: f32,
pub compact: bool,
}
impl Case {
pub fn name(self) -> String {
format!("{:?}-{:?}-{}-{}-{}x", self.component, self.state, self.theme.name(), if self.compact { "compact" } else { "normal" }, self.scale)
}
pub fn size(self) -> Vec2 {
match (self.component, self.compact) {
(Component::Tab, false) => vec2(44.0, 28.0),
(Component::Tab, true) => vec2(22.0, 12.0),
(_, false) => vec2(36.0, 28.0),
(_, true) => vec2(18.0, 12.0),
}
}
pub fn control_rect(self) -> Rect {
if self.component.is_split() { reference_split(self).1 } else { Rect::from_min_size(pos2(12.0, 12.0), self.size()) }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mutation {
None,
Color,
Spacing,
Stroke,
}
pub fn button_style(theme: Theme) -> IconButtonStyle {
let p = theme.palette();
let visual = |fill, icon| ButtonVisuals { fill, icon, stroke: Stroke::new(1.0, p.border) };
IconButtonStyle {
normal: visual(p.normal, p.icon),
hovered: visual(p.hovered, p.hover_icon),
pressed: Some(visual(p.pressed, p.press_icon)),
selected: visual(p.selected, Color32::WHITE),
corner_radius: CornerRadius::same(3),
focus_stroke: Stroke::new(1.0, p.focus),
}
}
pub fn icon(painter: &Painter, rect: Rect, color: Color32, compact: bool) {
let half = if compact { vec2(3.0, 2.0) } else { vec2(6.0, 4.0) };
painter.line_segment([rect.center() - half, rect.center() + half], Stroke::new(1.0, color));
painter.line_segment([rect.center() + vec2(-half.x, half.y), rect.center() + vec2(half.x, -half.y)], Stroke::new(1.0, color));
}
fn tab_body(ui: &Ui, response: &Response, selected: bool, palette: Palette, compact: bool) {
let (fill, tint) = if selected {
(palette.selected, Color32::WHITE)
} else if response.is_pointer_button_down_on() {
(palette.pressed, palette.press_icon)
} else if response.hovered() {
(palette.hovered, palette.hover_icon)
} else {
(palette.normal, palette.icon)
};
ui.painter().rect_filled(response.rect, 0.0, fill);
ui.painter().rect_stroke(response.rect, 0.0, Stroke::new(1.0, palette.border), StrokeKind::Inside);
icon(ui.painter(), response.rect, tint, compact);
}
/// Actual production components, driven through egui responses and app-supplied fixture styles.
pub fn actual(ui: &mut Ui, case: Case, mutation: Mutation) -> Vec<Response> {
let p = case.theme.palette();
if case.component.is_split() {
let axis = if matches!(case.component, Component::HorizontalSplit) { SplitAxis::Horizontal } else { SplitAxis::Vertical };
let area = Rect::from_min_size(pos2(12.0, 12.0), if case.compact { vec2(52.0, 20.0) } else { vec2(104.0, 44.0) });
let split =
split_rect(area, axis, SplitSize::Ratio(if case.compact { 0.5 } else { 0.375 }), if case.compact { 3.0 } else { 4.0 }, 4.0).unwrap();
ui.painter().rect_filled(split.first, 0.0, p.normal);
ui.painter().rect_filled(split.second, 0.0, p.hovered);
let response = ui
.add_enabled_ui(case.state.enabled(), |ui| {
ResizeHandle::new(
Id::new("visual-divider"),
axis,
split.divider,
"Resize panes",
ResizeHandleStyle { hovered: p.hovered, dragged: p.pressed, focused: p.focus },
)
.hit_margin(0.0)
.show(ui)
.response
})
.inner;
return vec![response];
}
let spacing = GAP + if mutation == Mutation::Spacing { 1.0 } else { 0.0 };
let mut style = button_style(case.theme);
if mutation == Mutation::Color {
style.normal.fill = Color32::from_rgb(200, 60, 130);
}
if mutation == Mutation::Stroke {
style.normal.stroke.width = 2.0;
}
let mut responses = Vec::new();
ui.scope_builder(
UiBuilder::new().max_rect(Rect::from_min_size(pos2(12.0, 12.0), CANVAS - vec2(24.0, 24.0))).layout(Layout::left_to_right(Align::Min)),
|ui| {
ui.spacing_mut().item_spacing.x = spacing;
ui.add_enabled_ui(case.state.enabled(), |ui| {
for index in 0..2 {
let selected = index == 0 && case.state.selected();
let response = if matches!(case.component, Component::Tab) {
let rect = Rect::from_min_size(pos2(12.0 + index as f32 * (case.size().x + spacing), 12.0), case.size());
Tab::new(Id::new(("visual-tab", index)), "Tab", selected)
.focus_stroke(style.focus_stroke)
.show_at(ui, rect, |ui, r| tab_body(ui, r, selected, p, case.compact))
} else {
let button = IconButton::new("Action", case.size(), &style).selected(selected);
let button = if matches!(case.component, Component::SingleShapeButton) { button.single_shape_frame() } else { button };
button.show(ui, |ui, rect, tint| icon(ui.painter(), rect, tint, case.compact))
};
responses.push(response);
}
});
},
);
responses
}
/// Fixed reference geometry, written independently of split_rect's implementation.
fn reference_split(case: Case) -> (Rect, Rect, Rect) {
let rect = |x, y, w, h| Rect::from_min_size(pos2(x, y), vec2(w, h));
match (case.component, case.compact) {
(Component::HorizontalSplit, false) => (rect(12.0, 12.0, 37.5, 44.0), rect(49.5, 12.0, 4.0, 44.0), rect(53.5, 12.0, 62.5, 44.0)),
(Component::HorizontalSplit, true) => (rect(12.0, 12.0, 24.5, 20.0), rect(36.5, 12.0, 3.0, 20.0), rect(39.5, 12.0, 24.5, 20.0)),
(Component::VerticalSplit, false) => (rect(12.0, 12.0, 104.0, 15.0), rect(12.0, 27.0, 104.0, 4.0), rect(12.0, 31.0, 104.0, 25.0)),
(Component::VerticalSplit, true) => (rect(12.0, 12.0, 52.0, 8.5), rect(12.0, 20.5, 52.0, 3.0), rect(12.0, 23.5, 52.0, 8.5)),
_ => panic!("split fixture required"),
}
}
/// Baseline painting uses only egui shapes, literal fixture geometry, and the specified state.
pub fn reference(ui: &Ui, case: Case) {
let p = case.theme.palette();
if case.component.is_split() {
let (first, divider, second) = reference_split(case);
ui.painter().rect_filled(first, 0.0, p.normal);
ui.painter().rect_filled(second, 0.0, p.hovered);
let fill = match case.state {
State::Hover => Some(p.hovered),
State::Press => Some(p.pressed),
State::Focus => Some(p.focus),
_ => None,
};
if let Some(fill) = fill {
ui.painter().rect_filled(divider, 0.0, fill);
}
return;
}
let mut painter = ui.painter().clone();
if case.state == State::Disabled {
// The fixture's known disabled alpha. Do not ask the actual widget how it was painted.
painter.multiply_opacity(0.4);
}
for index in 0..2 {
let state = if index == 0 { case.state } else { State::Normal };
let (fill, tint) = match state {
State::Selected | State::SelectedPress => (p.selected, Color32::WHITE),
State::Press => (p.pressed, p.press_icon),
State::Hover => (p.hovered, p.hover_icon),
_ => (p.normal, p.icon),
};
let rect = Rect::from_min_size(pos2(12.0 + index as f32 * (case.size().x + GAP), 12.0), case.size());
let radius = if matches!(case.component, Component::Tab) { 0 } else { 3 };
if matches!(case.component, Component::SingleShapeButton) {
painter.rect(rect, radius, fill, Stroke::new(1.0, p.border), StrokeKind::Inside);
} else {
painter.rect_filled(rect, radius, fill);
painter.rect_stroke(rect, radius, Stroke::new(1.0, p.border), StrokeKind::Inside);
}
icon(&painter, rect, tint, case.compact);
if state == State::Focus {
painter.rect_stroke(rect.shrink(2.0), radius, Stroke::new(1.0, p.focus), StrokeKind::Inside);
}
}
}
+394
View File
@@ -0,0 +1,394 @@
//! Exact offscreen rendering comparisons against independent reference painters.
//!
//! These are paired same-renderer comparisons, not portable GPU golden files. The reference
//! paths deliberately do not call the shared components. Literal geometry and colors specify
//! the baseline; deliberate mutations prove color, spacing and stroke regressions are caught.
//! Set CRAFT_UI_VISUAL_DIR to retain bounded before/after/difference PNGs and comparison tables.
#[path = "visual/fixtures.rs"]
mod fixtures;
use std::path::PathBuf;
use egui::{Color32, Context, Event, FullOutput, Modifiers, PointerButton, Pos2, RawInput, Rect, Response, TextureId, Ui};
use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer};
use fixtures::{CANVAS, Case, Component, Mutation, State, Theme};
use image::{Rgba, RgbaImage};
#[derive(Debug, PartialEq)]
struct Mesh {
clip: Rect,
texture: TextureId,
indices: Vec<u32>,
vertices: Vec<(Pos2, Pos2, Color32)>,
}
struct Capture {
image: RgbaImage,
meshes: Option<Vec<Mesh>>,
}
fn context(theme: Theme, scale: f32) -> Context {
let ctx = Context::default();
ctx.set_theme(theme.egui());
ctx.set_pixels_per_point(scale);
ctx.set_fonts(egui::FontDefinitions::empty());
ctx.all_styles_mut(|style| {
style.animation_time = 0.0;
style.visuals.text_cursor.blink = false;
style.visuals.disabled_alpha = 0.4;
});
ctx
}
fn raw_input(frame: usize, events: Vec<Event>) -> RawInput {
RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, CANVAS)),
time: Some(frame as f64 / 60.0),
events,
max_texture_side: Some(2048),
..Default::default()
}
}
fn background(ui: &mut Ui, theme: Theme) {
let screen = Rect::from_min_size(Pos2::ZERO, CANVAS);
ui.set_clip_rect(screen);
ui.painter().rect_filled(screen, 0.0, theme.surface());
}
fn finish_capture(renderer: &mut WgpuTestRenderer, ctx: &Context, output: &FullOutput) -> Capture {
let meshes = ctx
.tessellate(output.shapes.clone(), output.pixels_per_point)
.into_iter()
.map(|clipped| {
let egui::epaint::Primitive::Mesh(mesh) = clipped.primitive else { panic!("visual fixtures must not contain paint callbacks") };
Mesh {
clip: clipped.clip_rect,
texture: mesh.texture_id,
indices: mesh.indices,
vertices: mesh.vertices.into_iter().map(|vertex| (vertex.pos, vertex.uv, vertex.color)).collect(),
}
})
.collect();
let image = renderer.render(ctx, output).expect("offscreen renderer is required for the visual suite");
assert!(image.width() > 0 && image.height() > 0 && image.width() <= 256 && image.height() <= 144);
Capture { image, meshes: Some(meshes) }
}
fn capture(renderer: &mut WgpuTestRenderer, case: Case, reference: bool, mutation: Mutation) -> Capture {
let ctx = context(case.theme, case.scale);
let mut output = FullOutput::default();
let mut responses: Vec<Response> = Vec::new();
for frame in 0..4 {
let mut events = Vec::new();
if !reference && frame == 2 {
if case.state == State::Focus {
ctx.memory_mut(|memory| memory.request_focus(responses[0].id));
}
if case.state.pointer() {
let center = case.control_rect().center();
events.push(Event::PointerMoved(center));
if case.state.pressed() {
events.push(Event::PointerButton { pos: center, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
}
}
}
output = ctx.run_ui(raw_input(frame, events), |ui| {
background(ui, case.theme);
if reference {
fixtures::reference(ui, case);
} else {
responses = fixtures::actual(ui, case, mutation);
}
});
renderer.handle_delta(&mut output.textures_delta);
}
assert_eq!(output.pixels_per_point, case.scale);
if !reference {
let response = &responses[0];
assert_eq!(response.enabled(), case.state.enabled(), "{case:?}");
if case.state == State::Hover {
assert!(response.hovered(), "hover was not exercised: {case:?}");
}
if case.state.pressed() {
assert!(response.is_pointer_button_down_on(), "press was not exercised: {case:?}");
if case.component.is_split() {
assert!(response.dragged(), "divider drag was not exercised: {case:?}");
}
}
if case.state == State::Focus {
assert!(response.has_focus(), "focus was not exercised: {case:?}");
}
if case.state == State::Disabled {
assert!(!response.hovered() && !response.has_focus(), "disabled pointer input must remain inert: {case:?}");
}
}
finish_capture(renderer, &ctx, &output)
}
fn static_capture(renderer: &mut WgpuTestRenderer, theme: Theme, scale: f32, mut paint: impl FnMut(&mut Ui)) -> Capture {
let ctx = context(theme, scale);
let mut output = FullOutput::default();
for frame in 0..2 {
output = ctx.run_ui(raw_input(frame, vec![]), |ui| {
background(ui, theme);
paint(ui);
});
renderer.handle_delta(&mut output.textures_delta);
}
assert_eq!(output.pixels_per_point, scale);
finish_capture(renderer, &ctx, &output)
}
struct Artifacts {
directory: Option<PathBuf>,
rows: String,
}
impl Artifacts {
fn new(suite: &str) -> Self {
let directory = std::env::var_os("CRAFT_UI_VISUAL_DIR").map(|root| PathBuf::from(root).join(suite));
if let Some(directory) = &directory {
std::fs::create_dir_all(directory).unwrap();
}
Self { directory, rows: "case\twidth\theight\tchanged_pixels\tmesh_equal\texpected_change\n".into() }
}
fn record(&mut self, name: &str, before: &Capture, after: &Capture, expected_change: bool) -> usize {
assert_eq!(before.image.dimensions(), after.image.dimensions(), "{name}");
let mut changed = 0;
let mut difference = RgbaImage::new(before.image.width(), before.image.height());
for ((before, after), pixel) in before.image.pixels().zip(after.image.pixels()).zip(difference.pixels_mut()) {
if before != after {
changed += 1;
*pixel = Rgba([255, 0, 128, 255]);
}
}
let mesh_equal = match (&before.meshes, &after.meshes) {
(Some(before), Some(after)) => (before == after).to_string(),
(None, None) => "not_applicable".into(),
_ => panic!("both captures must have the same comparison scope"),
};
self.rows.push_str(&format!("{name}\t{}\t{}\t{changed}\t{mesh_equal}\t{expected_change}\n", before.image.width(), before.image.height()));
if let Some(directory) = &self.directory {
before.image.save(directory.join(format!("{name}-before.png"))).unwrap();
after.image.save(directory.join(format!("{name}-after.png"))).unwrap();
difference.save(directory.join(format!("{name}-difference.png"))).unwrap();
// Preserve completed rows even if a later assertion fails.
std::fs::write(directory.join("comparison.tsv"), &self.rows).unwrap();
}
changed
}
}
#[test]
fn rendered_controls_match_independent_reference_pixels_and_meshes() {
let mut renderer = WgpuTestRenderer::new();
let mut artifacts = Artifacts::new("controls");
let mut count = 0;
for component in Component::ALL {
for state in State::ALL {
if component.is_split() && state.selected() {
continue; // A resize handle has no selected state in its public contract.
}
for theme in [Theme::Light, Theme::Dark] {
for scale in [1.0, 1.5, 2.0] {
for compact in [false, true] {
let case = Case { component, state, theme, scale, compact };
let before = capture(&mut renderer, case, true, Mutation::None);
let after = capture(&mut renderer, case, false, Mutation::None);
let changed = artifacts.record(&case.name(), &before, &after, false);
assert_eq!(before.meshes, after.meshes, "reference mesh mismatch: {case:?}");
assert_eq!(changed, 0, "reference pixels changed: {case:?}");
count += 1;
}
}
}
}
}
assert_eq!(count, 372);
eprintln!("{count} control before/after pairs matched exact RGBA pixels and tessellated meshes");
}
#[test]
fn visual_comparisons_detect_color_spacing_and_stroke_mutations() {
let mut renderer = WgpuTestRenderer::new();
let mut artifacts = Artifacts::new("negative-controls");
let mut count = 0;
for theme in [Theme::Light, Theme::Dark] {
for scale in [1.0, 1.5, 2.0] {
for compact in [false, true] {
let case = Case { component: Component::Button, state: State::Normal, theme, scale, compact };
let before = capture(&mut renderer, case, true, Mutation::None);
for mutation in [Mutation::Color, Mutation::Spacing, Mutation::Stroke] {
let after = capture(&mut renderer, case, false, mutation);
let changed = artifacts.record(&format!("{}-{mutation:?}", case.name()), &before, &after, true);
assert_ne!(before.meshes, after.meshes, "mesh comparison missed {mutation:?}: {case:?}");
assert!(changed > 0, "pixel comparison missed {mutation:?}: {case:?}");
count += 1;
}
}
}
}
assert_eq!(count, 36);
eprintln!("{count} deliberate color/spacing/stroke changes were detected in both pixels and meshes");
}
#[test]
fn toolbar_fitting_matches_literal_group_and_overflow_positions() {
use craft_ui::buttons::IconButton;
use craft_ui::layout::fit_toolbar;
use egui::{Align, Layout, Stroke, StrokeKind, UiBuilder, pos2, vec2};
let mut renderer = WgpuTestRenderer::new();
let mut artifacts = Artifacts::new("toolbar");
let mut count = 0;
// Independently specified rectangles: three natural groups, one group + More, More only.
for (available, expected) in
[(112.0, vec![(12.0, 24.0), (40.0, 28.0), (72.0, 36.0)]), (64.0, vec![(12.0, 24.0), (40.0, 20.0)]), (20.0, vec![(12.0, 20.0)])]
{
for theme in [Theme::Light, Theme::Dark] {
for scale in [1.0, 1.5, 2.0] {
let p = theme.palette();
let before = static_capture(&mut renderer, theme, scale, |ui| {
for &(x, width) in &expected {
let rect = Rect::from_min_size(pos2(x, 12.0), vec2(width, 24.0));
ui.painter().rect_filled(rect, 3.0, p.normal);
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, p.border), StrokeKind::Inside);
fixtures::icon(ui.painter(), rect, p.icon, true);
}
});
let after = static_capture(&mut renderer, theme, scale, |ui| {
let fit = fit_toolbar(&[24.0, 28.0, 36.0], available, 4.0, 20.0).unwrap();
let style = fixtures::button_style(theme);
ui.scope_builder(
UiBuilder::new()
.max_rect(Rect::from_min_size(pos2(12.0, 12.0), vec2(available, 24.0)))
.layout(Layout::left_to_right(Align::Min)),
|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for &(_, width) in &fit.shown {
IconButton::new("Group", vec2(width, 24.0), &style)
.show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true));
}
if !fit.overflow.is_empty() {
IconButton::new("More", vec2(fit.overflow_width, 24.0), &style)
.show(ui, |ui, rect, tint| fixtures::icon(ui.painter(), rect, tint, true));
}
},
);
});
let name = format!("toolbar-{available}-{}-{scale}x", theme.name());
let changed = artifacts.record(&name, &before, &after, false);
assert_eq!(before.meshes, after.meshes, "{name}");
assert_eq!(changed, 0, "{name}");
count += 1;
}
}
}
assert_eq!(count, 18);
}
#[test]
fn widget_palette_renders_all_five_states_with_existing_borders_and_expansion() {
use craft_ui::theme::WidgetPalette;
use egui::{CornerRadius, Stroke, StrokeKind, pos2, vec2};
let mut renderer = WgpuTestRenderer::new();
let mut artifacts = Artifacts::new("widget-palette");
for theme in [Theme::Light, Theme::Dark] {
for scale in [1.0, 1.5, 2.0] {
for compact in [false, true] {
let p = theme.palette();
let fills = [p.normal, p.hovered, p.pressed, p.selected, theme.surface()];
let rect = |index| {
Rect::from_min_size(
pos2(12.0 + (index % 3) as f32 * 34.0, 12.0 + (index / 3) as f32 * 26.0),
if compact { vec2(20.0, 12.0) } else { vec2(26.0, 18.0) },
)
};
let borders = [Color32::RED, Color32::GREEN, Color32::BLUE, p.border, p.focus];
let before = static_capture(&mut renderer, theme, scale, |ui| {
for index in 0..5 {
let rect = rect(index).expand(if index % 2 == 0 { 0.0 } else { 0.5 });
ui.painter().rect_filled(rect, 2.0, fills[index]);
// A separate inset uses the weak fill, so omission of either fill is visible.
ui.painter().rect_filled(rect.shrink(3.0), 0.0, fills[index]);
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, borders[index]), StrokeKind::Inside);
ui.painter()
.line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], Stroke::new(1.5, p.icon));
}
});
let after = static_capture(&mut renderer, theme, scale, |ui| {
let mut widgets = ui.visuals().widgets.clone();
for (index, widget) in
[&mut widgets.noninteractive, &mut widgets.inactive, &mut widgets.hovered, &mut widgets.active, &mut widgets.open]
.into_iter()
.enumerate()
{
widget.bg_stroke = Stroke::new(1.0, borders[index]);
widget.expansion = if index % 2 == 0 { 0.0 } else { 0.5 };
}
WidgetPalette { noninteractive: p.normal, inactive: p.hovered, hovered: p.pressed, active: p.selected, open: theme.surface() }
.apply_to(&mut widgets, CornerRadius::same(2), Stroke::new(1.5, p.icon));
for (index, widget) in
[widgets.noninteractive, widgets.inactive, widgets.hovered, widgets.active, widgets.open].iter().enumerate()
{
let rect = rect(index).expand(widget.expansion);
ui.painter().rect_filled(rect, widget.corner_radius, widget.bg_fill);
ui.painter().rect_filled(rect.shrink(3.0), 0.0, widget.weak_bg_fill);
ui.painter().rect_stroke(rect, widget.corner_radius, widget.bg_stroke, StrokeKind::Inside);
ui.painter().line_segment([rect.left_center() + vec2(4.0, 0.0), rect.right_center() - vec2(4.0, 0.0)], widget.fg_stroke);
}
});
let name = format!("palette-{}-{scale}x-{}", theme.name(), if compact { "compact" } else { "normal" });
let changed = artifacts.record(&name, &before, &after, false);
assert_eq!(before.meshes, after.meshes, "{name}");
assert_eq!(changed, 0, "{name}");
}
}
}
}
#[test]
fn svg_preparation_matches_literal_raster_references_at_all_scales() {
use craft_ui::icons::{SvgIconSet, SvgStyle};
use egui::{
ImageSource,
load::{Bytes, ImagePoll, SizeHint},
};
const SOURCE: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"#;
const LIGHT: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0f172a" stroke-width="1.75"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"##;
const DARK: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e2e8f0" stroke-width="1.75"><rect x="4" y="4" width="16" height="16"/><path d="M4 4L20 20"/></svg>"##;
fn raster(ctx: &Context, uri: &str, size: u32) -> Capture {
let ImagePoll::Ready { image } = ctx.try_load_image(uri, SizeHint::Size { width: size, height: size, maintain_aspect_ratio: true }).unwrap()
else {
panic!("compiled-in SVG must rasterize synchronously")
};
assert_eq!(image.size, [size as usize; 2]);
let image = RgbaImage::from_raw(size, size, image.pixels.iter().flat_map(|pixel| pixel.to_srgba_unmultiplied()).collect()).unwrap();
assert!(image.pixels().any(|pixel| pixel[3] != 0), "blank SVG is not a valid matching baseline");
Capture { image, meshes: None }
}
let mut artifacts = Artifacts::new("svg");
for (theme, color, literal) in [(Theme::Light, "#0f172a", LIGHT), (Theme::Dark, "#e2e8f0", DARK)] {
for (scale, numerator) in [(1.0, 2), (1.5, 3), (2.0, 4)] {
for logical_size in [12, 24] {
let ctx = Context::default();
egui_extras::install_image_loaders(&ctx);
ctx.include_bytes("bytes://literal-reference.svg", Bytes::Static(literal));
let icons =
SvgIconSet::new(&[("box", SOURCE)], SvgStyle { current_color: color, stroke_width: Some("1.75") }, "bytes://actual/", "box");
let ImageSource::Bytes { uri, bytes } = icons.source("box") else { panic!("catalog must return embedded bytes") };
ctx.include_bytes(uri.clone(), bytes);
let size = logical_size * numerator / 2;
let before = raster(&ctx, "bytes://literal-reference.svg", size);
let after = raster(&ctx, &uri, size);
let name = format!("svg-{}-{scale}x-{logical_size}pt", theme.name());
assert_eq!(artifacts.record(&name, &before, &after, false), 0, "{name}");
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
# Shared UI visual regressions
`crates/ui/tests/visual_regression.rs` runs actual shared components through egui and renders
paired images with the same offscreen wgpu renderer. `visual/fixtures.rs` supplies independent
reference painters with fixed geometry and state colors. References do not call the shared
components or obtain expected rectangles from component responses. These fixtures specify the
shared components' visual contracts; they are not historical screenshots of applications.
## Coverage
| Surface | Paired cases | Dimensions exercised |
| --- | ---: | --- |
| Icon buttons (separate and single-shape frames), tabs, horizontal and vertical split handles | 372 | Light/dark, 1/1.5/2 pixels per point, normal/compact, normal/hover/pressed/disabled/focused; buttons and tabs also selected/selected-pressed |
| Toolbar fitting | 18 | Light/dark, three display scales, all groups visible / a group plus overflow / overflow only |
| Widget palette | 12 | Light/dark, three display scales, normal/compact; all five widget states, both fills, radius, foreground and preserved border/expansion |
| SVG preparation | 12 | Light/dark colors, three display scales, 12/24 point icons; literal recolor and stroke references |
The 414 positive pairs require identical RGBA pixels. The 402 wgpu pairs also require identical
tessellated vertices, indices, texture IDs and clip rectangles. SVG pairs compare the actual
image loader's raster output against literal prepared SVGs. Every SVG must contain visible pixels.
Controls receive real egui pointer movement, pointer press or focus requests; assertions verify
that the intended response state was reached. Disabled controls receive pointer input and must
remain inert. Selection takes precedence over a pointer press. Resize handles exercise a drag
and both split orientations. Compact fixtures test the same visual contracts at smaller sizes.
An additional 36 negative comparisons deliberately change normal fill color, inter-button
spacing or stroke width. Each must differ in both pixels and meshes. No tolerance, mask, crop,
perceptual score or automatic baseline update can turn a changed pixel into a passing result.
Toolbar unit tests separately reject malformed and excessive input, require a reachable overflow
control, and verify that accepted layouts preserve every group and fit the supplied width.
## Running and reviewing
```sh
cargo test -p craft-ui --test visual_regression
CRAFT_UI_VISUAL_DIR=/absolute/path/to/visuals cargo test -p craft-ui --test visual_regression
```
The optional directory receives one subdirectory per suite. Each case writes `-before.png`
(the independent reference), `-after.png` (the production component) and `-difference.png`
(changed pixels in magenta), plus a `comparison.tsv` with dimensions, changed pixel count,
mesh equality and whether a change is expected. Completed rows survive a subsequent failed
assertion. The fixture count and image dimensions are fixed and bounded; the largest control
image is 256 by 144 pixels. Tests fail if a renderer cannot initialize or render.
The test renderer compares both sides on the current machine. It does not assert cross-driver
pixel identity or substitute for application-specific native screenshots, complete themes,
text/font layout, command dispatch, window resizing, accessibility service acceptance, or
cross-platform runtime tests. Application baselines must retain their own source and binary
provenance. Review those captures separately when adopting or changing a shared adapter.