From d31e7222dac33813c7734123fb72a4ab2ccb5a0f Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Thu, 30 Jul 2026 11:19:37 +0200 Subject: [PATCH] =?UTF-8?q?list=5Fitem:=20trailing=20icon=20slot=20and=20h?= =?UTF-8?q?orizontal=20inset=20override=20Disclosure=20arrows=20in=20list?= =?UTF-8?q?=20rows=20could=20only=20be=20text:=20the=20trailing=20slot=20d?= =?UTF-8?q?raws=20a=20string,=20so=20apps=20fell=20back=20to=20the=20"?= =?UTF-8?q?=E2=80=BA"=20glyph=20at=2014=20px,=20which=20cannot=20use=20the?= =?UTF-8?q?=20theme's=20arrow=20SVGs=20and=20looks=20thin=20next=20to=2024?= =?UTF-8?q?=20px=20leading=20icons.=20Add=20ListItem::trailing=5Ficon(=20r?= =?UTF-8?q?gba,=20w,=20h=20):=20a=20right-aligned=20icon=20drawn=20at=20th?= =?UTF-8?q?e=20new=20theme::TRAILING=5FICON=5FSIZE=20(21=20px,=201.5x=20th?= =?UTF-8?q?e=20old=20glyph=20em=20box),=20vertically=20centered.=20It=20co?= =?UTF-8?q?exists=20with=20trailing=20text,=20which=20shifts=20to=20the=20?= =?UTF-8?q?icon's=20left.=20Like=20the=20leading=20icon,=20symbolic=20asse?= =?UTF-8?q?ts=20are=20pre-tinted=20by=20the=20caller=20(tint=5Fsymbolic),?= =?UTF-8?q?=20so=20the=20widget=20stays=20colour-agnostic.=20Add=20ListIte?= =?UTF-8?q?m::pad=5Fh(=20impl=20Into=20),=20a=20per-item=20overrid?= =?UTF-8?q?e=20of=20the=20horizontal=20inset=20between=20the=20row=20edge?= =?UTF-8?q?=20and=20its=20content=20(leading=20icon/label=20on=20the=20lef?= =?UTF-8?q?t,=20trailing=20text/icon=20on=20the=20right).=20Resolution=20f?= =?UTF-8?q?ollows=20the=20Separator=20pattern:=20an=20explicit=20Length=20?= =?UTF-8?q?wins,=20otherwise=20the=20theme=20default=20(16=20px=20through?= =?UTF-8?q?=20geom=5Fpx)=20applies,=20so=20existing=20rows=20are=20unaffec?= =?UTF-8?q?ted.=20This=20lets=20an=20app=20whose=20enclosing=20view=20alre?= =?UTF-8?q?ady=20provides=20the=20margin=20bring=20row=20content=20flush?= =?UTF-8?q?=20instead=20of=20stacking=20both=20insets.=20Document=20both?= =?UTF-8?q?=20builders=20in=20docs/widgets.md=20and=20the=20changelog,=20a?= =?UTF-8?q?nd=20rework=20the=20list=5Fitem=20rustdoc=20example=20to=20poin?= =?UTF-8?q?t=20at=20trailing=5Ficon=20with=20a=20theme=20icon=20for=20disc?= =?UTF-8?q?losure=20arrows=20instead=20of=20the=20text=20glyph.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 ++ docs/widgets.md | 25 ++++++++-- src/widget/list_item/mod.rs | 93 +++++++++++++++++++++++++++-------- src/widget/list_item/theme.rs | 2 + 4 files changed, 97 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200d3df..2fcd784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **`ListItem::trailing_icon( rgba, w, h )`** — right-aligned icon slot (disclosure arrow) drawn at `TRAILING_ICON_SIZE` (21 px) and vertically centered, alongside the existing leading `icon`. Coexists with `trailing` text, which shifts to the icon's left. Symbolic icons should be pre-tinted by the caller (`tint_symbolic`), matching the leading-icon contract. +- **`ListItem::pad_h( impl Into )`** — per-item override of the horizontal inset between the row edge and its content; without it the theme default (16 px) applies. + - **Responsive sizing system** with two selectable modes via `WidgetScaling` (`Fluid` / `Physical`; `set_widget_scaling` / `widget_scaling`, default `Fluid`). New `Length` constructors — `orient( portrait, landscape )` (a percentage of the width in portrait, of the height in landscape), `fluid( px )` (surface-proportional, calibrated against `set_fluid_reference` and bounded by `FLUID_MIN` / `FLUID_MAX`), `dp( px )` (constant physical size scaled by `set_density` / `density`), and `widget( px )` (picks fluid or dp per the active mode). `Canvas::geom_px` (geometry, physical layout space) and `Canvas::font_px` (font, bridging the logical / physical split per mode) give widgets and apps one resolution path. - **`Button::font_size` / `height` / `width`** and **`TextEdit::height`** builders, all `impl Into`, so control boxes scale with the surface. `Text::line_height( mult )` opens the gap between wrapped lines. `Separator::pad_v` (with `Length::px( 0.0 )` for a flush divider). - **Performance guardrails**: opt-in diagnostics via `LTK_PERF_WARN=1` (stuck animation, sustained software-render animation, low `poll_interval`) and a ~30 Hz software-animation cap overridable with `App::cap_software_animation`. diff --git a/docs/widgets.md b/docs/widgets.md index abd6e20..f450e74 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -144,8 +144,9 @@ each mode supplies. ### `list_item` -A row with a primary label, optional subtitle, optional right-aligned -trailing text, and a tappable surface. +A row with a primary label, optional subtitle, optional leading icon, +optional right-aligned trailing text and/or trailing icon, and a +tappable surface. **When**: settings menus, navigation lists, contact rows. Doubles its height when a subtitle is set. @@ -154,13 +155,27 @@ height when a subtitle is set. # use ltk::{ list_item, ListItem }; # #[ derive( Clone ) ] enum Msg { OpenWifi } # fn _ex() -> ListItem { -list_item( "Wi-Fi" ) +let mut it = list_item( "Wi-Fi" ) .subtitle( "Eduroam" ) - .trailing( "›" ) - .on_press( Msg::OpenWifi ) + .on_press( Msg::OpenWifi ); +// Disclosure arrow from the active theme, tinted to match the +// trailing-text colour. +if let Some( ( rgba, w, h ) ) = ltk::theme_icon_rgba( "general/right", 21 ) +{ + let tinted = std::sync::Arc::new( ltk::tint_symbolic( &rgba, ltk::theme_palette().text_secondary ) ); + it = it.trailing_icon( tinted, w, h ); +} +it # } ``` +`trailing( text )` right-aligns a text value (current setting, badge +count); `trailing_icon( rgba, w, h )` draws an icon at the right edge. +When both are set the text sits to the left of the icon. +`pad_h( px )` overrides the horizontal inset between the row edge and +its content (theme default 16 px) — lower it when the enclosing view's +own padding already provides the margin. + **See also**: [`pressable`](#pressable) for free-form tappable rows, [`scroll`](#scroll) to wrap a list of items in a scrollable container. diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 53c1d87..55d4462 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use crate::types::{ Rect, WidgetId }; +use crate::types::{ Length, Rect, WidgetId }; use crate::render::Canvas; use super::Element; @@ -42,9 +42,14 @@ pub struct ListItem /// Optional secondary line drawn below the label in muted colour. /// Doubles the row height when set. pub( crate ) subtitle: Option, - /// Optional right-aligned text (current setting, badge count, "›" - /// disclosure). Drawn in muted colour. + /// Optional right-aligned text (current setting, badge count). + /// Drawn in muted colour. pub( crate ) trailing: Option, + /// Optional right-aligned icon — RGBA bytes + native dimensions. + /// Drawn at `theme::TRAILING_ICON_SIZE`, to the right of the + /// trailing text when both are set. Symbolic icons should be + /// pre-tinted by the caller (see [`crate::tint_symbolic`]). + pub( crate ) trailing_icon: Option<( Arc>, u32, u32 )>, /// Message emitted on tap. `None` keeps the item visible but inert. pub( crate ) on_press: Option, /// Optional stable identifier for focus management. @@ -59,6 +64,9 @@ pub struct ListItem /// when this is set, and offsets the label / subtitle by the /// same amount. Pass `None` to keep the icon-less layout. pub( crate ) icon: Option<( Arc>, u32, u32 )>, + /// Optional override of the horizontal content inset. `None` + /// falls back to `theme::PAD_H`. + pub( crate ) pad_h: Option, } impl ListItem @@ -69,13 +77,15 @@ impl ListItem { Self { - label: label.into(), - subtitle: None, - trailing: None, - on_press: None, - id: None, - selected: false, - icon: None, + label: label.into(), + subtitle: None, + trailing: None, + trailing_icon: None, + on_press: None, + id: None, + selected: false, + icon: None, + pad_h: None, } } @@ -114,6 +124,25 @@ impl ListItem self } + /// Attach a right-aligned icon (disclosure arrow). Pass the decoded + /// RGBA buffer alongside the image's native width and height; the + /// draw path scales it down to `theme::TRAILING_ICON_SIZE`. + pub fn trailing_icon( mut self, rgba: Arc>, w: u32, h: u32 ) -> Self + { + self.trailing_icon = Some( ( rgba, w, h ) ); + self + } + + /// Override the horizontal inset between the row edge and its + /// content (leading icon / label on the left, trailing text or + /// icon on the right). Accepts logical `f32` pixels or any + /// [`Length`]; without this it uses the theme default. + pub fn pad_h( mut self, p: impl Into ) -> Self + { + self.pad_h = Some( p.into() ); + self + } + /// Set the message emitted when the row is tapped. pub fn on_press( mut self, msg: Msg ) -> Self { @@ -178,7 +207,9 @@ impl ListItem } let label_size = canvas.font_px( theme::LABEL_SIZE ); - let pad_h = canvas.geom_px( theme::PAD_H ); + let pad_h = self.pad_h + .map( |l| l.resolve( canvas.viewport_layout(), Length::EM_BASE_DEFAULT ) ) + .unwrap_or_else( || canvas.geom_px( theme::PAD_H ) ); let has_sub = self.subtitle.is_some(); let label_y = if has_sub { @@ -215,11 +246,27 @@ impl ListItem canvas.draw_text( sub, text_x, sub_y, sub_size, subtitle_color ); } + let mut trail_right = rect.x + rect.width - pad_h; + + if let Some( ( rgba, w, h ) ) = &self.trailing_icon + { + let icon_size = canvas.geom_px( theme::TRAILING_ICON_SIZE ); + let icon_rect = Rect + { + x: trail_right - icon_size, + y: rect.y + ( rect.height - icon_size ) / 2.0, + width: icon_size, + height: icon_size, + }; + canvas.draw_image_data( rgba, *w, *h, icon_rect, 1.0 ); + trail_right = icon_rect.x - canvas.geom_px( theme::ICON_GAP ); + } + if let Some( ref trail ) = self.trailing { let trail_size = canvas.font_px( theme::TRAILING_SIZE ); let tw = canvas.measure_text( trail, trail_size ); - let tx = rect.x + rect.width - pad_h - tw; + let tx = trail_right - tw; let ty = rect.y + ( rect.height + trail_size ) / 2.0 - 2.0; canvas.draw_text( trail, tx, ty, trail_size, trailing_color ); } @@ -232,20 +279,24 @@ impl ListItem { ListItem { - label: self.label, - subtitle: self.subtitle, - trailing: self.trailing, - on_press: self.on_press.map( |m| ( *f )( m ) ), - id: self.id, - selected: self.selected, - icon: self.icon, + label: self.label, + subtitle: self.subtitle, + trailing: self.trailing, + trailing_icon: self.trailing_icon, + on_press: self.on_press.map( |m| ( *f )( m ) ), + id: self.id, + selected: self.selected, + icon: self.icon, + pad_h: self.pad_h, } } } /// Create a [`ListItem`] with the given primary label. /// -/// Add detail and behaviour through the chained builders: +/// Add detail and behaviour through the chained builders. For a +/// disclosure arrow use [`ListItem::trailing_icon`] with a theme icon +/// (e.g. `"general/right"`) rather than a text glyph: /// /// ```rust,no_run /// # use ltk::{ list_item, ListItem }; @@ -253,7 +304,7 @@ impl ListItem /// # fn _ex() -> ListItem { /// list_item( "Display" ) /// .subtitle( "Resolution, brightness, night mode" ) -/// .trailing( "›" ) +/// .trailing( "Light" ) /// .on_press( Msg::OpenDisplay ) /// # } /// ``` diff --git a/src/widget/list_item/theme.rs b/src/widget/list_item/theme.rs index 52dd301..af681d6 100644 --- a/src/widget/list_item/theme.rs +++ b/src/widget/list_item/theme.rs @@ -33,5 +33,7 @@ pub const RADIUS: f32 = 12.0; pub const FOCUS_W: f32 = 2.0; /// Visible side of the optional leading icon (square). pub const ICON_SIZE: f32 = 24.0; +/// Visible side of the optional trailing icon (square). +pub const TRAILING_ICON_SIZE: f32 = 21.0; /// Gap between the leading icon's right edge and the label baseline. pub const ICON_GAP: f32 = 12.0;