Every face fontdue opened was parsed whole: `Font::from_bytes` walks the `cmap` and builds the outline of every glyph before returning. For the theme's Sora that is a few hundred glyphs and nobody notices. For a fallback it is the whole file: the first string with one arrow or one bullet resolved through the chain to `NotoSansCJK-Regular.ttc`, and a compositor or a notification daemon that had drawn a dozen labels was suddenly holding 330 MB of outlines for 44 810 glyphs, after 1.7 s of CPU, to paint the hundred or so glyphs a screen ever shows. Measured on forge: 240 MB before the first app-switcher label, 542 MB after it, and never given back, because the table is live, not garbage. `FontFace`. The new `src/font_face.rs` is the face type the whole crate now rasterises with, built on the `ttf-parser` that rustybuzz already brings in and on tiny-skia, which already fills paths. `from_bytes( data, index )` keeps the bytes and reads only what a face needs up front: units per em and the vertical line metrics. A `ttf_parser::Face` is rebuilt on every use rather than kept, since parsing one is table-offset arithmetic, in the noise next to the outline work it guards. Each glyph's outline is extracted the first time it is asked for, through `Outliner`, into a `tiny_skia::Path`, and cached for the process lifetime in a `HashMap<u16, Arc<Outline>>`; codepoint lookups are cached the same way. Rasterising fills that path into a `Mask` and hands back the 8-bit coverage, top row first, which is what both canvases stored before. The metrics keep fontdue's conventions, so no widget changed: sizes scale by `px / units_per_em`, `Metrics::xmin` / `ymin` are whole-pixel placements with the fractional remainder folded into the raster offset, `bounds` is the outline box in subpixels, `LineMetrics` carries ascent, descent, line gap and their sum. One thing is deliberately not what `glyf` declares: the box comes from the points the outline actually reaches, quadratic and cubic extrema included, because declared boxes may be loose and a box wider than the ink shifts the glyph on the pixel grid. Tests cover the turning-point arithmetic and the line-metric scaling. `FontFace`, `Metrics`, `LineMetrics` and `OutlineBounds` are exported, since `Canvas::font` and `font_for` hand them out. What changed around it. `fontdue` leaves `Cargo.toml`. The two canvases, their text and setup modules, the registry, the rich text and text widgets swap `Arc<Font>` for `Arc<FontFace>` and nothing else; the glyph caches still key on `(glyph_id, size_bits, font_id)`. `FontRegistry` now reads a source into an `Arc<Vec<u8>>` and gives the face the bytes it will pull outlines from, so the registry no longer owns a second copy; its parse error names the file rather than a library message. `FontHandle` keeps `font` and `bytes` side by side, now naming the same buffer, so shaping needs no second read and the comment that explained the duplication is gone. `system_fonts` builds fallback faces from the bytes each slot already holds, and the slot stays two-staged: the bytes are read once to probe coverage with `ttf-parser`, the face is built only when a glyph in that slot is needed, and a face that is built no longer costs anything until it draws. The chain. DejaVu Sans moves from last resort to just before the CJK collection. It owns the geometric shapes, arrows and box-drawing blocks, which are what a user interface most often needs outside its own family; behind the collection, every one of those pulled the 19 MB file in, since probing a slot for coverage reads it whole. The CJK entries stay last for the same reason. The two hooks are for a host that embeds another UI, trust in this case, and complete the ones `App` already had. `take_clipboard_request` is the counterpart of nothing: an app could paste from the system clipboard through its text fields but had no way to put text into it. Return `Some( text )` once and the run loop stores it and publishes it as the seat's selection through the data device, the same path `handle_copy` takes for Ctrl+C in a text field, so other applications see it. `on_pointer_secondary` reports a right-button press that no widget of ltk's own answered, in physical pixels like `on_pointer_button`: `on_pointer_right_click` already dispatched a widget's `on_long_press` and the built-in text menu, and in the remaining case only dismissed an open menu; it now also tells the app, which can forward the press to an embedded view that has a context menu of its own. Not changed. Variable fonts are read at their default instance, as before. Colour glyphs, `COLR` or bitmap, are not rendered by either rasteriser; the outline path draws what it can. The glyph caches are still unbounded per face, but they now hold what was drawn, not what the file contains.
349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
// Pixel-level "golden-like" coverage. We deliberately avoid storing reference
|
||
// PNG bytes: their value depends on the rasteriser / 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 ) );
|
||
}
|