use crate::{
App, Button, Color, CompOp, Container, FlexColumn, FlexRow, Framebuffer, GfxCtx,
Key, MouseButton, Modifiers, ScrollView, Size, SizedBox, Spacer, Splitter,
TabView, TextField, Widget,
};
fn sample(fb: &Framebuffer, x: u32, y: u32) -> [u8; 4] {
let idx = ((y * fb.width() + x) * 4) as usize;
let p = fb.pixels();
[p[idx], p[idx + 1], p[idx + 2], p[idx + 3]]
}
fn is_white(pixel: [u8; 4]) -> bool {
pixel[0] > 200 && pixel[1] > 200 && pixel[2] > 200
}
fn is_red(pixel: [u8; 4]) -> bool {
pixel[0] > 200 && pixel[1] < 50 && pixel[2] < 50
}
fn is_dark(pixel: [u8; 4]) -> bool {
pixel[0] < 50 && pixel[1] < 50 && pixel[2] < 50
}
#[test]
fn test_y_up_point_at_bottom() {
let mut fb = Framebuffer::new(100, 100);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.circle(50.0, 10.0, 5.0);
ctx.fill();
drop(ctx);
let center = sample(&fb, 50, 10);
assert!(is_white(center), "Y=10 should be near the bottom of the buffer (Y-up); got {center:?}");
let top_center = sample(&fb, 50, 90);
assert!(is_dark(top_center), "Y=90 should be dark (nothing drawn there); got {top_center:?}");
}
#[test]
fn test_rotation_ccw_positive() {
let size = 200u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
let cx = size as f64 / 2.0;
let cy = size as f64 / 2.0;
ctx.translate(cx, cy);
ctx.rotate(std::f64::consts::FRAC_PI_2);
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(10.0, -3.0, 40.0, 6.0);
ctx.fill();
drop(ctx);
let above_center = sample(&fb, cx as u32, cy as u32 + 25);
assert!(is_white(above_center), "+90° CCW rotation should produce upward bar; pixel above center is {above_center:?}");
let right_of_center = sample(&fb, cx as u32 + 25, cy as u32);
assert!(is_dark(right_of_center), "After +90° rotation, horizontal should be gone; pixel to right is {right_of_center:?}");
}
#[test]
fn test_bottom_left_origin() {
let mut fb = Framebuffer::new(200, 200);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
ctx.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
ctx.begin_path();
ctx.circle(10.0, 10.0, 6.0);
ctx.fill();
drop(ctx);
let center = sample(&fb, 10, 10);
assert!(is_red(center), "Bottom-left origin test: (10,10) should be red; got {center:?}");
let top_right = sample(&fb, 190, 190);
assert!(is_dark(top_right), "Top-right should be empty; got {top_right:?}");
}
#[test]
fn test_pixels_flipped_reversal() {
let w = 4u32;
let h = 4u32;
let mut fb = Framebuffer::new(w, h);
{
let pixels = fb.pixels_mut();
for x in 0..w as usize {
let i = x * 4;
pixels[i] = 255; pixels[i+1] = 0; pixels[i+2] = 0; pixels[i+3] = 255;
}
let base = 3 * w as usize * 4;
for x in 0..w as usize {
let i = base + x * 4;
pixels[i] = 0; pixels[i+1] = 0; pixels[i+2] = 255; pixels[i+3] = 255;
}
}
let flipped = fb.pixels_flipped();
assert_eq!(&flipped[0..4], &[0u8, 0, 255, 255], "Flipped[0] should be blue");
let last = (h as usize - 1) * w as usize * 4;
assert_eq!(&flipped[last..last+4], &[255u8, 0, 0, 255], "Flipped last row should be red");
}
#[test]
fn test_clip_rect_excludes_outside() {
let size = 100u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
ctx.clip_rect(50.0, 0.0, 50.0, 100.0);
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(0.0, 0.0, 100.0, 100.0);
ctx.fill();
drop(ctx);
let left = sample(&fb, 10, 50);
assert!(is_dark(left), "Left half should be clipped out; got {left:?}");
let right = sample(&fb, 75, 50);
assert!(is_white(right), "Right half should be white (inside clip); got {right:?}");
}
#[test]
fn test_clip_rect_restores_with_state() {
let size = 100u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
ctx.save();
ctx.clip_rect(60.0, 0.0, 40.0, 100.0); ctx.restore();
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(0.0, 0.0, 100.0, 100.0);
ctx.fill();
drop(ctx);
let left = sample(&fb, 10, 50);
assert!(is_white(left), "After restore, clip should be gone; got {left:?}");
}
#[test]
fn test_rounded_rect_zero_radius() {
let size = 100u32;
let mut fb_rr = Framebuffer::new(size, size);
let mut fb_r = Framebuffer::new(size, size);
{
let mut ctx = GfxCtx::new(&mut fb_rr);
ctx.clear(Color::black());
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rounded_rect(20.0, 20.0, 60.0, 60.0, 0.0);
ctx.fill();
}
{
let mut ctx = GfxCtx::new(&mut fb_r);
ctx.clear(Color::black());
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(20.0, 20.0, 60.0, 60.0);
ctx.fill();
}
assert!(is_white(sample(&fb_rr, 50, 50)), "rounded_rect center should be white");
assert!(is_white(sample(&fb_r, 50, 50)), "rect center should be white");
}
#[test]
fn test_rounded_rect_corners_are_clipped() {
let size = 100u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::black());
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rounded_rect(20.0, 20.0, 60.0, 60.0, 15.0);
ctx.fill();
drop(ctx);
let corner = sample(&fb, 20, 20);
assert!(is_dark(corner), "Corner should be clipped by radius; got {corner:?}");
let center = sample(&fb, 50, 50);
assert!(is_white(center), "Center should be white; got {center:?}");
}
#[test]
fn test_blend_mode_src_over_alpha() {
let size = 40u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::white());
ctx.set_blend_mode(CompOp::SrcOver);
ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.5));
ctx.begin_path();
ctx.rect(0.0, 0.0, size as f64, size as f64);
ctx.fill();
drop(ctx);
let p = sample(&fb, 20, 20);
assert!(p[0] > 100 && p[0] < 160, "50% black over white should be mid-gray; got {p:?}");
}
#[test]
fn test_global_alpha() {
let size = 40u32;
let mut fb = Framebuffer::new(size, size);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::white());
ctx.set_global_alpha(0.5);
ctx.set_fill_color(Color::rgb(1.0, 0.0, 0.0));
ctx.begin_path();
ctx.rect(0.0, 0.0, size as f64, size as f64);
ctx.fill();
drop(ctx);
let p = sample(&fb, 20, 20);
assert!(p[0] > 200, "Red channel should be high; got {p:?}");
assert!(p[1] > 100, "Green channel should be non-zero (blended with white); got {p:?}");
}
const TEST_FONT: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
#[test]
fn test_measure_text_longer_is_wider() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut fb = Framebuffer::new(400, 100);
let mut ctx = GfxCtx::new(&mut fb);
ctx.set_font(font);
ctx.set_font_size(20.0);
let short = ctx.measure_text("Hi").unwrap();
let longer = ctx.measure_text("Hello, World!").unwrap();
assert!(
longer.width > short.width,
"longer string should have greater advance: {} > {}",
longer.width,
short.width,
);
}
#[test]
fn test_fill_text_paints_pixels() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut fb = Framebuffer::new(300, 60);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::white());
ctx.set_fill_color(Color::black());
ctx.set_font(font);
ctx.set_font_size(24.0);
ctx.fill_text("Test", 10.0, 30.0);
drop(ctx);
let dark_count = (0..300_u32)
.flat_map(|x| (0..60_u32).map(move |y| (x, y)))
.filter(|&(x, y)| !is_white(sample(&fb, x, y)))
.count();
assert!(dark_count > 10, "fill_text should paint dark pixels; got {dark_count}");
}
#[test]
fn test_measure_text_metrics_positive() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut fb = Framebuffer::new(200, 60);
let mut ctx = GfxCtx::new(&mut fb);
ctx.set_font(font);
ctx.set_font_size(16.0);
let m = ctx.measure_text("Ag").unwrap();
assert!(m.ascent > 0.0, "ascent must be positive; got {}", m.ascent);
assert!(m.descent > 0.0, "descent must be positive; got {}", m.descent);
assert!(m.line_height >= m.ascent + m.descent,
"line_height ({}) should be >= ascent + descent ({})", m.line_height, m.ascent + m.descent);
}
#[test]
fn test_y_flip_at_ingestion() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut clicked = false;
let clicked_ptr = &mut clicked as *mut bool;
let mut button = Button::new("X", Arc::clone(&font))
.with_font_size(14.0)
.on_click(move || unsafe { *clicked_ptr = true });
button.layout(Size::new(200.0, 100.0));
button.set_bounds(crate::Rect::new(0.0, 0.0, 200.0, 100.0));
let mut app = App::new(Box::new(button) as Box<dyn Widget>);
app.layout(Size::new(200.0, 100.0));
app.on_mouse_move(100.0, 50.0);
app.on_mouse_down(100.0, 50.0, MouseButton::Left, Modifiers::default());
app.on_mouse_up(100.0, 50.0, MouseButton::Left, Modifiers::default());
assert!(clicked, "button inside viewport should be clicked");
}
#[test]
fn test_color_picker_opens_and_updates_on_drag() {
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::text::Font;
use crate::ColorPicker;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let start = Color::rgba(1.0, 0.0, 0.0, 1.0);
let cell = Rc::new(Cell::new(start));
let picker = ColorPicker::new(Rc::clone(&cell), Arc::clone(&font));
let mut app = App::new(Box::new(picker));
const VP_H: f64 = 400.0;
app.layout(Size::new(300.0, VP_H));
let swatch_screen_y = VP_H - 10.0;
app.on_mouse_down(50.0, swatch_screen_y, MouseButton::Left, Modifiers::default());
app.on_mouse_up (50.0, swatch_screen_y, MouseButton::Left, Modifiers::default());
app.layout(Size::new(300.0, VP_H));
let hue_screen_y = VP_H - 242.0;
app.on_mouse_down(220.0, hue_screen_y, MouseButton::Left, Modifiers::default());
app.on_mouse_move(210.0, hue_screen_y);
app.on_mouse_up (210.0, hue_screen_y, MouseButton::Left, Modifiers::default());
let final_color = cell.get();
assert_ne!(
(start.r, start.g, start.b), (final_color.r, final_color.g, final_color.b),
"hue drag must have mutated the bound colour cell (got {:?})",
final_color,
);
}
#[test]
fn test_click_outside_bounds_ignored() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let clicked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let clicked2 = std::sync::Arc::clone(&clicked);
let button = Button::new("X", font)
.with_font_size(14.0)
.on_click(move || { clicked2.store(true, std::sync::atomic::Ordering::Relaxed); });
let mut app = App::new(Box::new(button));
app.layout(Size::new(200.0, 100.0));
app.on_mouse_down(100.0, 200.0, MouseButton::Left, Modifiers::default());
app.on_mouse_up(100.0, 200.0, MouseButton::Left, Modifiers::default());
assert!(!clicked.load(std::sync::atomic::Ordering::Relaxed),
"click outside button bounds must not fire callback");
}
#[test]
fn test_tab_focus_advance() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut root = Container::new().with_padding(4.0);
root.children_mut().push(Box::new(TextField::new(Arc::clone(&font)).with_font_size(14.0)));
root.children_mut().push(Box::new(TextField::new(Arc::clone(&font)).with_font_size(14.0)));
let mut app = App::new(Box::new(root));
app.layout(Size::new(200.0, 200.0));
app.on_key_down(Key::Tab, Modifiers::default());
app.on_key_down(Key::Tab, Modifiers::default());
app.on_key_down(Key::Tab, Modifiers::default());
}
#[test]
fn test_flex_column_first_child_highest_y() {
let mut col = FlexColumn::new()
.with_gap(0.0)
.with_padding(0.0)
.add(Box::new(SizedBox::new().with_height(40.0))) .add(Box::new(SizedBox::new().with_height(60.0)));
col.layout(Size::new(200.0, 200.0));
let y0 = col.children()[0].bounds().y;
let y1 = col.children()[1].bounds().y;
assert!(
y0 > y1,
"first child (top) should have higher Y in Y-up; got y0={y0}, y1={y1}",
);
assert_eq!(col.children()[0].bounds().height, 40.0);
assert_eq!(col.children()[1].bounds().height, 60.0);
}
#[test]
fn test_flex_row_distributes_space() {
let mut row = FlexRow::new()
.with_gap(0.0)
.with_padding(0.0)
.add_flex(Box::new(SizedBox::new()), 1.0) .add_flex(Box::new(SizedBox::new()), 1.0);
row.layout(Size::new(200.0, 40.0));
let x0 = row.children()[0].bounds().x;
let x1 = row.children()[1].bounds().x;
assert_eq!(x0, 0.0, "first flex child should start at x=0");
assert!(x1 > x0, "second flex child should be to the right of first");
assert!((x1 - 100.0).abs() < 1.0, "second child should start at x≈100; got {x1}");
}
#[test]
fn test_scroll_view_tall_content_child_y() {
let content = SizedBox::new().with_height(500.0);
let mut scroll = ScrollView::new(Box::new(content));
let result = scroll.layout(Size::new(200.0, 200.0));
assert_eq!(result.width, 200.0);
assert_eq!(result.height, 200.0);
let child_y = scroll.children()[0].bounds().y;
assert!(
child_y < 0.0,
"tall content with offset=0 should have negative child_y; got {child_y}",
);
}
#[test]
fn test_splitter_drag_updates_ratio() {
let mut splitter = Splitter::new(
Box::new(SizedBox::new()),
Box::new(SizedBox::new()),
);
splitter.layout(Size::new(400.0, 200.0));
splitter.set_bounds(crate::Rect::new(0.0, 0.0, 400.0, 200.0));
let div_x = (400.0_f64 - 6.0) * 0.5;
splitter.on_event(&crate::Event::MouseDown {
pos: crate::Point::new(div_x + 1.0, 100.0),
button: MouseButton::Left,
modifiers: Modifiers::default(),
});
splitter.on_event(&crate::Event::MouseMove {
pos: crate::Point::new(100.0, 100.0),
});
assert!(
(splitter.ratio - 0.25).abs() < 0.01,
"ratio should be ≈0.25 after drag; got {}",
splitter.ratio,
);
}
#[test]
fn test_tab_view_always_has_one_child() {
use std::sync::Arc;
use crate::text::Font;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut tv = TabView::new(Arc::clone(&font))
.add_tab("A", Box::new(SizedBox::new().with_height(100.0)))
.add_tab("B", Box::new(SizedBox::new().with_height(200.0)));
tv.layout(Size::new(400.0, 300.0));
tv.set_bounds(crate::Rect::new(0.0, 0.0, 400.0, 300.0));
assert_eq!(tv.children().len(), 1, "TabView should always have exactly 1 active child");
tv.on_event(&crate::Event::MouseDown {
pos: crate::Point::new(300.0, 270.0),
button: MouseButton::Left,
modifiers: Modifiers::default(),
});
assert_eq!(tv.children().len(), 1, "TabView should still have exactly 1 active child after switch");
}
#[test]
fn test_window_close_hides_content() {
use std::sync::Arc;
use crate::text::Font;
use crate::widgets::window::Window;
use crate::widget::paint_subtree;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let content = Button::new("Content", Arc::clone(&font)).with_font_size(14.0);
let mut win = Window::new("Test", Arc::clone(&font), Box::new(content))
.with_bounds(crate::Rect::new(0.0, 0.0, 200.0, 200.0));
win.layout(Size::new(200.0, 200.0));
let mut fb_visible = Framebuffer::new(200, 200);
{
let mut ctx = GfxCtx::new(&mut fb_visible);
ctx.clear(Color::black());
paint_subtree(&mut win, &mut ctx);
}
win.hide();
let mut fb_hidden = Framebuffer::new(200, 200);
{
let mut ctx = GfxCtx::new(&mut fb_hidden);
ctx.clear(Color::black());
paint_subtree(&mut win, &mut ctx);
}
let visible_has_pixels = fb_visible.pixels()
.chunks(4)
.any(|p| p[0] > 50 || p[1] > 50 || p[2] > 50);
assert!(visible_has_pixels, "visible window must paint something");
let hidden_all_black = fb_hidden.pixels()
.chunks(4)
.all(|p| p[0] < 10 && p[1] < 10 && p[2] < 10);
assert!(hidden_all_black, "hidden window must not paint anything; content child leaked");
}
#[test]
fn test_inspector_row0_at_top() {
use std::sync::Arc;
use std::cell::RefCell;
use std::rc::Rc;
use crate::text::Font;
use crate::widgets::inspector::InspectorPanel;
use crate::widget::{InspectorNode, Widget};
use crate::geometry::Rect;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let hovered_bounds = Rc::new(RefCell::new(None));
let nodes: Rc<RefCell<Vec<InspectorNode>>> = Rc::new(RefCell::new(vec![
InspectorNode { type_name: "Root", screen_bounds: Rect::new(0.0,0.0,100.0,50.0), depth: 0, properties: vec![] },
InspectorNode { type_name: "Child", screen_bounds: Rect::new(0.0,0.0,50.0,20.0), depth: 1, properties: vec![] },
]));
let mut panel = InspectorPanel::new(Arc::clone(&font), Rc::clone(&nodes), Rc::clone(&hovered_bounds));
panel.layout(crate::Size::new(200.0, 300.0));
panel.set_bounds(Rect::new(0.0, 0.0, 200.0, 300.0));
assert_eq!(panel.children().len(), 1, "InspectorPanel must have one presence child");
assert_eq!(panel.children()[0].type_name(), "TreeView",
"The presence child must report type_name 'TreeView'");
assert_eq!(panel.tree_view.nodes.len(), 2, "tree_view must have 2 nodes");
assert!(panel.tree_view.nodes[0].parent.is_none(), "Root must have no parent");
assert_eq!(panel.tree_view.nodes[1].parent, Some(0), "Child must have Root (0) as parent");
let tv_bounds = panel.tree_view.bounds();
assert!(tv_bounds.height > 0.0, "TreeView must have positive height");
assert!(tv_bounds.y >= 60.0, "TreeView bottom must be above split handle");
assert!(
tv_bounds.y + tv_bounds.height <= 270.0 + 1.0,
"TreeView top must not exceed list_area_h (270); got {}",
tv_bounds.y + tv_bounds.height
);
}
#[test]
fn test_inspector_tree_populates_from_nodes() {
use std::sync::Arc;
use std::cell::RefCell;
use std::rc::Rc;
use crate::text::Font;
use crate::widgets::inspector::InspectorPanel;
use crate::widget::{InspectorNode, Widget};
use crate::geometry::Rect;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let hovered_bounds = Rc::new(RefCell::new(None));
let nodes: Rc<RefCell<Vec<InspectorNode>>> = Rc::new(RefCell::new(vec![
InspectorNode { type_name: "Root", screen_bounds: Rect::new(0.0,0.0,100.0,50.0), depth: 0, properties: vec![] },
InspectorNode { type_name: "Child", screen_bounds: Rect::new(0.0,0.0,50.0,20.0), depth: 1, properties: vec![] },
InspectorNode { type_name: "Sibling", screen_bounds: Rect::new(0.0,0.0,50.0,20.0), depth: 0, properties: vec![] },
]));
let mut panel = InspectorPanel::new(Arc::clone(&font), Rc::clone(&nodes), hovered_bounds);
panel.layout(crate::Size::new(200.0, 400.0));
assert_eq!(panel.tree_view.nodes.len(), 3, "must have 3 tree nodes");
assert!(panel.tree_view.nodes[0].parent.is_none(), "node 0 must be root-level");
assert_eq!(panel.tree_view.nodes[1].parent, Some(0), "node 1 must be child of node 0");
assert!(panel.tree_view.nodes[2].parent.is_none(), "node 2 must be root-level");
assert_eq!(panel.children().len(), 1,
"InspectorPanel must have one presence child");
assert_eq!(panel.children()[0].type_name(), "TreeView",
"Presence child must report type_name 'TreeView'");
}
#[test]
fn test_inspector_tree_default_expanded() {
use std::sync::Arc;
use std::cell::RefCell;
use std::rc::Rc;
use crate::text::Font;
use crate::widgets::inspector::InspectorPanel;
use crate::widget::{InspectorNode, Widget};
use crate::geometry::Rect;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let hovered_bounds = Rc::new(RefCell::new(None));
let nodes: Rc<RefCell<Vec<InspectorNode>>> = Rc::new(RefCell::new(vec![
InspectorNode { type_name: "Root", screen_bounds: Rect::new(0.0,0.0,100.0,50.0), depth: 0, properties: vec![] },
InspectorNode { type_name: "Child", screen_bounds: Rect::new(0.0,0.0,50.0,20.0), depth: 1, properties: vec![] },
]));
let mut panel = InspectorPanel::new(Arc::clone(&font), Rc::clone(&nodes), hovered_bounds);
panel.layout(crate::Size::new(200.0, 400.0));
for (i, node) in panel.tree_view.nodes.iter().enumerate() {
assert!(node.is_expanded, "node {} must be expanded by default", i);
}
}
#[test]
fn test_inspector_tree_drag_disabled() {
use std::sync::Arc;
use std::cell::RefCell;
use std::rc::Rc;
use crate::text::Font;
use crate::widgets::inspector::InspectorPanel;
use crate::widget::{InspectorNode, Widget};
use crate::geometry::Rect;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let hovered_bounds = Rc::new(RefCell::new(None));
let nodes: Rc<RefCell<Vec<InspectorNode>>> = Rc::new(RefCell::new(vec![
InspectorNode { type_name: "Root", screen_bounds: Rect::new(0.0,0.0,100.0,50.0), depth: 0, properties: vec![] },
]));
let panel = InspectorPanel::new(Arc::clone(&font), Rc::clone(&nodes), hovered_bounds);
assert!(!panel.tree_view.drag_enabled, "inspector TreeView must have drag disabled");
}
#[test]
fn test_expand_toggle_paints_arrow_only_when_has_children() {
use std::sync::Arc;
use crate::text::Font;
use crate::widgets::tree_view::row::ExpandToggle;
use crate::widget::paint_subtree;
let mut fb_with = Framebuffer::new(20, 20);
let mut fb_without = Framebuffer::new(20, 20);
{
let mut ctx = GfxCtx::new(&mut fb_with);
ctx.clear(Color::rgba(1.0, 1.0, 1.0, 1.0));
let mut toggle = ExpandToggle::new(true, false);
toggle.layout(Size::new(20.0, 20.0));
toggle.set_bounds(crate::Rect::new(0.0, 0.0, 20.0, 20.0));
paint_subtree(&mut toggle, &mut ctx);
}
{
let mut ctx = GfxCtx::new(&mut fb_without);
ctx.clear(Color::rgba(1.0, 1.0, 1.0, 1.0));
let mut toggle = ExpandToggle::new(false, false);
toggle.layout(Size::new(20.0, 20.0));
toggle.set_bounds(crate::Rect::new(0.0, 0.0, 20.0, 20.0));
paint_subtree(&mut toggle, &mut ctx);
}
assert_ne!(fb_with.pixels(), fb_without.pixels());
}
#[test]
fn test_text_field_typing() {
use std::sync::Arc;
use crate::text::Font;
use crate::widgets::text_field::TextField as TF;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut field = TF::new(font).with_font_size(14.0);
field.layout(Size::new(200.0, 36.0));
field.set_bounds(crate::Rect::new(0.0, 0.0, 200.0, 36.0));
field.on_event(&crate::Event::FocusGained);
field.on_event(&crate::Event::KeyDown { key: Key::Char('H'), modifiers: Modifiers::default() });
field.on_event(&crate::Event::KeyDown { key: Key::Char('i'), modifiers: Modifiers::default() });
assert_eq!(field.text(), "Hi", "typed characters should appear in text");
field.on_event(&crate::Event::KeyDown { key: Key::Backspace, modifiers: Modifiers::default() });
assert_eq!(field.text(), "H", "backspace should remove last character");
}
#[test]
fn test_treeview_children_count_equals_visible_rows() {
use std::sync::Arc;
use crate::text::Font;
use crate::widgets::tree_view::{NodeIcon, TreeView};
use crate::geometry::Size;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut tv = TreeView::new(Arc::clone(&font));
let root = tv.add_root("Root", NodeIcon::Folder);
tv.add_child(root, "Child A", NodeIcon::File);
tv.add_child(root, "Child B", NodeIcon::File);
tv.nodes[root].is_expanded = true;
tv.layout(Size::new(300.0, 200.0));
assert_eq!(tv.children().len(), 3, "expected 3 children after expanding root with 2 children");
}
#[test]
fn test_treeview_row_node_idx() {
use std::sync::Arc;
use crate::text::Font;
use crate::widgets::tree_view::{NodeIcon, TreeView};
use crate::geometry::Size;
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let mut tv = TreeView::new(Arc::clone(&font));
tv.add_root("Only Root", NodeIcon::Package);
tv.layout(Size::new(200.0, 100.0));
assert_eq!(tv.children().len(), 1);
assert_eq!(tv.children()[0].type_name(), "TreeRow");
}
#[test]
fn test_inspector_top_row_appears_at_top_of_tree_area() {
use std::sync::Arc;
use std::cell::RefCell;
use std::rc::Rc;
use crate::text::Font;
use crate::widgets::inspector::InspectorPanel;
use crate::widget::{InspectorNode, Widget, paint_subtree};
use crate::geometry::{Rect, Size};
let font = Arc::new(Font::from_slice(TEST_FONT).unwrap());
let nodes: Rc<RefCell<Vec<InspectorNode>>> = Rc::new(RefCell::new(vec![
InspectorNode {
type_name: "Window",
screen_bounds: Rect::new(0.0, 0.0, 100.0, 100.0),
depth: 0,
properties: vec![],
},
]));
let hovered = Rc::new(RefCell::new(None));
let mut panel = InspectorPanel::new(Arc::clone(&font), Rc::clone(&nodes), Rc::clone(&hovered));
let pw = 240u32;
let ph = 400u32;
let mut fb = Framebuffer::new(pw, ph);
{
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::rgba(1.0, 1.0, 1.0, 1.0));
panel.layout(Size::new(pw as f64, ph as f64));
panel.set_bounds(Rect::new(0.0, 0.0, pw as f64, ph as f64));
paint_subtree(&mut panel, &mut ctx);
}
let row_y_down: usize = 35;
let row_y_up = (ph as usize).saturating_sub(1).saturating_sub(row_y_down);
let pixels = fb.pixels();
let mut found_non_white = false;
for px in 5..(pw as usize - 5) {
let idx = (row_y_up * pw as usize + px) * 4;
if idx + 3 < pixels.len() {
let r = pixels[idx] as u32;
let g = pixels[idx + 1] as u32;
let b = pixels[idx + 2] as u32;
if r < 240 || g < 240 || b < 240 {
found_non_white = true;
break;
}
}
}
assert!(
found_non_white,
"expected non-white content just below the header at row_y_down={}, but got all-white — check clip_rect+translate ordering in InspectorPanel::paint()",
row_y_down
);
}
#[test]
fn test_treeview_drag_node_excluded_from_row_widgets() {
use std::sync::Arc;
use crate::widgets::tree_view::{NodeIcon, TreeView};
use crate::geometry::{Point, Size};
use crate::event::{Event, Modifiers, MouseButton};
let font = Arc::new(crate::text::Font::from_slice(TEST_FONT).unwrap());
use crate::geometry::Rect;
let mut tv = TreeView::new(Arc::clone(&font)).with_drag_enabled();
tv.add_root("Node A", NodeIcon::File);
tv.add_root("Node B", NodeIcon::File);
tv.layout(Size::new(200.0, 100.0));
tv.set_bounds(Rect::new(0.0, 0.0, 200.0, 100.0));
assert_eq!(tv.children().len(), 2);
tv.on_event(&Event::MouseDown {
pos: Point::new(50.0, 88.0),
button: MouseButton::Left,
modifiers: Modifiers::default(),
});
tv.on_event(&Event::MouseMove { pos: Point::new(50.0, 78.0) });
tv.layout(Size::new(200.0, 100.0));
assert_eq!(tv.children().len(), 1,
"dragged node must be excluded from row_widgets during live drag");
}
#[test]
fn test_button_has_label_child() {
use std::sync::Arc;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut btn = Button::new("Click me", font);
btn.layout(Size::new(200.0, 40.0));
assert_eq!(btn.children().len(), 1, "Button must expose exactly one Label child");
assert_eq!(btn.children()[0].type_name(), "Label",
"Button's child must be a Label widget");
}
#[test]
fn test_button_label_child_fills_button() {
use std::sync::Arc;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut btn = Button::new("Click me", font);
let size = btn.layout(Size::new(300.0, 50.0));
let label_bounds = btn.children()[0].bounds();
assert!(label_bounds.width < size.width,
"Label width must be tight (less than button width); got label_w={} btn_w={}",
label_bounds.width, size.width);
assert!(label_bounds.width > 0.0, "Label width must be positive");
assert!(label_bounds.height > 0.0, "Label height must be positive");
let expected_x = (size.width - label_bounds.width) * 0.5;
assert!((label_bounds.x - expected_x).abs() < 1.0,
"Label must be horizontally centred; expected x≈{:.1}, got x={:.1}",
expected_x, label_bounds.x);
let expected_y = (size.height - label_bounds.height) * 0.5;
assert!((label_bounds.y - expected_y).abs() < 1.0,
"Label must be vertically centred; expected y≈{:.1}, got y={:.1}",
expected_y, label_bounds.y);
}
#[test]
fn test_label_properties() {
use std::sync::Arc;
use crate::{Label, text::Font};
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let label = Label::new("Hello", font).with_font_size(13.0);
let props: std::collections::HashMap<_, _> = label.properties().into_iter().collect();
assert!(props.contains_key("text"), "Label must expose 'text' property");
assert_eq!(props["text"], "Hello");
assert!(props.contains_key("has_backbuffer"), "Label must expose 'has_backbuffer'");
assert_eq!(props["has_backbuffer"], "true");
}
#[test]
fn test_button_properties() {
use std::sync::Arc;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let btn = Button::new("Primary Action", font);
let props: std::collections::HashMap<_, _> = btn.properties().into_iter().collect();
assert!(props.contains_key("label"), "Button must expose 'label' property");
assert_eq!(props["label"], "Primary Action");
}
#[test]
fn test_button_inspector_hierarchy() {
use std::sync::Arc;
use crate::{text::Font, widget::collect_inspector_nodes, geometry::{Point, Rect}};
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut btn = Button::new("OK", font);
btn.layout(Size::new(200.0, 40.0));
btn.set_bounds(Rect::new(0.0, 0.0, 200.0, 40.0));
let mut nodes = Vec::new();
let boxed: Box<dyn Widget> = Box::new(btn);
collect_inspector_nodes(boxed.as_ref(), 0, Point::new(0.0, 0.0), &mut nodes);
assert!(nodes.len() >= 2, "Must have at least Button + Label nodes");
assert_eq!(nodes[0].type_name, "Button");
assert_eq!(nodes[0].depth, 0);
assert_eq!(nodes[1].type_name, "Label");
assert_eq!(nodes[1].depth, 1);
}
#[test]
fn test_invisible_widget_excluded_from_inspector() {
use crate::widget::{collect_inspector_nodes, Widget};
use crate::geometry::{Point, Rect, Size};
use crate::event::{Event, EventResult};
use crate::draw_ctx::DrawCtx;
struct ToggleWidget {
bounds: Rect,
visible: bool,
children: Vec<Box<dyn Widget>>,
}
impl Widget for ToggleWidget {
fn type_name(&self) -> &'static str { "ToggleWidget" }
fn is_visible(&self) -> bool { self.visible }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size { available }
fn paint(&mut self, _: &mut dyn DrawCtx) {}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
}
let visible = ToggleWidget {
bounds: Rect::new(0.0, 0.0, 100.0, 40.0),
visible: true,
children: Vec::new(),
};
let hidden = ToggleWidget {
bounds: Rect::new(0.0, 50.0, 100.0, 40.0),
visible: false,
children: Vec::new(),
};
let mut nodes = Vec::new();
collect_inspector_nodes(&visible, 0, Point::ORIGIN, &mut nodes);
assert_eq!(nodes.len(), 1, "visible widget appears once");
assert_eq!(nodes[0].type_name, "ToggleWidget");
nodes.clear();
collect_inspector_nodes(&hidden, 0, Point::ORIGIN, &mut nodes);
assert!(nodes.is_empty(), "invisible widget produces no inspector nodes");
}
#[test]
fn test_treeview_click_selects_without_collapsing_when_flag_off() {
use std::sync::Arc;
use crate::text::Font;
use crate::geometry::{Point, Size};
use crate::event::Modifiers;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut tv = crate::widgets::tree_view::TreeView::new(Arc::clone(&font))
.with_row_height(20.0);
let root = tv.add_root("Root", crate::widgets::tree_view::NodeIcon::Package);
tv.expand(root);
tv.add_child(root, "Child A", crate::widgets::tree_view::NodeIcon::File);
tv.add_child(root, "Child B", crate::widgets::tree_view::NodeIcon::File);
use crate::widget::Widget;
tv.layout(Size::new(300.0, 200.0));
tv.set_bounds(crate::geometry::Rect::new(0.0, 0.0, 300.0, 200.0));
assert_eq!(tv.children().len(), 3, "should have Root + 2 children visible");
let root_row_y = 200.0 - 20.0 * 0.5; tv.on_event(&crate::event::Event::MouseDown {
pos: Point::new(80.0, root_row_y),
button: crate::event::MouseButton::Left,
modifiers: Modifiers::default(),
});
tv.layout(Size::new(300.0, 200.0));
assert_eq!(
tv.children().len(), 3,
"clicking root row must NOT collapse it when toggle_on_row_click = false"
);
}
#[test]
fn test_treeview_click_collapses_when_flag_on() {
use std::sync::Arc;
use crate::text::Font;
use crate::geometry::{Point, Size};
use crate::event::Modifiers;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut tv = crate::widgets::tree_view::TreeView::new(Arc::clone(&font))
.with_row_height(20.0)
.with_toggle_on_row_click();
let root = tv.add_root("Root", crate::widgets::tree_view::NodeIcon::Package);
tv.expand(root);
tv.add_child(root, "Child A", crate::widgets::tree_view::NodeIcon::File);
use crate::widget::Widget;
tv.layout(Size::new(300.0, 200.0));
tv.set_bounds(crate::geometry::Rect::new(0.0, 0.0, 300.0, 200.0));
assert_eq!(tv.children().len(), 2, "Root + 1 child visible initially");
let root_row_y = 200.0 - 20.0 * 0.5;
tv.on_event(&crate::event::Event::MouseDown {
pos: Point::new(80.0, root_row_y), button: crate::event::MouseButton::Left,
modifiers: Modifiers::default(),
});
tv.layout(Size::new(300.0, 200.0));
assert_eq!(
tv.children().len(), 1,
"clicking root row body must collapse it when toggle_on_row_click = true"
);
}
#[test]
fn test_push_pop_layer_solid_composites_correctly() {
let mut fb = Framebuffer::new(20, 20);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::white());
ctx.push_layer(20.0, 20.0);
ctx.set_fill_color(Color::rgba(1.0, 0.0, 0.0, 1.0));
ctx.begin_path();
ctx.rect(0.0, 0.0, 20.0, 20.0);
ctx.fill();
ctx.pop_layer();
drop(ctx);
let center = sample(&fb, 10, 10);
assert!(is_red(center), "After layer composite, centre must be red; got {center:?}");
}
#[test]
fn test_push_pop_layer_alpha_blends_into_parent() {
let mut fb = Framebuffer::new(20, 20);
let mut ctx = GfxCtx::new(&mut fb);
ctx.clear(Color::white());
ctx.push_layer(20.0, 20.0);
ctx.set_fill_color(Color::rgba(1.0, 0.0, 0.0, 0.5));
ctx.begin_path();
ctx.rect(0.0, 0.0, 20.0, 20.0);
ctx.fill();
ctx.pop_layer();
drop(ctx);
let [r, g, b, _] = sample(&fb, 10, 10);
assert!(r > 200, "Red channel must be high; got {r}");
assert!(g > 80 && g < 200, "Green channel must be mid-tone (pink); got {g}");
assert!(b > 80 && b < 200, "Blue channel must be mid-tone (pink); got {b}");
}
#[cfg(any())]
fn _deleted_backbuffer_tests_marker() {}
#[test]
fn test_window_layout_never_mutates_bounds() {
use std::sync::Arc;
use crate::text::Font;
use crate::Label;
use crate::widgets::window::Window;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let content: Box<dyn crate::widget::Widget> =
Box::new(Label::new("content", Arc::clone(&font)));
let saved = crate::geometry::Rect::new(50.0, 800.0, 400.0, 200.0);
let mut win = Window::new("Test", Arc::clone(&font), content)
.with_bounds(saved);
let sizes = [
(800.0, 600.0), (1920.0, 1017.0), (800.0, 600.0), (1920.0, 1017.0), ];
for (w, h) in sizes {
let _ = <Window as crate::widget::Widget>::layout(
&mut win,
crate::geometry::Size::new(w, h),
);
assert_eq!(
win.bounds().y, 800.0,
"layout({w}, {h}) mutated bounds.y to {} — auto-save would \
now persist the mutated position, corrupting saved state",
win.bounds().y,
);
assert_eq!(win.bounds().x, 50.0);
}
}
#[test]
fn test_sidebar_toggle_reorders_stack_to_end() {
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::{geometry::{Rect, Size}, text::Font, Label, Widget};
use crate::widgets::{primitives::Stack, window::Window};
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let a_visible = Rc::new(Cell::new(true));
let b_visible = Rc::new(Cell::new(true));
let c_visible = Rc::new(Cell::new(true));
let make = |x: f64, vis: Rc<Cell<bool>>| -> Box<dyn Widget> {
Box::new(
Window::new("W", Arc::clone(&font),
Box::new(Label::new("x", Arc::clone(&font))))
.with_bounds(Rect::new(x, 0.0, 200.0, 120.0))
.with_visible_cell(vis)
)
};
let mut stack: Box<dyn Widget> = Box::new(
Stack::new()
.add(make(100.0, Rc::clone(&a_visible)))
.add(make(200.0, Rc::clone(&b_visible)))
.add(make(300.0, Rc::clone(&c_visible)))
);
let _ = stack.layout(Size::new(1024.0, 768.0));
b_visible.set(false);
let _ = stack.layout(Size::new(1024.0, 768.0));
b_visible.set(true);
let _ = stack.layout(Size::new(1024.0, 768.0));
let last_x = stack.children()[2].bounds().x;
assert_eq!(
last_x, 200.0,
"after sidebar-toggle-on of B (x=200), B must be at the END of \
Stack.children (got child with bounds.x={last_x} at index 2)"
);
let first_x = stack.children()[0].bounds().x;
assert_eq!(first_x, 100.0, "A preserved at index 0");
let mid_x = stack.children()[1].bounds().x;
assert_eq!(mid_x, 300.0, "C preserved at index 1");
}
#[test]
fn test_raise_takes_effect_same_frame_as_visibility_toggle() {
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::{geometry::{Rect, Size}, text::Font, Label, Widget};
use crate::widgets::{primitives::Stack, window::Window};
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let a_visible = Rc::new(Cell::new(false)); let b_visible = Rc::new(Cell::new(true));
let make = |vis: Rc<Cell<bool>>| -> Box<dyn Widget> {
Box::new(
Window::new("W", Arc::clone(&font),
Box::new(Label::new("x", Arc::clone(&font))))
.with_bounds(Rect::new(0.0, 0.0, 200.0, 120.0))
.with_visible_cell(vis)
)
};
let mut stack: Box<dyn Widget> = Box::new(
Stack::new()
.add(make(Rc::clone(&a_visible))) .add(make(Rc::clone(&b_visible))) );
let _ = stack.layout(Size::new(1024.0, 768.0));
a_visible.set(true);
let _ = stack.layout(Size::new(1024.0, 768.0));
assert!(!stack.children_mut()[0].take_raise_request(),
"child 0 still has a pending raise — Stack drain ran before \
Window.layout set the flag; sidebar-opened windows will paint \
in the back for one frame");
assert!(!stack.children_mut()[1].take_raise_request(),
"child 1 still has a pending raise — same bug");
}
#[test]
fn test_window_raises_on_visibility_rising_edge() {
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use crate::{geometry::{Rect, Size}, text::Font, Label, Widget};
use crate::widgets::{primitives::Stack, window::Window};
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let a_visible = Rc::new(Cell::new(true));
let b_visible = Rc::new(Cell::new(true));
let make = |title: &str, vis: Rc<Cell<bool>>| -> Box<dyn Widget> {
Box::new(
Window::new(title, Arc::clone(&font),
Box::new(Label::new("x", Arc::clone(&font))))
.with_bounds(Rect::new(0.0, 0.0, 200.0, 120.0))
.with_visible_cell(vis)
)
};
let mut stack: Box<dyn Widget> = Box::new(
Stack::new()
.add(make("A", Rc::clone(&a_visible)))
.add(make("B", Rc::clone(&b_visible)))
);
let _ = stack.layout(Size::new(1024.0, 768.0));
assert_eq!(stack.children()[0].type_name(), "Window");
assert_eq!(stack.children()[1].type_name(), "Window");
a_visible.set(false);
let _ = stack.layout(Size::new(1024.0, 768.0)); a_visible.set(true);
let _ = stack.layout(Size::new(1024.0, 768.0)); b_visible.set(false);
let _ = stack.layout(Size::new(1024.0, 768.0)); b_visible.set(true);
let _ = stack.layout(Size::new(1024.0, 768.0));
assert!(!stack.children_mut()[0].take_raise_request(),
"first child still has a pending raise — Stack didn't consume it");
assert!(!stack.children_mut()[1].take_raise_request(),
"second child still has a pending raise — Stack didn't consume it");
a_visible.set(false);
let _ = stack.layout(Size::new(1024.0, 768.0));
a_visible.set(true);
let raise_flags_before: Vec<bool> = (0..stack.children().len())
.map(|i| {
let _ = i;
false
})
.collect();
let _ = raise_flags_before;
let _ = stack.layout(Size::new(1024.0, 768.0));
assert!(!stack.children_mut()[0].take_raise_request());
assert!(!stack.children_mut()[1].take_raise_request());
}
#[test]
fn test_paint_subtree_snaps_ctm_for_manual_translate_entry() {
use std::cell::Cell;
use std::rc::Rc;
use crate::draw_ctx::DrawCtx;
use crate::geometry::Rect;
use crate::widget::{paint_subtree, Widget};
use crate::event::{Event, EventResult};
use agg_rust::trans_affine::TransAffine;
struct CtmProbe {
bounds: Rect,
children: Vec<Box<dyn Widget>>,
captured: Rc<Cell<Option<TransAffine>>>,
}
impl Widget for CtmProbe {
fn type_name(&self) -> &'static str { "CtmProbe" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size {
Size::new(self.bounds.width.min(available.width),
self.bounds.height.min(available.height))
}
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
self.captured.set(Some(ctx.transform()));
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
}
let captured = Rc::new(Cell::new(None));
let mut probe = CtmProbe {
bounds: Rect::new(0.0, 0.0, 10.0, 10.0),
children: Vec::new(),
captured: Rc::clone(&captured),
};
let mut fb = Framebuffer::new(100, 100);
let mut ctx = GfxCtx::new(&mut fb);
ctx.translate(100.3, 50.7);
paint_subtree(&mut probe, &mut ctx);
let ctm = captured.get().expect("probe must have been painted");
assert_eq!(
ctm.tx.fract(), 0.0,
"tx still fractional at paint() entry: {} — paint_subtree snap regressed",
ctm.tx,
);
assert_eq!(
ctm.ty.fract(), 0.0,
"ty still fractional at paint() entry: {} — paint_subtree snap regressed",
ctm.ty,
);
assert_eq!(ctm.tx, 100.0);
assert_eq!(ctm.ty, 50.0);
}
#[test]
fn test_paint_subtree_preserves_fractional_ctm_when_opted_out() {
use std::cell::Cell;
use std::rc::Rc;
use crate::draw_ctx::DrawCtx;
use crate::geometry::Rect;
use crate::widget::{paint_subtree, Widget};
use crate::event::{Event, EventResult};
use agg_rust::trans_affine::TransAffine;
struct SubpixelProbe {
bounds: Rect,
children: Vec<Box<dyn Widget>>,
captured: Rc<Cell<Option<TransAffine>>>,
}
impl Widget for SubpixelProbe {
fn type_name(&self) -> &'static str { "SubpixelProbe" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size {
Size::new(self.bounds.width.min(available.width),
self.bounds.height.min(available.height))
}
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
self.captured.set(Some(ctx.transform()));
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
fn enforce_integer_bounds(&self) -> bool { false } }
let captured = Rc::new(Cell::new(None));
let mut probe = SubpixelProbe {
bounds: Rect::new(0.0, 0.0, 10.0, 10.0),
children: Vec::new(),
captured: Rc::clone(&captured),
};
let mut fb = Framebuffer::new(100, 100);
let mut ctx = GfxCtx::new(&mut fb);
ctx.translate(100.3, 50.7);
paint_subtree(&mut probe, &mut ctx);
let ctm = captured.get().expect("probe must have been painted");
assert!((ctm.tx - 100.3).abs() < 1e-9, "opt-out widget had tx snapped: {}", ctm.tx);
assert!((ctm.ty - 50.7).abs() < 1e-9, "opt-out widget had ty snapped: {}", ctm.ty);
}
#[test]
fn test_agg_rasters_1px_stripes_with_zero_gray() {
use crate::framebuffer::unpremultiply_rgba_inplace;
let w = 96_u32;
let h = 96_u32;
let mut fb = Framebuffer::new(w, h);
{
let mut gfx = GfxCtx::new(&mut fb);
for i in 0..(w as usize / 2) {
let x = (2 * i) as f64;
gfx.set_fill_color(Color::white());
gfx.begin_path();
gfx.rect(x, 0.0, 1.0, h as f64);
gfx.fill();
gfx.set_fill_color(Color::black());
gfx.begin_path();
gfx.rect(x + 1.0, 0.0, 1.0, h as f64);
gfx.fill();
}
}
let mut pixels = fb.pixels_flipped();
unpremultiply_rgba_inplace(&mut pixels);
let row_bytes = (w * 4) as usize;
for y in 0..h as usize {
for x in 0..w as usize {
let off = y * row_bytes + x * 4;
let px = &pixels[off..off + 4];
let expected_white = x % 2 == 0;
let (er, eg, eb) = if expected_white { (255, 255, 255) } else { (0, 0, 0) };
assert_eq!(
(px[0], px[1], px[2], px[3]),
(er, eg, eb, 255),
"pixel ({x}, {y}) should be {} but is {:?}",
if expected_white { "white" } else { "black" },
px,
);
}
}
}
#[test]
fn test_snap_to_pixel_zeros_fractional_translation() {
use crate::draw_ctx::DrawCtx;
let mut fb = Framebuffer::new(10, 10);
let mut ctx = GfxCtx::new(&mut fb);
ctx.translate(100.3, 50.7);
let before = ctx.transform();
assert!((before.tx - 100.3).abs() < 1e-9);
assert!((before.ty - 50.7).abs() < 1e-9);
ctx.snap_to_pixel();
let after = ctx.transform();
assert_eq!(after.tx.fract(), 0.0, "tx still fractional: {}", after.tx);
assert_eq!(after.ty.fract(), 0.0, "ty still fractional: {}", after.ty);
assert_eq!(after.tx, 100.0);
assert_eq!(after.ty, 50.0);
let mut fb2 = Framebuffer::new(10, 10);
let mut ctx2 = GfxCtx::new(&mut fb2);
ctx2.translate(-3.3, -4.7);
ctx2.snap_to_pixel();
let after2 = ctx2.transform();
assert_eq!(after2.tx, -4.0);
assert_eq!(after2.ty, -5.0);
let mut fb3 = Framebuffer::new(10, 10);
let mut ctx3 = GfxCtx::new(&mut fb3);
ctx3.translate(7.0, 13.0);
ctx3.snap_to_pixel();
let after3 = ctx3.transform();
assert_eq!(after3.tx, 7.0);
assert_eq!(after3.ty, 13.0);
}
#[test]
fn test_slider_drag_outside_bounds_tracks_cursor() {
use std::rc::Rc;
use std::cell::Cell;
use std::sync::Arc;
use crate::widgets::slider::Slider;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let last_val = Rc::new(Cell::new(0.5_f64));
let lv = Rc::clone(&last_val);
let slider = Slider::new(0.5, 0.0, 1.0, Arc::clone(&font))
.on_change(move |v| lv.set(v));
let mut app = App::new(Box::new(
SizedBox::new().with_width(200.0).with_height(36.0)
.with_child(Box::new(slider)),
));
app.layout(Size::new(200.0, 36.0));
app.on_mouse_down(100.0, 18.0, MouseButton::Left, Modifiers::default());
app.on_mouse_move(9999.0, 18.0);
assert_eq!(
last_val.get(), 1.0,
"dragging outside right must clamp to max (1.0), not snap to 0.0"
);
app.on_mouse_move(-9999.0, 18.0);
assert_eq!(
last_val.get(), 0.0,
"dragging outside left must clamp to min (0.0)"
);
app.on_mouse_up(0.0, 18.0, MouseButton::Left, Modifiers::default());
last_val.set(999.0); app.on_mouse_move(100.0, 18.0);
assert_eq!(
last_val.get(), 999.0,
"after mouse-up the slider must stop tracking cursor movement"
);
}
use crate::{HAnchor, Insets, Padding, Separator, VAnchor, WidgetBase,
device_scale, set_device_scale, resolve_fit_or_stretch};
#[test]
fn test_insets_all() {
let i = Insets::all(5.0);
assert_eq!(i.left, 5.0);
assert_eq!(i.right, 5.0);
assert_eq!(i.top, 5.0);
assert_eq!(i.bottom, 5.0);
}
#[test]
fn test_insets_symmetric() {
let i = Insets::symmetric(10.0, 4.0);
assert_eq!(i.horizontal(), 20.0);
assert_eq!(i.vertical(), 8.0);
}
#[test]
fn test_insets_scale() {
let i = Insets::all(3.0).scale(2.0);
assert_eq!(i.left, 6.0);
assert_eq!(i.top, 6.0);
}
#[test]
fn test_hanchor_stretch_contains_left_and_right() {
assert!(HAnchor::STRETCH.contains(HAnchor::LEFT));
assert!(HAnchor::STRETCH.contains(HAnchor::RIGHT));
assert!(HAnchor::STRETCH.is_stretch());
}
#[test]
fn test_hanchor_left_not_stretch() {
assert!(!HAnchor::LEFT.is_stretch());
}
#[test]
fn test_hanchor_max_fit_or_stretch_contains_stretch() {
assert!(HAnchor::MAX_FIT_OR_STRETCH.contains(HAnchor::LEFT));
assert!(HAnchor::MAX_FIT_OR_STRETCH.contains(HAnchor::RIGHT));
assert!(HAnchor::MAX_FIT_OR_STRETCH.contains(HAnchor::FIT));
}
#[test]
fn test_vanchor_stretch() {
assert!(VAnchor::STRETCH.is_stretch());
assert!(VAnchor::STRETCH.contains(VAnchor::BOTTOM));
assert!(VAnchor::STRETCH.contains(VAnchor::TOP));
}
#[test]
fn test_resolve_max_fit_or_stretch_prefers_larger() {
assert_eq!(resolve_fit_or_stretch(100.0, 60.0, true), 100.0);
assert_eq!(resolve_fit_or_stretch(40.0, 80.0, true), 80.0);
}
#[test]
fn test_resolve_min_fit_or_stretch_prefers_smaller() {
assert_eq!(resolve_fit_or_stretch(100.0, 60.0, false), 60.0);
assert_eq!(resolve_fit_or_stretch(40.0, 80.0, false), 40.0);
}
#[test]
fn test_widget_base_clamp_size() {
let mut base = WidgetBase::new();
base.min_size = Size::new(50.0, 30.0);
base.max_size = Size::new(200.0, 100.0);
let clamped = base.clamp_size(Size::new(10.0, 150.0));
assert_eq!(clamped.width, 50.0, "below min should clamp to min_w");
assert_eq!(clamped.height, 100.0, "above max should clamp to max_h");
}
#[test]
fn test_device_scale_default_is_one() {
set_device_scale(1.0);
assert_eq!(device_scale(), 1.0);
}
#[test]
fn test_padding_asymmetric_layout() {
let child = Box::new(Spacer::new());
let mut w = Padding::new(
Insets::from_sides(10.0, 20.0, 5.0, 15.0), child,
);
let outer = w.layout(Size::new(100.0, 80.0));
assert_eq!(outer.width, 100.0, "outer width should equal available.width");
assert_eq!(outer.height, 80.0, "outer height should equal available.height");
let cb = w.children()[0].bounds();
assert_eq!(cb.x, 10.0, "child x should be left inset");
assert_eq!(cb.y, 15.0, "child y should be bottom inset (Y-up)");
assert_eq!(cb.width, 70.0, "child width = available.width - h_insets");
assert_eq!(cb.height, 60.0, "child height = available.height - v_insets");
}
#[test]
fn test_padding_uniform_alias() {
let mut w = Padding::uniform(8.0, Box::new(Spacer::new()));
let outer = w.layout(Size::new(50.0, 40.0));
assert_eq!(outer.width, 50.0);
assert_eq!(outer.height, 40.0);
let cb = w.children()[0].bounds();
assert_eq!(cb.x, 8.0);
assert_eq!(cb.y, 8.0);
}
#[test]
fn test_sized_box_child_right_anchor() {
let child = Box::new(
SizedBox::fixed(30.0, 20.0)
.with_h_anchor(HAnchor::RIGHT),
);
let mut outer = SizedBox::new()
.with_width(100.0)
.with_height(50.0)
.with_child(child);
outer.layout(Size::new(100.0, 50.0));
let cb = outer.children()[0].bounds();
assert_eq!(cb.x, 70.0, "right-anchor child x should be box_w - child_w");
assert_eq!(cb.width, 30.0);
}
#[test]
fn test_sized_box_child_top_anchor() {
let child = Box::new(
SizedBox::fixed(20.0, 15.0)
.with_v_anchor(VAnchor::TOP),
);
let mut outer = SizedBox::new()
.with_width(50.0)
.with_height(60.0)
.with_child(child);
outer.layout(Size::new(50.0, 60.0));
let cb = outer.children()[0].bounds();
assert_eq!(cb.y, 45.0, "top-anchor child y should be box_h - child_h (Y-up)");
assert_eq!(cb.height, 15.0);
}
#[test]
fn test_sized_box_child_center_h_anchor() {
let child = Box::new(
SizedBox::fixed(20.0, 10.0)
.with_h_anchor(HAnchor::CENTER),
);
let mut outer = SizedBox::new()
.with_width(100.0)
.with_height(50.0)
.with_child(child);
outer.layout(Size::new(100.0, 50.0));
let cb = outer.children()[0].bounds();
assert_eq!(cb.x, 40.0, "center-h child x should be (box_w - child_w) / 2");
}
#[test]
fn test_sized_box_child_stretch() {
let child = Box::new(
SizedBox::fixed(20.0, 10.0)
.with_h_anchor(HAnchor::STRETCH),
);
let mut outer = SizedBox::new()
.with_width(100.0)
.with_height(50.0)
.with_child(child);
outer.layout(Size::new(100.0, 50.0));
let cb = outer.children()[0].bounds();
assert_eq!(cb.x, 0.0, "stretched child should start at x=0");
assert_eq!(cb.width, 100.0, "stretched child should fill box width");
}
#[test]
fn test_flex_column_cross_axis_anchors() {
let left_child = Box::new(
SizedBox::fixed(30.0, 10.0).with_h_anchor(HAnchor::LEFT),
);
let center_child = Box::new(
SizedBox::fixed(30.0, 10.0).with_h_anchor(HAnchor::CENTER),
);
let right_child = Box::new(
SizedBox::fixed(30.0, 10.0).with_h_anchor(HAnchor::RIGHT),
);
let stretch_child = Box::new(
SizedBox::fixed(30.0, 10.0).with_h_anchor(HAnchor::STRETCH),
);
let mut col = FlexColumn::new()
.with_gap(0.0)
.add(left_child)
.add(center_child)
.add(right_child)
.add(stretch_child);
col.layout(Size::new(100.0, 80.0));
let children = col.children();
assert_eq!(children[0].bounds().x, 0.0, "LEFT child x");
let center_x = children[1].bounds().x;
assert!((center_x - 35.0).abs() < 0.5, "CENTER child x ≈ 35, got {center_x}");
assert_eq!(children[2].bounds().x, 70.0, "RIGHT child x");
assert_eq!(children[3].bounds().x, 0.0, "STRETCH child x");
assert_eq!(children[3].bounds().width, 100.0, "STRETCH child width");
}
#[test]
fn test_flex_column_child_margin_spacing() {
set_device_scale(1.0);
let top_child = Box::new(
SizedBox::fixed(50.0, 10.0)
.with_margin(Insets::from_sides(0.0, 0.0, 0.0, 5.0)), );
let bot_child = Box::new(
SizedBox::fixed(50.0, 10.0)
.with_margin(Insets::from_sides(0.0, 0.0, 3.0, 0.0)), );
let mut col = FlexColumn::new()
.with_gap(0.0)
.add(top_child)
.add(bot_child);
col.layout(Size::new(100.0, 100.0));
let children = col.children();
let top_bounds = children[0].bounds();
let bot_bounds = children[1].bounds();
let gap_between = top_bounds.y - (bot_bounds.y + bot_bounds.height);
assert!(
(gap_between - 8.0).abs() < 0.5,
"gap between children should equal 5+3=8 (additive margins), got {gap_between}"
);
}
#[test]
fn test_flex_row_cross_axis_anchors() {
let bot_child = Box::new(
SizedBox::fixed(20.0, 15.0).with_v_anchor(VAnchor::BOTTOM),
);
let center_child = Box::new(
SizedBox::fixed(20.0, 15.0).with_v_anchor(VAnchor::CENTER),
);
let top_child = Box::new(
SizedBox::fixed(20.0, 15.0).with_v_anchor(VAnchor::TOP),
);
let mut row = FlexRow::new()
.with_gap(0.0)
.add(bot_child)
.add(center_child)
.add(top_child);
row.layout(Size::new(200.0, 60.0));
let children = row.children();
assert_eq!(children[0].bounds().y, 0.0, "BOTTOM child y");
let cy = children[1].bounds().y;
assert_eq!(cy, 23.0, "CENTER child y rounded to integer, got {cy}");
assert_eq!(children[2].bounds().y, 45.0, "TOP child y (Y-up)");
}
#[test]
fn test_flex_column_respects_child_min_size() {
let tiny = Box::new(
SizedBox::fixed(50.0, 5.0).with_min_size(Size::new(50.0, 20.0)),
);
let mut col = FlexColumn::new().add(tiny);
col.layout(Size::new(100.0, 200.0));
assert_eq!(col.children()[0].bounds().height, 20.0,
"fixed child height must respect min_size");
}
#[test]
fn test_flex_column_respects_child_max_size() {
let big = Box::new(
SizedBox::fixed(50.0, 50.0).with_max_size(Size::new(50.0, 30.0)),
);
let mut col = FlexColumn::new().add_flex(big, 1.0);
col.layout(Size::new(100.0, 200.0));
assert_eq!(col.children()[0].bounds().height, 30.0,
"flex child height must respect max_size");
}
#[test]
fn test_min_fit_or_stretch_uses_fit_when_smaller() {
let child = Box::new(
SizedBox::fixed(40.0, 10.0)
.with_h_anchor(HAnchor::MIN_FIT_OR_STRETCH),
);
let mut col = FlexColumn::new().add(child);
col.layout(Size::new(100.0, 50.0));
assert_eq!(col.children()[0].bounds().width, 40.0,
"MIN_FIT_OR_STRETCH should use fit (40) when fit < stretch (100)");
}
#[test]
fn test_max_fit_or_stretch_uses_stretch_when_larger() {
let child = Box::new(
SizedBox::fixed(40.0, 10.0)
.with_h_anchor(HAnchor::MAX_FIT_OR_STRETCH),
);
let mut col = FlexColumn::new().add(child);
col.layout(Size::new(100.0, 50.0));
assert_eq!(col.children()[0].bounds().width, 100.0,
"MAX_FIT_OR_STRETCH should use stretch (100) when stretch > fit (40)");
}
#[test]
fn test_lcd_mask_rounds_fractional_dst_to_pixel_grid() {
use crate::DrawCtx;
let mask: Vec<u8> = vec![
0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 255, 255, 255, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0,
];
let draw = |dst_x: f64, dst_y: f64| -> Framebuffer {
let mut fb = Framebuffer::new(8, 8);
for p in fb.pixels_mut().chunks_exact_mut(4) {
p[0] = 255; p[1] = 255; p[2] = 255; p[3] = 255;
}
{
let mut ctx = GfxCtx::new(&mut fb);
ctx.draw_lcd_mask(&mask, 3, 3, Color::black(), dst_x, dst_y);
}
fb
};
let integer = draw(2.0, 2.0);
let fractional = draw(2.4, 2.4); let fractional2 = draw(1.6, 1.6); assert_eq!(integer.pixels(), fractional.pixels(),
"LCD mask at fractional dst (2.4, 2.4) must round to integer grid");
assert_eq!(integer.pixels(), fractional2.pixels(),
"LCD mask at fractional dst (1.6, 1.6) must round to integer grid");
let shifted = draw(3.0, 2.0);
assert_ne!(integer.pixels(), shifted.pixels(),
"integer-pixel shift should change output — otherwise the rounding test is vacuous");
}
#[test]
fn test_paint_subtree_backbuffered_lcd_coverage_routes_through_lcd_pipeline() {
use std::sync::Arc;
use crate::widget::{paint_subtree, BackbufferCache, BackbufferMode, Widget};
use crate::geometry::{Rect, Size};
use crate::event::{Event, EventResult};
use crate::draw_ctx::DrawCtx;
use crate::framebuffer::Framebuffer;
use crate::gfx_ctx::GfxCtx;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
struct LcdTestWidget {
bounds: Rect,
cache: BackbufferCache,
font: Arc<Font>,
children: Vec<Box<dyn Widget>>,
}
impl Widget for LcdTestWidget {
fn type_name(&self) -> &'static str { "LcdTestWidget" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size { available }
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
ctx.fill();
ctx.set_fill_color(Color::black());
ctx.set_font(Arc::clone(&self.font));
ctx.set_font_size(18.0);
ctx.fill_text("abc", 4.0, 16.0);
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
Some(&mut self.cache)
}
fn backbuffer_mode(&self) -> BackbufferMode { BackbufferMode::LcdCoverage }
}
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut widget = LcdTestWidget {
bounds: Rect::new(0.0, 0.0, 60.0, 24.0),
cache: BackbufferCache::default(),
font,
children: Vec::new(),
};
widget.cache.invalidate();
let mut fb = Framebuffer::new(60, 24);
{
let mut ctx = GfxCtx::new(&mut fb);
paint_subtree(&mut widget, &mut ctx);
}
let cache = widget.backbuffer_cache_mut().unwrap();
let color = cache.pixels.as_ref().expect("colour plane must be populated");
let alpha = cache.lcd_alpha.as_ref().expect("LcdCoverage mode must populate lcd_alpha");
assert_eq!(cache.width, 60);
assert_eq!(cache.height, 24);
assert_eq!(color.len(), 60 * 24 * 3, "colour plane is 3 bytes/pixel");
assert_eq!(alpha.len(), 60 * 24 * 3, "alpha plane is 3 bytes/pixel");
let mut saw_chroma = false;
for px in alpha.chunks_exact(3) {
let (r, g, b) = (px[0] as i32, px[1] as i32, px[2] as i32);
let mx = r.max(g).max(b);
let mn = r.min(g).min(b);
if mx > 30 && (mx - mn) > 10 {
saw_chroma = true;
break;
}
}
assert!(saw_chroma,
"cached alpha plane must show per-channel variation — proves LcdGfxCtx, not GfxCtx, painted");
let fully_covered = alpha.chunks_exact(3)
.filter(|px| px[0] == 255 && px[1] == 255 && px[2] == 255)
.count();
assert!(fully_covered > 60 * 24 / 2,
"more than half of cached pixels should have full per-channel alpha \
(opaque-bg widget); got {fully_covered} of {}", 60 * 24);
}
#[test]
fn test_paint_subtree_backbuffered_rgba_mode_unchanged() {
use std::sync::Arc;
use crate::widget::{paint_subtree, BackbufferCache, BackbufferMode, Widget};
use crate::geometry::{Rect, Size};
use crate::event::{Event, EventResult};
use crate::draw_ctx::DrawCtx;
use crate::framebuffer::Framebuffer;
use crate::gfx_ctx::GfxCtx;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
struct RgbaTestWidget {
bounds: Rect,
cache: BackbufferCache,
font: Arc<Font>,
children: Vec<Box<dyn Widget>>,
}
impl Widget for RgbaTestWidget {
fn type_name(&self) -> &'static str { "RgbaTestWidget" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size { available }
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
ctx.set_fill_color(Color::black());
ctx.set_font(Arc::clone(&self.font));
ctx.set_font_size(18.0);
ctx.fill_text("abc", 4.0, 16.0);
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
Some(&mut self.cache)
}
fn backbuffer_mode(&self) -> BackbufferMode { BackbufferMode::Rgba }
}
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut widget = RgbaTestWidget {
bounds: Rect::new(0.0, 0.0, 60.0, 24.0),
cache: BackbufferCache::default(),
font,
children: Vec::new(),
};
widget.cache.invalidate();
let mut fb = Framebuffer::new(60, 24);
{
let mut ctx = GfxCtx::new(&mut fb);
paint_subtree(&mut widget, &mut ctx);
}
let cache = widget.backbuffer_cache_mut().unwrap();
let bmp = cache.pixels.as_ref().expect("backbuffer cache must be populated");
for (i, px) in bmp.chunks_exact(4).enumerate() {
let (r, g, b) = (px[0], px[1], px[2]);
assert!(r == g && g == b,
"Rgba mode must produce grayscale pixels (R==G==B); pixel {i} = ({r}, {g}, {b})");
}
}
#[test]
fn test_gfx_ctx_draw_lcd_backbuffer_arc_preserves_per_channel_chroma() {
use std::sync::Arc;
use crate::draw_ctx::DrawCtx;
let color = Arc::new(vec![0u8, 0, 0]);
let alpha = Arc::new(vec![50u8, 100, 200]);
let mut fb = Framebuffer::new(1, 1);
{
let mut ctx = GfxCtx::new(&mut fb);
ctx.set_fill_color(Color::rgba(1.0, 1.0, 1.0, 1.0));
ctx.begin_path();
ctx.rect(0.0, 0.0, 1.0, 1.0);
ctx.fill();
ctx.draw_lcd_backbuffer_arc(&color, &alpha, 1, 1, 0.0, 0.0, 1.0, 1.0);
}
let r = fb.pixels()[0];
let g = fb.pixels()[1];
let b = fb.pixels()[2];
assert!((r as i32 - 205).abs() <= 1, "R should be ~205 (255-50), got {r}");
assert!((g as i32 - 155).abs() <= 1, "G should be ~155 (255-100), got {g}");
assert!((b as i32 - 55).abs() <= 1, "B should be ~55 (255-200), got {b}");
let mx = r.max(g).max(b);
let mn = r.min(g).min(b);
assert!((mx - mn) > 100,
"per-channel blit must preserve chroma spread; got R={r} G={g} B={b}");
}
#[test]
fn test_paint_subtree_backbuffered_lcd_cache_preserves_chroma_at_destination() {
use std::sync::Arc;
use crate::widget::{paint_subtree, BackbufferCache, BackbufferMode, Widget};
use crate::geometry::{Rect, Size};
use crate::event::{Event, EventResult};
use crate::draw_ctx::DrawCtx;
use crate::text::Font;
const FONT_BYTES: &[u8] = include_bytes!("../../demo/assets/CascadiaCode.ttf");
struct LcdW { bounds: Rect, cache: BackbufferCache, font: Arc<Font>, children: Vec<Box<dyn Widget>> }
impl Widget for LcdW {
fn type_name(&self) -> &'static str { "LcdW" }
fn bounds(&self) -> Rect { self.bounds }
fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
fn children(&self) -> &[Box<dyn Widget>] { &self.children }
fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
fn layout(&mut self, available: Size) -> Size { available }
fn paint(&mut self, ctx: &mut dyn DrawCtx) {
ctx.set_fill_color(Color::white());
ctx.begin_path();
ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
ctx.fill();
ctx.set_fill_color(Color::black());
ctx.set_font(Arc::clone(&self.font));
ctx.set_font_size(22.0);
ctx.fill_text("Wing", 4.0, 20.0);
}
fn on_event(&mut self, _: &Event) -> EventResult { EventResult::Ignored }
fn backbuffer_cache_mut(&mut self) -> Option<&mut BackbufferCache> {
Some(&mut self.cache)
}
fn backbuffer_mode(&self) -> BackbufferMode { BackbufferMode::LcdCoverage }
}
let font = Arc::new(Font::from_slice(FONT_BYTES).expect("font"));
let mut widget = LcdW {
bounds: Rect::new(0.0, 0.0, 100.0, 30.0),
cache: BackbufferCache::default(),
font,
children: Vec::new(),
};
widget.cache.invalidate();
let mut fb = Framebuffer::new(100, 30);
{
let mut ctx = GfxCtx::new(&mut fb);
paint_subtree(&mut widget, &mut ctx);
}
let w = 100usize;
let h = 30usize;
let mut saw_chroma = false;
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
let r = fb.pixels()[i] as i32;
let g = fb.pixels()[i + 1] as i32;
let b = fb.pixels()[i + 2] as i32;
let mx = r.max(g).max(b);
let mn = r.min(g).min(b);
if mx > 30 && mn < 230 && (mx - mn) > 15 {
saw_chroma = true;
break;
}
}
if saw_chroma { break; }
}
assert!(saw_chroma,
"LcdCoverage cache + draw_lcd_backbuffer_arc blit must land per-channel \
chroma in the destination framebuffer — proves chroma survived the cache");
}