From 7c1ef86afe0d6d5188040727dfedb016bbd7bde6 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 10 Sep 2026 21:18:24 +0200 Subject: [PATCH] =?UTF-8?q?Resolve=20fluid=20geometry=20bounds=20in=20logi?= =?UTF-8?q?cal=20space=20and=20let=20the=20runtime=20set=20the=20density?= =?UTF-8?q?=20from=20the=20buffer=20scale=20Stock=20widgets=20size=20thems?= =?UTF-8?q?elves=20with=20`Length::fluid`,=20whose=20auto-clamp=20`[0.7?= =?UTF-8?q?=C2=B7px,=201.5=C2=B7px]`=20is=20documented=20as=20logical=20px?= =?UTF-8?q?,=20yet=20`Canvas::resolve=5Fgeom`=20resolved=20it=20against=20?= =?UTF-8?q?the=20physical=20viewport=20and=20applied=20the=20bounds=20to?= =?UTF-8?q?=20the=20physical=20value.=20On=20any=20HiDPI=20surface=20whose?= =?UTF-8?q?=20physical=20short=20side=20exceeds=20618=20px=20(412=20=C3=97?= =?UTF-8?q?=201.5)=20the=20cap=20engaged:=20a=201080=20=C3=97=201920=20pho?= =?UTF-8?q?ne=20at=20scale=202=20got=20its=20geometry=20at=201.5=20physica?= =?UTF-8?q?l=20px=20per=20design=20px,=20that=20is=200.75=20logical,=20whi?= =?UTF-8?q?le=20fonts,=20resolved=20by=20`font=5Fpx`=20against=20the=20log?= =?UTF-8?q?ical=20viewport=20and=20scaled=20at=20raster=20time,=20kept=20g?= =?UTF-8?q?rowing=20to=202.62.=20Rows,=20paddings=20and=20icons=20came=20o?= =?UTF-8?q?ut=20at=2060=20%=20of=20their=20intended=20size=20with=20the=20?= =?UTF-8?q?text=20overflowing=20them;=20at=20scale=201=20physical=20and=20?= =?UTF-8?q?logical=20coincide,=20so=20a=20winit=20session=20never=20showed?= =?UTF-8?q?=20it.=20`Length::resolve=5Fphysical`=20takes=20the=20buffer=20?= =?UTF-8?q?scale=20and=20multiplies=20`min=5Fpx`=20/=20`max=5Fpx`=20by=20i?= =?UTF-8?q?t=20before=20clamping;=20`resolve=5Fwith=5Fdensity`=20delegates?= =?UTF-8?q?=20to=20it=20with=20scale=201.=20`Canvas::resolve=5Fgeom`=20pas?= =?UTF-8?q?ses=20its=20`dpi=5Fscale`,=20so=20every=20stock-widget=20geomet?= =?UTF-8?q?ry=20shares=20the=20surface=20factor=20of=20the=20fonts,=20and?= =?UTF-8?q?=20`overlays=5Freconcile`=20does=20the=20same=20for=20`OverlayS?= =?UTF-8?q?pec::size`,=20which=20had=20the=20same=20mix.=20A=20zero=20scal?= =?UTF-8?q?e=20falls=20back=20to=20unscaled=20bounds.=20The=20event=20loop?= =?UTF-8?q?=20now=20calls=20`set=5Fdensity(=20sf=20)`=20right=20before=20`?= =?UTF-8?q?App::on=5Fscale=5Fchanged`,=20both=20on=20`configure`=20and=20o?= =?UTF-8?q?n=20`scale=5Ffactor=5Fchanged`=20of=20the=20main=20surface,=20s?= =?UTF-8?q?o=20`Length::dp(=20n=20)`=20means=20n=20logical=20px=20in=20eve?= =?UTF-8?q?ry=20app=20without=20plumbing;=20an=20app=20wanting=20a=20DPI-d?= =?UTF-8?q?erived=20density=20still=20overrides=20it=20from=20that=20callb?= =?UTF-8?q?ack,=20and=20crustace=20already=20sets=20the=20same=20value=20t?= =?UTF-8?q?here.=20Two=20tests=20cover=20the=20clamp=20at=20scale=202=20an?= =?UTF-8?q?d=20the=20geometry/font=20parity=20on=20a=201080=20=C3=97=20246?= =?UTF-8?q?0=20canvas.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/event_loop/app_data.rs | 1 + src/event_loop/handlers.rs | 1 + src/event_loop/overlays_reconcile.rs | 5 ++-- src/render/mod.rs | 19 +++++++++++++- src/types.rs | 39 +++++++++++++++++++++++----- 5 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 9fb3a01..051b5f7 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -447,6 +447,7 @@ impl AppData // current buffer scale before handing the dimensions to the app. let sf = self.main.scale_factor.max( 1 ) as u32; crate::types::set_viewport_size( w * sf, h * sf ); + crate::types::set_density( sf as f32 ); self.app.on_scale_changed( sf ); self.app.on_resize( w * sf, h * sf ); // `on_resize` may flip app-state that the view depends on (apps that diff --git a/src/event_loop/handlers.rs b/src/event_loop/handlers.rs index 67d77e3..9761471 100644 --- a/src/event_loop/handlers.rs +++ b/src/event_loop/handlers.rs @@ -130,6 +130,7 @@ impl CompositorHandler for AppData if matches!( focus, super::SurfaceFocus::Main ) { crate::types::set_viewport_size( pw, ph ); + crate::types::set_density( new_factor as f32 ); self.app.on_scale_changed( new_factor as u32 ); self.app.on_resize( pw, ph ); self.dirty_caches(); diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index 86f717d..76bcdaa 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -114,9 +114,10 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let main_vp = ( data.main.physical_width() as f32, data.main.physical_height() as f32 ); let resolve_size = move | s: &( Length, Length ) | -> ( u32, u32 ) { + let density = crate::types::density(); ( - s.0.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32, - s.1.resolve( main_vp, Length::EM_BASE_DEFAULT ).max( 0.0 ).round() as u32, + s.0.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32, + s.1.resolve_physical( main_vp, Length::EM_BASE_DEFAULT, density, parent_scale ).max( 0.0 ).round() as u32, ) }; // `OverlaySpec::size` is physical pixels (the app's layout space); the diff --git a/src/render/mod.rs b/src/render/mod.rs index 4810fb4..6c28d46 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -318,7 +318,7 @@ impl Canvas /// 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() ) + l.resolve_physical( self.viewport_layout(), Length::EM_BASE_DEFAULT, self.density(), self.dpi_scale() ) } /// Resolve an explicit [`Length`] in **font** space: against @@ -960,6 +960,23 @@ mod viewport_tests set_widget_scaling( WidgetScaling::Fluid ); } + #[ test ] + fn geom_px_fluid_cap_is_logical_on_hidpi() + { + use crate::types::{ set_widget_scaling, WidgetScaling }; + let _g = crate::TEST_GLOBALS_LOCK.lock().unwrap_or_else( |e| e.into_inner() ); + set_widget_scaling( WidgetScaling::Fluid ); + + // 1080 × 2460 phone at buffer scale 2: geometry and fonts must share + // the same surface factor (2.62 physical px per design px). + let mut c = Canvas::new( 1080, 2460 ); + c.set_dpi_scale( 2.0 ); + let geom = c.geom_px( 48.0 ); + let font = c.font_px( 48.0 ) * 2.0; + assert!( ( geom - 48.0 / 412.0 * 1080.0 ).abs() < 1e-2 ); + assert!( ( geom - font ).abs() < 1e-2 ); + } + #[ test ] fn canvas_density_overrides_process_density() { diff --git a/src/types.rs b/src/types.rs index 03fcb49..ec76a60 100644 --- a/src/types.rs +++ b/src/types.rs @@ -639,10 +639,22 @@ impl Length /// 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 + { + self.resolve_physical( viewport, em_base, density, 1.0 ) + } + + /// [`Self::resolve_with_density`] against a **physical** viewport + /// (`dpi_scale` physical px per logical px). The bounds are logical + /// px, so they are scaled to physical before they apply; otherwise a + /// `fluid` value hits its cap on any HiDPI surface whose physical + /// short side exceeds the cap, while fonts (resolved in logical space) + /// keep growing. + pub fn resolve_physical( &self, viewport: ( f32, f32 ), em_base: f32, density: f32, dpi_scale: f32 ) -> f32 { let raw = self.base.resolve( viewport, em_base, density ); - let lo = self.min_px; - let hi = self.max_px; + let k = if dpi_scale > 0.0 { dpi_scale } else { 1.0 }; + let lo = self.min_px.map( |v| v * k ); + let hi = self.max_px.map( |v| v * k ); // If both bounds present, normalise their order so swapped args // don't produce NaN out of f32::clamp. let ( lo, hi ) = match ( lo, hi ) @@ -732,16 +744,17 @@ pub fn text_scale() -> f32 static DENSITY_BITS: AtomicU32 = AtomicU32::new( 1.0_f32.to_bits() ); /// Set the process-wide pixel density used by [`Length::dp`] (the -/// [`WidgetScaling::Physical`] mode). Typically derived from the output's -/// physical DPI so `dp` sizes stay physically constant across displays. -/// Default: `1.0`. +/// [`WidgetScaling::Physical`] mode). The runtime sets it to the main +/// surface's buffer scale right before [`crate::App::on_scale_changed`], +/// so `dp( n )` is `n` logical px by default; an app that wants a +/// DPI-derived density overrides it from that callback. Default: `1.0`. pub fn set_density( d: f32 ) { DENSITY_BITS.store( d.max( 0.0 ).to_bits(), Ordering::Relaxed ); } /// Current pixel density — the factor [`Length::dp`] multiplies its design -/// pixels by. `1.0` until [`set_density`] is called. +/// pixels by. `1.0` until the runtime or [`set_density`] sets it. pub fn density() -> f32 { f32::from_bits( DENSITY_BITS.load( Ordering::Relaxed ) ) @@ -946,6 +959,20 @@ mod length_tests assert_eq!( p, l ); } + #[ test ] + fn fluid_bounds_scale_with_dpi_on_a_physical_viewport() + { + // 1080 × 2460 at scale 2: the physical short side is 2.62 × the + // reference, the cap is 1.5 × 48 logical = 144 physical, so the + // unclamped 125.8 stands. + let v = Length::fluid( 48.0 ).resolve_physical( ( 1080.0, 2460.0 ), 16.0, 1.0, 2.0 ); + assert!( ( v - 48.0 / 412.0 * 1080.0 ).abs() < 1e-3 ); + // Same surface treated as scale 1 hits the cap in physical px. + assert_eq!( Length::fluid( 48.0 ).resolve_physical( ( 1080.0, 2460.0 ), 16.0, 1.0, 1.0 ), 72.0 ); + // A zero scale falls back to unscaled bounds instead of collapsing to 0. + assert_eq!( Length::fluid( 48.0 ).resolve_physical( ( 1080.0, 2460.0 ), 16.0, 1.0, 0.0 ), 72.0 ); + } + #[ test ] fn dp_is_identity_at_default_density() {