Length::dp no longer collapses to absolute pixels at construction: the design value travels in a new LengthBase::Dp variant and the density multiplication happens when the length is resolved. Previously dp( n ) baked in whatever density() returned at view-build time, so correctness across output changes depended on the view being rebuilt after set_density and in that order; now a density change is picked up by the very next paint with no reconstruction. Length::resolve keeps its signature (process density), and the new Length::resolve_with_density takes an explicit factor. dp becomes const in the bargain. Density also becomes overridable per canvas, the first step towards surface-local responsive state. SoftwareCanvas and GlesCanvas carry a density: Option<f32> analogous to the layout_viewport introduced for sub-canvas fluid resolution: None means "use the process global", Canvas::set_density pins a local factor, and sub-canvases inherit it. All canvas-routed resolution honours it — geom_px / font_px for stock-widget design pixels, and the new Canvas::resolve_geom / resolve_font for explicit Length values, which every widget now uses in place of the raw l.resolve( canvas.viewport_layout(), EM ) pattern (row, column, wrap_grid, spacer, container, separator, button, text, rich_text, text_edit, list_item, vslider, image, and the container draw path). Overlay sizing keeps resolving against the main surface with the global density, which is what it describes. New tests cover explicit-density resolution, resolution-time application, the local-over-global override and sub-canvas inheritance. The GLES image texture cache is now bounded. It was content-keyed but unbounded and never evicted, so a stream of distinct buffers — a photo carousel, video thumbnails — grew GPU memory for the lifetime of the canvas. The cache now tracks an estimated byte total (RGBA8, w × h × 4) against a 32 MiB budget and evicts least-recently-drawn textures on insert; the most recent entry is never evicted, so a single texture larger than the whole budget still draws and simply owns the cache until replaced. Drop-time cleanup is unchanged: drain deletes whatever the map holds. CI gains a Clippy step (workspace, all targets, test-support, -D warnings) sharing the build cache of the test job, with make clippy mirroring the invocation locally and CONTRIBUTING listing it. Run make clippy locally before pushing the first time — the gate has not seen the tree yet and pre-existing lints will fail CI until addressed. docs/backends.md formalises the software/GLES capability matrix that was previously scattered across per-method rustdoc: parity set (fills, strokes, text, images, paths, path clips), graceful degradations on software (flat-fill gradients, no shadows, no backdrop blur, hard bottom edge), GPU-only features (external textures), the shared Oklab-fallback limitation, and the cross-backend blit panic. Linked from README, onboarding and architecture's known-gaps list, which now states the parity gaps explicitly. The dp/density prose in architecture.md, lib.rs and the Length rustdoc is updated for resolution-time semantics and the per-canvas override.
206 lines
8.2 KiB
Rust
206 lines
8.2 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! Raster-image draw path for [`GlesCanvas`]. Uploads the RGBA
|
||
//! bytes as a premultiplied-alpha texture (cached by content
|
||
//! fingerprint so repeated draws of the same buffer do not
|
||
//! re-upload) and composites it through the texture shader,
|
||
//! honouring the canvas' `global_alpha` via the opacity uniform.
|
||
//!
|
||
//! The cache is keyed by `(size, fingerprint)` where the fingerprint
|
||
//! is a 64-bit hash sampled from the RGBA bytes. This avoids the
|
||
//! address-reuse trap of a pointer-based key — when an `Arc<Vec<u8>>`
|
||
//! gets dropped and the allocator hands the same heap address to a
|
||
//! *different* buffer on the next frame, a pointer-keyed cache would
|
||
//! serve the stale texture for the new content. Content-keying makes
|
||
//! that impossible: identical bytes → identical key, regardless of
|
||
//! where they live in memory.
|
||
//!
|
||
//! The cache is bounded to [`IMAGE_CACHE_MAX_BYTES`] of estimated GPU
|
||
//! memory (RGBA8: `w × h × 4` per texture) with least-recently-drawn
|
||
//! eviction, so a stream of distinct buffers (a photo carousel, video
|
||
//! thumbnails) recycles textures instead of growing GPU memory for the
|
||
//! canvas' lifetime. The most recent entry is never evicted, so a
|
||
//! single texture larger than the whole budget still draws — the cache
|
||
//! then holds that one entry until something replaces it.
|
||
|
||
use std::collections::hash_map::DefaultHasher;
|
||
use std::hash::{ Hash, Hasher };
|
||
|
||
use glow::HasContext;
|
||
|
||
use crate::types::Rect;
|
||
|
||
use super::helpers::{ ortho_rect, upload_rgba_texture };
|
||
use super::GlesCanvas;
|
||
|
||
/// Byte budget for the image texture cache. 32 MiB holds a phone-sized
|
||
/// wallpaper (~10 MB at 1080×2400) plus a working set of icons and
|
||
/// thumbnails; sized for mobile GPUs where this memory competes with
|
||
/// the compositor.
|
||
pub const IMAGE_CACHE_MAX_BYTES: usize = 32 * 1024 * 1024;
|
||
|
||
/// Compute a 64-bit fingerprint of an RGBA buffer for the texture
|
||
/// cache. Hashes the full byte slice for small buffers (icons,
|
||
/// thumbnails — below 16 KB ≈ 64×64 RGBA), and falls back to a
|
||
/// strided 8 × 512-byte sample for anything larger so a wallpaper
|
||
/// blit does not pay an 8 MB hash on every frame. Both modes
|
||
/// distinguish the chevron-icon-style cases that motivated the move
|
||
/// to content-keying — the SVG-rasterised buffers differ across
|
||
/// most of their interior bytes, not just at the corners.
|
||
fn fingerprint_rgba( bytes: &[u8] ) -> u64
|
||
{
|
||
const FULL_HASH_THRESHOLD: usize = 16 * 1024;
|
||
const SAMPLE_CHUNKS: usize = 8;
|
||
const SAMPLE_CHUNK_BYTES: usize = 512;
|
||
|
||
let mut h = DefaultHasher::new();
|
||
let n = bytes.len();
|
||
n.hash( &mut h );
|
||
if n <= FULL_HASH_THRESHOLD
|
||
{
|
||
bytes.hash( &mut h );
|
||
} else {
|
||
let stride = n / SAMPLE_CHUNKS;
|
||
for i in 0..SAMPLE_CHUNKS
|
||
{
|
||
let pos = ( i * stride ).min( n - SAMPLE_CHUNK_BYTES );
|
||
bytes[ pos..pos + SAMPLE_CHUNK_BYTES ].hash( &mut h );
|
||
}
|
||
}
|
||
h.finish()
|
||
}
|
||
|
||
impl GlesCanvas
|
||
{
|
||
/// Blit RGBA image data scaled to dest rect with opacity.
|
||
///
|
||
/// Defensive: rejects buffers whose declared `img_w × img_h × 4` does not
|
||
/// match `rgba_data.len()`. The mismatch path logs a one-line warning
|
||
/// and returns without uploading or drawing — the same boundary that
|
||
/// the internal `upload_rgba_texture` helper enforces, raised one
|
||
/// level so the cache key is never seeded with a bogus mapping.
|
||
pub fn draw_image_data( &mut self, rgba_data: &[u8], img_w: u32, img_h: u32, dest: Rect, opacity: f32 )
|
||
{
|
||
if !crate::render::helpers::validate_rgba_dims( "GlesCanvas", rgba_data, img_w, img_h )
|
||
{
|
||
return;
|
||
}
|
||
|
||
self.activate_target();
|
||
// Content-fingerprint key — see the module doc for the
|
||
// rationale. The (w, h) prefix means a pathological pair of
|
||
// buffers with identical bytes but different declared sizes
|
||
// stays distinct (cannot happen for valid input, defence in
|
||
// depth).
|
||
let cache_key = ( img_w, img_h, fingerprint_rgba( rgba_data ) );
|
||
|
||
if !self.image_cache.contains_key( &cache_key )
|
||
{
|
||
let tex = upload_rgba_texture( &self.gl, self.version, rgba_data, img_w as i32, img_h as i32 );
|
||
self.image_cache.insert( cache_key, ( tex, img_w, img_h ) );
|
||
self.image_cache_bytes += ( img_w as usize ) * ( img_h as usize ) * 4;
|
||
self.image_cache_lru.push_back( cache_key );
|
||
self.evict_image_cache_overflow();
|
||
} else if let Some( pos ) = self.image_cache_lru.iter().position( |k| *k == cache_key )
|
||
{
|
||
self.image_cache_lru.remove( pos );
|
||
self.image_cache_lru.push_back( cache_key );
|
||
}
|
||
|
||
// Snap to integer pixels. With GL_LINEAR sampling, a
|
||
// fractional `dest.x` / `dest.y` makes every fragment sample
|
||
// at sub-texel offset — bilinear blends adjacent texels and
|
||
// the result reads as ~1 px softer than the source. At
|
||
// integer offset every fragment center maps to a texel
|
||
// centre and the bilinear collapses to identity, so a 1:1
|
||
// sampled icon renders crisp.
|
||
let dest = Rect
|
||
{
|
||
x: dest.x.round(),
|
||
y: dest.y.round(),
|
||
width: dest.width.round(),
|
||
height: dest.height.round(),
|
||
};
|
||
|
||
if let Some( ( tex, _, _ ) ) = self.image_cache.get( &cache_key )
|
||
{
|
||
let mvp = ortho_rect( self.width, self.height, dest );
|
||
let alpha = opacity * self.global_alpha;
|
||
// SAFETY: see `primitives.rs` module doc. `*tex` is owned by
|
||
// `self.image_cache` so it outlives the call. Eviction ran
|
||
// before this borrow and never removes the most-recent key,
|
||
// which is `cache_key`.
|
||
unsafe
|
||
{
|
||
self.gl.use_program( Some( self.tex_program ) );
|
||
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
|
||
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
|
||
self.gl.active_texture( glow::TEXTURE0 );
|
||
self.gl.bind_texture( glow::TEXTURE_2D, Some( *tex ) );
|
||
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
|
||
self.gl.bind_vertex_array( Some( self.quad_vao ) );
|
||
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
|
||
self.gl.bind_vertex_array( None );
|
||
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Delete least-recently-drawn cached textures until the estimated
|
||
/// byte total fits [`IMAGE_CACHE_MAX_BYTES`]. The back of the LRU —
|
||
/// the entry the current draw is about to use — is never removed.
|
||
fn evict_image_cache_overflow( &mut self )
|
||
{
|
||
while self.image_cache_bytes > IMAGE_CACHE_MAX_BYTES && self.image_cache_lru.len() > 1
|
||
{
|
||
let Some( key ) = self.image_cache_lru.pop_front() else { break };
|
||
if let Some( ( tex, w, h ) ) = self.image_cache.remove( &key )
|
||
{
|
||
self.image_cache_bytes = self.image_cache_bytes
|
||
.saturating_sub( ( w as usize ) * ( h as usize ) * 4 );
|
||
// SAFETY: `tex` was created through `self.gl` in the insert
|
||
// path above and just left the map, so it is deleted exactly
|
||
// once. Deleting a bound texture is defined in GLES (the
|
||
// binding reverts to 0); no draw is in flight here.
|
||
unsafe { self.gl.delete_texture( tex ); }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw an externally-owned GL texture into `dest`.
|
||
///
|
||
/// The caller owns the texture and is responsible for keeping it valid
|
||
/// for the duration of this call. No upload, no caching — used to
|
||
/// composite content rendered by another GL producer (web engine,
|
||
/// video decoder, …) into the LTK widget tree.
|
||
pub fn draw_external_texture( &mut self, texture: glow::Texture, dest: Rect, opacity: f32 )
|
||
{
|
||
self.activate_target();
|
||
let dest = Rect
|
||
{
|
||
x: dest.x.round(),
|
||
y: dest.y.round(),
|
||
width: dest.width.round(),
|
||
height: dest.height.round(),
|
||
};
|
||
let mvp = ortho_rect( self.width, self.height, dest );
|
||
let alpha = opacity * self.global_alpha;
|
||
// SAFETY: caller-owned texture must outlive this call. We only
|
||
// sample it; we never delete or reassign the GL name.
|
||
unsafe
|
||
{
|
||
self.gl.use_program( Some( self.tex_program ) );
|
||
self.gl.uniform_matrix_4_f32_slice( Some( &self.u_tex_mvp ), false, &mvp );
|
||
self.gl.uniform_1_f32( Some( &self.u_tex_opacity ), alpha );
|
||
self.gl.active_texture( glow::TEXTURE0 );
|
||
self.gl.bind_texture( glow::TEXTURE_2D, Some( texture ) );
|
||
self.gl.uniform_1_i32( Some( &self.u_tex_sampler ), 0 );
|
||
self.gl.bind_vertex_array( Some( self.quad_vao ) );
|
||
self.gl.draw_arrays( glow::TRIANGLES, 0, 6 );
|
||
self.gl.bind_vertex_array( None );
|
||
self.gl.bind_texture( glow::TEXTURE_2D, None );
|
||
}
|
||
}
|
||
}
|