Support viewport-relative widget sizing
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

This commit is contained in:
yamabush1
2026-06-04 12:38:59 +02:00
parent ae8380b1ac
commit 68c6a87bf6
8 changed files with 61 additions and 33 deletions

View File

@@ -594,6 +594,11 @@ pub trait App: 'static
/// physical dimensions. /// physical dimensions.
fn on_resize( &mut self, _width: u32, _height: u32 ) {} fn on_resize( &mut self, _width: u32, _height: u32 ) {}
/// Called when the compositor reports a new integer buffer scale for the
/// main surface. The default is inert so apps that only care about physical
/// pixels can keep using [`on_resize`](Self::on_resize).
fn on_scale_changed( &mut self, _scale: u32 ) {}
/// Called once at startup with a channel sender that can be used from any /// Called once at startup with a channel sender that can be used from any
/// thread to deliver messages into the event loop. Sending a message /// thread to deliver messages into the event loop. Sending a message
/// immediately wakes the event loop — no polling delay. /// immediately wakes the event loop — no polling delay.

View File

@@ -411,6 +411,7 @@ impl<A: App> AppData<A>
// configure.new_size is surface-local (logical), so multiply by the // configure.new_size is surface-local (logical), so multiply by the
// current buffer scale before handing the dimensions to the app. // current buffer scale before handing the dimensions to the app.
let sf = self.main.scale_factor.max( 1 ) as u32; let sf = self.main.scale_factor.max( 1 ) as u32;
self.app.on_scale_changed( sf );
self.app.on_resize( w * sf, h * sf ); self.app.on_resize( w * sf, h * sf );
// `on_resize` may flip app-state that the view depends on (apps that // `on_resize` may flip app-state that the view depends on (apps that
// branch on the new dimensions, layout caches keyed by size, …), so // branch on the new dimensions, layout caches keyed by size, …), so

View File

@@ -99,6 +99,7 @@ impl<A: App> CompositorHandler for AppData<A>
// `App::on_resize`. // `App::on_resize`.
if matches!( focus, super::SurfaceFocus::Main ) if matches!( focus, super::SurfaceFocus::Main )
{ {
self.app.on_scale_changed( new_factor as u32 );
self.app.on_resize( pw, ph ); self.app.on_resize( pw, ph );
self.dirty_caches(); self.dirty_caches();
} }

View File

@@ -51,7 +51,7 @@ impl<Msg: Clone> Element<Msg>
{ {
Element::Button( b ) => b.preferred_size( max_width, canvas ), Element::Button( b ) => b.preferred_size( max_width, canvas ),
Element::TextEdit( t ) => t.preferred_size( max_width, canvas ), Element::TextEdit( t ) => t.preferred_size( max_width, canvas ),
Element::Image( i ) => i.preferred_size( max_width ), Element::Image( i ) => i.preferred_size( max_width, canvas ),
Element::Column( c ) => c.preferred_size( max_width, canvas ), Element::Column( c ) => c.preferred_size( max_width, canvas ),
Element::Row( r ) => r.preferred_size( max_width, canvas ), Element::Row( r ) => r.preferred_size( max_width, canvas ),
Element::Stack( s ) => s.preferred_size( max_width, canvas ), Element::Stack( s ) => s.preferred_size( max_width, canvas ),

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc; use std::sync::Arc;
use crate::types::Rect; use crate::types::{ Length, Rect };
use crate::render::Canvas; use crate::render::Canvas;
/// A static image widget that renders RGBA pixel data. /// A static image widget that renders RGBA pixel data.
@@ -34,8 +34,8 @@ pub struct Image
pub height: u32, pub height: u32,
/// When `true` the image scales to fill the available width (cover mode). /// When `true` the image scales to fill the available width (cover mode).
pub cover: bool, pub cover: bool,
/// Optional explicit display size in logical pixels. /// Optional explicit display size in logical units.
pub display_size: Option<( f32, f32 )>, pub display_size: Option<( Length, Length )>,
/// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`. /// Opacity multiplier in `[0.0, 1.0]`. Default: `1.0`.
pub opacity: f32, pub opacity: f32,
} }
@@ -66,10 +66,10 @@ impl Image
self self
} }
/// Set an explicit display size in logical pixels. /// Set an explicit display size in logical units.
pub fn size( mut self, width: f32, height: f32 ) -> Self pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
{ {
self.display_size = Some( ( width.max( 0.0 ), height.max( 0.0 ) ) ); self.display_size = Some( ( width.into(), height.into() ) );
self self
} }
@@ -81,11 +81,15 @@ impl Image
} }
/// Return the preferred `(width, height)` given available `max_width`. /// Return the preferred `(width, height)` given available `max_width`.
pub fn preferred_size( &self, max_width: f32 ) -> (f32, f32) pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
if let Some( size ) = self.display_size if let Some( ( width, height ) ) = self.display_size
{ {
return size; let viewport = canvas.viewport_logical();
return (
width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ),
height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 0.0 ),
);
} }
if self.cover if self.cover
{ {

View File

@@ -32,14 +32,14 @@ fn cover_builder_sets_cover_flag()
fn size_builder_sets_explicit_display_size() fn size_builder_sets_explicit_display_size()
{ {
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 ); let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( 120.0, 80.0 );
assert_eq!( img.display_size, Some( ( 120.0, 80.0 ) ) ); assert_eq!( img.display_size, Some( ( Length::px( 120.0 ), Length::px( 80.0 ) ) ) );
} }
#[ test ] #[ test ]
fn size_builder_clamps_negative_dimensions_to_zero() fn size_builder_keeps_length_values_for_later_viewport_resolution()
{ {
let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 ); let img = Image::new( solid_rgba( 4, 4 ), 4, 4 ).size( -10.0, -20.0 );
assert_eq!( img.display_size, Some( ( 0.0, 0.0 ) ) ); assert_eq!( img.display_size, Some( ( Length::px( -10.0 ), Length::px( -20.0 ) ) ) );
} }
#[ test ] #[ test ]
@@ -57,7 +57,8 @@ fn preferred_size_default_scales_height_to_match_width_aspect()
{ {
// 200×100 source image at max_width = 400 → scale = 2 → height 200. // 200×100 source image at max_width = 400 → scale = 2 → height 200.
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ); let img = Image::new( solid_rgba( 200, 100 ), 200, 100 );
let ( w, h ) = img.preferred_size( 400.0 ); let canvas = Canvas::new( 480, 900 );
let ( w, h ) = img.preferred_size( 400.0, &canvas );
assert_eq!( w, 400.0 ); assert_eq!( w, 400.0 );
assert_eq!( h, 200.0 ); assert_eq!( h, 200.0 );
} }
@@ -66,7 +67,16 @@ fn preferred_size_default_scales_height_to_match_width_aspect()
fn preferred_size_with_explicit_size_wins_over_aspect_logic() fn preferred_size_with_explicit_size_wins_over_aspect_logic()
{ {
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 ); let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( 50.0, 25.0 );
assert_eq!( img.preferred_size( 1000.0 ), ( 50.0, 25.0 ) ); let canvas = Canvas::new( 480, 900 );
assert_eq!( img.preferred_size( 1000.0, &canvas ), ( 50.0, 25.0 ) );
}
#[ test ]
fn preferred_size_resolves_explicit_viewport_lengths()
{
let img = Image::new( solid_rgba( 200, 100 ), 200, 100 ).size( Length::vw( 10.0 ), Length::vh( 10.0 ) );
let canvas = Canvas::new( 480, 900 );
assert_eq!( img.preferred_size( 1000.0, &canvas ), ( 48.0, 90.0 ) );
} }
#[ test ] #[ test ]
@@ -74,7 +84,8 @@ fn preferred_size_cover_mode_keeps_aspect_ratio()
{ {
// 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150. // 100×50 source in cover mode at max_width = 300 → height = 300 * (50/100) = 150.
let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); let img = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
let ( w, h ) = img.preferred_size( 300.0 ); let canvas = Canvas::new( 480, 900 );
let ( w, h ) = img.preferred_size( 300.0, &canvas );
assert_eq!( w, 300.0 ); assert_eq!( w, 300.0 );
assert_eq!( h, 150.0 ); assert_eq!( h, 150.0 );
} }
@@ -87,7 +98,8 @@ fn preferred_size_cover_and_default_match_for_uniform_scaling()
// when the source is taller than wide. // when the source is taller than wide.
let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 ); let normal = Image::new( solid_rgba( 100, 50 ), 100, 50 );
let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover(); let covered = Image::new( solid_rgba( 100, 50 ), 100, 50 ).cover();
assert_eq!( normal.preferred_size( 200.0 ), covered.preferred_size( 200.0 ) ); let canvas = Canvas::new( 480, 900 );
assert_eq!( normal.preferred_size( 200.0, &canvas ), covered.preferred_size( 200.0, &canvas ) );
} }
// ── Arc sharing ─────────────────────────────────────────────────────────── // ── Arc sharing ───────────────────────────────────────────────────────────

View File

@@ -2,7 +2,7 @@
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc; use std::sync::Arc;
use crate::types::Rect; use crate::types::{ Length, Rect };
use crate::render::Canvas; use crate::render::Canvas;
use super::Element; use super::Element;
use super::slider::intersect_clip; use super::slider::intersect_clip;
@@ -65,10 +65,10 @@ pub struct VSlider<Msg: Clone>
/// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the /// Current value in `[0.0, 1.0]`. `0.0` paints no fill; `1.0` fills the
/// whole pill. /// whole pill.
pub value: f32, pub value: f32,
/// Fixed width of the pill in pixels. Defaults to 56. /// Fixed width of the pill in logical units. Defaults to 56 px.
pub width: f32, pub width: Length,
/// Fixed height of the pill in pixels. Defaults to 160. /// Fixed height of the pill in logical units. Defaults to 160 px.
pub height: f32, pub height: Length,
/// Callback invoked with the new value when the slider is tapped or /// Callback invoked with the new value when the slider is tapped or
/// dragged. `Arc` (not `Box`) so the layout pass can clone it into the /// dragged. `Arc` (not `Box`) so the layout pass can clone it into the
/// per-leaf handler snapshot for O(1) dispatch on input events. /// per-leaf handler snapshot for O(1) dispatch on input events.
@@ -93,8 +93,8 @@ impl<Msg: Clone> VSlider<Msg>
Self Self
{ {
value: value.clamp( 0.0, 1.0 ), value: value.clamp( 0.0, 1.0 ),
width: theme::WIDTH, width: Length::px( theme::WIDTH ),
height: theme::HEIGHT, height: Length::px( theme::HEIGHT ),
on_change: None, on_change: None,
track_surface: theme::SURFACE_TRACK, track_surface: theme::SURFACE_TRACK,
fill_surface: theme::SURFACE_FILL, fill_surface: theme::SURFACE_FILL,
@@ -117,12 +117,13 @@ impl<Msg: Clone> VSlider<Msg>
self self
} }
/// Override the fixed pill `(width, height)` in pixels. Both are clamped /// Override the fixed pill `(width, height)` in logical units. Both are
/// to a minimum of `2.0` so a rounded pill can always be drawn. /// clamped to a minimum of `2.0` after viewport-relative values resolve,
pub fn size( mut self, width: f32, height: f32 ) -> Self /// so a rounded pill can always be drawn.
pub fn size( mut self, width: impl Into<Length>, height: impl Into<Length> ) -> Self
{ {
self.width = width.max( 2.0 ); self.width = width.into();
self.height = height.max( 2.0 ); self.height = height.into();
self self
} }
@@ -135,9 +136,13 @@ impl<Msg: Clone> VSlider<Msg>
/// Return the preferred `(width, height)`. `max_width` is ignored — see /// Return the preferred `(width, height)`. `max_width` is ignored — see
/// the type-level docs on intrinsic sizing. /// the type-level docs on intrinsic sizing.
pub fn preferred_size( &self, _max_width: f32, _canvas: &Canvas ) -> (f32, f32) pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32)
{ {
( self.width, self.height ) let viewport = canvas.viewport_logical();
(
self.width.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ),
self.height.resolve( viewport, Length::EM_BASE_DEFAULT ).max( 2.0 ),
)
} }
/// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`. /// Compute the value `[0.0, 1.0]` from a tap/drag y position within `rect`.

View File

@@ -100,8 +100,8 @@ fn preferred_size_ignores_max_width()
fn default_dimensions_are_the_theme_constants() fn default_dimensions_are_the_theme_constants()
{ {
let s: VSlider<()> = vslider( 0.0 ); let s: VSlider<()> = vslider( 0.0 );
assert_eq!( s.width, theme::WIDTH ); assert_eq!( s.width, Length::px( theme::WIDTH ) );
assert_eq!( s.height, theme::HEIGHT ); assert_eq!( s.height, Length::px( theme::HEIGHT ) );
} }
#[ test ] #[ test ]