#![allow(clippy::expect_used, reason = "test helpers fail loudly")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use ravenrs::prelude::*;
use ravenrs::render::{GpuContext, Renderer, wgpu};
use ravenrs::widgets::grid::Value;
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const CHANNEL_TOLERANCE: u8 = 24;
const MAX_DIFF_RATIO: f64 = 0.004;
fn gpu() -> Option<GpuContext> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
match pollster::block_on(GpuContext::new(&instance, None)) {
Ok(gpu) => Some(gpu),
Err(e) => {
eprintln!("skipping visual test: {e}");
None
}
}
}
fn render(gpu: &GpuContext, view: impl View + 'static, theme: Theme, size: Size) -> image::RgbaImage {
let mut ui = Ui::new(view, theme);
ui.set_viewport(size, 1.0);
ui.update();
ui.update();
let (w, h) = (size.width as u32, size.height as u32);
let texture = gpu.device.create_texture(&wgpu::TextureDescriptor {
label: Some("visual"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut renderer = Renderer::new(&gpu.device, &gpu.queue, FORMAT);
renderer.render(ui.scene(), &view, (w, h), 1.0);
let padded = (w * 4).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: u64::from(padded * h),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
gpu.queue.submit(Some(encoder.finish()));
let slice = buffer.slice(..);
slice.map_async(wgpu::MapMode::Read, |r| r.expect("map readback"));
gpu.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
let data = slice.get_mapped_range().expect("mapped range");
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h {
let start = (row * padded) as usize;
pixels.extend_from_slice(&data[start..start + (w * 4) as usize]);
}
image::RgbaImage::from_raw(w, h, pixels).expect("image")
}
fn check(name: &str, actual: &image::RgbaImage) {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden");
let path = dir.join(format!("{name}.png"));
let update = std::env::var_os("RAVENRS_UPDATE_GOLDEN").is_some();
if update || !path.exists() {
actual.save(&path).expect("write golden");
eprintln!("wrote reference {}", path.display());
return;
}
let expected = image::open(&path).expect("read golden").into_rgba8();
assert_eq!(expected.dimensions(), actual.dimensions(), "{name}: size changed");
let differing = expected
.pixels()
.zip(actual.pixels())
.filter(|(a, b)| a.0.iter().zip(b.0.iter()).any(|(x, y)| x.abs_diff(*y) > CHANNEL_TOLERANCE))
.count();
let ratio = differing as f64 / f64::from(expected.width() * expected.height());
if ratio > MAX_DIFF_RATIO {
let out = dir.join(format!("{name}.actual.png"));
actual.save(&out).expect("write actual");
panic!(
"{name}: {:.2}% of pixels differ from {} (limit {:.2}%); see {}",
ratio * 100.0,
path.display(),
MAX_DIFF_RATIO * 100.0,
out.display()
);
}
}
fn buttons() -> impl View {
VStack::new((
HStack::new((
Button::new("Primary"),
Button::new("Secondary").style(ButtonStyle::Secondary),
Button::new("Ghost").style(ButtonStyle::Ghost),
Button::new("Delete").style(ButtonStyle::Danger).icon(IconName::Trash2),
))
.spacing(10.0),
HStack::new((
Button::new("Small").size(ButtonSize::Small),
Button::new("Large").size(ButtonSize::Large).trailing_icon(IconName::ArrowRight),
Button::new("Disabled").disabled(true),
IconButton::new(IconName::Settings).style(ButtonStyle::Secondary),
))
.spacing(10.0)
.align(Align::Center),
))
.spacing(14.0)
.padding(20.0)
}
fn inputs() -> impl View {
VStack::new((
TextField::new(State::new("Ana Lima".to_string()).binding()).icon(IconName::User),
TextField::new(State::new(String::new()).binding()).placeholder("Search…").icon(IconName::Search),
HStack::new((
Toggle::new(State::new(true).binding()).label("On"),
Toggle::new(State::new(false).binding()).label("Off"),
Checkbox::new("Checked", State::new(true).binding()),
Checkbox::new("Unchecked", State::new(false).binding()),
))
.spacing(16.0),
Slider::new(State::new(0.35_f32).binding()),
SegmentedControl::new(State::new(1).binding(), ["Day", "Week", "Month"]),
RadioGroup::new(State::new(0).binding(), ["Starter", "Team"]),
))
.spacing(14.0)
.padding(20.0)
.align(Align::Stretch)
}
fn cards_and_feedback() -> impl View {
VStack::new((
Card::new(Stat::new("Revenue", "R$ 128.450").delta("+12.4%", true)),
Alert::new("Storage almost full", "You are using 92% of your quota.").tone(Tone::Warning),
HStack::new((
Badge::new("New").tone(Tone::Accent),
Badge::new("Paid").tone(Tone::Success).icon(IconName::Check),
Avatar::new("Ana Lima"),
Kbd::new("⌘K"),
))
.spacing(8.0)
.align(Align::Center),
ProgressBar::new(0.62),
))
.spacing(14.0)
.padding(20.0)
.align(Align::Stretch)
}
fn calendar() -> impl View {
let d = Date::new(2026, 10, 31).expect("date");
VStack::new((
Calendar::new(State::new(Some(Date::new(2026, 10, 14).expect("date"))).binding(), d).today(d),
))
.padding(16.0)
}
fn data_grid() -> impl View {
let rows = (0..12)
.map(|i| {
vec![
Value::from(format!("Customer {i:02}")),
Value::from(["Brazil", "Chile", "Peru"][i % 3]),
Value::from((i * 37 % 100) as f64),
]
})
.collect();
VStack::new((DataGrid::new(
GridData::new(rows),
[
GridColumn::text("Customer"),
GridColumn::text("Country"),
GridColumn::number("Seats").summary(Summary::Sum),
],
)
.selection_mode(SelectionMode::Multiple)
.selection(State::new(BTreeSet::from([1, 2])).binding())
.sort_by(2, SortDirection::Descending)
.height(380.0),))
.padding(12.0)
}
fn ribbon() -> impl View {
VStack::new((Ribbon::new()
.app_menu("File", vec![MenuItem::new("Open")])
.quick_access(RibbonItem::button("Save", IconName::Save))
.page(
RibbonPage::new("Home")
.group(
RibbonGroup::new("Clipboard")
.item(RibbonItem::button("Paste", IconName::ClipboardPaste))
.item(RibbonItem::button("Cut", IconName::Scissors).small())
.item(RibbonItem::button("Copy", IconName::Copy).small()),
)
.group(RibbonGroup::new("Font").item(
RibbonItem::toggle("Bold", IconName::Bold, State::new(true).binding()).icon_only(),
)),
)
.page(RibbonPage::new("Insert"))
.page(RibbonPage::new("Layout").contextual("Table tools", Tone::Warning, true)),))
}
fn charts() -> impl View {
let months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
let (first, second) =
([186.0, 305.0, 237.0, 73.0, 209.0, 214.0], [80.0, 200.0, 120.0, 190.0, 130.0, 140.0]);
let chart = |kind| Chart::new(kind).categories(months).series("First", first).height(150.0);
let padded = |view: Chart| Card::new(view).padding(10.0);
Grid::new(
3,
(
padded(chart(ChartKind::Area).series("Second", second).stack(ChartStack::Normal).gradient()),
padded(chart(ChartKind::Bar).series("Second", second)),
padded(chart(ChartKind::Line).dots().hide_legend()),
padded(chart(ChartKind::Doughnut).hide_legend().center_text("1,224", "Total")),
padded(chart(ChartKind::Radar).dots().hide_legend()),
padded(chart(ChartKind::RadialBar).tracks().hide_legend()),
),
)
.spacing(8.0)
}
macro_rules! visual {
($name:ident, $view:expr, $w:expr, $h:expr) => {
#[test]
fn $name() {
let Some(gpu) = gpu() else { return };
for (theme, suffix) in [(Theme::raven_dark(), "dark"), (Theme::raven_light(), "light")] {
let img = render(&gpu, $view, theme, Size::new($w, $h));
check(&format!("{}_{suffix}", stringify!($name)), &img);
}
}
};
}
visual!(buttons_all_styles, buttons(), 520.0, 140.0);
visual!(inputs_and_choices, inputs(), 480.0, 420.0);
visual!(cards_alerts_badges, cards_and_feedback(), 480.0, 330.0);
visual!(calendar_month, calendar(), 300.0, 340.0);
visual!(data_grid_sorted_selected, data_grid(), 520.0, 404.0);
visual!(ribbon_home_page, ribbon(), 700.0, 160.0);
visual!(charts_of_every_family, charts(), 720.0, 500.0);