diff --git a/src/layout/column.rs b/src/layout/column.rs index b21ab2c..aec5bf2 100644 --- a/src/layout/column.rs +++ b/src/layout/column.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use crate::types::Rect; +use crate::types::{ Length, Rect }; use crate::render::Canvas; use crate::widget::Element; @@ -23,14 +23,36 @@ use crate::widget::Element; /// .into() /// # } /// ``` +/// +/// `padding`, `spacing` and `max_width` all accept any +/// [`crate::Length`], so a responsive layout reads as: +/// +/// ```rust,no_run +/// # use ltk::{ button, column, text, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg { Ok } +/// # fn _ex() -> Element { +/// column() +/// // Padding is 3 % of the viewport's smaller side, clamped to 16..48 px. +/// .padding( Length::vmin( 3.0 ).clamp( 16.0, 48.0 ) ) +/// .spacing( Length::vmin( 1.5 ).at_least( 8.0 ) ) +/// .max_width( Length::vw( 60.0 ).at_most( 720.0 ) ) +/// .push( text( "Responsive" ) ) +/// .push( button( "OK" ).on_press( Msg::Ok ) ) +/// .into() +/// # } +/// ``` pub struct Column { pub children: Vec>, - pub spacing: f32, - pub padding: f32, + /// Vertical gap between children. Stored as [`Length`] so a `Vmin(2.0)` + /// or `Em(0.5)` gap scales with the viewport instead of freezing at a + /// px constant. + pub spacing: Length, + /// Padding on all sides. Same [`Length`] semantics as `spacing`. + pub padding: Length, pub align_center_x: bool, pub center_y: bool, - pub max_width: Option, + pub max_width: Option, pub fit_content: bool, } @@ -41,8 +63,8 @@ impl Column Self { children: Vec::new(), - spacing: 8.0, - padding: 16.0, + spacing: Length::px( 8.0 ), + padding: Length::px( 16.0 ), align_center_x: true, center_y: false, max_width: None, @@ -57,17 +79,20 @@ impl Column self } - /// Set the vertical gap between children in pixels. Default: `8.0`. - pub fn spacing( mut self, s: f32 ) -> Self + /// Set the vertical gap between children. Default: `8.0` px. Accepts + /// any [`Length`] — pass an `f32` for the px case, or a relative + /// value like `Length::vmin( 2.0 )` to scale with the viewport. + pub fn spacing( mut self, s: impl Into ) -> Self { - self.spacing = s; + self.spacing = s.into(); self } - /// Set the padding (all sides) in pixels. Default: `16.0`. - pub fn padding( mut self, p: f32 ) -> Self + /// Set the padding (all sides). Default: `16.0` px. Accepts any + /// [`Length`]. + pub fn padding( mut self, p: impl Into ) -> Self { - self.padding = p; + self.padding = p.into(); self } @@ -85,14 +110,33 @@ impl Column self } - /// Limit the content width in pixels. The column still reports `max_width` as - /// its preferred width so the parent allocates the full available rect. - pub fn max_width( mut self, w: f32 ) -> Self + /// Limit the content width. Accepts any [`Length`]. The column still + /// reports the parent's `max_width` as its preferred width so the + /// parent allocates the full available rect. + pub fn max_width( mut self, w: impl Into ) -> Self { - self.max_width = Some( w ); + self.max_width = Some( w.into() ); self } + #[ inline ] + fn resolved_spacing( &self, canvas: &Canvas ) -> f32 + { + self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_padding( &self, canvas: &Canvas ) -> f32 + { + self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_max_width( &self, canvas: &Canvas ) -> Option + { + self.max_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + } + /// Report the intrinsic content width as preferred width instead of filling /// the available `max_width`. Use this when the column represents a card /// or widget meant to sit side-by-side with other children inside a @@ -108,10 +152,10 @@ impl Column self } - fn inner_w( &self, available: f32 ) -> f32 + fn inner_w( &self, available: f32, canvas: &Canvas ) -> f32 { - let w = available - self.padding * 2.0; - self.max_width.map( |m| w.min( m ) ).unwrap_or( w ) + let w = available - self.resolved_padding( canvas ) * 2.0; + self.resolved_max_width( canvas ).map( |m| w.min( m ) ).unwrap_or( w ) } fn content_h( &self, inner_w: f32, canvas: &Canvas ) -> f32 @@ -120,18 +164,19 @@ impl Column self.children.iter() .map( |c| match c { - Element::Spacer( s ) => s.fixed_height.unwrap_or( 0.0 ), + Element::Spacer( s ) => s.resolved_height( canvas ).unwrap_or( 0.0 ), other => other.preferred_size( inner_w, canvas ).1, } ) .sum::() - + self.spacing * (self.children.len().saturating_sub( 1 )) as f32 + + self.resolved_spacing( canvas ) * ( self.children.len().saturating_sub( 1 ) ) as f32 } /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { - let inner_w = self.inner_w( max_width ); - let total_h = self.content_h( inner_w, canvas ) + self.padding * 2.0; + let inner_w = self.inner_w( max_width, canvas ); + let pad = self.resolved_padding( canvas ); + let total_h = self.content_h( inner_w, canvas ) + pad * 2.0; let w = if self.fit_content { @@ -162,7 +207,7 @@ impl Column other => other.preferred_size( inner_w, canvas ).0, } ) .fold( 0.0_f32, f32::max ); - ( content_w + self.padding * 2.0 ).min( max_width ) + ( content_w + pad * 2.0 ).min( max_width ) } else { max_width }; @@ -175,14 +220,16 @@ impl Column /// Layout children within rect and return (rect, child_index) pairs. pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> { - let inner_w = self.inner_w( rect.width ); + let inner_w = self.inner_w( rect.width, canvas ); + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); // Flexible spacers and Scroll widgets claim remaining vertical space. // Fixed-height spacers behave like normal fixed-size children. let total_weight: u32 = self.children.iter() .map( |c| match c { - Element::Spacer( s ) if s.fixed_height.is_none() => s.weight, + Element::Spacer( s ) if s.resolved_height( canvas ).is_none() => s.weight, Element::Scroll( _ ) => 1, _ => 0, } ) @@ -195,23 +242,23 @@ impl Column { 0.0 } else if let Element::Spacer( s ) = c { - s.fixed_height.unwrap_or( 0.0 ) + s.resolved_height( canvas ).unwrap_or( 0.0 ) } else { c.preferred_size( inner_w, canvas ).1 } } ) .sum::() - + self.spacing * (self.children.len().saturating_sub( 1 )) as f32; + + spacing * ( self.children.len().saturating_sub( 1 ) ) as f32; - let avail_h = rect.height - self.padding * 2.0; - let avail_spare = (avail_h - fixed_h).max( 0.0 ); + let avail_h = rect.height - pad * 2.0; + let avail_spare = ( avail_h - fixed_h ).max( 0.0 ); // `center_y` only applies when there are no spacers. let start_y = if total_weight == 0 && self.center_y { - rect.y + self.padding + avail_spare / 2.0 + rect.y + pad + avail_spare / 2.0 } else { - rect.y + self.padding + rect.y + pad }; let start_x = rect.x + (rect.width - inner_w) / 2.0; @@ -224,7 +271,7 @@ impl Column { Element::Spacer( s ) => { - let h = if let Some( fixed ) = s.fixed_height + let h = if let Some( fixed ) = s.resolved_height( canvas ) { fixed } else if total_weight > 0 @@ -254,7 +301,7 @@ impl Column start_x }; result.push( ( Rect { x, y, width: w, height: h }, i ) ); - y += h + self.spacing; + y += h + spacing; } result } @@ -330,16 +377,18 @@ mod tests #[ test ] fn inner_w_respects_padding_and_max_width() { + let canvas = make_canvas(); let col = column::<()>().padding( 20.0 ).max_width( 100.0 ); // available = 200, minus padding*2 = 160, capped at max_width = 100 - assert_eq!( col.inner_w( 200.0 ), 100.0 ); + assert_eq!( col.inner_w( 200.0, &canvas ), 100.0 ); } #[ test ] fn inner_w_without_max_width_subtracts_padding() { + let canvas = make_canvas(); let col = column::<()>().padding( 10.0 ); - assert_eq!( col.inner_w( 200.0 ), 180.0 ); + assert_eq!( col.inner_w( 200.0, &canvas ), 180.0 ); } #[ test ] @@ -356,4 +405,39 @@ mod tests let ( _, h ) = col.preferred_size( 100.0, &canvas ); assert_eq!( h, 16.0 ); } + + #[ test ] + fn vmin_spacing_resolves_against_canvas_viewport() + { + // Canvas is 800x600 → vmin = 600. 5 % of 600 = 30 px per gap. + // Three zero-height spacers → two gaps → 60 px total. + let canvas = make_canvas(); + let col = column::<()>() + .padding( 0.0 ) + .spacing( Length::vmin( 5.0 ) ) + .push( crate::spacer() ) + .push( crate::spacer() ) + .push( crate::spacer() ); + let ( _, h ) = col.preferred_size( 100.0, &canvas ); + assert_eq!( h, 60.0 ); + } + + #[ test ] + fn vmin_padding_doubles_around_content() + { + // 4 % of 600 = 24 px padding on each side → 48 px on an empty column. + let canvas = make_canvas(); + let col = column::<()>().padding( Length::vmin( 4.0 ) ); + let ( _, h ) = col.preferred_size( 100.0, &canvas ); + assert_eq!( h, 48.0 ); + } + + #[ test ] + fn vmin_max_width_caps_inner_w() + { + // 20 % of 600 = 120 px max-width. + let canvas = make_canvas(); + let col = column::<()>().padding( 0.0 ).max_width( Length::vmin( 20.0 ) ); + assert_eq!( col.inner_w( 200.0, &canvas ), 120.0 ); + } } diff --git a/src/layout/row.rs b/src/layout/row.rs index 273da49..ee77327 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. -use crate::types::Rect; +use crate::types::{ Length, Rect }; use crate::render::Canvas; use crate::widget::Element; @@ -23,11 +23,29 @@ use crate::widget::Element; /// .into() /// # } /// ``` +/// +/// `spacing` and `padding` accept any [`crate::Length`]: +/// +/// ```rust,no_run +/// # use std::sync::Arc; +/// # use ltk::{ icon_button, row, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg { A, B } +/// # fn _ex( a_rgba: Arc>, b_rgba: Arc>, w: u32, h: u32 ) -> Element { +/// row() +/// // 2 % of the viewport's smaller side, never below 8 px. +/// .spacing( Length::vmin( 2.0 ).at_least( 8.0 ) ) +/// .push( icon_button( a_rgba, w, h ).on_press( Msg::A ) ) +/// .push( icon_button( b_rgba, w, h ).on_press( Msg::B ) ) +/// .into() +/// # } +/// ``` pub struct Row { pub children: Vec>, - pub spacing: f32, - pub padding: f32, + /// Horizontal gap between children. [`Length`]; default `8.0` px. + pub spacing: Length, + /// Padding on all sides. [`Length`]; default `0.0` px. + pub padding: Length, pub align_right: bool, } @@ -35,7 +53,13 @@ impl Row { pub fn new() -> Self { - Self { children: Vec::new(), spacing: 8.0, padding: 0.0, align_right: false } + Self + { + children: Vec::new(), + spacing: Length::px( 8.0 ), + padding: Length::px( 0.0 ), + align_right: false, + } } /// Append a child widget or layout. @@ -45,20 +69,34 @@ impl Row self } - /// Set the horizontal gap between children in pixels. Default: `8.0`. - pub fn spacing( mut self, s: f32 ) -> Self + /// Set the horizontal gap between children. Default: `8.0` px. Accepts + /// any [`Length`] so the gap can scale with the viewport. + pub fn spacing( mut self, s: impl Into ) -> Self { - self.spacing = s; + self.spacing = s.into(); self } - /// Set the padding (all sides) in pixels. Default: `0.0`. - pub fn padding( mut self, p: f32 ) -> Self + /// Set the padding (all sides). Default: `0.0` px. Accepts any + /// [`Length`]. + pub fn padding( mut self, p: impl Into ) -> Self { - self.padding = p; + self.padding = p.into(); self } + #[ inline ] + fn resolved_spacing( &self, canvas: &Canvas ) -> f32 + { + self.spacing.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + + #[ inline ] + fn resolved_padding( &self, canvas: &Canvas ) -> f32 + { + self.padding.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + /// Push the content block to the right edge of the available width. pub fn align_right( mut self ) -> Self { @@ -69,16 +107,18 @@ impl Row /// Return the preferred `(width, height)` given available `max_width`. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32) { + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); // Width contribution of every fixed (non-flex, non-flex-spacer) child. // Used to compute the residual width that wrap-style children will // actually render in, so their reported height matches the layout. - let inner_w = ( max_width - self.padding * 2.0 ).max( 0.0 ); - let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let inner_w = ( max_width - pad * 2.0 ).max( 0.0 ); + let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32; let fixed_w: f32 = self.children.iter() .filter( |c| match c { Element::Flex( _ ) => false, - Element::Spacer( s ) => s.fixed_width.is_some(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_some(), _ => true, } ) .map( |c| c.preferred_size( max_width, canvas ).0 ) @@ -103,7 +143,7 @@ impl Row let has_flex = self.children.iter().any( |c| match c { Element::Flex( _ ) => true, - Element::Spacer( s ) => s.fixed_width.is_none(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_none(), _ => false, } ); @@ -115,11 +155,11 @@ impl Row .map( |c| c.preferred_size( max_width, canvas ).0 ) .sum::() + gaps - + self.padding * 2.0; + + pad * 2.0; total_w.min( max_width ) }; - ( w, max_h + self.padding * 2.0 ) + ( w, max_h + pad * 2.0 ) } pub fn draw( &self, _canvas: &mut Canvas, _rect: Rect, _focused: bool ) {} @@ -134,7 +174,9 @@ impl Row /// [`Row::align_right`]). pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)> { - let inner_h = rect.height - self.padding * 2.0; + let pad = self.resolved_padding( canvas ); + let spacing = self.resolved_spacing( canvas ); + let inner_h = rect.height - pad * 2.0; // Spacers and `Flex` wrappers report 0 width here; their real width // comes from the flex distribution below. @@ -142,7 +184,7 @@ impl Row .map( |c| c.preferred_size( rect.width, canvas ) ) .collect(); - let gaps = self.spacing * self.children.len().saturating_sub( 1 ) as f32; + let gaps = spacing * self.children.len().saturating_sub( 1 ) as f32; let fixed_w: f32 = self.children.iter().zip( sizes.iter() ) .filter( |( c, _ )| match c { @@ -151,7 +193,7 @@ impl Row // `Spacer::width(...)`-pinned spacers, contributes to the // fixed-width tally. Element::Flex( _ ) => false, - Element::Spacer( s ) => s.fixed_width.is_some(), + Element::Spacer( s ) => s.resolved_width( canvas ).is_some(), _ => true, } ) .map( |( _, ( w, _ ) )| *w ) @@ -159,13 +201,13 @@ impl Row let total_weight: u32 = self.children.iter() .filter_map( |c| match c { - Element::Spacer( s ) if s.fixed_width.is_none() => Some( s.weight ), + Element::Spacer( s ) if s.resolved_width( canvas ).is_none() => Some( s.weight ), Element::Flex( f ) => Some( f.weight ), _ => None, } ) .sum(); - let inner_w = ( rect.width - self.padding * 2.0 ).max( 0.0 ); + let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 ); let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 ); let has_spacers = total_weight > 0; @@ -173,15 +215,15 @@ impl Row { // Spacers and `Flex` wrappers claim the leftover; the cluster // sits flush to the left edge of the inner rect. - ( rect.x + self.padding, leftover / total_weight as f32 ) + ( rect.x + pad, leftover / total_weight as f32 ) } else if self.align_right { - ( rect.x + rect.width - (fixed_w + gaps) - self.padding, 0.0 ) + ( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 ) } else { - ( rect.x + (rect.width - fixed_w - gaps) / 2.0, 0.0 ) + ( rect.x + ( rect.width - fixed_w - gaps ) / 2.0, 0.0 ) }; let mut x = start_x; @@ -190,7 +232,7 @@ impl Row { let width = match child { - Element::Spacer( s ) => match s.fixed_width + Element::Spacer( s ) => match s.resolved_width( canvas ) { Some( fw ) => fw, None => flex_unit * s.weight as f32, @@ -198,9 +240,9 @@ impl Row Element::Flex( f ) => flex_unit * f.weight as f32, _ => w, }; - let y = rect.y + self.padding + (inner_h - h) / 2.0; + let y = rect.y + pad + ( inner_h - h ) / 2.0; result.push( ( Rect { x, y, width, height: h }, i ) ); - x += width + self.spacing; + x += width + spacing; } result } @@ -296,4 +338,37 @@ mod tests let rect = Rect { x: 0., y: 0., width: 400., height: 48. }; assert!( r.layout( rect, &canvas ).is_empty() ); } + + #[ test ] + fn vmin_padding_doubles_around_content() + { + // 800x600 canvas → vmin = 600. 4 % = 24 px each side → 48 px height. + let canvas = make_canvas(); + let r = row::<()>().padding( Length::vmin( 4.0 ) ); + let ( _, h ) = r.preferred_size( 500.0, &canvas ); + assert_eq!( h, 48.0 ); + } + + #[ test ] + fn vmin_spacing_pins_visible_layout_gap() + { + // Two fixed-width spacers separated by a vmin spacing of 5 %. + // Canvas vmin = 600 → 30 px between the inner edges. With both + // spacers 10 px wide, the second one's `x` minus the first one's + // `x + width` must equal the gap regardless of where the row chose + // to anchor the cluster (centered, since there are no flex spacers). + let canvas = make_canvas(); + let r = row::<()>() + .padding( 0.0 ) + .spacing( Length::vmin( 5.0 ) ) + .push( crate::spacer().width( 10.0 ) ) + .push( crate::spacer().width( 10.0 ) ); + let rect = Rect { x: 0., y: 0., width: 200., height: 48. }; + let placed = r.layout( rect, &canvas ); + assert_eq!( placed.len(), 2 ); + let ( first_rect, _ ) = placed[ 0 ]; + let ( second_rect, _ ) = placed[ 1 ]; + let gap = second_rect.x - ( first_rect.x + first_rect.width ); + assert!( ( gap - 30.0 ).abs() < 1e-3, "expected ~30 px gap, got {gap}" ); + } } diff --git a/src/layout/spacer.rs b/src/layout/spacer.rs index 4c682ba..ad7b825 100644 --- a/src/layout/spacer.rs +++ b/src/layout/spacer.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use crate::render::Canvas; +use crate::types::Length; use crate::widget::Element; /// A flexible, invisible spacer that expands to fill available space. @@ -44,7 +46,23 @@ use crate::widget::Element; /// # fn _ex() -> Element { /// column() /// .push( text( "Header" ) ) -/// .push( spacer().height( 20.0 ) ) // Exactly 20px gap +/// .push( spacer().height( 20.0 ) ) // Exactly 20 px gap +/// .push( text( "Content" ) ) +/// .into() +/// # } +/// ``` +/// +/// `.height(...)` and `.width(...)` also accept any [`crate::Length`], so the +/// gap can scale with the surface instead of being frozen at a px constant: +/// +/// ```rust,no_run +/// # use ltk::{ column, spacer, text, Length, Element }; +/// # #[ derive( Clone ) ] enum Msg {} +/// # fn _ex() -> Element { +/// column() +/// .push( text( "Header" ) ) +/// // 6 % of the surface's smaller side, never below 16 px or above 64 px. +/// .push( spacer().height( Length::vmin( 6.0 ).clamp( 16.0, 64.0 ) ) ) /// .push( text( "Content" ) ) /// .into() /// # } @@ -53,10 +71,14 @@ pub struct Spacer { /// Relative weight of this spacer (default 1). pub weight: u32, - /// Fixed height in pixels (overrides flexible behavior in a column). - pub fixed_height: Option, - /// Fixed width in pixels (overrides flexible behavior in a row). - pub fixed_width: Option, + /// Fixed height (overrides flexible behavior in a column). Accepts any + /// [`Length`] — pass an `f32`/`i32`/`u32` for the px case (kept for + /// backward compatibility with existing call sites), or + /// `Length::vmin( … )` etc. for viewport-relative gaps. + pub fixed_height: Option, + /// Fixed width (overrides flexible behavior in a row). Same length-type + /// semantics as [`Self::fixed_height`]. + pub fixed_width: Option, } impl Spacer @@ -68,34 +90,50 @@ impl Spacer self } - /// Set a fixed height for this spacer in pixels. - /// When set, the spacer will occupy exactly this much vertical space - /// instead of expanding flexibly. - pub fn height( mut self, h: f32 ) -> Self + /// Set a fixed height for this spacer. Accepts any [`Length`]: a bare + /// `24.0_f32` is treated as `Length::px( 24.0 )` for source-level + /// backwards compatibility; a `Length::vmin( 6.0 )` makes the gap + /// scale with the surface's smaller dimension. + pub fn height( mut self, h: impl Into ) -> Self { - self.fixed_height = Some( h ); + self.fixed_height = Some( h.into() ); self } - /// Set a fixed width for this spacer in pixels. - /// When set, the spacer occupies exactly this much horizontal space - /// inside a [`Row`](crate::layout::row::Row) instead of expanding - /// flexibly. Mirrors [`Self::height`] for the horizontal axis — useful - /// to reserve a precise visual margin while a sibling - /// [`Flex`](crate::Flex) claims the remaining width. - pub fn width( mut self, w: f32 ) -> Self + /// Set a fixed width for this spacer. Mirrors [`Self::height`] for the + /// horizontal axis. + pub fn width( mut self, w: impl Into ) -> Self { - self.fixed_width = Some( w ); + self.fixed_width = Some( w.into() ); self } - /// Returns `( fixed_width, fixed_height )`, falling back to `0.0` on the - /// axes that were not pinned. The parent layout distributes leftover - /// along its main axis among the still-flexible spacers and `Flex` - /// wrappers, weighted by `weight`. - pub fn preferred_size( &self ) -> (f32, f32) + /// Returns `( fixed_width, fixed_height )` resolved against the + /// current canvas viewport, falling back to `0.0` on axes that were + /// not pinned. The parent layout distributes leftover along its main + /// axis among the still-flexible spacers and `Flex` wrappers, + /// weighted by `weight`. + pub fn preferred_size( &self, canvas: &Canvas ) -> ( f32, f32 ) { - ( self.fixed_width.unwrap_or( 0.0 ), self.fixed_height.unwrap_or( 0.0 ) ) + let vp = canvas.viewport_logical(); + 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 ), + ) + } + + /// Resolved fixed height in logical pixels, or `None` when the + /// spacer is flex. Cheaper than [`Self::preferred_size`] when the + /// layout only needs the main-axis size for one orientation. + pub fn resolved_height( &self, canvas: &Canvas ) -> Option + { + self.fixed_height.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) + } + + pub fn resolved_width( &self, canvas: &Canvas ) -> Option + { + self.fixed_width.map( |l| l.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) ) } /// No-op — spacers are invisible. @@ -141,3 +179,47 @@ pub fn spacer() -> Spacer { Spacer { weight: 1, fixed_height: None, fixed_width: None } } + +#[ cfg( test ) ] +mod tests +{ + use super::*; + use crate::render::Canvas; + + fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + + #[ test ] + fn height_accepts_f32_as_pixels() + { + let s = spacer().height( 24.0 ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), Some( 24.0 ) ); + } + + #[ test ] + fn height_accepts_length_and_resolves_against_viewport() + { + // 10 % of the smaller side (= 600) = 60 px. + let s = spacer().height( Length::vmin( 10.0 ) ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), Some( 60.0 ) ); + } + + #[ test ] + fn width_accepts_length_and_resolves_against_viewport() + { + let s = spacer().width( Length::vw( 25.0 ) ); + let canvas = make_canvas(); + // 25 % of 800 = 200. + assert_eq!( s.resolved_width( &canvas ), Some( 200.0 ) ); + } + + #[ test ] + fn flex_spacer_has_no_resolved_dimensions() + { + let s = spacer().weight( 3 ); + let canvas = make_canvas(); + assert_eq!( s.resolved_height( &canvas ), None ); + assert_eq!( s.resolved_width( &canvas ), None ); + } +} diff --git a/src/layout/stack.rs b/src/layout/stack.rs index c7b13b4..b7eb404 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -128,7 +128,7 @@ impl Stack let content_w = self.children.iter() .map( |( c, _, _, _, _, _ )| match c { - Element::Spacer( s ) => s.fixed_width.unwrap_or( 0.0 ), + Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ), Element::Separator( _ ) => 0.0, Element::Scroll( _ ) => 0.0, Element::ProgressBar( _ ) => 0.0, diff --git a/src/lib.rs b/src/lib.rs index fd6d3b1..5b1b2c5 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,9 +107,36 @@ //! Geometry and primitive values that flow through every builder: //! //! - [`Color`], [`Rect`], [`Point`], [`Size`], [`Corners`], [`WidgetId`]. +//! - [`Length`] — a size/distance that may be absolute pixels +//! ([`LengthBase::Px`]) or relative to the surface viewport +//! ([`LengthBase::Vw`] / [`LengthBase::Vh`] / [`LengthBase::Vmin`] / +//! [`LengthBase::Vmax`]) or to the root font size ([`LengthBase::Em`]). +//! Every setter that takes a size, padding, spacing or font height +//! now accepts `impl Into`, 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 that scales with the screen. //! //! See [`types`] for the full module with `//!` description. //! +//! ## Designing for multiple resolutions +//! +//! ltk supports three approaches to making a layout adapt to the screen, +//! ordered from most to least common: +//! +//! 1. **Relative [`Length`] values** for font sizes, padding, spacing and +//! spacer height/width. Default to `Length::vmin( pct ).clamp( lo, hi )`: +//! the percentage tracks the surface's smaller side (so portrait and +//! landscape behave coherently), and the px clamps protect both ends +//! of the spectrum from breaking the design. +//! 2. **Responsive typographic scale** via [`theme::typography::h0`]… +//! [`theme::typography::body_xs`] — pre-calibrated [`Length`] values +//! coherent with the default Sora-based theme. Calibrated so a +//! 1000-px smaller side reproduces the legacy px constants exactly. +//! 3. **`view()`-level branching on `surface_width` / `surface_height`** +//! when the layout structure itself must change (e.g. sidebar → +//! bottom-tabs below a breakpoint). Keep this for genuine restructuring, +//! not for sizing — `Length` covers sizing. +//! //! ## Runtime-free embedding //! //! Use [`core::UiSurface`] when you need ltk's layout, drawing and @@ -202,7 +229,7 @@ pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as th pub use gles_render::{ BorrowedGlesTexture, GlesVersion }; pub use render::is_software_render; pub use wallpaper::{ WallpaperBundle, ImageData }; -pub use types::{ Color, Corners, CursorShape, Point, Rect, Size, WidgetId }; +pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId }; pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container }; pub use widget::button::ButtonVariant; pub use widget::slider::{ Slider, slider, SliderAxis }; diff --git a/src/render/mod.rs b/src/render/mod.rs index d96ff63..77e57b4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -199,6 +199,31 @@ impl Canvas } } + /// `(width, height)` of the surface in **logical** pixels (physical + /// size divided by `dpi_scale`). This is the right viewport for + /// resolving [`crate::Length`] values, which are themselves in + /// logical units. Falls back to physical size if `dpi_scale` is + /// 0 or negative so a misconfigured canvas still returns a usable + /// non-zero viewport instead of `NaN`/`inf`. + /// + /// ```rust,no_run + /// # use ltk::core::Canvas; + /// let mut c = Canvas::new( 720, 1440 ); + /// c.set_dpi_scale( 2.0 ); + /// assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); + /// ``` + pub fn viewport_logical( &self ) -> ( f32, f32 ) + { + let ( pw, ph ) = self.size(); + let scale = self.dpi_scale(); + if scale > 0.0 + { + ( pw as f32 / scale, ph as f32 / scale ) + } else { + ( pw as f32, ph as f32 ) + } + } + /// Borrow the GLES texture backing this canvas, when the canvas /// is GPU-backed. pub fn borrowed_gles_texture( &self ) -> Option @@ -642,3 +667,34 @@ impl Canvas } } } + +#[ cfg( test ) ] +mod viewport_tests +{ + use super::Canvas; + + #[ test ] + fn viewport_logical_at_scale_one_matches_physical() + { + let c = Canvas::new( 800, 600 ); + assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); + } + + #[ test ] + fn viewport_logical_divides_by_dpi_scale() + { + let mut c = Canvas::new( 720, 1440 ); + c.set_dpi_scale( 2.0 ); + assert_eq!( c.viewport_logical(), ( 360.0, 720.0 ) ); + } + + #[ test ] + fn viewport_logical_falls_back_to_physical_when_scale_is_zero() + { + // Guard the misconfigured-scale path: a divide-by-zero would + // poison every `Length::Vmin`/`Vw`/`Vh` resolution downstream. + let mut c = Canvas::new( 800, 600 ); + c.set_dpi_scale( 0.0 ); + assert_eq!( c.viewport_logical(), ( 800.0, 600.0 ) ); + } +} diff --git a/src/theme/typography.rs b/src/theme/typography.rs index 30532af..f786a27 100644 --- a/src/theme/typography.rs +++ b/src/theme/typography.rs @@ -6,6 +6,17 @@ //! Designed around the **Sora** typeface (Google Fonts). If Sora is not //! installed on the system, ltk falls back to Liberation Sans / DejaVu Sans; //! glyph metrics will differ slightly but the scale still reads correctly. +//! +//! Two scales coexist: the historic **px** constants (`H0` through +//! `BODY_XS`) for code that wants a frozen pixel size, and the new +//! **responsive** scale ([`h0`], [`h1`], …, [`body_xs`]) that returns +//! viewport-relative [`crate::Length`] values clamped to the same px +//! range that used to be the constant. Call sites can mix freely: +//! `.size( typography::H2 )` still resolves to `Length::px( 24.0 )` via +//! `From`, while `.size( typography::h2() )` scales with the +//! surface's smaller dimension. + +use crate::types::Length; pub const H0: f32 = 50.0; pub const H1: f32 = 34.0; @@ -18,3 +29,59 @@ pub const BODY_XS: f32 = 12.0; /// Interlineado (line-height) multiplier recommended by the kit. Apply as /// `size * LINE_HEIGHT` when laying out multi-line text blocks. pub const LINE_HEIGHT: f32 = 1.5; + +// ─── Responsive scale ──────────────────────────────────────────────────────── +// +// The percentages are calibrated so the px clamps match the legacy constants +// at a 1000 px logical smaller side (a typical landscape tablet). On a Librem 5 +// portrait (~720 px) headings round down sensibly; on a 4K desktop the upper +// clamp kicks in before display titles get absurd. + +pub fn h0() -> Length { Length::vmin( 5.0 ).clamp( 32.0, 80.0 ) } +pub fn h1() -> Length { Length::vmin( 3.4 ).clamp( 24.0, 56.0 ) } +pub fn h2() -> Length { Length::vmin( 2.4 ).clamp( 18.0, 40.0 ) } +pub fn h3() -> Length { Length::vmin( 2.0 ).clamp( 16.0, 32.0 ) } +pub fn body() -> Length { Length::vmin( 1.6 ).clamp( 14.0, 22.0 ) } +pub fn body_s() -> Length { Length::vmin( 1.4 ).clamp( 12.0, 18.0 ) } +pub fn body_xs() -> Length { Length::vmin( 1.2 ).clamp( 11.0, 15.0 ) } + +#[ cfg( test ) ] +mod tests +{ + use super::*; + + /// 360-px-wide portrait phone — Vmin ratio under the clamp's lower bound, + /// so every scale snaps to its `min_px`. + #[ test ] + fn responsive_scale_pins_to_min_on_narrow_phones() + { + let vp = ( 360.0, 720.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 32.0 ); + assert_eq!( body().resolve( vp, em ), 14.0 ); + assert_eq!( body_xs().resolve( vp, em ), 11.0 ); + } + + /// 1000-px smaller side — the calibration point. Numbers should be + /// "around" the legacy px constants without exceeding the upper clamp. + #[ test ] + fn responsive_scale_centers_around_legacy_px_constants() + { + let vp = ( 1000.0, 1000.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 50.0 ); + assert_eq!( h2().resolve( vp, em ), 24.0 ); + assert_eq!( body().resolve( vp, em ), 16.0 ); + } + + /// 4K-class smaller side — every scale should saturate to its upper + /// clamp instead of growing absurdly. + #[ test ] + fn responsive_scale_pins_to_max_on_large_displays() + { + let vp = ( 2160.0, 3840.0 ); + let em = Length::EM_BASE_DEFAULT; + assert_eq!( h0().resolve( vp, em ), 80.0 ); + assert_eq!( body().resolve( vp, em ), 22.0 ); + } +} diff --git a/src/types.rs b/src/types.rs index 5475a84..823fa94 100644 --- a/src/types.rs +++ b/src/types.rs @@ -430,3 +430,226 @@ mod tests assert_eq!( r, e ); } } + +// ─── Length ────────────────────────────────────────────────────────────────── + +/// One of the pure relative-or-absolute modes a [`Length`] can carry. +/// Split out so [`Length`] itself can stay `Copy` while still supporting +/// optional clamp bounds — the recursive `Clamp` variant of the original +/// sketch would have forced a `Box` allocation, which on a widget tree +/// that builds these values per frame is the wrong trade. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub enum LengthBase +{ + /// Absolute, in logical pixels. + Px( f32 ), + /// Percentage of the viewport's width (`Vw(10.0)` == 10 % of width). + Vw( f32 ), + /// Percentage of the viewport's height. + Vh( f32 ), + /// Percentage of the viewport's **smaller** dimension. The right + /// default for typography and gutters that must survive a + /// portrait/landscape rotation without growing absurd. + Vmin( f32 ), + /// Percentage of the viewport's **larger** dimension. + Vmax( f32 ), + /// Multiple of the root font size (typographic hierarchy: a heading + /// of `Em(2.0)` is twice the body size, regardless of viewport). + Em( f32 ), +} + +impl LengthBase +{ + fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 + { + let ( vw, vh ) = viewport; + match self + { + LengthBase::Px( v ) => *v, + LengthBase::Vw( pct ) => vw * pct / 100.0, + LengthBase::Vh( pct ) => vh * pct / 100.0, + LengthBase::Vmin( pct ) => vw.min( vh ) * pct / 100.0, + LengthBase::Vmax( pct ) => vw.max( vh ) * pct / 100.0, + LengthBase::Em( mul ) => em_base * mul, + } + } +} + +/// A size or distance value that may be expressed in absolute pixels or +/// relative to the rendering surface. Every widget API that used to take +/// `f32` for a size, padding, spacing or font height now takes +/// `impl Into`, so existing call sites keep compiling unchanged +/// while new code can switch to viewport-relative units for layouts that +/// must scale across screen sizes (portrait phone, landscape tablet, +/// 4K desktop) without per-target tweaks. +/// +/// 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. +/// +/// Construct directly via the [`LengthBase`] variants +/// (`Length::vmin( 18.0 )`, `Length::px( 24.0 )`, …) or implicitly from +/// `f32`/`i32`/`u32` for the px case so legacy `.size( 24.0 )` style +/// keeps compiling unchanged. Optionally chain `.clamp( min_px, max_px )` +/// to bound a relative value into a safe range. +#[ derive( Debug, Clone, Copy, PartialEq ) ] +pub struct Length +{ + pub base: LengthBase, + /// Lower bound in absolute logical px. `None` means unbounded. + pub min_px: Option, + /// Upper bound in absolute logical px. `None` means unbounded. + pub max_px: Option, +} + +impl Length +{ + /// Default font-size that [`LengthBase::Em`] is a multiple of. Matches + /// the `typography::BODY` constant of the default theme. + pub const EM_BASE_DEFAULT: f32 = 16.0; + + pub const fn from_base( base: LengthBase ) -> Self + { + Self { base, min_px: None, max_px: None } + } + + /// Shorthand constructors. `Length::vmin( 18.0 )` reads better than + /// `Length::from_base( LengthBase::Vmin( 18.0 ) )` at every call site + /// and the brevity matters when these appear in tight view code. + pub const fn px( v: f32 ) -> Self { Self::from_base( LengthBase::Px( v ) ) } + pub const fn vw( v: f32 ) -> Self { Self::from_base( LengthBase::Vw( v ) ) } + pub const fn vh( v: f32 ) -> Self { Self::from_base( LengthBase::Vh( v ) ) } + pub const fn vmin( v: f32 ) -> Self { Self::from_base( LengthBase::Vmin( v ) ) } + pub const fn vmax( v: f32 ) -> Self { Self::from_base( LengthBase::Vmax( v ) ) } + pub const fn em( v: f32 ) -> Self { Self::from_base( LengthBase::Em( v ) ) } + + /// Resolve to a concrete logical-pixel value given a viewport and an + /// `em_base` (the root font size that `Em` is a fraction of). + pub fn resolve( &self, viewport: ( f32, f32 ), em_base: f32 ) -> f32 + { + let raw = self.base.resolve( viewport, em_base ); + let lo = self.min_px; + let hi = self.max_px; + // If both bounds present, normalise their order so swapped args + // don't produce NaN out of f32::clamp. + let ( lo, hi ) = match ( lo, hi ) + { + ( Some( a ), Some( b ) ) if a > b => ( Some( b ), Some( a ) ), + other => other, + }; + let v = match lo { Some( a ) => raw.max( a ), None => raw }; + match hi { Some( b ) => v.min( b ), None => v } + } + + /// Cap the resolved value to `[min_px, max_px]`. Bounds are + /// absolute px because the typical use is "this Vmin should never + /// shrink past readable nor balloon past comfortable"; bounding + /// a relative value with another relative value is rare enough to + /// not justify boxing the type. If you swap min/max the resolver + /// tolerates it instead of panicking. + pub fn clamp( mut self, min_px: f32, max_px: f32 ) -> Length + { + self.min_px = Some( min_px ); + self.max_px = Some( max_px ); + self + } + + /// One-sided bound: never resolve below `min_px`. Named `at_least` + /// (rather than `min`) to avoid clashing visually with `f32::min`, + /// which has the opposite semantics ("return the smaller of two"). + pub fn at_least( mut self, min_px: f32 ) -> Length + { + self.min_px = Some( min_px ); + self + } + + /// One-sided bound: never resolve above `max_px`. Counterpart to + /// [`Self::at_least`]. + pub fn at_most( mut self, max_px: f32 ) -> Length + { + self.max_px = Some( max_px ); + self + } +} + +impl From for Length +{ + fn from( v: f32 ) -> Self { Length::px( v ) } +} + +impl From for Length +{ + fn from( v: i32 ) -> Self { Length::px( v as f32 ) } +} + +impl From for Length +{ + fn from( v: u32 ) -> Self { Length::px( v as f32 ) } +} + +impl From for Length +{ + fn from( base: LengthBase ) -> Self { Length::from_base( base ) } +} + +#[ cfg( test ) ] +mod length_tests +{ + use super::Length; + + #[ test ] + fn px_is_passthrough() + { + assert_eq!( Length::px( 42.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 42.0 ); + } + + #[ test ] + fn vw_vh_are_percent_of_viewport() + { + assert_eq!( Length::vw( 50.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 400.0 ); + assert_eq!( Length::vh( 25.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 150.0 ); + } + + #[ test ] + fn vmin_picks_smaller_side() + { + assert_eq!( Length::vmin( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 60.0 ); + assert_eq!( Length::vmin( 10.0 ).resolve( ( 600.0, 800.0 ), 16.0 ), 60.0 ); + } + + #[ test ] + fn vmax_picks_larger_side() + { + assert_eq!( Length::vmax( 10.0 ).resolve( ( 800.0, 600.0 ), 16.0 ), 80.0 ); + } + + #[ test ] + fn em_uses_em_base() + { + assert_eq!( Length::em( 2.0 ).resolve( ( 800.0, 600.0 ), 18.0 ), 36.0 ); + } + + #[ test ] + fn clamp_bounds_relative_value() + { + // 50 % of the smaller side (= 300) capped to [100, 200] → 200. + let l = Length::vmin( 50.0 ).clamp( 100.0, 200.0 ); + assert_eq!( l.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 ); + + // 1 % of the smaller side (= 6) lifted to the min of 50. + let l2 = Length::vmin( 1.0 ).clamp( 50.0, 200.0 ); + assert_eq!( l2.resolve( ( 800.0, 600.0 ), 16.0 ), 50.0 ); + + // Caller swapped min/max — resolver tolerates without panic. + let l3 = Length::vmin( 50.0 ).clamp( 200.0, 100.0 ); + assert_eq!( l3.resolve( ( 800.0, 600.0 ), 16.0 ), 200.0 ); + } + + #[ test ] + fn f32_converts_to_px() + { + let l: Length = 24.0_f32.into(); + assert_eq!( l.base, super::LengthBase::Px( 24.0 ) ); + } +} diff --git a/src/widget/element.rs b/src/widget/element.rs index 9317cbf..7c834f1 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -56,7 +56,7 @@ impl Element Element::Row( r ) => r.preferred_size( max_width, canvas ), Element::Stack( s ) => s.preferred_size( max_width, canvas ), Element::Text( t ) => t.preferred_size( max_width, canvas ), - Element::Spacer( s ) => s.preferred_size(), + Element::Spacer( s ) => s.preferred_size( canvas ), Element::Scroll( s ) => s.preferred_size( max_width, canvas ), Element::Viewport( v ) => v.preferred_size( max_width, canvas ), Element::WrapGrid( g ) => g.preferred_size( max_width, canvas ), diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 271f46d..c416a89 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use fontdue::Font; use crate::theme::FontStyle; -use crate::types::{ Color, Rect }; +use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; use super::Element; @@ -24,7 +24,10 @@ pub enum TextAlign pub struct Text { pub content: String, - pub size: f32, + /// Font size as a [`Length`]. Resolved against the surface's logical + /// viewport at layout time, so a `Length::Vmin( 5.0 )` heading scales + /// with the screen instead of being frozen at a px constant. + pub size: Length, pub color: Color, pub align: TextAlign, pub wrap: bool, @@ -47,7 +50,7 @@ impl Text Self { content: content.into(), - size: 16.0, + size: Length::px( 16.0 ), color: Color::WHITE, align: TextAlign::Left, wrap: false, @@ -56,6 +59,15 @@ impl Text } } + /// Resolve the font size against the canvas viewport. Internal + /// helper: every method that needs an `f32` size for a `fontdue` + /// call routes through this so the field can stay a `Length`. + #[ inline ] + fn resolved_size( &self, canvas: &Canvas ) -> f32 + { + self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT ) + } + pub fn no_truncate( mut self ) -> Self { self.truncate = false; @@ -81,9 +93,9 @@ impl Text self } - pub fn size( mut self, s: f32 ) -> Self + pub fn size( mut self, s: impl Into ) -> Self { - self.size = s; + self.size = s.into(); self } @@ -126,41 +138,49 @@ impl Text fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> f32 { + let size = self.resolved_size( canvas ); match font { - Some( f ) => canvas.measure_text_with_font( text, self.size, f ), - None => canvas.measure_text( text, self.size ), + Some( f ) => canvas.measure_text_with_font( text, size, f ), + None => canvas.measure_text( text, size ), } } fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc> ) -> f32 { + let size = self.resolved_size( canvas ); match font { - Some( f ) => f.metrics( ch, self.size * canvas.dpi_scale() ).advance_width, - None => canvas.font_metrics( ch, self.size ).advance_width, + Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width, + None => canvas.font_metrics( ch, size ).advance_width, } } fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc> ) { + let size = self.resolved_size( canvas ); match font { - Some( f ) => canvas.draw_text_with_font( text, x, y, self.size, self.color, f ), - None => canvas.draw_text( text, x, y, self.size, self.color ), + Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ), + None => canvas.draw_text( text, x, y, size, self.color ), } } pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { - let line_h = canvas.font_line_metrics( self.size ) - .map( |m| m.ascent - m.descent ) - .unwrap_or( self.size ); + let size = self.resolved_size( canvas ); + // `new_line_size = ascent - descent + line_gap` is the standard + // typographic line height; the previous `ascent - descent` discarded + // the font-declared leading and at large sizes left adjacent rows + // visibly overlapping when stacked tight in a column. + let line_h = canvas.font_line_metrics( size ) + .map( |m| m.new_line_size ) + .unwrap_or( size ); let font = self.resolve_font( canvas ); if self.wrap { - let lines = wrap_lines( &self.content, self.size, max_width, canvas, font.as_ref() ); + let lines = wrap_lines( &self.content, size, max_width, canvas, font.as_ref() ); let h = line_h * lines.len().max( 1 ) as f32; ( max_width, h ) } @@ -173,17 +193,18 @@ impl Text pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) { - let ascent = canvas.font_line_metrics( self.size ) + let size = self.resolved_size( canvas ); + let ascent = canvas.font_line_metrics( size ) .map( |m| m.ascent ) - .unwrap_or( self.size * 0.8 ); - let line_h = canvas.font_line_metrics( self.size ) - .map( |m| m.ascent - m.descent ) - .unwrap_or( self.size ); + .unwrap_or( size * 0.8 ); + let line_h = canvas.font_line_metrics( size ) + .map( |m| m.new_line_size ) + .unwrap_or( size ); let font = self.resolve_font( canvas ); if self.wrap { - let lines = wrap_lines( &self.content, self.size, rect.width, canvas, font.as_ref() ); + let lines = wrap_lines( &self.content, size, rect.width, canvas, font.as_ref() ); for ( i, line ) in lines.iter().enumerate() { let line_w = self.measure( line, canvas, font.as_ref() ); diff --git a/src/widget/text/tests.rs b/src/widget/text/tests.rs index 88fe232..489df37 100644 --- a/src/widget/text/tests.rs +++ b/src/widget/text/tests.rs @@ -11,7 +11,7 @@ fn new_uses_documented_defaults() { let t = Text::new( "hello" ); assert_eq!( t.content, "hello" ); - assert_eq!( t.size, 16.0 ); + assert_eq!( t.size, crate::Length::px( 16.0 ) ); assert_eq!( t.color, Color::WHITE ); assert_eq!( t.align, TextAlign::Left ); } @@ -20,7 +20,14 @@ fn new_uses_documented_defaults() fn size_builder_overrides_default() { let t = Text::new( "" ).size( 32.0 ); - assert_eq!( t.size, 32.0 ); + assert_eq!( t.size, crate::Length::px( 32.0 ) ); +} + +#[ test ] +fn size_builder_accepts_length() +{ + let t = Text::new( "" ).size( crate::Length::vmin( 5.0 ) ); + assert_eq!( t.size.base, crate::LengthBase::Vmin( 5.0 ) ); } #[ test ] diff --git a/tests/layout_stack_spacer.rs b/tests/layout_stack_spacer.rs index b0706ee..a4eff53 100644 --- a/tests/layout_stack_spacer.rs +++ b/tests/layout_stack_spacer.rs @@ -2,7 +2,7 @@ // the way `Spacer` distributes leftover space inside a `Column`. use ltk::core::Canvas; -use ltk::{ stack, spacer, HAlign, VAlign, Rect }; +use ltk::{ stack, spacer, HAlign, Length, VAlign, Rect }; fn make_canvas() -> Canvas { @@ -181,29 +181,32 @@ fn stack_empty_preferred_size_height_is_zero() #[ test ] fn spacer_default_has_weight_one_and_no_fixed_size() { + let canvas = make_canvas(); let s = spacer(); assert_eq!( s.weight, 1 ); assert!( s.fixed_height.is_none() ); assert!( s.fixed_width.is_none() ); - assert_eq!( s.preferred_size(), ( 0.0, 0.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 0.0, 0.0 ) ); } #[ test ] fn spacer_height_pins_vertical_axis_only() { + let canvas = make_canvas(); let s = spacer().height( 24.0 ); - assert_eq!( s.fixed_height, Some( 24.0 ) ); + assert_eq!( s.fixed_height, Some( Length::px( 24.0 ) ) ); assert!( s.fixed_width.is_none() ); - assert_eq!( s.preferred_size(), ( 0.0, 24.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 0.0, 24.0 ) ); } #[ test ] fn spacer_width_pins_horizontal_axis_only() { + let canvas = make_canvas(); let s = spacer().width( 12.0 ); - assert_eq!( s.fixed_width, Some( 12.0 ) ); + assert_eq!( s.fixed_width, Some( Length::px( 12.0 ) ) ); assert!( s.fixed_height.is_none() ); - assert_eq!( s.preferred_size(), ( 12.0, 0.0 ) ); + assert_eq!( s.preferred_size( &canvas ), ( 12.0, 0.0 ) ); } #[ test ]