Files
ltk/src/gles_render/image.rs
Pedro M. de Echanove Pasquin 806dee5167
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
render, types, ci: resolution-time dp with per-canvas density, bounded GLES image cache, clippy gate, backend capability matrix
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.
2026-08-01 10:17:00 +02:00

206 lines
8.2 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.
// 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 );
}
}
}