mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-13 23:17:44 +00:00
UI.6: add precise visual regressions and toolbar overflow fitting
This commit is contained in:
1 parent
5de0356334
commit
67ce2e9a82
8 files changed
+3384
-7
No files matched your search
Generated
+2433
-6
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user