list_item: optional leading icon
Some checks failed
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Add a leading-icon slot to ListItem so settings-style rows can pair
a 24 px symbolic glyph with the label. `.icon( rgba, w, h )` takes
the same shape the rest of the toolkit uses for raw pixmaps; the
draw path reserves `ICON_SIZE + ICON_GAP` on the left and shifts
the label / subtitle text origin so existing icon-less rows render
unchanged.
This commit is contained in:
yamabush1
2026-05-25 09:00:00 +02:00
parent 24f4d2703a
commit f7ef932976
2 changed files with 45 additions and 4 deletions

View File

@@ -1,6 +1,8 @@
// SPDX-License-Identifier: LGPL-2.1-only // SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net> // Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use std::sync::Arc;
use crate::types::{ Rect, WidgetId }; use crate::types::{ Rect, WidgetId };
use crate::render::Canvas; use crate::render::Canvas;
use super::Element; use super::Element;
@@ -49,9 +51,14 @@ pub struct ListItem<Msg: Clone>
pub id: Option<WidgetId>, pub id: Option<WidgetId>,
/// `true` paints the row with the dark selected surface and white /// `true` paints the row with the dark selected surface and white
/// text, regardless of hover / press state. Use to indicate the /// text, regardless of hover / press state. Use to indicate the
/// active item in a list of choices (combo dropdown, segmented /// active item in a list of choices (combo dropdown, settings
/// picker, settings group with a single active value). /// group with a single active value).
pub selected: bool, pub selected: bool,
/// Optional leading icon — RGBA bytes + native dimensions. The
/// row reserves `theme::ICON_SIZE + theme::ICON_GAP` on the left
/// when this is set, and offsets the label / subtitle by the
/// same amount. Pass `None` to keep the icon-less layout.
pub icon: Option<( Arc<Vec<u8>>, u32, u32 )>,
} }
impl<Msg: Clone> ListItem<Msg> impl<Msg: Clone> ListItem<Msg>
@@ -68,9 +75,21 @@ impl<Msg: Clone> ListItem<Msg>
on_press: None, on_press: None,
id: None, id: None,
selected: false, selected: false,
icon: None,
} }
} }
/// Attach a leading icon. Pass the decoded RGBA buffer alongside
/// the image's native width and height; the draw path scales it
/// down to [`theme::ICON_SIZE`] on the same row baseline as the
/// label. Symbolic icons should be pre-tinted by the caller (see
/// [`crate::tint_symbolic`]).
pub fn icon( mut self, rgba: Arc<Vec<u8>>, w: u32, h: u32 ) -> Self
{
self.icon = Some( ( rgba, w, h ) );
self
}
/// Mark this row as the currently-selected option in its list. /// Mark this row as the currently-selected option in its list.
/// Selected rows paint with a dark surface and white text and /// Selected rows paint with a dark surface and white text and
/// override hover / press visuals. /// override hover / press visuals.
@@ -161,12 +180,29 @@ impl<Msg: Clone> ListItem<Msg>
rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0 rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0
}; };
canvas.draw_text( &self.label, rect.x + theme::PAD_H, label_y, theme::LABEL_SIZE, label_color ); // Leading-icon column shifts the text right by
// `ICON_SIZE + ICON_GAP`. Symbolic icons (single channel
// pre-tinted) draw at full opacity; the colour comes from
// the bytes the caller passed in, which is why the helper
// is in `theme::icon_rgba` rather than baked here.
let text_x = if let Some( ( rgba, w, h ) ) = &self.icon
{
let icon_y = rect.y + ( rect.height - theme::ICON_SIZE ) / 2.0;
let icon_rect = Rect { x: rect.x + theme::PAD_H, y: icon_y, width: theme::ICON_SIZE, height: theme::ICON_SIZE };
canvas.draw_image_data( rgba, *w, *h, icon_rect, 1.0 );
rect.x + theme::PAD_H + theme::ICON_SIZE + theme::ICON_GAP
}
else
{
rect.x + theme::PAD_H
};
canvas.draw_text( &self.label, text_x, label_y, theme::LABEL_SIZE, label_color );
if let Some( ref sub ) = self.subtitle if let Some( ref sub ) = self.subtitle
{ {
let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3; let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3;
canvas.draw_text( sub, rect.x + theme::PAD_H, sub_y, theme::SUBTITLE_SIZE, subtitle_color ); canvas.draw_text( sub, text_x, sub_y, theme::SUBTITLE_SIZE, subtitle_color );
} }
if let Some( ref trail ) = self.trailing if let Some( ref trail ) = self.trailing
@@ -191,6 +227,7 @@ impl<Msg: Clone> ListItem<Msg>
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,
} }
} }
} }

View File

@@ -31,3 +31,7 @@ pub const HEIGHT_SUB: f32 = 68.0;
pub const PAD_H: f32 = 16.0; pub const PAD_H: f32 = 16.0;
pub const RADIUS: f32 = 12.0; 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).
pub const ICON_SIZE: f32 = 24.0;
/// Gap between the leading icon's right edge and the label baseline.
pub const ICON_GAP: f32 = 12.0;