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}