Files
ltk/tests/render_pixels.rs
T
admin 53e7ccc46e
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s
Keep the GLES path when the scale arrives first, stop re-rendering what does not change, and let the compositor skip what the surface covers
Measured on a Librem 5 (Cortex-A53, GC7000Lite, 720×1440 at scale 2) with a frame trace on both sides of the socket: the shell's main surface repainted in 110 to 136 ms, quick settings drew at 20 fps with the GPU saturated, and every panel open uploaded its wallpaper and icons again. Most of it was the toolkit doing work the frame did not need.
Backend choice. When the compositor announces the output scale before the first `configure`, `scale_factor_changed` allocated an SHM pool for the surface and `on_configure` took that pool as the sign that the software path had been chosen, locking the surface to tiny-skia for its whole life. Every ltk main surface on a scale-2 phone ran in software while its overlays, which inherit the scale from the main and never receive that event, ran on the GPU. The scale handler now only reallocates a pool that already exists; before the first configure neither path is chosen.
Wallpapers. `WallpaperBundle::for_size` returned the crop at the source resolution, a 1103×2160 slice of a 4K image for a 720×1440 panel, 10 MB uploaded per texture and a hundred milliseconds each time. The crop is now downscaled to the surface (bilinear, once per size, cached), also for landscape surfaces smaller than the source.
Viewport. `Element::Viewport` created a fresh sub-canvas every frame, on GLES a framebuffer, a 2048² glyph atlas and empty image and glyph caches, so a panel clipped to a height that animates re-uploaded its wallpaper and re-rasterised every glyph on every frame. Without a bottom fade the clip is now a scissor on the parent canvas (a mask on the software backend): no offscreen pass, no blit, and the child's textures stay in the parent's caches. `local_viewport` is honoured through a temporary override of the layout viewport. Viewports with a fade keep their sub-canvas, which, like the scroll sub-canvases, is now kept across frames and resized in place instead of reallocated when its size changes.
Caches. The image texture cache moves from the canvas to the GL context: every canvas and sub-canvas shares one content-keyed LRU (32 MiB), so an overlay or subsurface that is destroyed and recreated finds its images resident. A GLES canvas dropped with an overlay is pooled in `AppData::canvas_pool` and handed to the next overlay, with its compiled programs and glyph atlas, which took 130 to 290 ms to build on that GPU. Subsurface slots keep their scroll and viewport sub-canvases between rasters.
Surfaces. A `container.surface()` with shadows costs a Gaussian drop-shadow pass, a fill and one pass per inset shadow, all full-quad fragment shaders; a dozen glass cards were 40 ms of GPU per frame for chrome that never changes. `Canvas::fill_surface` on GLES now renders the chrome once per size, corners and style into a texture held by the context cache (`gles_render/surface_cache.rs`, 16 MiB LRU) and blits it, drawing directly only for surfaces with `PlusLighter` or `Overlay` insets, which depend on the backdrop.
Opaque regions. ltk never called `wl_surface.set_opaque_region`, so the compositor blended every ltk surface and repainted everything underneath a full-screen homescreen. A surface whose `background_color` is opaque now declares itself opaque, and the new `App::overlay_opaque_region( id )` lets an overlay declare the physical rects it paints opaque, such as a panel column over an opaque wallpaper. `sync_opaque_region` sends the rects only when they change.
Frame pacing. `eglSwapBuffers` blocked 13 to 37 ms per frame waiting for Mesa's own throttle callback; ltk paces itself on `wl_surface.frame`, so `make_current` sets the swap interval to 0. A subsurface move is now applied only when its parent has no frame callback outstanding, and the parent commit that applies it requests the next callback: a drag commits once per vblank with the latest finger position instead of once per input sample, without the app throttling samples itself. While the app reports `subsurface_motion_only`, the main's callback, and the callback of an overlay that hosts a subsurface, no longer force a full repaint; they mark the surface and the reconcile keeps the cadence with a bare commit when nothing moved. Swipe releases arm a settle the same way instead of dirtying every cache. Two hooks, `on_overlay_frame_committed` and `on_subsurface_committed`, let an app hand a panel over between its host and a sliding subsurface on real commits rather than loop ticks.
Lines. A diagonal `draw_line` on GLES stamped one `fill_rect` per pixel of length, 190 draw calls for a spinner and over 2000 for a pattern-lock stroke; it is now a single rotated quad through the rect shader, with round caps and antialiased edges.
A pixel test covers the scissor viewport, `for_size` gets a downscale test, and `docs/architecture.md`, `docs/widgets.md` and `docs/backends.md` describe the new hook, the viewport paths, the shared caches and the line rasteriser.
2026-09-18 09:55:09 +02:00

349 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pixel-level "golden-like" coverage. We deliberately avoid storing reference
// PNG bytes: their value depends on fontdue / tiny-skia / theme versions, so a
// byte-exact reference would break on every dependency bump. Instead these
// tests assert *structural* properties of the rendered buffer (background
// colour, transparency, determinism, focus-state divergence) plus a few
// canonical scenes that exercise the partial-redraw and full-redraw paths.
use ltk::core::{ Canvas, RenderOptions, UiSurface };
use ltk::{ button, column, container, spacer, text, viewport, Color, Element };
// ── helpers ───────────────────────────────────────────────────────────────────
fn extract_pixels( surface: &UiSurface<()>, w: u32, h: u32 ) -> Vec<u8>
{
let mut buf = vec![ 0u8; ( w * h * 4 ) as usize ];
// Software canvas only — `Canvas::Gles::write_to_wayland_buf` is a no-op
// (presentation goes through `eglSwapBuffers` elsewhere). UiSurface::new
// returns a software surface.
assert!( matches!( surface.canvas(), Canvas::Software( _ ) ) );
surface.canvas().write_to_wayland_buf( &mut buf, false );
buf
}
fn fnv1a_64( bytes: &[u8] ) -> u64
{
let mut h: u64 = 0xcbf29ce484222325;
for &b in bytes
{
h ^= b as u64;
h = h.wrapping_mul( 0x100000001b3 );
}
h
}
// Approximate equality on a single u8 channel — accounts for AA / rounding
// drift on the boundary of background-vs-content fills.
fn near( a: u8, b: u8, tol: u8 ) -> bool
{
a.abs_diff( b ) <= tol
}
// ── background colour ─────────────────────────────────────────────────────────
#[ test ]
fn fully_transparent_background_yields_all_zero_alpha()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::TRANSPARENT ),
);
let buf = extract_pixels( &surface, 64, 64 );
for chunk in buf.chunks_exact( 4 )
{
// Every pixel ends up with alpha = 0; RGB after premultiplication is
// also 0 because the background fill is the only paint.
assert_eq!( chunk[ 3 ], 0, "transparent bg must produce zero alpha" );
}
}
#[ test ]
fn opaque_background_paints_canonical_rgb_in_corner_pixel()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 64, 64 ).background( Color::rgb( 1.0, 0.25, 0.0 ) ),
);
let buf = extract_pixels( &surface, 64, 64 );
// Top-left pixel: nothing draws there beyond the background fill, so the
// channels should match (255, ~64, 0, 255) with mild rounding tolerance.
assert!( near( buf[ 0 ], 255, 1 ) );
assert!( near( buf[ 1 ], 64, 2 ) );
assert!( near( buf[ 2 ], 0, 1 ) );
assert_eq!( buf[ 3 ], 255 );
}
#[ test ]
fn background_fills_every_pixel_when_view_is_empty()
{
let mut surface = UiSurface::<()>::new( 32, 32 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 32, 32 ).background( Color::rgb( 0.0, 0.5, 0.0 ) ),
);
let buf = extract_pixels( &surface, 32, 32 );
for chunk in buf.chunks_exact( 4 )
{
assert!( near( chunk[ 0 ], 0, 1 ) );
assert!( near( chunk[ 1 ], 128, 2 ) );
assert!( near( chunk[ 2 ], 0, 1 ) );
assert_eq!( chunk[ 3 ], 255 );
}
}
// ── viewport clipping ─────────────────────────────────────────────────────────
// A viewport without a bottom fade clips on the parent canvas (scissor /
// mask) instead of drawing into a sub-canvas: the child must still be cut
// at the viewport's height, and nothing of it may leak below.
#[ test ]
fn viewport_clips_its_child_to_the_assigned_height()
{
let mut surface = UiSurface::<()>::new( 32, 64 );
let child: Element<()> = container::<()>( spacer().height( 48.0 ) )
.background( Color::rgb( 1.0, 0.0, 0.0 ) )
.into();
let view: Element<()> = column::<()>()
.padding( 0.0 )
.spacing( 0.0 )
.push( viewport( child ).height( 16.0 ) )
.into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 32, 64 ).background( Color::rgb( 0.0, 0.0, 0.0 ) ),
);
let buf = extract_pixels( &surface, 32, 64 );
let red_at = |x: u32, y: u32| buf[ ( ( y * 32 + x ) * 4 ) as usize ];
assert!( near( red_at( 16, 4 ), 255, 2 ), "the child paints inside the viewport" );
assert!( near( red_at( 16, 24 ), 0, 2 ), "the child is clipped right below the viewport" );
assert!( near( red_at( 16, 40 ), 0, 2 ), "nothing of the child leaks further down" );
}
// ── determinism ───────────────────────────────────────────────────────────────
fn counter_view( count: u32 ) -> Element<()>
{
column::<()>()
.padding( 16.0 )
.spacing( 8.0 )
.push( text( format!( "Count: {count}" ) ) )
.push( button( "Increment" ) )
.into()
}
#[ test ]
fn rendering_the_same_view_twice_produces_identical_pixel_buffers()
{
let mut surface = UiSurface::<()>::new( 240, 120 );
let opts = RenderOptions::full_canvas( 240, 120 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
let _ = surface.render( &counter_view( 0 ), opts );
let buf_a = extract_pixels( &surface, 240, 120 );
// Force a clean re-paint via mark_content_dirty so both passes hit the
// same code path (full redraw rather than partial-damage clip).
surface.mark_content_dirty();
let _ = surface.render( &counter_view( 0 ), opts );
let buf_b = extract_pixels( &surface, 240, 120 );
assert_eq!( buf_a, buf_b, "two identical renders must produce identical pixels" );
}
#[ test ]
fn render_hash_is_stable_across_two_back_to_back_passes()
{
let mut surface = UiSurface::<()>::new( 320, 200 );
let opts = RenderOptions::full_canvas( 320, 200 ).background( Color::rgb( 0.1, 0.1, 0.1 ) );
let _ = surface.render( &counter_view( 7 ), opts );
let h1 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
surface.mark_content_dirty();
let _ = surface.render( &counter_view( 7 ), opts );
let h2 = fnv1a_64( &extract_pixels( &surface, 320, 200 ) );
assert_eq!( h1, h2, "a deterministic render pass must produce a stable hash" );
}
// ── content presence ──────────────────────────────────────────────────────────
#[ test ]
fn rendering_a_button_produces_pixels_that_differ_from_the_background()
{
// Render on a WHITE canvas, not black: with no theme dir configured the
// active theme is the embedded B/W fallback, whose Light mode defines
// `accent` (the default button fill) and `text-primary` as pure BLACK on
// a white page. A black canvas would therefore hide a fallback button
// entirely (black-on-black) and this structural "button paints content"
// check would spuriously fail. White matches the fallback's page colour,
// so the black button stands out — and a real theme's saturated accent
// (e.g. teal) differs from white too, keeping the test theme-robust.
let mut surface = UiSurface::<()>::new( 240, 120 );
let bg = Color::WHITE;
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 240, 120 ).background( bg ),
);
let buf = extract_pixels( &surface, 240, 120 );
let differs = buf.chunks_exact( 4 ).any( |chunk|
!( chunk[ 0 ] == 255 && chunk[ 1 ] == 255 && chunk[ 2 ] == 255 && chunk[ 3 ] == 255 )
);
assert!( differs, "a button must paint at least one non-background pixel" );
}
#[ test ]
fn rendering_text_produces_pixels_that_differ_from_the_background()
{
let mut surface = UiSurface::<()>::new( 240, 120 );
let bg = Color::rgb( 0.0, 0.0, 0.0 );
let view: Element<()> = column::<()>().push( text( "ltk" ).size( 32.0 ) ).into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 240, 120 ).background( bg ),
);
let buf = extract_pixels( &surface, 240, 120 );
let glyph_pixels = buf.chunks_exact( 4 )
.filter( |chunk| !( chunk[ 0 ] == 0 && chunk[ 1 ] == 0 && chunk[ 2 ] == 0 ) )
.count();
assert!( glyph_pixels > 0, "text rasterisation must produce non-bg pixels" );
}
#[ test ]
fn container_with_explicit_color_fill_paints_inside_padding()
{
// Fill a sized container with a known colour, render on a black
// background, and confirm a measurable region of red pixels appears.
let mut surface = UiSurface::<()>::new( 100, 100 );
let view: Element<()> = column::<()>()
.padding( 0.0 )
.push(
container::<()>( spacer().width( 40.0 ).height( 40.0 ) )
.background( Color::rgb( 1.0, 0.0, 0.0 ) ),
)
.into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 100, 100 ).background( Color::BLACK ),
);
let bytes = extract_pixels( &surface, 100, 100 );
let red_pixels = bytes.chunks_exact( 4 )
.filter( |c| c[ 0 ] > 200 && c[ 1 ] < 30 && c[ 2 ] < 30 && c[ 3 ] == 255 )
.count();
// A 40×40 fill produces 1600 fully-saturated red pixels; allow a wide
// floor to absorb anti-aliased edge pixels that mix with the background.
assert!(
red_pixels > 500,
"container with red fill must produce a substantial red region (got {red_pixels})",
);
}
// ── focus-state divergence ────────────────────────────────────────────────────
#[ test ]
fn focusing_a_button_changes_pixels_inside_its_paint_rect()
{
// The focus ring is one of the cheapest ways to verify the partial-
// redraw path actually paints something different. Render with no focus,
// snapshot the pixels, set focus, render again, and confirm the buffer
// has changed somewhere.
let mut surface = UiSurface::<()>::new( 200, 80 );
let opts = RenderOptions::full_canvas( 200, 80 ).background( Color::rgb( 0.05, 0.05, 0.05 ) );
let view: Element<()> = column::<()>().push( button( "tap" ) ).into();
let _ = surface.render( &view, opts );
let unfocused = extract_pixels( &surface, 200, 80 );
let idx = surface.widget_rects()[ 0 ].flat_idx;
surface.set_focused( Some( idx ) );
let _ = surface.render( &view, opts );
let focused = extract_pixels( &surface, 200, 80 );
assert_ne!(
unfocused, focused,
"focus state change must produce visibly different pixels",
);
}
// ── invariants ────────────────────────────────────────────────────────────────
#[ test ]
fn pixel_buffer_length_matches_canvas_size()
{
let surface = UiSurface::<()>::new( 100, 50 );
let mut buf = vec![ 0u8; 100 * 50 * 4 ];
surface.canvas().write_to_wayland_buf( &mut buf, false );
// All pixels are zero before render — confirms the buffer is correctly
// sized and write_to_wayland_buf doesn't overwrite past the end.
assert!( buf.iter().all( |&b| b == 0 ) );
}
#[ test ]
fn resize_changes_the_required_buffer_length()
{
let mut surface = UiSurface::<()>::new( 64, 64 );
let view: Element<()> = column().into();
let opts = RenderOptions::full_canvas( 64, 64 );
let _ = surface.render( &view, opts );
surface.resize( 128, 96 );
assert_eq!( surface.size(), ( 128, 96 ) );
// Old buffer would be too short; the new size dictates a 49152-byte
// buffer. Confirm `write_to_wayland_buf` accepts and fills it without
// panicking.
let mut new_buf = vec![ 0u8; 128 * 96 * 4 ];
let _ = surface.render(
&view,
RenderOptions::full_canvas( 128, 96 ).background( Color::rgb( 0.0, 0.0, 0.5 ) ),
);
surface.canvas().write_to_wayland_buf( &mut new_buf, false );
// Last pixel should carry the navy bg.
let last = &new_buf[ new_buf.len() - 4 .. ];
assert!( near( last[ 2 ], 128, 2 ), "last pixel blue channel ≈ 128" );
assert_eq!( last[ 3 ], 255 );
}
// ── canonical scene byte equality ─────────────────────────────────────────────
#[ test ]
fn canonical_solid_red_scene_has_exact_byte_layout()
{
// A scene with NO text (so no font-version drift) and a colour with NO
// rounding ambiguity (1.0 → 255, 0.0 → 0). The buffer is portable
// across machines because the path is plain rectangle clear → tiny-skia
// premul → byte copy, and the values land exactly. If this assertion
// drifts, either the renderer or the buffer format changed; investigate
// before regenerating.
let mut surface = UiSurface::<()>::new( 16, 16 );
let view: Element<()> = column().into();
let _ = surface.render(
&view,
RenderOptions::full_canvas( 16, 16 ).background( Color::rgb( 1.0, 0.0, 0.0 ) ),
);
let actual = extract_pixels( &surface, 16, 16 );
let expected: Vec<u8> = ( 0..16 * 16 ).flat_map( |_| [ 255u8, 0, 0, 255 ] ).collect();
assert_eq!( actual, expected, "16×16 solid red must serialise to exact RGBA bytes" );
}
#[ test ]
fn fnv1a_hash_changes_when_a_single_byte_flips()
{
// Sanity check on the in-test hash function itself — guards the rest of
// the determinism tests against a regression that breaks the hash but
// leaves the assertions trivially passing.
let a = [ 0u8, 0, 0, 0 ];
let mut b = a;
b[ 2 ] = 1;
assert_ne!( fnv1a_64( &a ), fnv1a_64( &b ) );
}