render, types, ci: resolution-time dp with per-canvas density, bounded GLES image cache, clippy gate, backend capability matrix
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

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.
This commit is contained in:
2026-08-01 10:17:00 +02:00
parent a7f953ca42
commit 806dee5167
29 changed files with 323 additions and 80 deletions

View File

@@ -15,6 +15,14 @@
//! 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 };
@@ -26,6 +34,12 @@ 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
@@ -85,6 +99,13 @@ impl GlesCanvas
{
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
@@ -107,9 +128,9 @@ impl GlesCanvas
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. The image cache
// stays valid as long as `&mut self` is held — no eviction
// path runs concurrently with the draw.
// `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 ) );
@@ -126,6 +147,27 @@ impl GlesCanvas
}
}
/// 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