row, button, widget: distribute a row's width shortfall instead of overflowing, elide button labels into the rect they are granted, icon_size takes a Length
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Row deficit distribution (layout/row.rs). `Row` knew how to hand out leftover width but had no notion of a shortfall: `leftover` was floored at zero and every child was laid out at its preferred width, so a cluster wider than its rect simply overflowed — and symmetrically, because the no-spacer branch centres the block, which is why both end labels of a segmented control were clipped at once rather than only the trailing one. The shortfall now comes off the widest children first, by water filling: `width_cap` sorts the flexible widths and returns the largest per-child cap `c` for which `sum( min( w, c ) ) <= available`, so a long title absorbs the whole deficit while an icon button beside it keeps its size, and equal siblings — a segmented control — share it evenly. A uniform scale-down, which was the first attempt, was wrong precisely there: it thinned a header's back arrow along with the title it sat next to. Spacers and flex children stay out of the flexible set, so a pinned gap keeps the width it was given — an explicit spacer is a decision, not slack — and `Row::no_shrink()` opts a row out entirely, for strips deliberately wider than their viewport such as a carousel rail meant to be scrolled. `align_right` and the centring branch now measure the laid width rather than the preferred one, so a shrunken row is positioned against what it actually occupies.
Button label elision (widget/button/mod.rs, widget/mod.rs, widget/list_item/mod.rs). Shrinking a row is only half the fix: a leaf handed less width than it asked for still painted its full string, so the deficit came out clipped instead of truncated. `draw_text_button` now elides the label against the rect the layout granted, less the horizontal padding, for all three variants. The truncation rule moves out of `ListItem`, where it was a private helper, into a single crate-internal `widget::elide` the two share. `Text` keeps its own inline copy for now: it measures through an optional font override that this signature does not carry, and folding the two together is a separate change.
icon_size as a Length (widget/button/mod.rs). `Button::icon_size` was the one geometry setter that ignored the widget-scaling mode — it took a bare `f32`, pinned it, and used `0.0` as the "unset" sentinel. It now takes `impl Into<Length>` behind an `Option` and resolves through `Canvas::resolve_geom`, the way `height`, `width` and `font_size` already do. A bare number still means `Length::px`, so every existing call keeps its exact size; what it adds is `Length::widget( n )`, which follows the active mode the way stock icons do. Without it a back arrow pinned at 21 px sat next to a `list_item` chevron that fluid sizing had grown well past 21, and read as visibly smaller on the same row.
This commit is contained in:
2026-08-05 12:37:03 +02:00
parent 530a5696b9
commit 1290f9400e
6 changed files with 228 additions and 50 deletions

View File

@@ -49,6 +49,9 @@ pub struct Row<Msg: Clone>
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<Msg: Clone> Row<Msg>
@@ -63,6 +66,7 @@ impl<Msg: Clone> Row<Msg>
align_right: false,
align_top: false,
fill_height: false,
shrink: true,
}
}
@@ -101,6 +105,19 @@ impl<Msg: Clone> Row<Msg>
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<Msg: Clone> Row<Msg>
} )
.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<f32> = 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::<f32>();
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<Msg: Clone> Row<Msg>
}
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<Msg: Clone> Row<Msg>
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<Msg: Clone> Row<Msg>
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::<f32>() <= 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<Msg: Clone>() -> Row<Msg>
{
@@ -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()
{

View File

@@ -69,7 +69,7 @@ pub struct Button<Msg: Clone>
/// 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<Length>,
/// 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<Msg: Clone> Button<Msg>
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<Msg: Clone> Button<Msg>
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<Msg: Clone> Button<Msg>
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<Length> ) -> Self
{
self.icon_size = size;
self.icon_size = Some( size.into() );
self
}
@@ -293,11 +299,17 @@ impl<Msg: Clone> Button<Msg>
.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<Msg: Clone> Button<Msg>
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<Msg: Clone> Button<Msg>
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<Msg: Clone> Button<Msg>
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<Msg: Clone> Button<Msg>
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,

View File

@@ -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<Msg: Clone> ListItem<Msg>
}
}
/// 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

View File

@@ -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<Msg, U> = std::sync::Arc<dyn Fn( Msg ) -> 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}" )
}