Skip to main content

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}