ltk/widget/list_item/
mod.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

use std::sync::Arc;

use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;

mod theme;

#[cfg(test)]
mod tests;

/// A row inside a list with a primary label and optional subtitle / trailing
/// text.
///
/// Use to build settings menus, navigation lists, contact rows or any other
/// vertically-stacked tappable content. The widget paints its own hover and
/// pressed surfaces and a rounded focus ring; wrap a column of `ListItem`s
/// inside a [`scroll`](crate::scroll) for scrollable lists.
///
/// ```rust,no_run
/// # use ltk::{ column, list_item, scroll, Element };
/// # #[ derive( Clone ) ] enum Msg { OpenWifi, OpenBluetooth, OpenDisplay }
/// # fn _ex() -> Element<Msg> {
/// // In view():
/// scroll(
///     column()
///         .push( list_item( "Wi-Fi" ).trailing( "Eduroam" ).on_press( Msg::OpenWifi ) )
///         .push( list_item( "Bluetooth" ).subtitle( "AirPods Pro" ).on_press( Msg::OpenBluetooth ) )
///         .push( list_item( "Display" ).trailing( "Light" ).on_press( Msg::OpenDisplay ) ),
/// )
/// .into()
/// # }
/// ```
pub struct ListItem<Msg: Clone>
{
	/// Primary label (always visible, top-aligned when a subtitle is
	/// present).
	pub label:    String,
	/// Optional secondary line drawn below the label in muted colour.
	/// Doubles the row height when set.
	pub subtitle: Option<String>,
	/// Optional right-aligned text (current setting, badge count, "›"
	/// disclosure). Drawn in muted colour.
	pub trailing: Option<String>,
	/// Message emitted on tap. `None` keeps the item visible but inert.
	pub on_press: Option<Msg>,
	/// Optional stable identifier for focus management.
	pub id:       Option<WidgetId>,
	/// `true` paints the row with the dark selected surface and white
	/// text, regardless of hover / press state. Use to indicate the
	/// active item in a list of choices (combo dropdown, settings
	/// group with a single active value).
	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>
{
	/// Create a list item with the given primary label, no subtitle, no
	/// trailing text and no callback.
	pub fn new( label: impl Into<String> ) -> Self
	{
		Self
		{
			label:    label.into(),
			subtitle: None,
			trailing: None,
			on_press: None,
			id:       None,
			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.
	/// Selected rows paint with a dark surface and white text and
	/// override hover / press visuals.
	pub fn selected( mut self, yes: bool ) -> Self
	{
		self.selected = yes;
		self
	}

	/// Add a secondary line below the label. Doubles the row height to
	/// fit both lines comfortably.
	pub fn subtitle( mut self, s: impl Into<String> ) -> Self
	{
		self.subtitle = Some( s.into() );
		self
	}

	/// Add right-aligned text (settings value, badge, disclosure arrow).
	pub fn trailing( mut self, s: impl Into<String> ) -> Self
	{
		self.trailing = Some( s.into() );
		self
	}

	/// Set the message emitted when the row is tapped.
	pub fn on_press( mut self, msg: Msg ) -> Self
	{
		self.on_press = Some( msg );
		self
	}

	/// Assign a stable identifier for focus management.
	pub fn id( mut self, id: WidgetId ) -> Self
	{
		self.id = Some( id );
		self
	}

	pub fn preferred_size( &self, max_width: f32, _canvas: &Canvas ) -> (f32, f32)
	{
		let h = if self.subtitle.is_some() { theme::HEIGHT_SUB } else { theme::HEIGHT };
		( max_width, h )
	}

	/// Focus stroke is centered on `rect`, so half the stroke width plus ~1 px
	/// of antialiasing bleed sits outside.
	pub fn paint_bounds( &self, rect: Rect ) -> Rect
	{
		rect.expand( theme::FOCUS_W * 0.5 + 1.0 )
	}

	pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool, hovered: bool, pressed: bool )
	{
		// Selected wins over hover and press: a chosen item stays
		// chosen even when the pointer is over it.
		let ( label_color, subtitle_color, trailing_color ) = if self.selected
		{
			canvas.fill_rect( rect, theme::selected_bg(), theme::RADIUS );
			// Selected row is filled with `text_primary` colour;
			// label / subtitle / trailing read as the inverse via
			// `bg` so they stay legible.
			let inverse = crate::theme::palette().bg;
			( inverse, inverse, inverse )
		}
		else
		{
			if pressed
			{
				canvas.fill_rect( rect, theme::press_bg(), theme::RADIUS );
			}
			else if hovered
			{
				canvas.fill_rect( rect, theme::hover_bg(), theme::RADIUS );
			}
			( theme::label_color(), theme::subtitle_color(), theme::trailing_color() )
		};

		if focused
		{
			canvas.stroke_rect( rect, theme::focus_color(), theme::FOCUS_W, theme::RADIUS );
		}

		let has_sub  = self.subtitle.is_some();
		let label_y  = if has_sub
		{
			rect.y + rect.height * 0.35 + theme::LABEL_SIZE * 0.3
		} else {
			rect.y + ( rect.height + theme::LABEL_SIZE ) / 2.0 - 2.0
		};

		// 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
		{
			let sub_y = rect.y + rect.height * 0.62 + theme::SUBTITLE_SIZE * 0.3;
			canvas.draw_text( sub, text_x, sub_y, theme::SUBTITLE_SIZE, subtitle_color );
		}

		if let Some( ref trail ) = self.trailing
		{
			let tw = canvas.measure_text( trail, theme::TRAILING_SIZE );
			let tx = rect.x + rect.width - theme::PAD_H - tw;
			let ty = rect.y + ( rect.height + theme::TRAILING_SIZE ) / 2.0 - 2.0;
			canvas.draw_text( trail, tx, ty, theme::TRAILING_SIZE, trailing_color );
		}
	}

	pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> ListItem<U>
	where
		U: Clone + 'static,
		Msg: 'static,
	{
		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,
		}
	}
}

/// Create a [`ListItem`] with the given primary label.
///
/// Add detail and behaviour through the chained builders:
///
/// ```rust,no_run
/// # use ltk::{ list_item, ListItem };
/// # #[ derive( Clone ) ] enum Msg { OpenDisplay }
/// # fn _ex() -> ListItem<Msg> {
/// list_item( "Display" )
///     .subtitle( "Resolution, brightness, night mode" )
///     .trailing( "›" )
///     .on_press( Msg::OpenDisplay )
/// # }
/// ```
pub fn list_item<Msg: Clone>( label: impl Into<String> ) -> ListItem<Msg>
{
	ListItem::new( label )
}

impl<Msg: Clone + 'static> From<ListItem<Msg>> for Element<Msg>
{
	fn from( l: ListItem<Msg> ) -> Self
	{
		Element::ListItem( l )
	}
}