From 821037f509308adb11fae19e41af2561520f4be7 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Wed, 13 May 2026 19:38:41 +0200 Subject: [PATCH] =?UTF-8?q?container,=20text,=20date=5Fpicker:=20width-awa?= =?UTF-8?q?re=20sizing=20pass=20`Container::max=5Fwidth(px)`=20mirrors=20t?= =?UTF-8?q?he=20same=20flag=20on=20`Column`=20/=20`Row`=20=E2=80=94=20the?= =?UTF-8?q?=20container=20reports=20`min(=20offered,=20px=20)`=20upwards?= =?UTF-8?q?=20and=20the=20draw=20pass=20caps=20`rect.width`=20to=20`px`,?= =?UTF-8?q?=20so=20a=20decorated=20child=20wrapped=20in=20`container().max?= =?UTF-8?q?=5Fwidth(260)`=20no=20longer=20needs=20a=20`column()`-of-one=20?= =?UTF-8?q?shell=20just=20to=20access=20the=20cap.=20Propagated=20through?= =?UTF-8?q?=20`map=5Fmsg`,=20covered=20by=20a=20test,=20and=20documented?= =?UTF-8?q?=20under=20the=20`container`=20section=20of=20`docs/widgets.md`?= =?UTF-8?q?.=20`Text::no=5Ftruncate()`=20opts=20out=20of=20the=20default?= =?UTF-8?q?=20ellipsis=20behaviour:=20when=20`truncate=20=3D=20false`=20th?= =?UTF-8?q?e=20draw=20pass=20paints=20the=20full=20string=20even=20if=20`m?= =?UTF-8?q?easure(text)=20>=20rect.width`.=20Useful=20for=20very=20short?= =?UTF-8?q?=20labels=20(calendar=20days=20"1"=E2=80=93"31",=20day-of-week?= =?UTF-8?q?=20stubs=20"Lu"=20/=20"Mi"=20/=20"S=C3=A1")=20inside=20grid=20s?= =?UTF-8?q?lots=20whose=20width=20is=20dictated=20by=20the=20parent=20?= =?UTF-8?q?=E2=80=94=20a=20couple=20of=20pixels=20of=20overflow=20centred?= =?UTF-8?q?=20in=20the=20rect=20is=20invisible,=20while=20"..."=20in=20pla?= =?UTF-8?q?ce=20of=20a=20single-digit=20number=20is=20loud.=20`DatePicker:?= =?UTF-8?q?:width(px)`=20is=20a=20layout=20hint:=20when=20set,=20`build()`?= =?UTF-8?q?=20derives=20`header=5Ffs`,=20`dow=5Ffs`=20and=20`day=5Ffs`=20f?= =?UTF-8?q?rom=20the=20slot=20width=20so=20the=20worst-case=20label=20in?= =?UTF-8?q?=20each=20row=20("September=202026"=20in=20the=20header,=20"Mi?= =?UTF-8?q?=C3=A9"=20/=20"S=C3=A1b"=20in=20the=20DOW=20row,=20"30"=20in=20?= =?UTF-8?q?the=20day=20cell)=20fits=20at=20a=20sensible=20size;=20the=20de?= =?UTF-8?q?sign=20defaults=20stay=20as=20upper=20bounds.=20Day=20and=20DOW?= =?UTF-8?q?=20cells=20also=20flip=20on=20`Text::no=5Ftruncate()`=20as=20a?= =?UTF-8?q?=20safety=20net=20=E2=80=94=20heuristic=20font=20sizing=20can't?= =?UTF-8?q?=20perfectly=20predict=20glyph=20widths=20across=20families,=20?= =?UTF-8?q?and=20overflowing=20one=20pixel=20beats=20truncating=20to=20"..?= =?UTF-8?q?.".?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ docs/widgets.md | 6 ++++++ src/draw/layout.rs | 7 +++++++ src/widget/container.rs | 30 +++++++++++++++++++++++++++++- src/widget/date_picker.rs | 39 +++++++++++++++++++++++++++++++++------ src/widget/text.rs | 37 +++++++++++++++++++++++++------------ 6 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e7caa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +target/ diff --git a/docs/widgets.md b/docs/widgets.md index 8fc010c..c248fc8 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -384,6 +384,12 @@ Per-edge padding is available with `padding_top` / `padding_right` / `padding_bottom` / `padding_left`. Per-corner radius takes a [`Corners`] struct or a tuple. +`max_width(px)` caps the container's outer width — when the parent +offers a wider rect, the container reports `min( offered, px )` as its +preferred width instead of stretching to fill. Mirrors the same flag on +[`column`](#column) and [`row`](#row), so a single decorated child no +longer needs a `column`-of-one wrap just to access a width cap. + **See also**: [`pressable`](#pressable) wrapping a `container` makes a card interactive. diff --git a/src/draw/layout.rs b/src/draw/layout.rs index cf90cc5..a85d07d 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -138,6 +138,13 @@ pub( crate ) fn layout_and_draw( let saved_alpha = canvas.global_alpha(); canvas.set_global_alpha( saved_alpha * c.opacity ); + let rect = if let Some( mw ) = c.max_width + { + crate::types::Rect { width: rect.width.min( mw ), ..rect } + } else { + rect + }; + // Surface slot takes precedence over flat background; falls // through to `c.background` when the slot is absent (third- // party theme without the named surface — content still diff --git a/src/widget/container.rs b/src/widget/container.rs index e9295c1..2d78f00 100644 --- a/src/widget/container.rs +++ b/src/widget/container.rs @@ -83,6 +83,12 @@ pub struct Container /// container's rounded rectangle, after the fill / surface and /// before the child draws. `None` leaves the chrome flat. pub border: Option<( Color, f32 )>, + /// Optional hard cap on the container's outer width. When the parent + /// offers more, the container reports its preferred width as + /// `min( offered, max_width )` so it does not stretch to fill. + /// Mirrors the same flag on [`Column`](crate::layout::column::Column) + /// and [`Row`](crate::layout::row::Row). + pub max_width: Option, } impl Container @@ -101,6 +107,7 @@ impl Container pad_left: 0.0, opacity: 1.0, border: None, + max_width: None, } } @@ -237,12 +244,22 @@ impl Container self } + /// Cap the container's outer width in logical px. When the parent + /// offers a wider rect, the container reports its preferred width as + /// `min( offered, w )` so it does not stretch to fill. + pub fn max_width( mut self, w: f32 ) -> Self + { + self.max_width = Some( w ); + self + } + /// Return the preferred `(width, height)` accounting for padding. pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { + let avail = self.max_width.map( |m| max_width.min( m ) ).unwrap_or( max_width ); let pad_x = self.pad_left + self.pad_right; let pad_y = self.pad_top + self.pad_bottom; - let inner_w = ( max_width - pad_x ).max( 0.0 ); + let inner_w = ( avail - pad_x ).max( 0.0 ); let ( cw, ch ) = self.child.preferred_size( inner_w, canvas ); ( cw + pad_x, ch + pad_y ) } @@ -264,6 +281,7 @@ impl Container pad_left: self.pad_left, opacity: self.opacity, border: self.border, + max_width: self.max_width, } } } @@ -378,5 +396,15 @@ mod tests let c = container::<()>( spacer() ).surface( "surface-card" ); assert_eq!( c.surface.as_deref(), Some( "surface-card" ) ); } + + #[ test ] + fn max_width_caps_preferred_width() + { + use crate::render::Canvas; + let canvas = Canvas::new( 800, 600 ); + let c = container::<()>( spacer() ).max_width( 200.0 ); + let ( w, _ ) = c.preferred_size( 800.0, &canvas ); + assert!( w <= 200.0 ); + } } diff --git a/src/widget/date_picker.rs b/src/widget/date_picker.rs index f52b187..07d77c0 100644 --- a/src/widget/date_picker.rs +++ b/src/widget/date_picker.rs @@ -213,6 +213,7 @@ pub struct DatePicker pub on_change: Option Msg>>, pub on_navigate: Option Msg>>, pub locale: Locale, + pub width: Option, } impl DatePicker @@ -231,6 +232,7 @@ impl DatePicker on_change: None, on_navigate: None, locale: Locale::default(), + width: None, } } @@ -274,6 +276,17 @@ impl DatePicker self } + /// Outer width the picker will be laid out at. Used to shrink the + /// header / day-of-week / day-cell font sizes so the widest labels + /// ("30", "September 2026", "Mié") still fit without ellipsis. When + /// unset, the picker uses the design defaults and may truncate + /// inside very narrow rects. + pub fn width( mut self, w: f32 ) -> Self + { + self.width = Some( w ); + self + } + /// Build the `Element` tree representing this date picker. pub fn build( self ) -> Element { @@ -290,6 +303,18 @@ impl DatePicker let on_nav = self.on_navigate.clone(); let first = self.locale.first_dow; + let ( header_fs, dow_fs, day_fs ) = if let Some( w ) = self.width + { + let inner_w = ( w - 2.0 * theme::PADDING ).max( 8.0 ); + let slot_w = ( ( inner_w - 6.0 * theme::SPACING ) / 7.0 ).max( 4.0 ); + let day = ( ( slot_w - 8.0 ) / 1.6 ).clamp( 8.0, theme::DAY_FS ); + let dow = ( slot_w / 2.0 ).clamp( 8.0, theme::DOW_FS ); + let head = ( ( inner_w - 52.0 ) / ( 14.0 * 0.7 ) ).clamp( 10.0, theme::HEADER_FS ); + ( head, dow, day ) + } else { + ( theme::HEADER_FS, theme::DOW_FS, theme::DAY_FS ) + }; + // Header chevrons load from the active theme as SVG icons // (`icons/catalogue/filled/multimedia/{previous,next}.svg`), // tinted to the primary text colour so they read against @@ -333,11 +358,11 @@ impl DatePicker // `Stack` (the only layout that lets independent children // sit at left / centre / right of the same rect) wrapped in a // container that fixes the row height. - let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( theme::HEADER_FS + 16.0 ); + let header_height = ( CHEVRON_PX as f32 + 16.0 ).max( header_fs + 16.0 ); let header_inner: Element = stack::() .push_aligned( prev, HAlign::Start, VAlign::Center ) .push_aligned( - text( title ).size( theme::HEADER_FS ).color( theme::text() ), + text( title ).size( header_fs ).color( theme::text() ), HAlign::Center, VAlign::Center, ) .push_aligned( next, HAlign::End, VAlign::Center ) @@ -360,9 +385,10 @@ impl DatePicker let dow = ( ( slot + first ) % 7 ) as u8; let cell: Element = container::( text( dow_short( dow ) ) - .size( theme::DOW_FS ) + .size( dow_fs ) .color( theme::text_muted() ) - .align_center(), + .align_center() + .no_truncate(), ) .padding_h( 0.0 ) .padding_v( 4.0 ) @@ -404,9 +430,10 @@ impl DatePicker let mut card = container::( text( format!( "{}", day ) ) - .size( theme::DAY_FS ) + .size( day_fs ) .color( label_color ) - .align_center(), + .align_center() + .no_truncate(), ) .padding_h( 4.0 ) .padding_v( 8.0 ) diff --git a/src/widget/text.rs b/src/widget/text.rs index b93043f..174da1e 100644 --- a/src/widget/text.rs +++ b/src/widget/text.rs @@ -20,11 +20,17 @@ pub enum TextAlign pub struct Text { - pub content: String, - pub size: f32, - pub color: Color, - pub align: TextAlign, - pub wrap: bool, + pub content: String, + pub size: f32, + pub color: Color, + pub align: TextAlign, + pub wrap: bool, + /// When `true` (default), overflowing single-line text is truncated + /// with an ellipsis. When `false`, the full string is painted even + /// if it extends past the layout rect — useful for very short + /// labels (calendar day numbers, day-of-week stubs) where a couple + /// of pixels of overflow is invisible but "..." is noisy. + pub truncate: bool, /// Optional `(family, weight, style)` override resolved through /// the active theme's font registry on every draw. `None` keeps /// the canvas default font (Sora Regular in `ltk-theme-default`). @@ -37,15 +43,22 @@ impl Text { Self { - content: content.into(), - size: 16.0, - color: Color::WHITE, - align: TextAlign::Left, - wrap: false, - font: None, + content: content.into(), + size: 16.0, + color: Color::WHITE, + align: TextAlign::Left, + wrap: false, + truncate: true, + font: None, } } + pub fn no_truncate( mut self ) -> Self + { + self.truncate = false; + self + } + /// Set a `(family, weight, style)` override for this text node. /// The triple is resolved through [`Canvas::font_for`] at draw /// time, so missing weights fall through the registry's nearest- @@ -187,7 +200,7 @@ impl Text let text_w = self.measure( &self.content, canvas, font.as_ref() ); - let display = if text_w > rect.width && rect.width > 0.0 + let display = if self.truncate && text_w > rect.width && rect.width > 0.0 { let ellipsis = "..."; let ell_w = self.measure( ellipsis, canvas, font.as_ref() );