egui_material3/list.rs
1use crate::theme::get_global_color;
2use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
3use crate::icons::icon_text;
4
5/// Material Design list component.
6///
7/// Lists are continuous, vertical indexes of text or images.
8/// They are composed of items containing primary and related actions.
9///
10/// # Example
11/// ```rust
12/// # egui::__run_test_ui(|ui| {
13/// let list = MaterialList::new()
14/// .item(ListItem::new("Inbox")
15/// .leading_icon("inbox")
16/// .trailing_text("12"))
17/// .item(ListItem::new("Starred")
18/// .leading_icon("star")
19/// .trailing_text("3"))
20/// .dividers(true);
21///
22/// ui.add(list);
23/// # });
24/// ```
25#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
26pub struct MaterialList<'a> {
27 /// List of items to display
28 items: Vec<ListItem<'a>>,
29 /// Whether to show dividers between items
30 dividers: bool,
31}
32
33/// Individual item in a Material Design list.
34///
35/// List items can contain primary text, secondary text, overline text,
36/// leading and trailing icons, and custom actions.
37///
38/// # Example
39/// ```rust
40/// let item = ListItem::new("Primary Text")
41/// .secondary_text("Secondary supporting text")
42/// .leading_icon("person")
43/// .trailing_icon("more_vert")
44/// .on_click(|| println!("Item clicked"));
45/// ```
46pub struct ListItem<'a> {
47 /// Main text displayed for this item
48 primary_text: String,
49 /// Optional secondary text displayed below primary text
50 secondary_text: Option<String>,
51 /// Optional overline text displayed above primary text
52 overline_text: Option<String>,
53 /// Optional icon displayed at the start of the item
54 leading_icon: Option<String>,
55 /// Optional icon displayed at the end of the item
56 trailing_icon: Option<String>,
57 /// Optional text displayed at the end of the item
58 trailing_text: Option<String>,
59 /// Whether the item is enabled and interactive
60 enabled: bool,
61 /// Callback function to execute when the item is clicked
62 action: Option<Box<dyn Fn() + 'a>>,
63}
64
65impl<'a> MaterialList<'a> {
66 /// Create a new empty list.
67 ///
68 /// # Example
69 /// ```rust
70 /// let list = MaterialList::new();
71 /// ```
72 pub fn new() -> Self {
73 Self {
74 items: Vec::new(),
75 dividers: true,
76 }
77 }
78
79 /// Add an item to the list.
80 ///
81 /// # Arguments
82 /// * `item` - The list item to add
83 ///
84 /// # Example
85 /// ```rust
86 /// # egui::__run_test_ui(|ui| {
87 /// let item = ListItem::new("Sample Item");
88 /// let list = MaterialList::new().item(item);
89 /// # });
90 /// ```
91 pub fn item(mut self, item: ListItem<'a>) -> Self {
92 self.items.push(item);
93 self
94 }
95
96 /// Set whether to show dividers between items.
97 ///
98 /// # Arguments
99 /// * `dividers` - Whether to show divider lines between items
100 ///
101 /// # Example
102 /// ```rust
103 /// let list = MaterialList::new().dividers(false); // No dividers
104 /// ```
105 pub fn dividers(mut self, dividers: bool) -> Self {
106 self.dividers = dividers;
107 self
108 }
109}
110
111impl<'a> ListItem<'a> {
112 /// Create a new list item with primary text.
113 ///
114 /// # Arguments
115 /// * `primary_text` - The main text to display
116 ///
117 /// # Example
118 /// ```rust
119 /// let item = ListItem::new("My List Item");
120 /// ```
121 pub fn new(primary_text: impl Into<String>) -> Self {
122 Self {
123 primary_text: primary_text.into(),
124 secondary_text: None,
125 overline_text: None,
126 leading_icon: None,
127 trailing_icon: None,
128 trailing_text: None,
129 enabled: true,
130 action: None,
131 }
132 }
133
134 /// Set the secondary text for the item.
135 ///
136 /// Secondary text is displayed below the primary text.
137 ///
138 /// # Arguments
139 /// * `text` - The secondary text to display
140 ///
141 /// # Example
142 /// ```rust
143 /// let item = ListItem::new("Item")
144 /// .secondary_text("This is some secondary text");
145 /// ```
146 pub fn secondary_text(mut self, text: impl Into<String>) -> Self {
147 self.secondary_text = Some(text.into());
148 self
149 }
150
151 /// Set the overline text for the item.
152 ///
153 /// Overline text is displayed above the primary text.
154 ///
155 /// # Arguments
156 /// * `text` - The overline text to display
157 ///
158 /// # Example
159 /// ```rust
160 /// let item = ListItem::new("Item")
161 /// .overline("Important")
162 /// .secondary_text("This is some secondary text");
163 /// ```
164 pub fn overline(mut self, text: impl Into<String>) -> Self {
165 self.overline_text = Some(text.into());
166 self
167 }
168
169 /// Set a leading icon for the item.
170 ///
171 /// A leading icon is displayed at the start of the item, before the text.
172 ///
173 /// # Arguments
174 /// * `icon` - The name of the icon to display
175 ///
176 /// # Example
177 /// ```rust
178 /// let item = ListItem::new("Item")
179 /// .leading_icon("check");
180 /// ```
181 pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
182 self.leading_icon = Some(icon.into());
183 self
184 }
185
186 /// Set a trailing icon for the item.
187 ///
188 /// A trailing icon is displayed at the end of the item, after the text.
189 ///
190 /// # Arguments
191 /// * `icon` - The name of the icon to display
192 ///
193 /// # Example
194 /// ```rust
195 /// let item = ListItem::new("Item")
196 /// .trailing_icon("more_vert");
197 /// ```
198 pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
199 self.trailing_icon = Some(icon.into());
200 self
201 }
202
203 /// Set trailing text for the item.
204 ///
205 /// Trailing text is displayed at the end of the item, after the icons.
206 ///
207 /// # Arguments
208 /// * `text` - The trailing text to display
209 ///
210 /// # Example
211 /// ```rust
212 /// let item = ListItem::new("Item")
213 /// .trailing_text("99+");
214 /// ```
215 pub fn trailing_text(mut self, text: impl Into<String>) -> Self {
216 self.trailing_text = Some(text.into());
217 self
218 }
219
220 /// Enable or disable the item.
221 ///
222 /// Disabled items are not interactive and are typically displayed with
223 /// reduced opacity.
224 ///
225 /// # Arguments
226 /// * `enabled` - Whether the item should be enabled
227 ///
228 /// # Example
229 /// ```rust
230 /// let item = ListItem::new("Item")
231 /// .enabled(false); // This item is disabled
232 /// ```
233 pub fn enabled(mut self, enabled: bool) -> Self {
234 self.enabled = enabled;
235 self
236 }
237
238 /// Set a click action for the item.
239 ///
240 /// # Arguments
241 /// * `f` - A function to call when the item is clicked
242 ///
243 /// # Example
244 /// ```rust
245 /// let item = ListItem::new("Item")
246 /// .on_click(|| {
247 /// println!("Item was clicked!");
248 /// });
249 /// ```
250 pub fn on_click<F>(mut self, f: F) -> Self
251 where
252 F: Fn() + 'a,
253 {
254 self.action = Some(Box::new(f));
255 self
256 }
257}
258
259impl<'a> Widget for MaterialList<'a> {
260 fn ui(self, ui: &mut Ui) -> Response {
261 let mut total_height = 0.0;
262 let item_height = if self.items.iter().any(|item| item.secondary_text.is_some() || item.overline_text.is_some()) {
263 if self.items.iter().any(|item| item.overline_text.is_some() && item.secondary_text.is_some()) {
264 88.0 // Three-line item height (overline + primary + secondary)
265 } else {
266 72.0 // Two-line item height
267 }
268 } else {
269 56.0 // Single-line item height
270 };
271
272 // Calculate dynamic width based on content
273 let mut max_content_width = 200.0; // minimum width
274 for item in &self.items {
275 let mut item_width = 32.0; // base padding
276
277 // Add leading icon width
278 if item.leading_icon.is_some() {
279 item_width += 40.0;
280 }
281
282 // Add text width (approximate)
283 let primary_text_width = item.primary_text.len() as f32 * 8.0; // rough estimate
284 let secondary_text_width = item.secondary_text.as_ref()
285 .map_or(0.0, |s| s.len() as f32 * 6.0); // smaller font
286 let overline_text_width = item.overline_text.as_ref()
287 .map_or(0.0, |s| s.len() as f32 * 5.5); // smallest font
288 let max_text_width = primary_text_width.max(secondary_text_width).max(overline_text_width);
289 item_width += max_text_width;
290
291 // Add trailing text width
292 if let Some(ref trailing_text) = item.trailing_text {
293 item_width += trailing_text.len() as f32 * 6.0;
294 }
295
296 // Add trailing icon width
297 if item.trailing_icon.is_some() {
298 item_width += 40.0;
299 }
300
301 // Add some padding
302 item_width += 32.0;
303
304 if item_width > max_content_width {
305 max_content_width = item_width;
306 }
307 }
308
309 // Cap the width to available width but allow it to be smaller
310 let list_width = max_content_width.min(ui.available_width());
311
312 total_height += item_height * self.items.len() as f32;
313 if self.dividers && self.items.len() > 1 {
314 total_height += (self.items.len() - 1) as f32; // 1px dividers
315 }
316
317 let desired_size = Vec2::new(list_width, total_height);
318 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
319
320 // Material Design colors
321 let surface = get_global_color("surface");
322 let on_surface = get_global_color("onSurface");
323 let on_surface_variant = get_global_color("onSurfaceVariant");
324 let outline_variant = get_global_color("outlineVariant");
325
326 // Draw list background with rounded rectangle border
327 ui.painter().rect_filled(rect, 8.0, surface);
328 ui.painter().rect_stroke(rect, 8.0, Stroke::new(1.0, outline_variant), egui::epaint::StrokeKind::Outside);
329
330 let mut current_y = rect.min.y;
331 let mut pending_actions = Vec::new();
332
333 let items_len = self.items.len();
334 for (index, item) in self.items.into_iter().enumerate() {
335 let item_rect = Rect::from_min_size(
336 Pos2::new(rect.min.x, current_y),
337 Vec2::new(rect.width(), item_height),
338 );
339
340 // Use unique ID combining index and text content to prevent clashes
341 let unique_id = egui::Id::new(("list_item", index, item.primary_text.clone()));
342 let item_response = ui.interact(item_rect, unique_id, Sense::click());
343
344 // Draw item background on hover
345 if item_response.hovered() && item.enabled {
346 let hover_color = Color32::from_rgba_premultiplied(on_surface.r(), on_surface.g(), on_surface.b(), 20);
347 ui.painter().rect_filled(item_rect, 0.0, hover_color);
348 }
349
350 // Handle click
351 if item_response.clicked() && item.enabled {
352 if let Some(action) = item.action {
353 pending_actions.push(action);
354 }
355 }
356
357 // Layout item content
358 let mut content_x = item_rect.min.x + 16.0;
359 let content_y = item_rect.center().y;
360
361 // Draw leading icon
362 if let Some(icon_name) = &item.leading_icon {
363 let icon_pos = Pos2::new(content_x + 12.0, content_y);
364
365 let icon_color = if item.enabled { on_surface_variant } else {
366 get_global_color("onSurfaceVariant").linear_multiply(0.38)
367 };
368
369 let icon_string = icon_text(icon_name);
370 ui.painter().text(
371 icon_pos,
372 egui::Align2::CENTER_CENTER,
373 &icon_string,
374 egui::FontId::proportional(20.0),
375 icon_color,
376 );
377 content_x += 40.0;
378 }
379
380 // Calculate text area with trailing text support
381 let trailing_icon_width = if item.trailing_icon.is_some() { 40.0 } else { 0.0 };
382 let trailing_text_width = if item.trailing_text.is_some() { 80.0 } else { 0.0 }; // Estimate
383 let total_trailing_width = trailing_icon_width + trailing_text_width;
384 let _text_width = item_rect.max.x - content_x - total_trailing_width - 16.0;
385
386 // Draw text content
387 let text_color = if item.enabled { on_surface } else {
388 get_global_color("onSurfaceVariant").linear_multiply(0.38)
389 };
390
391 // Layout text based on what's present
392 match (&item.overline_text, &item.secondary_text) {
393 (Some(overline), Some(secondary)) => {
394 // Three-line layout: overline + primary + secondary
395 let overline_pos = Pos2::new(content_x, content_y - 20.0);
396 let primary_pos = Pos2::new(content_x, content_y);
397 let secondary_pos = Pos2::new(content_x, content_y + 20.0);
398
399 ui.painter().text(
400 overline_pos,
401 egui::Align2::LEFT_CENTER,
402 overline,
403 egui::FontId::proportional(11.0),
404 on_surface_variant,
405 );
406
407 ui.painter().text(
408 primary_pos,
409 egui::Align2::LEFT_CENTER,
410 &item.primary_text,
411 egui::FontId::default(),
412 text_color,
413 );
414
415 ui.painter().text(
416 secondary_pos,
417 egui::Align2::LEFT_CENTER,
418 secondary,
419 egui::FontId::proportional(12.0),
420 on_surface_variant,
421 );
422 },
423 (Some(overline), None) => {
424 // Two-line layout: overline + primary
425 let overline_pos = Pos2::new(content_x, content_y - 10.0);
426 let primary_pos = Pos2::new(content_x, content_y + 10.0);
427
428 ui.painter().text(
429 overline_pos,
430 egui::Align2::LEFT_CENTER,
431 overline,
432 egui::FontId::proportional(11.0),
433 on_surface_variant,
434 );
435
436 ui.painter().text(
437 primary_pos,
438 egui::Align2::LEFT_CENTER,
439 &item.primary_text,
440 egui::FontId::default(),
441 text_color,
442 );
443 },
444 (None, Some(secondary)) => {
445 // Two-line layout: primary + secondary
446 let primary_pos = Pos2::new(content_x, content_y - 10.0);
447 let secondary_pos = Pos2::new(content_x, content_y + 10.0);
448
449 ui.painter().text(
450 primary_pos,
451 egui::Align2::LEFT_CENTER,
452 &item.primary_text,
453 egui::FontId::default(),
454 text_color,
455 );
456
457 ui.painter().text(
458 secondary_pos,
459 egui::Align2::LEFT_CENTER,
460 secondary,
461 egui::FontId::proportional(12.0),
462 on_surface_variant,
463 );
464 },
465 (None, None) => {
466 // Single-line layout: primary only
467 let text_pos = Pos2::new(content_x, content_y);
468 ui.painter().text(
469 text_pos,
470 egui::Align2::LEFT_CENTER,
471 &item.primary_text,
472 egui::FontId::default(),
473 text_color,
474 );
475 }
476 }
477
478 // Draw trailing supporting text
479 if let Some(ref trailing_text) = item.trailing_text {
480 let trailing_text_pos = Pos2::new(
481 item_rect.max.x - trailing_icon_width - trailing_text_width + 10.0,
482 content_y
483 );
484
485 ui.painter().text(
486 trailing_text_pos,
487 egui::Align2::LEFT_CENTER,
488 trailing_text,
489 egui::FontId::proportional(12.0),
490 on_surface_variant,
491 );
492 }
493
494 // Draw trailing icon
495 if let Some(icon_name) = &item.trailing_icon {
496 let icon_pos = Pos2::new(item_rect.max.x - 28.0, content_y);
497
498 let icon_color = if item.enabled { on_surface_variant } else {
499 get_global_color("onSurfaceVariant").linear_multiply(0.38)
500 };
501
502 let icon_string = icon_text(icon_name);
503 ui.painter().text(
504 icon_pos,
505 egui::Align2::CENTER_CENTER,
506 &icon_string,
507 egui::FontId::proportional(20.0),
508 icon_color,
509 );
510 }
511
512 current_y += item_height;
513
514 // Draw divider
515 if self.dividers && index < items_len - 1 {
516 let divider_y = current_y;
517 let divider_start = Pos2::new(rect.min.x + 16.0, divider_y);
518 let divider_end = Pos2::new(rect.max.x - 16.0, divider_y);
519
520 ui.painter().line_segment(
521 [divider_start, divider_end],
522 Stroke::new(1.0, outline_variant),
523 );
524 current_y += 1.0;
525 }
526 }
527
528 // Execute pending actions
529 for action in pending_actions {
530 action();
531 }
532
533 response
534 }
535}
536
537pub fn list_item(primary_text: impl Into<String>) -> ListItem<'static> {
538 ListItem::new(primary_text)
539}
540
541pub fn list() -> MaterialList<'static> {
542 MaterialList::new()
543}