egui_material3/list.rs
1use crate::material_symbol::material_symbol_text;
2use crate::theme::get_global_color;
3use eframe::egui::{self, Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2, Widget};
4
5/// Defines the title font used for ListTile descendants.
6///
7/// List tiles that appear in a drawer use a smaller text style,
8/// while standard list tiles use the default title text style.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ListTileStyle {
11 /// Use a title font appropriate for a list tile in a list.
12 List,
13 /// Use a title font appropriate for a list tile in a drawer.
14 Drawer,
15}
16
17/// Defines how leading and trailing widgets are vertically aligned
18/// relative to the list tile's titles (title and subtitle).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ListTileTitleAlignment {
21 /// The top of leading/trailing widgets are placed below the title top
22 /// if three-line, otherwise centered relative to title and subtitle.
23 /// This is the default for Material 3.
24 ThreeLine,
25 /// Leading/trailing are placed 16px below title top if tile height > 72,
26 /// otherwise centered. This is the default for Material 2.
27 TitleHeight,
28 /// Leading/trailing tops are placed at min vertical padding below title top.
29 Top,
30 /// Leading/trailing are centered relative to the titles.
31 Center,
32 /// Leading/trailing bottoms are placed at min vertical padding above title bottom.
33 Bottom,
34}
35
36/// Defines the visual density for the list tile layout.
37///
38/// Visual density allows for compact, comfortable, or spacious layouts.
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct VisualDensity {
41 /// Horizontal density adjustment (-4.0 to 4.0)
42 pub horizontal: f32,
43 /// Vertical density adjustment (-4.0 to 4.0)
44 pub vertical: f32,
45}
46
47impl VisualDensity {
48 /// Standard density (no adjustment)
49 pub const STANDARD: Self = Self {
50 horizontal: 0.0,
51 vertical: 0.0,
52 };
53
54 /// Comfortable density (slightly more spacious)
55 pub const COMFORTABLE: Self = Self {
56 horizontal: -1.0,
57 vertical: -1.0,
58 };
59
60 /// Compact density (space-efficient)
61 pub const COMPACT: Self = Self {
62 horizontal: -2.0,
63 vertical: -2.0,
64 };
65
66 /// Create a custom visual density
67 pub fn new(horizontal: f32, vertical: f32) -> Self {
68 Self {
69 horizontal: horizontal.clamp(-4.0, 4.0),
70 vertical: vertical.clamp(-4.0, 4.0),
71 }
72 }
73
74 /// Get the base size adjustment as a Vec2
75 pub fn base_size_adjustment(&self) -> Vec2 {
76 Vec2::new(self.horizontal * 4.0, self.vertical * 4.0)
77 }
78}
79
80impl Default for VisualDensity {
81 fn default() -> Self {
82 Self::STANDARD
83 }
84}
85
86/// Material Design list component.
87///
88/// Lists are continuous, vertical indexes of text or images.
89/// They are composed of items containing primary and related actions.
90///
91/// # Example
92/// ```rust
93/// # egui::__run_test_ui(|ui| {
94/// let list = MaterialList::new()
95/// .item(ListItem::new("Inbox")
96/// .leading_icon("inbox")
97/// .trailing_text("12"))
98/// .item(ListItem::new("Starred")
99/// .leading_icon("star")
100/// .trailing_text("3"))
101/// .dividers(true);
102///
103/// ui.add(list);
104/// # });
105/// ```
106#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
107pub struct MaterialList<'a> {
108 /// List of items to display
109 items: Vec<ListItem<'a>>,
110 /// Whether to show dividers between items
111 dividers: bool,
112 /// Optional unique ID for this list to avoid widget ID collisions
113 id: Option<egui::Id>,
114}
115
116/// Individual item in a Material Design list.
117///
118/// List items can contain primary text, secondary text, overline text,
119/// leading and trailing icons, and custom actions.
120///
121/// # Example
122/// ```rust
123/// let item = ListItem::new("Primary Text")
124/// .secondary_text("Secondary supporting text")
125/// .leading_icon("person")
126/// .trailing_icon("more_vert")
127/// .on_click(|| println!("Item clicked"));
128/// ```
129pub struct ListItem<'a> {
130 /// Main text displayed for this item
131 primary_text: String,
132 /// Optional secondary text displayed below primary text
133 secondary_text: Option<String>,
134 /// Optional overline text displayed above primary text
135 overline_text: Option<String>,
136 /// Optional icon displayed at the start of the item
137 leading_icon: Option<String>,
138 /// Optional icon displayed at the end of the item
139 trailing_icon: Option<String>,
140 /// Optional text displayed at the end of the item
141 trailing_text: Option<String>,
142 /// Whether the item is enabled and interactive
143 enabled: bool,
144 /// Whether the item is selected
145 selected: bool,
146 /// Whether this list tile is part of a vertically dense list
147 dense: Option<bool>,
148 /// Whether this list tile is intended to display three lines of text
149 is_three_line: Option<bool>,
150 /// Defines how compact the list tile's layout will be
151 visual_density: Option<VisualDensity>,
152 /// Defines the font used for the title
153 style: Option<ListTileStyle>,
154 /// Defines how leading and trailing are vertically aligned
155 title_alignment: Option<ListTileTitleAlignment>,
156 /// The horizontal gap between the titles and the leading/trailing widgets
157 horizontal_title_gap: Option<f32>,
158 /// The minimum padding on the top and bottom of the title and subtitle widgets
159 min_vertical_padding: Option<f32>,
160 /// The minimum width allocated for the leading widget
161 min_leading_width: Option<f32>,
162 /// The minimum height allocated for the list tile widget
163 min_tile_height: Option<f32>,
164 /// Background color when selected is false
165 tile_color: Option<Color32>,
166 /// Background color when selected is true
167 selected_tile_color: Option<Color32>,
168 /// Color for icons and text when selected
169 selected_color: Option<Color32>,
170 /// Default color for leading and trailing icons
171 icon_color: Option<Color32>,
172 /// Text color for title, subtitle, leading, and trailing
173 text_color: Option<Color32>,
174 /// Callback function to execute when the item is clicked
175 action: Option<Box<dyn Fn() + 'a>>,
176}
177
178impl<'a> MaterialList<'a> {
179 /// Create a new empty list.
180 ///
181 /// # Example
182 /// ```rust
183 /// let list = MaterialList::new();
184 /// ```
185 pub fn new() -> Self {
186 Self {
187 items: Vec::new(),
188 dividers: true,
189 id: None,
190 }
191 }
192
193 /// Add an item to the list.
194 ///
195 /// # Arguments
196 /// * `item` - The list item to add
197 ///
198 /// # Example
199 /// ```rust
200 /// # egui::__run_test_ui(|ui| {
201 /// let item = ListItem::new("Sample Item");
202 /// let list = MaterialList::new().item(item);
203 /// # });
204 /// ```
205 pub fn item(mut self, item: ListItem<'a>) -> Self {
206 self.items.push(item);
207 self
208 }
209
210 /// Set whether to show dividers between items.
211 ///
212 /// # Arguments
213 /// * `dividers` - Whether to show divider lines between items
214 ///
215 /// # Example
216 /// ```rust
217 /// let list = MaterialList::new().dividers(false); // No dividers
218 /// ```
219 pub fn dividers(mut self, dividers: bool) -> Self {
220 self.dividers = dividers;
221 self
222 }
223
224 /// Set a custom ID for this list to avoid widget ID collisions.
225 ///
226 /// Use this when you have multiple lists with similar content in the same UI.
227 ///
228 /// # Arguments
229 /// * `id` - A unique identifier for this list
230 ///
231 /// # Example
232 /// ```rust
233 /// let list = MaterialList::new()
234 /// .id(egui::Id::new("my_list"))
235 /// .dividers(false);
236 /// ```
237 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
238 self.id = Some(id.into());
239 self
240 }
241}
242
243impl<'a> ListItem<'a> {
244 /// Create a new list item with primary text.
245 ///
246 /// # Arguments
247 /// * `primary_text` - The main text to display
248 ///
249 /// # Example
250 /// ```rust
251 /// let item = ListItem::new("My List Item");
252 /// ```
253 pub fn new(primary_text: impl Into<String>) -> Self {
254 Self {
255 primary_text: primary_text.into(),
256 secondary_text: None,
257 overline_text: None,
258 leading_icon: None,
259 trailing_icon: None,
260 trailing_text: None,
261 enabled: true,
262 selected: false,
263 dense: None,
264 is_three_line: None,
265 visual_density: None,
266 style: None,
267 title_alignment: None,
268 horizontal_title_gap: None,
269 min_vertical_padding: None,
270 min_leading_width: None,
271 min_tile_height: None,
272 tile_color: None,
273 selected_tile_color: None,
274 selected_color: None,
275 icon_color: None,
276 text_color: None,
277 action: None,
278 }
279 }
280
281 /// Set the secondary text for the item.
282 ///
283 /// Secondary text is displayed below the primary text.
284 ///
285 /// # Arguments
286 /// * `text` - The secondary text to display
287 ///
288 /// # Example
289 /// ```rust
290 /// let item = ListItem::new("Item")
291 /// .secondary_text("This is some secondary text");
292 /// ```
293 pub fn secondary_text(mut self, text: impl Into<String>) -> Self {
294 self.secondary_text = Some(text.into());
295 self
296 }
297
298 /// Set the overline text for the item.
299 ///
300 /// Overline text is displayed above the primary text.
301 ///
302 /// # Arguments
303 /// * `text` - The overline text to display
304 ///
305 /// # Example
306 /// ```rust
307 /// let item = ListItem::new("Item")
308 /// .overline("Important")
309 /// .secondary_text("This is some secondary text");
310 /// ```
311 pub fn overline(mut self, text: impl Into<String>) -> Self {
312 self.overline_text = Some(text.into());
313 self
314 }
315
316 /// Set a leading icon for the item.
317 ///
318 /// A leading icon is displayed at the start of the item, before the text.
319 ///
320 /// # Arguments
321 /// * `icon` - The name of the icon to display
322 ///
323 /// # Example
324 /// ```rust
325 /// let item = ListItem::new("Item")
326 /// .leading_icon("check");
327 /// ```
328 pub fn leading_icon(mut self, icon: impl Into<String>) -> Self {
329 self.leading_icon = Some(icon.into());
330 self
331 }
332
333 /// Set a trailing icon for the item.
334 ///
335 /// A trailing icon is displayed at the end of the item, after the text.
336 ///
337 /// # Arguments
338 /// * `icon` - The name of the icon to display
339 ///
340 /// # Example
341 /// ```rust
342 /// let item = ListItem::new("Item")
343 /// .trailing_icon("more_vert");
344 /// ```
345 pub fn trailing_icon(mut self, icon: impl Into<String>) -> Self {
346 self.trailing_icon = Some(icon.into());
347 self
348 }
349
350 /// Set trailing text for the item.
351 ///
352 /// Trailing text is displayed at the end of the item, after the icons.
353 ///
354 /// # Arguments
355 /// * `text` - The trailing text to display
356 ///
357 /// # Example
358 /// ```rust
359 /// let item = ListItem::new("Item")
360 /// .trailing_text("99+");
361 /// ```
362 pub fn trailing_text(mut self, text: impl Into<String>) -> Self {
363 self.trailing_text = Some(text.into());
364 self
365 }
366
367 /// Enable or disable the item.
368 ///
369 /// Disabled items are not interactive and are typically displayed with
370 /// reduced opacity.
371 ///
372 /// # Arguments
373 /// * `enabled` - Whether the item should be enabled
374 ///
375 /// # Example
376 /// ```rust
377 /// let item = ListItem::new("Item")
378 /// .enabled(false); // This item is disabled
379 /// ```
380 pub fn enabled(mut self, enabled: bool) -> Self {
381 self.enabled = enabled;
382 self
383 }
384
385 /// Set the selected state of the item.
386 ///
387 /// Selected items are highlighted with a different background color
388 /// and may use different text/icon colors.
389 ///
390 /// # Arguments
391 /// * `selected` - Whether the item should appear selected
392 ///
393 /// # Example
394 /// ```rust
395 /// let item = ListItem::new("Item")
396 /// .selected(true); // This item appears selected
397 /// ```
398 pub fn selected(mut self, selected: bool) -> Self {
399 self.selected = selected;
400 self
401 }
402
403 /// Set whether this list tile is part of a vertically dense list.
404 ///
405 /// Dense list tiles default to a smaller height.
406 ///
407 /// # Arguments
408 /// * `dense` - Whether to use dense layout
409 ///
410 /// # Example
411 /// ```rust
412 /// let item = ListItem::new("Item")
413 /// .dense(true); // Compact layout
414 /// ```
415 pub fn dense(mut self, dense: bool) -> Self {
416 self.dense = Some(dense);
417 self
418 }
419
420 /// Set whether this list tile is intended to display three lines of text.
421 ///
422 /// # Arguments
423 /// * `is_three_line` - Whether to use three-line layout
424 ///
425 /// # Example
426 /// ```rust
427 /// let item = ListItem::new("Item")
428 /// .is_three_line(true);
429 /// ```
430 pub fn is_three_line(mut self, is_three_line: bool) -> Self {
431 self.is_three_line = Some(is_three_line);
432 self
433 }
434
435 /// Set the visual density for compact/comfortable/spacious layouts.
436 ///
437 /// # Arguments
438 /// * `density` - The visual density to apply
439 ///
440 /// # Example
441 /// ```rust
442 /// let item = ListItem::new("Item")
443 /// .visual_density(VisualDensity::COMPACT);
444 /// ```
445 pub fn visual_density(mut self, density: VisualDensity) -> Self {
446 self.visual_density = Some(density);
447 self
448 }
449
450 /// Set the title style (List or Drawer).
451 ///
452 /// # Arguments
453 /// * `style` - The list tile style
454 ///
455 /// # Example
456 /// ```rust
457 /// let item = ListItem::new("Item")
458 /// .style(ListTileStyle::Drawer);
459 /// ```
460 pub fn style(mut self, style: ListTileStyle) -> Self {
461 self.style = Some(style);
462 self
463 }
464
465 /// Set how leading and trailing widgets are vertically aligned.
466 ///
467 /// # Arguments
468 /// * `alignment` - The title alignment mode
469 ///
470 /// # Example
471 /// ```rust
472 /// let item = ListItem::new("Item")
473 /// .title_alignment(ListTileTitleAlignment::Center);
474 /// ```
475 pub fn title_alignment(mut self, alignment: ListTileTitleAlignment) -> Self {
476 self.title_alignment = Some(alignment);
477 self
478 }
479
480 /// Set the horizontal gap between titles and leading/trailing widgets.
481 ///
482 /// # Arguments
483 /// * `gap` - The gap in pixels
484 ///
485 /// # Example
486 /// ```rust
487 /// let item = ListItem::new("Item")
488 /// .horizontal_title_gap(20.0);
489 /// ```
490 pub fn horizontal_title_gap(mut self, gap: f32) -> Self {
491 self.horizontal_title_gap = Some(gap);
492 self
493 }
494
495 /// Set the minimum padding on top and bottom of title/subtitle.
496 ///
497 /// # Arguments
498 /// * `padding` - The minimum vertical padding in pixels
499 ///
500 /// # Example
501 /// ```rust
502 /// let item = ListItem::new("Item")
503 /// .min_vertical_padding(8.0);
504 /// ```
505 pub fn min_vertical_padding(mut self, padding: f32) -> Self {
506 self.min_vertical_padding = Some(padding);
507 self
508 }
509
510 /// Set the minimum width allocated for the leading widget.
511 ///
512 /// # Arguments
513 /// * `width` - The minimum leading width in pixels
514 ///
515 /// # Example
516 /// ```rust
517 /// let item = ListItem::new("Item")
518 /// .min_leading_width(48.0);
519 /// ```
520 pub fn min_leading_width(mut self, width: f32) -> Self {
521 self.min_leading_width = Some(width);
522 self
523 }
524
525 /// Set the minimum height allocated for the list tile.
526 ///
527 /// # Arguments
528 /// * `height` - The minimum tile height in pixels
529 ///
530 /// # Example
531 /// ```rust
532 /// let item = ListItem::new("Item")
533 /// .min_tile_height(64.0);
534 /// ```
535 pub fn min_tile_height(mut self, height: f32) -> Self {
536 self.min_tile_height = Some(height);
537 self
538 }
539
540 /// Set the background color when not selected.
541 ///
542 /// # Arguments
543 /// * `color` - The tile background color
544 ///
545 /// # Example
546 /// ```rust
547 /// let item = ListItem::new("Item")
548 /// .tile_color(Color32::from_rgb(240, 240, 240));
549 /// ```
550 pub fn tile_color(mut self, color: Color32) -> Self {
551 self.tile_color = Some(color);
552 self
553 }
554
555 /// Set the background color when selected.
556 ///
557 /// # Arguments
558 /// * `color` - The selected tile background color
559 ///
560 /// # Example
561 /// ```rust
562 /// let item = ListItem::new("Item")
563 /// .selected_tile_color(Color32::from_rgb(200, 230, 255));
564 /// ```
565 pub fn selected_tile_color(mut self, color: Color32) -> Self {
566 self.selected_tile_color = Some(color);
567 self
568 }
569
570 /// Set the color for icons and text when selected.
571 ///
572 /// # Arguments
573 /// * `color` - The selected content color
574 ///
575 /// # Example
576 /// ```rust
577 /// let item = ListItem::new("Item")
578 /// .selected_color(Color32::from_rgb(0, 100, 200));
579 /// ```
580 pub fn selected_color(mut self, color: Color32) -> Self {
581 self.selected_color = Some(color);
582 self
583 }
584
585 /// Set the default color for leading and trailing icons.
586 ///
587 /// # Arguments
588 /// * `color` - The icon color
589 ///
590 /// # Example
591 /// ```rust
592 /// let item = ListItem::new("Item")
593 /// .icon_color(Color32::from_rgb(100, 100, 100));
594 /// ```
595 pub fn icon_color(mut self, color: Color32) -> Self {
596 self.icon_color = Some(color);
597 self
598 }
599
600 /// Set the text color for title, subtitle, leading, and trailing.
601 ///
602 /// # Arguments
603 /// * `color` - The text color
604 ///
605 /// # Example
606 /// ```rust
607 /// let item = ListItem::new("Item")
608 /// .text_color(Color32::from_rgb(0, 0, 0));
609 /// ```
610 pub fn text_color(mut self, color: Color32) -> Self {
611 self.text_color = Some(color);
612 self
613 }
614
615 /// Set a click action for the item.
616 ///
617 /// # Arguments
618 /// * `f` - A function to call when the item is clicked
619 ///
620 /// # Example
621 /// ```rust
622 /// let item = ListItem::new("Item")
623 /// .on_click(|| {
624 /// println!("Item was clicked!");
625 /// });
626 /// ```
627 pub fn on_click<F>(mut self, f: F) -> Self
628 where
629 F: Fn() + 'a,
630 {
631 self.action = Some(Box::new(f));
632 self
633 }
634}
635
636impl<'a> Widget for MaterialList<'a> {
637 fn ui(self, ui: &mut Ui) -> Response {
638 // Material Design colors
639 let surface = get_global_color("surface");
640 let on_surface = get_global_color("onSurface");
641 let on_surface_variant = get_global_color("onSurfaceVariant");
642 let outline_variant = get_global_color("outlineVariant");
643 let primary = get_global_color("primary");
644 let on_primary_container = get_global_color("onPrimaryContainer");
645 let primary_container = get_global_color("primaryContainer");
646
647 // Calculate total height and max width
648 let mut total_height = 0.0;
649 let mut max_content_width = 200.0;
650
651 for item in &self.items {
652 // Calculate item height based on configuration
653 let visual_density = item.visual_density.unwrap_or_default();
654 let density_adjustment = visual_density.base_size_adjustment().y;
655 let is_dense = item.dense.unwrap_or(false);
656
657 let base_height = if item.is_three_line.unwrap_or(false)
658 || (item.overline_text.is_some() && item.secondary_text.is_some())
659 {
660 if is_dense {
661 76.0
662 } else {
663 88.0
664 }
665 } else if item.secondary_text.is_some() || item.overline_text.is_some() {
666 if is_dense {
667 64.0
668 } else {
669 72.0
670 }
671 } else {
672 if is_dense {
673 48.0
674 } else {
675 56.0
676 }
677 };
678
679 let item_height = item
680 .min_tile_height
681 .unwrap_or(base_height + density_adjustment);
682 total_height += item_height;
683
684 // Calculate item width
685 let mut item_width = 32.0; // base padding
686 if item.leading_icon.is_some() {
687 item_width += item.min_leading_width.unwrap_or(40.0);
688 }
689 let primary_text_width = item.primary_text.len() as f32 * 8.0;
690 let secondary_text_width = item
691 .secondary_text
692 .as_ref()
693 .map_or(0.0, |s| s.len() as f32 * 6.0);
694 let overline_text_width = item
695 .overline_text
696 .as_ref()
697 .map_or(0.0, |s| s.len() as f32 * 5.5);
698 let max_text_width = primary_text_width
699 .max(secondary_text_width)
700 .max(overline_text_width);
701 item_width += max_text_width;
702 if let Some(ref trailing_text) = item.trailing_text {
703 item_width += trailing_text.len() as f32 * 6.0;
704 }
705 if item.trailing_icon.is_some() {
706 item_width += 40.0;
707 }
708 item_width += 32.0;
709
710 if item_width > max_content_width {
711 max_content_width = item_width;
712 }
713 }
714
715 if self.dividers && self.items.len() > 1 {
716 total_height += (self.items.len() - 1) as f32;
717 }
718
719 let list_width = max_content_width.min(ui.available_width());
720 let desired_size = Vec2::new(list_width, total_height);
721 let (rect, response) = ui.allocate_exact_size(desired_size, Sense::hover());
722
723 // Draw list background
724 ui.painter().rect_filled(rect, 8.0, surface);
725 ui.painter().rect_stroke(
726 rect,
727 8.0,
728 Stroke::new(1.0, outline_variant),
729 egui::epaint::StrokeKind::Outside,
730 );
731
732 let mut current_y = rect.min.y;
733 let mut pending_actions = Vec::new();
734 let items_len = self.items.len();
735
736 for (index, item) in self.items.into_iter().enumerate() {
737 // Calculate item-specific dimensions
738 let visual_density = item.visual_density.unwrap_or_default();
739 let density_adjustment = visual_density.base_size_adjustment().y;
740 let is_dense = item.dense.unwrap_or(false);
741
742 let base_height = if item.is_three_line.unwrap_or(false)
743 || (item.overline_text.is_some() && item.secondary_text.is_some())
744 {
745 if is_dense {
746 76.0
747 } else {
748 88.0
749 }
750 } else if item.secondary_text.is_some() || item.overline_text.is_some() {
751 if is_dense {
752 64.0
753 } else {
754 72.0
755 }
756 } else {
757 if is_dense {
758 48.0
759 } else {
760 56.0
761 }
762 };
763
764 let item_height = item
765 .min_tile_height
766 .unwrap_or(base_height + density_adjustment);
767
768 let item_rect = Rect::from_min_size(
769 Pos2::new(rect.min.x, current_y),
770 Vec2::new(rect.width(), item_height),
771 );
772
773 // Use list's ID (or auto-generate one) to scope item IDs and avoid collisions
774 let list_id = self.id.unwrap_or_else(|| ui.id().with("material_list"));
775 let unique_id = list_id.with(("item", index));
776 let item_response = ui.interact(item_rect, unique_id, Sense::click());
777
778 // Determine background color
779 let bg_color = if item.selected {
780 item.selected_tile_color.unwrap_or_else(|| {
781 Color32::from_rgba_premultiplied(
782 primary_container.r(),
783 primary_container.g(),
784 primary_container.b(),
785 255,
786 )
787 })
788 } else {
789 item.tile_color.unwrap_or(Color32::TRANSPARENT)
790 };
791
792 // Draw background
793 if bg_color != Color32::TRANSPARENT {
794 ui.painter().rect_filled(item_rect, 0.0, bg_color);
795 }
796
797 // Draw hover effect
798 if item_response.hovered() && item.enabled {
799 let hover_color = Color32::from_rgba_premultiplied(
800 on_surface.r(),
801 on_surface.g(),
802 on_surface.b(),
803 20,
804 );
805 ui.painter().rect_filled(item_rect, 0.0, hover_color);
806 }
807
808 // Handle click
809 if item_response.clicked() && item.enabled {
810 if let Some(action) = item.action {
811 pending_actions.push(action);
812 }
813 }
814
815 // Calculate colors
816 let icon_color = if item.selected {
817 item.selected_color.unwrap_or(on_primary_container)
818 } else if item.enabled {
819 item.icon_color.unwrap_or(on_surface_variant)
820 } else {
821 on_surface_variant.linear_multiply(0.38)
822 };
823
824 let text_color = if item.selected {
825 item.selected_color.unwrap_or(on_primary_container)
826 } else if item.enabled {
827 item.text_color.unwrap_or(on_surface)
828 } else {
829 on_surface.linear_multiply(0.38)
830 };
831
832 // Layout constants
833 let horizontal_title_gap = item.horizontal_title_gap.unwrap_or(16.0)
834 + visual_density.horizontal * 2.0;
835 let min_vertical_padding = item.min_vertical_padding.unwrap_or(8.0);
836 let min_leading_width = item.min_leading_width.unwrap_or(40.0);
837
838 let mut content_x = item_rect.min.x + 16.0;
839 let content_y = item_rect.center().y;
840
841 // Draw leading icon
842 if let Some(icon_name) = &item.leading_icon {
843 let leading_width = min_leading_width;
844 let icon_pos = Pos2::new(content_x + leading_width / 2.0, content_y);
845
846 let icon_string = material_symbol_text(icon_name);
847 ui.painter().text(
848 icon_pos,
849 egui::Align2::CENTER_CENTER,
850 &icon_string,
851 egui::FontId::proportional(20.0),
852 icon_color,
853 );
854 content_x += leading_width + horizontal_title_gap;
855 }
856
857 // Calculate trailing width
858 let trailing_icon_width = if item.trailing_icon.is_some() {
859 40.0
860 } else {
861 0.0
862 };
863 let trailing_text_width = if item.trailing_text.is_some() {
864 80.0
865 } else {
866 0.0
867 };
868 let total_trailing_width = trailing_icon_width + trailing_text_width;
869
870 // Draw text content based on configuration
871 match (&item.overline_text, &item.secondary_text) {
872 (Some(overline), Some(secondary)) => {
873 // Three-line layout
874 let overline_pos = Pos2::new(content_x, content_y - 20.0);
875 let primary_pos = Pos2::new(content_x, content_y);
876 let secondary_pos = Pos2::new(content_x, content_y + 20.0);
877
878 ui.painter().text(
879 overline_pos,
880 egui::Align2::LEFT_CENTER,
881 overline,
882 egui::FontId::proportional(if is_dense { 10.0 } else { 11.0 }),
883 on_surface_variant,
884 );
885
886 ui.painter().text(
887 primary_pos,
888 egui::Align2::LEFT_CENTER,
889 &item.primary_text,
890 egui::FontId::proportional(if is_dense { 13.0 } else { 14.0 }),
891 text_color,
892 );
893
894 ui.painter().text(
895 secondary_pos,
896 egui::Align2::LEFT_CENTER,
897 secondary,
898 egui::FontId::proportional(if is_dense { 11.0 } else { 12.0 }),
899 on_surface_variant,
900 );
901 }
902 (Some(overline), None) => {
903 // Two-line layout: overline + primary
904 let overline_pos = Pos2::new(content_x, content_y - 10.0);
905 let primary_pos = Pos2::new(content_x, content_y + 10.0);
906
907 ui.painter().text(
908 overline_pos,
909 egui::Align2::LEFT_CENTER,
910 overline,
911 egui::FontId::proportional(if is_dense { 10.0 } else { 11.0 }),
912 on_surface_variant,
913 );
914
915 ui.painter().text(
916 primary_pos,
917 egui::Align2::LEFT_CENTER,
918 &item.primary_text,
919 egui::FontId::proportional(if is_dense { 13.0 } else { 14.0 }),
920 text_color,
921 );
922 }
923 (None, Some(secondary)) => {
924 // Two-line layout: primary + secondary
925 let primary_pos = Pos2::new(content_x, content_y - 10.0);
926 let secondary_pos = Pos2::new(content_x, content_y + 10.0);
927
928 ui.painter().text(
929 primary_pos,
930 egui::Align2::LEFT_CENTER,
931 &item.primary_text,
932 egui::FontId::proportional(if is_dense { 13.0 } else { 14.0 }),
933 text_color,
934 );
935
936 ui.painter().text(
937 secondary_pos,
938 egui::Align2::LEFT_CENTER,
939 secondary,
940 egui::FontId::proportional(if is_dense { 11.0 } else { 12.0 }),
941 on_surface_variant,
942 );
943 }
944 (None, None) => {
945 // Single-line layout
946 let text_pos = Pos2::new(content_x, content_y);
947 ui.painter().text(
948 text_pos,
949 egui::Align2::LEFT_CENTER,
950 &item.primary_text,
951 egui::FontId::proportional(if is_dense { 13.0 } else { 14.0 }),
952 text_color,
953 );
954 }
955 }
956
957 // Draw trailing text
958 if let Some(ref trailing_text) = item.trailing_text {
959 let trailing_text_pos = Pos2::new(
960 item_rect.max.x - trailing_icon_width - trailing_text_width + 10.0,
961 content_y,
962 );
963
964 ui.painter().text(
965 trailing_text_pos,
966 egui::Align2::LEFT_CENTER,
967 trailing_text,
968 egui::FontId::proportional(12.0),
969 on_surface_variant,
970 );
971 }
972
973 // Draw trailing icon
974 if let Some(icon_name) = &item.trailing_icon {
975 let icon_pos = Pos2::new(item_rect.max.x - 28.0, content_y);
976
977 let icon_string = material_symbol_text(icon_name);
978 ui.painter().text(
979 icon_pos,
980 egui::Align2::CENTER_CENTER,
981 &icon_string,
982 egui::FontId::proportional(20.0),
983 icon_color,
984 );
985 }
986
987 current_y += item_height;
988
989 // Draw divider
990 if self.dividers && index < items_len - 1 {
991 let divider_y = current_y;
992 let divider_start = Pos2::new(rect.min.x + 16.0, divider_y);
993 let divider_end = Pos2::new(rect.max.x - 16.0, divider_y);
994
995 ui.painter().line_segment(
996 [divider_start, divider_end],
997 Stroke::new(1.0, outline_variant),
998 );
999 current_y += 1.0;
1000 }
1001 }
1002
1003 // Execute pending actions
1004 for action in pending_actions {
1005 action();
1006 }
1007
1008 response
1009 }
1010}
1011
1012pub fn list_item(primary_text: impl Into<String>) -> ListItem<'static> {
1013 ListItem::new(primary_text)
1014}
1015
1016pub fn list() -> MaterialList<'static> {
1017 MaterialList::new()
1018}