list_item: trailing icon slot and horizontal inset override
Disclosure arrows in list rows could only be text: the trailing slot draws a string, so apps fell back to the "›" glyph at 14 px, which cannot use the theme's arrow SVGs and looks thin next to 24 px leading icons. Add ListItem::trailing_icon( rgba, w, h ): a right-aligned icon drawn at the new theme::TRAILING_ICON_SIZE (21 px, 1.5x the old glyph em box), vertically centered. It coexists with trailing text, which shifts to the icon's left. Like the leading icon, symbolic assets are pre-tinted by the caller (tint_symbolic), so the widget stays colour-agnostic. Add ListItem::pad_h( impl Into<Length> ), a per-item override of the horizontal inset between the row edge and its content (leading icon/label on the left, trailing text/icon on the right). Resolution follows the Separator pattern: an explicit Length wins, otherwise the theme default (16 px through geom_px) applies, so existing rows are unaffected. This lets an app whose enclosing view already provides the margin bring row content flush instead of stacking both insets. Document both builders in docs/widgets.md and the changelog, and rework the list_item rustdoc example to point at trailing_icon with a theme icon for disclosure arrows instead of the text glyph.
This commit is contained in:
@@ -6,6 +6,9 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
|
|||||||
|
|
||||||
### Added
|
### 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<Length> )`** — 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.
|
- **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<Length>`, 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).
|
- **`Button::font_size` / `height` / `width`** and **`TextEdit::height`** builders, all `impl Into<Length>`, 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`.
|
- **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`.
|
||||||
|
|||||||
@@ -144,8 +144,9 @@ each mode supplies.
|
|||||||
|
|
||||||
### `list_item`
|
### `list_item`
|
||||||
|
|
||||||
A row with a primary label, optional subtitle, optional right-aligned
|
A row with a primary label, optional subtitle, optional leading icon,
|
||||||
trailing text, and a tappable surface.
|
optional right-aligned trailing text and/or trailing icon, and a
|
||||||
|
tappable surface.
|
||||||
|
|
||||||
**When**: settings menus, navigation lists, contact rows. Doubles its
|
**When**: settings menus, navigation lists, contact rows. Doubles its
|
||||||
height when a subtitle is set.
|
height when a subtitle is set.
|
||||||
@@ -154,13 +155,27 @@ height when a subtitle is set.
|
|||||||
# use ltk::{ list_item, ListItem };
|
# use ltk::{ list_item, ListItem };
|
||||||
# #[ derive( Clone ) ] enum Msg { OpenWifi }
|
# #[ derive( Clone ) ] enum Msg { OpenWifi }
|
||||||
# fn _ex() -> ListItem<Msg> {
|
# fn _ex() -> ListItem<Msg> {
|
||||||
list_item( "Wi-Fi" )
|
let mut it = list_item( "Wi-Fi" )
|
||||||
.subtitle( "Eduroam" )
|
.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,
|
**See also**: [`pressable`](#pressable) for free-form tappable rows,
|
||||||
[`scroll`](#scroll) to wrap a list of items in a scrollable container.
|
[`scroll`](#scroll) to wrap a list of items in a scrollable container.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::types::{ Rect, WidgetId };
|
use crate::types::{ Length, Rect, WidgetId };
|
||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::Element;
|
use super::Element;
|
||||||
|
|
||||||
@@ -42,9 +42,14 @@ pub struct ListItem<Msg: Clone>
|
|||||||
/// Optional secondary line drawn below the label in muted colour.
|
/// Optional secondary line drawn below the label in muted colour.
|
||||||
/// Doubles the row height when set.
|
/// Doubles the row height when set.
|
||||||
pub( crate ) subtitle: Option<String>,
|
pub( crate ) subtitle: Option<String>,
|
||||||
/// Optional right-aligned text (current setting, badge count, "›"
|
/// Optional right-aligned text (current setting, badge count).
|
||||||
/// disclosure). Drawn in muted colour.
|
/// Drawn in muted colour.
|
||||||
pub( crate ) trailing: Option<String>,
|
pub( crate ) trailing: Option<String>,
|
||||||
|
/// 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<Vec<u8>>, u32, u32 )>,
|
||||||
/// Message emitted on tap. `None` keeps the item visible but inert.
|
/// Message emitted on tap. `None` keeps the item visible but inert.
|
||||||
pub( crate ) on_press: Option<Msg>,
|
pub( crate ) on_press: Option<Msg>,
|
||||||
/// Optional stable identifier for focus management.
|
/// Optional stable identifier for focus management.
|
||||||
@@ -59,6 +64,9 @@ pub struct ListItem<Msg: Clone>
|
|||||||
/// when this is set, and offsets the label / subtitle by the
|
/// when this is set, and offsets the label / subtitle by the
|
||||||
/// same amount. Pass `None` to keep the icon-less layout.
|
/// same amount. Pass `None` to keep the icon-less layout.
|
||||||
pub( crate ) icon: Option<( Arc<Vec<u8>>, u32, u32 )>,
|
pub( crate ) icon: Option<( Arc<Vec<u8>>, u32, u32 )>,
|
||||||
|
/// Optional override of the horizontal content inset. `None`
|
||||||
|
/// falls back to `theme::PAD_H`.
|
||||||
|
pub( crate ) pad_h: Option<Length>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Msg: Clone> ListItem<Msg>
|
impl<Msg: Clone> ListItem<Msg>
|
||||||
@@ -72,10 +80,12 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
label: label.into(),
|
label: label.into(),
|
||||||
subtitle: None,
|
subtitle: None,
|
||||||
trailing: None,
|
trailing: None,
|
||||||
|
trailing_icon: None,
|
||||||
on_press: None,
|
on_press: None,
|
||||||
id: None,
|
id: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
icon: None,
|
icon: None,
|
||||||
|
pad_h: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +124,25 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
self
|
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<Vec<u8>>, 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<Length> ) -> Self
|
||||||
|
{
|
||||||
|
self.pad_h = Some( p.into() );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the message emitted when the row is tapped.
|
/// Set the message emitted when the row is tapped.
|
||||||
pub fn on_press( mut self, msg: Msg ) -> Self
|
pub fn on_press( mut self, msg: Msg ) -> Self
|
||||||
{
|
{
|
||||||
@@ -178,7 +207,9 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let label_size = canvas.font_px( theme::LABEL_SIZE );
|
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 has_sub = self.subtitle.is_some();
|
||||||
let label_y = if has_sub
|
let label_y = if has_sub
|
||||||
{
|
{
|
||||||
@@ -215,11 +246,27 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
canvas.draw_text( sub, text_x, sub_y, sub_size, subtitle_color );
|
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
|
if let Some( ref trail ) = self.trailing
|
||||||
{
|
{
|
||||||
let trail_size = canvas.font_px( theme::TRAILING_SIZE );
|
let trail_size = canvas.font_px( theme::TRAILING_SIZE );
|
||||||
let tw = canvas.measure_text( trail, trail_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;
|
let ty = rect.y + ( rect.height + trail_size ) / 2.0 - 2.0;
|
||||||
canvas.draw_text( trail, tx, ty, trail_size, trailing_color );
|
canvas.draw_text( trail, tx, ty, trail_size, trailing_color );
|
||||||
}
|
}
|
||||||
@@ -235,17 +282,21 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
label: self.label,
|
label: self.label,
|
||||||
subtitle: self.subtitle,
|
subtitle: self.subtitle,
|
||||||
trailing: self.trailing,
|
trailing: self.trailing,
|
||||||
|
trailing_icon: self.trailing_icon,
|
||||||
on_press: self.on_press.map( |m| ( *f )( m ) ),
|
on_press: self.on_press.map( |m| ( *f )( m ) ),
|
||||||
id: self.id,
|
id: self.id,
|
||||||
selected: self.selected,
|
selected: self.selected,
|
||||||
icon: self.icon,
|
icon: self.icon,
|
||||||
|
pad_h: self.pad_h,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a [`ListItem`] with the given primary label.
|
/// 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
|
/// ```rust,no_run
|
||||||
/// # use ltk::{ list_item, ListItem };
|
/// # use ltk::{ list_item, ListItem };
|
||||||
@@ -253,7 +304,7 @@ impl<Msg: Clone> ListItem<Msg>
|
|||||||
/// # fn _ex() -> ListItem<Msg> {
|
/// # fn _ex() -> ListItem<Msg> {
|
||||||
/// list_item( "Display" )
|
/// list_item( "Display" )
|
||||||
/// .subtitle( "Resolution, brightness, night mode" )
|
/// .subtitle( "Resolution, brightness, night mode" )
|
||||||
/// .trailing( "›" )
|
/// .trailing( "Light" )
|
||||||
/// .on_press( Msg::OpenDisplay )
|
/// .on_press( Msg::OpenDisplay )
|
||||||
/// # }
|
/// # }
|
||||||
/// ```
|
/// ```
|
||||||
|
|||||||
@@ -33,5 +33,7 @@ pub const RADIUS: f32 = 12.0;
|
|||||||
pub const FOCUS_W: f32 = 2.0;
|
pub const FOCUS_W: f32 = 2.0;
|
||||||
/// Visible side of the optional leading icon (square).
|
/// Visible side of the optional leading icon (square).
|
||||||
pub const ICON_SIZE: f32 = 24.0;
|
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.
|
/// Gap between the leading icon's right edge and the label baseline.
|
||||||
pub const ICON_GAP: f32 = 12.0;
|
pub const ICON_GAP: f32 = 12.0;
|
||||||
|
|||||||
Reference in New Issue
Block a user