diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9326f..d809606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Changed +- **`Button::icon_size` takes `impl Into`** and resolves through `Canvas::resolve_geom`, joining `font_size` / `height` / `width`. A bare `f32` still means `Length::px` — every existing call keeps its exact size — but a caller can now pass `Length::widget( n )` to have an icon button follow the widget-scaling mode the way stock icons do. Without it an explicit `icon_size` was the one geometry setter that ignored the mode, so a 21 px back arrow sat next to a `list_item` chevron that fluid sizing had grown well past 21 and looked visibly smaller. - **`Length::dp` now applies the density at resolution time, not at construction.** The value carries its design pixels in a new `LengthBase::Dp` variant and `resolve` multiplies by the density in effect when it runs, so a `set_density` change takes effect on the next paint without rebuilding the view's lengths — previously a `dp` value was frozen to the density read when it was constructed. Behaviour is unchanged for code that sets density once at startup. - **The GLES image texture cache is now bounded** to 32 MiB of estimated GPU memory with least-recently-drawn eviction (the in-use entry is never evicted). Previously it grew without limit for the canvas' lifetime, so a stream of distinct buffers (photo carousel, video thumbnails) could exhaust GPU memory. - **`OverlaySpec::size`** is now `( Length, Length )` (was `( u32, u32 )`), resolved against the main surface when the overlay is materialized; wrap existing sizes in `Length::px( … )` for the old fixed behaviour. @@ -42,6 +43,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Fixed +- **Rows no longer spill their children out of the layout.** `Row` distributed leftover width but had no notion of a shortfall: `leftover` floored at zero, so a cluster wider than its rect kept every preferred width and overflowed — symmetrically, since the no-spacer branch centres it, which is why both end labels of a segmented control were clipped at once. The shortfall now comes off the widest children first (water filling), so a long title gives up its width while the icon beside it keeps its size, and equal siblings — a segmented control — share it evenly; pinned spacers keep their width, and `Row::no_shrink()` opts out for strips deliberately wider than their viewport. `Button` elides its label into the rect it is granted, so shrinking degrades to a truncated label rather than clipped glyphs; the truncation rule moves to one crate-internal helper shared with `ListItem`. - **`ListItem` labels no longer run under the trailing slot.** The label and subtitle were painted with no width limit, so a title longer than the row overlapped the trailing text or the disclosure icon instead of truncating. The trailing slots are now laid out first and both lines are elided with an ellipsis against the space they leave, matching what `Text` already did. - **Text inputs no longer insert mid-string when the value grows after focus.** Focusing a text input pinned the cursor to a snapshot of `value.len()`; if the value kept growing without the widget seeing keystrokes (a field fed over IPC), the first normally-delivered key inserted at the stale position. The focus-time cursor is now an end-of-value sentinel that every consumer clamps to the current value length, collapsing to a concrete position on the first real keystroke or click. - **Single-line caret height now follows the text line.** The caret spanned `rect.height - 16`, so a field taller than its text line grew an oversized caret; it now measures `font_size + 4` and is vertically centered like the text, matching the multiline caret. diff --git a/docs/widgets.md b/docs/widgets.md index 260f46f..4003506 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -78,6 +78,11 @@ icon_button( rgba_bytes, w, h ).on_press( Msg::OpenSearch ) # } ``` +`icon_size( n )` sets the drawn size. A bare number is an absolute pixel +count; pass `Length::widget( n )` instead to follow the widget-scaling +mode, which is what stock icons (the [`list_item`](#list_item) disclosure +arrow) already do — use it when the two sit side by side and should match. + **See also**: [`button`](#button), [`window_button`](#window_button) (specialised icon button for window decorations). @@ -954,6 +959,15 @@ row() # } ``` +When the children do not fit, the row takes the shortfall off the widest +ones first, so a long label gives up its width while a narrow neighbour +(an icon button beside a title) keeps its size, and equal siblings share +it evenly. Leaves that paint a line of text (`button`, `list_item`) +truncate with an ellipsis into what they are given. Pinned spacers keep +their width — an explicit gap is a decision, not slack. Use +`no_shrink()` for strips that are meant to be wider than their viewport +and scrolled. + ### `stack` Z-order overlay. Each child gets explicit horizontal and vertical diff --git a/src/layout/row.rs b/src/layout/row.rs index f2c207b..f7df914 100644 --- a/src/layout/row.rs +++ b/src/layout/row.rs @@ -49,6 +49,9 @@ pub struct Row pub( crate ) align_right: bool, pub( crate ) align_top: bool, pub( crate ) fill_height: bool, + /// When false the row keeps every child at its preferred width even if + /// the cluster overflows. Default true. + pub( crate ) shrink: bool, } impl Row @@ -63,6 +66,7 @@ impl Row align_right: false, align_top: false, fill_height: false, + shrink: true, } } @@ -101,6 +105,19 @@ impl Row canvas.resolve_geom( self.padding ) } + /// Keep every child at its preferred width even when the cluster does not + /// fit, letting it overflow the row's rect. + /// + /// The default distributes the shortfall instead, which is what a row of + /// labelled buttons wants. Opt out for strips that are deliberately wider + /// than their viewport — a carousel rail, a tray meant to be scrolled — + /// where shrinking to fit would defeat the point. + pub fn no_shrink( mut self ) -> Self + { + self.shrink = false; + self + } + /// Push the content block to the right edge of the available width. pub fn align_right( mut self ) -> Self { @@ -231,8 +248,28 @@ impl Row } ) .sum(); - let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 ); - let leftover = ( inner_w - fixed_w - gaps ).max( 0.0 ); + let inner_w = ( rect.width - pad * 2.0 ).max( 0.0 ); + + // Deficit distribution: cap the widest children first, so a long + // label gives up its width before a neighbouring icon does. A uniform + // scale-down would thin a header's back arrow along with its title. + // Spacers keep the width they were pinned to — an explicit gap is a + // decision, not slack. + let mut flexible: Vec = self.children.iter().zip( sizes.iter() ) + .filter( |( c, _ )| !matches!( c, Element::Flex( _ ) | Element::Spacer( _ ) ) ) + .map( |( _, ( w, _ ) )| *w ) + .collect(); + let flexible_w: f32 = flexible.iter().sum(); + let rigid_w = ( fixed_w - flexible_w ).max( 0.0 ); + let cap = if self.shrink + { + width_cap( &mut flexible, ( inner_w - gaps - rigid_w ).max( 0.0 ) ) + } else { + f32::INFINITY + }; + + let laid_w = rigid_w + flexible.iter().map( |w| w.min( cap ) ).sum::(); + let leftover = ( inner_w - laid_w - gaps ).max( 0.0 ); let has_spacers = total_weight > 0; let ( start_x, flex_unit ) = if has_spacers @@ -243,11 +280,11 @@ impl Row } else if self.align_right { - ( rect.x + rect.width - ( fixed_w + gaps ) - pad, 0.0 ) + ( rect.x + rect.width - ( laid_w + gaps ) - pad, 0.0 ) } else { - ( rect.x + ( rect.width - fixed_w - gaps ) / 2.0, 0.0 ) + ( rect.x + ( rect.width - laid_w - gaps ) / 2.0, 0.0 ) }; let mut x = start_x; @@ -262,7 +299,7 @@ impl Row None => flex_unit * s.weight as f32, }, Element::Flex( f ) => flex_unit * f.weight as f32, - _ => w, + _ => w.min( cap ), }; let ( y, height ) = if self.fill_height && !matches!( child, Element::Spacer( _ ) ) { @@ -291,10 +328,40 @@ impl Row align_right: self.align_right, align_top: self.align_top, fill_height: self.fill_height, + shrink: self.shrink, } } } +/// Largest per-child width `c` for which `sum( min( w, c ) ) <= available`. +/// +/// Water filling: the widest children give up width first and the narrow ones +/// are left alone until the wide ones have been levelled down to them. In a +/// header that means the title absorbs the whole shortfall while the back +/// arrow beside it keeps its size. Sorts `widths` in place; returns infinity +/// when everything already fits. +fn width_cap( widths: &mut [f32], available: f32 ) -> f32 +{ + if widths.iter().sum::() <= available + { + return f32::INFINITY; + } + widths.sort_by( |a, b| a.partial_cmp( b ).unwrap_or( std::cmp::Ordering::Equal ) ); + + let mut prefix = 0.0_f32; + for ( i, w ) in widths.iter().enumerate() + { + let remaining = ( widths.len() - i ) as f32; + let cap = ( available - prefix ) / remaining; + if cap <= *w + { + return cap.max( 0.0 ); + } + prefix += *w; + } + 0.0 +} + /// Create an empty row layout. pub fn row() -> Row { @@ -318,6 +385,75 @@ mod tests fn make_canvas() -> Canvas { Canvas::new( 800, 600 ) } + /// Children whose preferred widths overflow the rect are scaled down to + /// fit, rather than spilling symmetrically out of it. + #[ test ] + fn overflowing_children_shrink_to_fit() + { + let canvas = make_canvas(); + let build = || row::<()>() + .spacing( 0.0 ) + .padding( 0.0 ) + .push( crate::toggle( false ) ) + .push( crate::toggle( false ) ); + + let natural = build().preferred_size( 10_000.0, &canvas ).0; + assert!( natural > 0.0, "toggles must report a width for this to test anything" ); + let rect = Rect { x: 0.0, y: 0.0, width: natural / 2.0, height: 48.0 }; + + let laid: f32 = build().layout( rect, &canvas ) + .iter().map( |( r, _ )| r.width ).sum(); + assert!( laid <= rect.width + 0.5, "laid out {laid} into {}", rect.width ); + + let kept: f32 = build().no_shrink().layout( rect, &canvas ) + .iter().map( |( r, _ )| r.width ).sum(); + assert!( kept > rect.width, "no_shrink kept {kept}, expected wider than {}", rect.width ); + } + + /// The shortfall comes off the widest child first: a narrow neighbour — + /// a header's back arrow next to its title — keeps its size. + #[ test ] + fn narrow_child_survives_a_wide_neighbour() + { + let mut w = vec![ 20.0_f32, 300.0 ]; + let cap = width_cap( &mut w, 200.0 ); + assert_eq!( 20.0_f32.min( cap ), 20.0 ); + assert_eq!( 300.0_f32.min( cap ), 180.0 ); + } + + /// Equal children share the shortfall equally, which is what a segmented + /// control wants. + #[ test ] + fn equal_children_shrink_equally() + { + let mut w = vec![ 100.0_f32, 100.0, 100.0 ]; + let cap = width_cap( &mut w, 150.0 ); + assert_eq!( cap, 50.0 ); + } + + #[ test ] + fn nothing_shrinks_when_it_all_fits() + { + let mut w = vec![ 10.0_f32, 20.0 ]; + assert_eq!( width_cap( &mut w, 100.0 ), f32::INFINITY ); + } + + /// Pinned spacers are an explicit gap, not slack, so the shrink pass + /// leaves them alone. + #[ test ] + fn pinned_spacers_keep_their_width_when_shrinking() + { + let canvas = make_canvas(); + let r = row::<()>() + .spacing( 0.0 ) + .padding( 0.0 ) + .push( crate::spacer().width( 60.0 ).height( 10.0 ) ) + .push( crate::toggle( false ) ); + let rect = Rect { x: 0.0, y: 0.0, width: 70.0, height: 48.0 }; + let cells = r.layout( rect, &canvas ); + assert_eq!( cells[ 0 ].0.width, 60.0 ); + } + #[ test ] fn align_right_returns_full_max_width() { diff --git a/src/widget/button/mod.rs b/src/widget/button/mod.rs index cd58de7..8d6d30d 100644 --- a/src/widget/button/mod.rs +++ b/src/widget/button/mod.rs @@ -69,7 +69,7 @@ pub struct Button /// Width and height in pixels for icon buttons. The `0.0` default /// follows the process [`crate::WidgetScaling`] mode (via /// [`crate::Canvas::geom_px`]); any positive value pins an explicit size. - pub( crate ) icon_size: f32, + pub( crate ) icon_size: Option, /// Optional label font size for text buttons. `None` uses the theme's /// default (`theme::FONT_SIZE`); a [`Length`] scales the label with the /// surface (e.g. `Length::vmin( 2.2 ).clamp( 14.0, 22.0 )`). @@ -115,7 +115,7 @@ impl Button on_long_press: None, on_drag_start: None, variant: ButtonVariant::Primary, - icon_size: 0.0, + icon_size: None, font_size: None, height: None, width: None, @@ -153,7 +153,7 @@ impl Button on_long_press: None, on_drag_start: None, variant: ButtonVariant::Tertiary, - icon_size: 0.0, + icon_size: None, font_size: None, height: None, width: None, @@ -237,10 +237,16 @@ impl Button self } - /// Set the display size (width = height) for icon buttons in pixels. - pub fn icon_size( mut self, size: f32 ) -> Self + /// Set the display size (width = height) for icon buttons. + /// + /// A bare `f32` is `Length::px`, which pins the icon at that many canvas + /// pixels. Pass `Length::widget( n )` to have it follow the active + /// widget-scaling mode, which is what stock icons — a `list_item` + /// disclosure arrow, for one — already do, so the two match on a surface + /// where fluid sizing has grown them. + pub fn icon_size( mut self, size: impl Into ) -> Self { - self.icon_size = size; + self.icon_size = Some( size.into() ); self } @@ -293,11 +299,17 @@ impl Button .unwrap_or_else( || canvas.geom_px( theme::HEIGHT ) ) } - /// Resolve the icon-button size: a positive [`Self::icon_size`] pins it, - /// the `0.0` sentinel follows the widget-scaling mode. + /// Resolve the icon-button size. Unset follows the widget-scaling mode + /// through `theme::HEIGHT`; an explicit value resolves like any other + /// geometry `Length`, so `Length::px` pins a physical size while + /// `Length::widget` tracks the surface the way stock icons do. fn resolved_icon_size( &self, canvas: &Canvas ) -> f32 { - if self.icon_size > 0.0 { self.icon_size } else { canvas.geom_px( theme::HEIGHT ) } + match self.icon_size + { + Some( l ) => canvas.resolve_geom( l ), + None => canvas.geom_px( theme::HEIGHT ), + } } /// Assign a stable identifier for focus management. @@ -389,6 +401,9 @@ impl Button let is_disabled = self.on_press.is_none(); let fs = self.label_font_size( canvas ); let text_y = rect.y + (rect.height + fs) / 2.0 - 2.0; + // `rect` is what the layout granted, which is not always the + // preferred width: a row that cannot fit its children shrinks them. + let label = super::elide( canvas, label, fs, rect.width - canvas.geom_px( theme::PAD_H ) * 2.0 ); match self.variant { @@ -412,9 +427,9 @@ impl Button theme::RADIUS + theme::FOCUS_W + 2.0, ); } - let text_w = canvas.measure_text( label, fs ); + let text_w = canvas.measure_text( &label, fs ); canvas.draw_text( - label, + &label, rect.x + (rect.width - text_w) / 2.0, text_y, fs, @@ -438,9 +453,9 @@ impl Button theme::RADIUS + theme::FOCUS_W + 2.0, ); } - let text_w = canvas.measure_text( label, fs ); + let text_w = canvas.measure_text( &label, fs ); canvas.draw_text( - label, + &label, rect.x + (rect.width - text_w) / 2.0, text_y, fs, @@ -455,9 +470,9 @@ impl Button let ring = rect.expand( 2.0 ); canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, theme::RADIUS ); } - let text_w = canvas.measure_text( label, fs ); + let text_w = canvas.measure_text( &label, fs ); canvas.draw_text( - label, + &label, rect.x + (rect.width - text_w) / 2.0, text_y, fs, diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 8a78244..c888dd5 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use crate::types::{ Length, Rect, WidgetId }; use crate::render::Canvas; -use super::Element; +use super::{ elide, Element }; mod theme; @@ -357,35 +357,6 @@ impl ListItem } } -/// Shorten `text` with a trailing ellipsis so it fits `max_w`, mirroring the -/// single-line truncation [`super::text::Text`] applies. -fn elide( canvas: &Canvas, text: &str, size: f32, max_w: f32 ) -> String -{ - if max_w <= 0.0 - { - return String::new(); - } - if canvas.measure_text( text, size ) <= max_w - { - return text.to_string(); - } - - let ellipsis = "..."; - let budget = max_w - canvas.measure_text( ellipsis, size ); - if budget <= 0.0 - { - return ellipsis.to_string(); - } - - let mut accum = 0.0_f32; - let kept: String = text.chars().take_while( |ch| - { - accum += canvas.measure_text( &ch.to_string(), size ); - accum <= budget - } ).collect(); - format!( "{kept}{ellipsis}" ) -} - /// Create a [`ListItem`] with the given primary label. /// /// Add detail and behaviour through the chained builders. For a diff --git a/src/widget/mod.rs b/src/widget/mod.rs index cee01d7..b57e606 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -108,3 +108,43 @@ pub use factory::{ button, icon_button, text_edit, image, text, container, exter /// — the same closure is invoked once per emitted message, regardless /// of how many leaves the sub-tree has. pub( crate ) type MapFn = std::sync::Arc U>; + +/// Shorten `text` with a trailing ellipsis so it fits `max_w` at `size`. +/// +/// The single truncation rule for widgets that paint a line of text into a +/// rect they do not control: a leaf must stay inside what the layout gave +/// it, or a long label runs under its neighbour. Widths accumulate per +/// character, which is what [`text::Text`] does with its own inline copy of +/// this — folding that one in is pending, as it measures through a font +/// override this signature does not carry. +pub( crate ) fn elide( + canvas: &crate::render::Canvas, + text: &str, + size: f32, + max_w: f32, +) -> String +{ + if max_w <= 0.0 + { + return String::new(); + } + if canvas.measure_text( text, size ) <= max_w + { + return text.to_string(); + } + + let ellipsis = "..."; + let budget = max_w - canvas.measure_text( ellipsis, size ); + if budget <= 0.0 + { + return ellipsis.to_string(); + } + + let mut accum = 0.0_f32; + let kept: String = text.chars().take_while( |ch| + { + accum += canvas.measure_text( &ch.to_string(), size ); + accum <= budget + } ).collect(); + format!( "{kept}{ellipsis}" ) +}