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.
This commit is contained in:
@@ -212,12 +212,10 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
||||
{
|
||||
canvas.stroke_rect( rect, color, width, c.corners );
|
||||
}
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = crate::types::Length::EM_BASE_DEFAULT;
|
||||
let pad_l = c.pad_left.resolve( vp, em );
|
||||
let pad_r = c.pad_right.resolve( vp, em );
|
||||
let pad_t = c.pad_top.resolve( vp, em );
|
||||
let pad_b = c.pad_bottom.resolve( vp, em );
|
||||
let pad_l = canvas.resolve_geom( c.pad_left );
|
||||
let pad_r = canvas.resolve_geom( c.pad_right );
|
||||
let pad_t = canvas.resolve_geom( c.pad_top );
|
||||
let pad_b = canvas.resolve_geom( c.pad_bottom );
|
||||
let inner = crate::types::Rect
|
||||
{
|
||||
x: rect.x + pad_l,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
//! method — see the module's own doc for when to use the guards
|
||||
//! and when not to.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{ HashMap, VecDeque };
|
||||
use std::sync::Arc;
|
||||
|
||||
use fontdue::Font;
|
||||
@@ -157,6 +157,11 @@ pub struct GlesCanvas
|
||||
/// the same inside offscreen content (scroll viewports, clip layers)
|
||||
/// as outside it.
|
||||
pub( crate ) layout_viewport: Option<( f32, f32 )>,
|
||||
/// Canvas-local pixel density for `Dp` resolution, when the owning
|
||||
/// surface sits on an output whose density differs from the process
|
||||
/// [`crate::density`]. `None` falls back to the global. Inherited by
|
||||
/// sub-canvases.
|
||||
pub( crate ) density: Option<f32>,
|
||||
pub global_alpha: f32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
@@ -349,8 +354,13 @@ pub struct GlesCanvas
|
||||
// frame (the cache would happily serve the stale texture).
|
||||
// Content-keying tolerates that case at the cost of one
|
||||
// `DefaultHasher` pass over the bytes per draw call — fast for
|
||||
// any reasonable icon size.
|
||||
// any reasonable icon size. Bounded by `image.rs`'s
|
||||
// `IMAGE_CACHE_MAX_BYTES` with LRU eviction.
|
||||
image_cache: HashMap<(u32, u32, u64), (glow::Texture, u32, u32)>,
|
||||
// LRU order of `image_cache` keys, least-recent at the front.
|
||||
image_cache_lru: VecDeque<(u32, u32, u64)>,
|
||||
// Estimated GPU bytes held by `image_cache` (RGBA8: w × h × 4).
|
||||
image_cache_bytes: usize,
|
||||
|
||||
// Gradient LUT cache: FNV-ish hash of the 512×RGBA8 LUT bytes → texture.
|
||||
// Gradients are theme-derived and constant across frames; caching avoids
|
||||
|
||||
@@ -337,6 +337,7 @@ impl GlesCanvas
|
||||
font_registry: None,
|
||||
dpi_scale: 1.0,
|
||||
layout_viewport: None,
|
||||
density: None,
|
||||
global_alpha: 1.0,
|
||||
width,
|
||||
height,
|
||||
@@ -459,6 +460,8 @@ impl GlesCanvas
|
||||
atlas_row_height: 0,
|
||||
glyph_cache: HashMap::new(),
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_lru: std::collections::VecDeque::new(),
|
||||
image_cache_bytes: 0,
|
||||
gradient_lut_cache: HashMap::new(),
|
||||
clip_scissor: None,
|
||||
clip_layer: None,
|
||||
@@ -550,6 +553,7 @@ impl GlesCanvas
|
||||
dpi_scale: self.dpi_scale,
|
||||
layout_viewport: Some( self.layout_viewport.unwrap_or(
|
||||
( self.width as f32, self.height as f32 ) ) ),
|
||||
density: self.density,
|
||||
global_alpha: self.global_alpha,
|
||||
width,
|
||||
height,
|
||||
@@ -672,6 +676,8 @@ impl GlesCanvas
|
||||
atlas_row_height: 0,
|
||||
glyph_cache: HashMap::new(),
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_lru: std::collections::VecDeque::new(),
|
||||
image_cache_bytes: 0,
|
||||
gradient_lut_cache: HashMap::new(),
|
||||
clip_scissor: None,
|
||||
clip_layer: None,
|
||||
|
||||
@@ -122,19 +122,19 @@ impl<Msg: Clone> Column<Msg>
|
||||
#[ inline ]
|
||||
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_geom( self.spacing )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn resolved_padding( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_geom( self.padding )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn resolved_max_width( &self, canvas: &Canvas ) -> Option<f32>
|
||||
{
|
||||
self.max_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
self.max_width.map( |l| canvas.resolve_geom( l ) )
|
||||
}
|
||||
|
||||
/// Report the intrinsic content width as preferred width instead of filling
|
||||
|
||||
@@ -92,13 +92,13 @@ impl<Msg: Clone> Row<Msg>
|
||||
#[ inline ]
|
||||
fn resolved_spacing( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.spacing.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_geom( self.spacing )
|
||||
}
|
||||
|
||||
#[ inline ]
|
||||
fn resolved_padding( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.padding.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_geom( self.padding )
|
||||
}
|
||||
|
||||
/// Push the content block to the right edge of the available width.
|
||||
|
||||
@@ -115,11 +115,9 @@ impl Spacer
|
||||
/// weighted by `weight`.
|
||||
pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 )
|
||||
{
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = Length::EM_BASE_DEFAULT;
|
||||
(
|
||||
self.fixed_width .map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||
self.fixed_height.map( |l| l.resolve( vp, em ) ).unwrap_or( 0.0 ),
|
||||
self.fixed_width .map( |l| canvas.resolve_geom( l ) ).unwrap_or( 0.0 ),
|
||||
self.fixed_height.map( |l| canvas.resolve_geom( l ) ).unwrap_or( 0.0 ),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,12 +126,12 @@ impl Spacer
|
||||
/// layout only needs the main-axis size for one orientation.
|
||||
pub fn resolved_height( &self, canvas: &Canvas ) -> Option<f32>
|
||||
{
|
||||
self.fixed_height.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
self.fixed_height.map( |l| canvas.resolve_geom( l ) )
|
||||
}
|
||||
|
||||
pub fn resolved_width( &self, canvas: &Canvas ) -> Option<f32>
|
||||
{
|
||||
self.fixed_width.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
self.fixed_width.map( |l| canvas.resolve_geom( l ) )
|
||||
}
|
||||
|
||||
/// No-op — spacers are invisible.
|
||||
|
||||
@@ -116,7 +116,7 @@ impl<Msg: Clone> WrapGrid<Msg>
|
||||
{
|
||||
Some( m ) =>
|
||||
{
|
||||
let m = m.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).max( 1.0 );
|
||||
let m = canvas.resolve_geom( m ).max( 1.0 );
|
||||
let cols = ( ( ( inner_w + sx ) / ( m + sx ) ).floor() as usize ).max( 1 );
|
||||
match self.max_columns
|
||||
{
|
||||
@@ -130,12 +130,10 @@ impl<Msg: Clone> WrapGrid<Msg>
|
||||
|
||||
fn resolved( &self, canvas: &Canvas ) -> ( f32, f32, f32 )
|
||||
{
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = Length::EM_BASE_DEFAULT;
|
||||
(
|
||||
self.spacing_x.resolve( vp, em ),
|
||||
self.spacing_y.resolve( vp, em ),
|
||||
self.padding.resolve( vp, em ),
|
||||
canvas.resolve_geom( self.spacing_x ),
|
||||
canvas.resolve_geom( self.spacing_y ),
|
||||
canvas.resolve_geom( self.padding ),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
10
src/lib.rs
10
src/lib.rs
@@ -115,8 +115,9 @@
|
||||
//! - [`Length`] — a size/distance that may be absolute pixels
|
||||
//! ([`LengthBase::Px`]), relative to the surface viewport
|
||||
//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] /
|
||||
//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]) or to the root font
|
||||
//! size ([`LengthBase::Em`]). Every setter that takes a size, padding,
|
||||
//! [`LengthBase::Vmax`] / [`LengthBase::Orient`]), to the root font
|
||||
//! size ([`LengthBase::Em`]) or to the pixel density
|
||||
//! ([`LengthBase::Dp`]). Every setter that takes a size, padding,
|
||||
//! spacing or font height now accepts `impl Into<Length>`, so legacy
|
||||
//! `.size( 24.0 )` keeps working while new code can write
|
||||
//! `.size( Length::vmin( 4.0 ).clamp( 16.0, 32.0 ) )` for a typeface
|
||||
@@ -188,8 +189,9 @@
|
||||
//! millimetres — and legibility is a function of physical (angular) size,
|
||||
//! not of what fraction of the screen a glyph fills. When a size must stay
|
||||
//! a **constant physical size** across very different displays, use the
|
||||
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×`
|
||||
//! [`density`], the mainstream HiDPI unit), or [`LengthBase::Em`] for text
|
||||
//! other mode: [`Length::dp`] (a density-independent pixel — `n ×` the
|
||||
//! pixel density, applied when the value is resolved — the mainstream
|
||||
//! HiDPI unit), or [`LengthBase::Em`] for text
|
||||
//! relative to the root font size. The pre-calibrated
|
||||
//! [`theme::typography`] scale
|
||||
//! ([`theme::typography::h0`]…[`theme::typography::body_xs`]) is built on
|
||||
|
||||
@@ -143,6 +143,11 @@ pub struct SoftwareCanvas
|
||||
/// the same inside offscreen content (scroll viewports, clip layers)
|
||||
/// as outside it.
|
||||
pub( crate ) layout_viewport: Option<( f32, f32 )>,
|
||||
/// Canvas-local pixel density for `Dp` resolution, when the owning
|
||||
/// surface sits on an output whose density differs from the process
|
||||
/// [`crate::density`]. `None` falls back to the global. Inherited by
|
||||
/// sub-canvases.
|
||||
pub( crate ) density: Option<f32>,
|
||||
/// Global alpha multiplier for all drawing operations (0.0 =
|
||||
/// transparent, 1.0 = opaque).
|
||||
pub global_alpha: f32,
|
||||
@@ -264,6 +269,51 @@ impl Canvas
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixel density used to resolve [`crate::LengthBase::Dp`] values on
|
||||
/// this canvas: the canvas-local density when one was pinned with
|
||||
/// [`Self::set_density`], the process [`crate::density`] otherwise.
|
||||
pub fn density( &self ) -> f32
|
||||
{
|
||||
let local = match self
|
||||
{
|
||||
Canvas::Software( c ) => c.density,
|
||||
Canvas::Gles( c ) => c.density,
|
||||
};
|
||||
local.unwrap_or_else( crate::types::density )
|
||||
}
|
||||
|
||||
/// Pin this canvas — and every sub-canvas later derived from it — to
|
||||
/// a pixel density, overriding the process [`crate::density`] for
|
||||
/// `Dp` resolution. For a surface sitting on an output whose DPI
|
||||
/// differs from the one the process global was derived from.
|
||||
pub fn set_density( &mut self, d: f32 )
|
||||
{
|
||||
let d = d.max( 0.0 );
|
||||
match self
|
||||
{
|
||||
Canvas::Software( c ) => c.density = Some( d ),
|
||||
Canvas::Gles( c ) => c.density = Some( d ),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an explicit [`Length`] in **geometry** space: against
|
||||
/// [`Self::viewport_layout`], with this canvas' [`Self::density`].
|
||||
/// Widgets resolve caller-supplied geometry lengths through this so
|
||||
/// a `Length::dp` override follows the canvas the widget draws on.
|
||||
pub fn resolve_geom( &self, l: Length ) -> f32
|
||||
{
|
||||
l.resolve_with_density( self.viewport_layout(), Length::EM_BASE_DEFAULT, self.density() )
|
||||
}
|
||||
|
||||
/// Resolve an explicit [`Length`] in **font** space: against
|
||||
/// [`Self::viewport_logical`], with this canvas' [`Self::density`].
|
||||
/// Counterpart of [`Self::resolve_geom`] for font sizes, which are
|
||||
/// handed to the raster path pre-`dpi_scale`.
|
||||
pub fn resolve_font( &self, l: Length ) -> f32
|
||||
{
|
||||
l.resolve_with_density( self.viewport_logical(), Length::EM_BASE_DEFAULT, self.density() )
|
||||
}
|
||||
|
||||
/// Resolve a stock-widget **geometry** design pixel (height, padding,
|
||||
/// box size, gap…) through the process-wide [`crate::WidgetScaling`]
|
||||
/// mode, into a concrete physical-pixel value for the layout tree.
|
||||
@@ -275,7 +325,7 @@ impl Canvas
|
||||
/// [`Self::viewport_layout`].
|
||||
pub fn geom_px( &self, design_px: f32 ) -> f32
|
||||
{
|
||||
Length::widget( design_px ).resolve( self.viewport_layout(), Length::EM_BASE_DEFAULT )
|
||||
self.resolve_geom( Length::widget( design_px ) )
|
||||
}
|
||||
|
||||
/// Resolve a stock-widget **font** design pixel through the process-wide
|
||||
@@ -292,13 +342,13 @@ impl Canvas
|
||||
{
|
||||
WidgetScaling::Fluid =>
|
||||
{
|
||||
Length::fluid( design_px ).resolve( self.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||
self.resolve_font( Length::fluid( design_px ) )
|
||||
}
|
||||
WidgetScaling::Physical =>
|
||||
{
|
||||
let scale = self.dpi_scale();
|
||||
let scale = if scale > 0.0 { scale } else { 1.0 };
|
||||
design_px * crate::types::density() / scale
|
||||
design_px * self.density() / scale
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,6 +940,40 @@ mod viewport_tests
|
||||
set_widget_scaling( WidgetScaling::Fluid );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn canvas_density_overrides_process_density()
|
||||
{
|
||||
use crate::types::{ set_widget_scaling, set_density, WidgetScaling, Length };
|
||||
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||
|
||||
set_widget_scaling( WidgetScaling::Physical );
|
||||
set_density( 1.0 );
|
||||
let mut c = Canvas::new( 412, 900 );
|
||||
|
||||
// No local density → the process global applies.
|
||||
assert_eq!( c.geom_px( 48.0 ), 48.0 );
|
||||
|
||||
// A pinned canvas density wins over the global, for stock-widget
|
||||
// design pixels and explicit dp lengths alike.
|
||||
c.set_density( 2.0 );
|
||||
assert_eq!( c.density(), 2.0 );
|
||||
assert_eq!( c.geom_px( 48.0 ), 96.0 );
|
||||
assert_eq!( c.resolve_geom( Length::dp( 10.0 ) ), 20.0 );
|
||||
|
||||
set_widget_scaling( WidgetScaling::Fluid );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn sub_canvas_inherits_density()
|
||||
{
|
||||
let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||
|
||||
let mut c = Canvas::new( 412, 900 );
|
||||
c.set_density( 3.0 );
|
||||
let sub = c.sub_canvas( 100, 100 );
|
||||
assert_eq!( sub.density(), 3.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn font_px_is_constant_physical_in_physical_mode()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ impl SoftwareCanvas
|
||||
font_registry: None,
|
||||
dpi_scale: 1.0,
|
||||
layout_viewport: None,
|
||||
density: None,
|
||||
global_alpha: 1.0,
|
||||
glyph_cache: HashMap::new(),
|
||||
clip_mask: None,
|
||||
@@ -57,6 +58,7 @@ impl SoftwareCanvas
|
||||
dpi_scale: self.dpi_scale,
|
||||
layout_viewport: Some( self.layout_viewport.unwrap_or(
|
||||
( self.pixmap.width() as f32, self.pixmap.height() as f32 ) ) ),
|
||||
density: self.density,
|
||||
global_alpha: self.global_alpha,
|
||||
glyph_cache: HashMap::new(),
|
||||
clip_mask: None,
|
||||
|
||||
77
src/types.rs
77
src/types.rs
@@ -480,11 +480,15 @@ pub enum LengthBase
|
||||
/// Multiple of the root font size (typographic hierarchy: a heading
|
||||
/// of `Em(2.0)` is twice the body size, regardless of viewport).
|
||||
Em( f32 ),
|
||||
/// Density-independent pixel: the design value times the pixel
|
||||
/// density in effect **when the length is resolved** (the canvas'
|
||||
/// own density, or the process [`density`]). See [`Length::dp`].
|
||||
Dp( f32 ),
|
||||
}
|
||||
|
||||
impl LengthBase
|
||||
{
|
||||
fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||
fn resolve( &self, viewport: ( f32, f32 ), em_base: f32, density: f32 ) -> f32
|
||||
{
|
||||
let ( vw, vh ) = viewport;
|
||||
match self
|
||||
@@ -504,6 +508,7 @@ impl LengthBase
|
||||
}
|
||||
}
|
||||
LengthBase::Em( mul ) => em_base * mul,
|
||||
LengthBase::Dp( v ) => *v * density,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,7 +524,8 @@ impl LengthBase
|
||||
/// Resolution requires a viewport — passed in as `(width, height)` in
|
||||
/// **logical** pixels — and an `em_base` (the body-text font size that
|
||||
/// `Em` is a multiple of). All resolution funnels through
|
||||
/// [`Length::resolve`], so widgets can stay backend-agnostic.
|
||||
/// [`Length::resolve`] (or [`Length::resolve_with_density`] where a
|
||||
/// canvas-local density applies), so widgets can stay backend-agnostic.
|
||||
///
|
||||
/// Construct directly via the [`LengthBase`] variants
|
||||
/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from
|
||||
@@ -584,15 +590,22 @@ impl Length
|
||||
}
|
||||
|
||||
/// **Density-independent** pixel (the [`WidgetScaling::Physical`] mode).
|
||||
/// `px` is multiplied by the process [`density`] (derived from the
|
||||
/// output's DPI, or set with [`set_density`]) to yield a **constant
|
||||
/// physical size** across displays — the mainstream `dp` of Android /
|
||||
/// Flutter / CSS. Unlike [`Length::fluid`] it does **not** scale with
|
||||
/// the surface size, only with pixel density. Density defaults to
|
||||
/// `1.0`, so `dp( n )` == `n` px until a density is set.
|
||||
pub fn dp( px: f32 ) -> Self
|
||||
/// `px` is multiplied by the pixel density (derived from the output's
|
||||
/// DPI, or set with [`set_density`]) to yield a **constant physical
|
||||
/// size** across displays — the mainstream `dp` of Android / Flutter /
|
||||
/// CSS. Unlike [`Length::fluid`] it does **not** scale with the
|
||||
/// surface size, only with pixel density. Density defaults to `1.0`,
|
||||
/// so `dp( n )` == `n` px until a density is set.
|
||||
///
|
||||
/// The multiplication happens at **resolution time**, not here: the
|
||||
/// value carries its design pixels, and [`Length::resolve`] applies
|
||||
/// the process [`density`] — or the canvas' own density
|
||||
/// ([`crate::Canvas::set_density`]) on canvas-routed resolution — so
|
||||
/// a density change takes effect on the next paint without
|
||||
/// reconstructing the view's lengths.
|
||||
pub const fn dp( px: f32 ) -> Self
|
||||
{
|
||||
Length::px( px * density() )
|
||||
Self::from_base( LengthBase::Dp( px ) )
|
||||
}
|
||||
|
||||
/// Resolve a stock-widget design pixel through the process-wide
|
||||
@@ -613,9 +626,21 @@ impl Length
|
||||
|
||||
/// Resolve to a concrete logical-pixel value given a viewport and an
|
||||
/// `em_base` (the root font size that `Em` is a fraction of).
|
||||
/// [`LengthBase::Dp`] values use the process [`density`]; resolution
|
||||
/// paths that know a more local density (a canvas tied to a specific
|
||||
/// output) go through [`Self::resolve_with_density`] instead.
|
||||
pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32
|
||||
{
|
||||
let raw = self.base.resolve( viewport, em_base );
|
||||
self.resolve_with_density( viewport, em_base, density() )
|
||||
}
|
||||
|
||||
/// [`Self::resolve`] with an explicit pixel density for
|
||||
/// [`LengthBase::Dp`], instead of the process [`density`]. This is
|
||||
/// what [`crate::Canvas`]-routed resolution calls with the canvas'
|
||||
/// own density.
|
||||
pub fn resolve_with_density( &self, viewport: ( f32, f32 ), em_base: f32, density: f32 ) -> f32
|
||||
{
|
||||
let raw = self.base.resolve( viewport, em_base, density );
|
||||
let lo = self.min_px;
|
||||
let hi = self.max_px;
|
||||
// If both bounds present, normalise their order so swapped args
|
||||
@@ -912,9 +937,33 @@ mod length_tests
|
||||
assert_eq!( Length::dp( 48.0 ).resolve( ( 3840.0, 2160.0 ), 16.0 ), 48.0 );
|
||||
}
|
||||
|
||||
// Serialised: this is the only test that mutates the process-wide density
|
||||
// and widget-scaling globals, so it owns them start-to-finish and restores
|
||||
// the defaults, keeping the other (read-only-default) tests deterministic.
|
||||
#[ test ]
|
||||
fn dp_resolves_against_explicit_density()
|
||||
{
|
||||
let l = Length::dp( 48.0 );
|
||||
assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 2.0 ), 96.0 );
|
||||
assert_eq!( l.resolve_with_density( ( 412.0, 900.0 ), 16.0, 1.0 ), 48.0 );
|
||||
}
|
||||
|
||||
#[ test ]
|
||||
fn dp_is_applied_at_resolution_time_not_construction()
|
||||
{
|
||||
use super::set_density;
|
||||
|
||||
let _g = GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() );
|
||||
|
||||
// Construct while density is 1.0, resolve after it changes: the
|
||||
// length must follow the new density.
|
||||
let l = Length::dp( 48.0 );
|
||||
set_density( 2.0 );
|
||||
assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 96.0 );
|
||||
set_density( 1.0 );
|
||||
assert_eq!( l.resolve( ( 412.0, 900.0 ), 16.0 ), 48.0 );
|
||||
}
|
||||
|
||||
// Serialised: mutates the process-wide density and widget-scaling
|
||||
// globals, so it owns them start-to-finish and restores the defaults,
|
||||
// keeping the other (read-only-default) tests deterministic.
|
||||
#[ test ]
|
||||
fn density_and_widget_scaling_modes()
|
||||
{
|
||||
|
||||
@@ -279,7 +279,7 @@ impl<Msg: Clone> Button<Msg>
|
||||
fn label_font_size( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.font_size
|
||||
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_font( l ) )
|
||||
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ impl<Msg: Clone> Button<Msg>
|
||||
fn resolved_height( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.height
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_geom( l ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) )
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ impl<Msg: Clone> Button<Msg>
|
||||
{
|
||||
let w = match self.width
|
||||
{
|
||||
Some( l ) => l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ),
|
||||
Some( l ) => canvas.resolve_geom( l ).min( max_width ),
|
||||
None =>
|
||||
{
|
||||
let text_w = canvas.measure_text( label, self.label_font_size( canvas ) );
|
||||
|
||||
@@ -275,12 +275,10 @@ impl<Msg: Clone> Container<Msg>
|
||||
/// Return the preferred `(width, height)` accounting for padding.
|
||||
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
||||
{
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = Length::EM_BASE_DEFAULT;
|
||||
let pad_l = self.pad_left.resolve( vp, em );
|
||||
let pad_r = self.pad_right.resolve( vp, em );
|
||||
let pad_t = self.pad_top.resolve( vp, em );
|
||||
let pad_b = self.pad_bottom.resolve( vp, em );
|
||||
let pad_l = canvas.resolve_geom( self.pad_left );
|
||||
let pad_r = canvas.resolve_geom( self.pad_right );
|
||||
let pad_t = canvas.resolve_geom( self.pad_top );
|
||||
let pad_b = canvas.resolve_geom( self.pad_bottom );
|
||||
let avail = self.max_width.map( |m| max_width.min( m ) ).unwrap_or( max_width );
|
||||
let pad_x = pad_l + pad_r;
|
||||
let pad_y = pad_t + pad_b;
|
||||
|
||||
@@ -108,7 +108,7 @@ impl Image
|
||||
if let Some( extent ) = &self.short_side
|
||||
{
|
||||
let ( vw, vh ) = canvas.viewport_layout();
|
||||
let s = extent.resolve( ( vw, vh ), Length::EM_BASE_DEFAULT ).max( 0.0 );
|
||||
let s = canvas.resolve_geom( *extent ).max( 0.0 );
|
||||
let sw = self.width as f32;
|
||||
let sh = self.height as f32;
|
||||
if sw <= 0.0 || sh <= 0.0 { return ( s, s ); }
|
||||
@@ -123,10 +123,8 @@ impl Image
|
||||
}
|
||||
if let Some( ( w, h ) ) = &self.display_size
|
||||
{
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = Length::EM_BASE_DEFAULT;
|
||||
let rw = w.resolve( vp, em ).max( 0.0 );
|
||||
let rh = h.resolve( vp, em ).max( 0.0 );
|
||||
let rw = canvas.resolve_geom( *w ).max( 0.0 );
|
||||
let rh = canvas.resolve_geom( *h ).max( 0.0 );
|
||||
return ( rw, rh );
|
||||
}
|
||||
if self.cover
|
||||
|
||||
@@ -208,7 +208,7 @@ impl<Msg: Clone> ListItem<Msg>
|
||||
|
||||
let label_size = canvas.font_px( theme::LABEL_SIZE );
|
||||
let pad_h = self.pad_h
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_geom( l ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::PAD_H ) );
|
||||
let has_sub = self.subtitle.is_some();
|
||||
let label_y = if has_sub
|
||||
|
||||
@@ -97,7 +97,7 @@ impl<Msg: Clone> RichText<Msg>
|
||||
#[ inline ]
|
||||
fn resolved_size( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_font( self.size )
|
||||
}
|
||||
|
||||
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Separator
|
||||
fn resolved_thickness( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.thickness
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_geom( l ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::THICKNESS ) )
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ impl Separator
|
||||
fn resolved_pad_v( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.pad_v
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_geom( l ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::PAD_V ) )
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ impl Text
|
||||
#[ inline ]
|
||||
fn resolved_size( &self, canvas: &Canvas ) -> f32
|
||||
{
|
||||
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||
canvas.resolve_font( self.size )
|
||||
}
|
||||
|
||||
/// Paint the full string even when it overflows, instead of truncating
|
||||
|
||||
@@ -34,7 +34,7 @@ pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_
|
||||
pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option<Length> ) -> f32
|
||||
{
|
||||
fs
|
||||
.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_font( l ) )
|
||||
.unwrap_or_else( || canvas.font_px( theme::FONT_SIZE ) )
|
||||
}
|
||||
|
||||
@@ -444,10 +444,10 @@ impl<Msg: Clone> TextEdit<Msg>
|
||||
( max_width, h )
|
||||
} else {
|
||||
let w = self.fixed_width
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ).min( max_width ) )
|
||||
.map( |l| canvas.resolve_geom( l ).min( max_width ) )
|
||||
.unwrap_or( max_width );
|
||||
let h = self.height
|
||||
.map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) )
|
||||
.map( |l| canvas.resolve_geom( l ) )
|
||||
.unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) );
|
||||
( w, h )
|
||||
}
|
||||
|
||||
@@ -139,10 +139,8 @@ impl<Msg: Clone> VSlider<Msg>
|
||||
/// the type-level docs on intrinsic sizing.
|
||||
pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
||||
{
|
||||
let vp = canvas.viewport_layout();
|
||||
let em = Length::EM_BASE_DEFAULT;
|
||||
let w = self.width.resolve( vp, em ).max( 2.0 );
|
||||
let h = self.height.resolve( vp, em ).max( 2.0 );
|
||||
let w = canvas.resolve_geom( self.width ).max( 2.0 );
|
||||
let h = canvas.resolve_geom( self.height ).max( 2.0 );
|
||||
( w, h )
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user