Skip to main content

egui_charts/ui_kit/list_item/
mod.rs

1//! List Item Module - Composable list items
2//!
3//! This module provides a flexible list item system with composable content:
4//!
5//! - `ListItem` - The main container widget
6//! - `ListItemContent` - Trait for custom content types
7//! - `LabelContent` - Simple text with optional icon
8//! - `PropertyContent` - Key-value pair display
9//! - `ButtonContent` - Clickable button content
10//!
11//! # Usage
12//!
13//! ```
14//! use egui::Color32;
15//! use egui_charts::icons::icons;
16//! use egui_charts::ui_kit::list_item::{ButtonContent, LabelContent, ListItem, PropertyContent};
17//!
18//! # egui::__run_test_ui(|ui| {
19//! # let is_selected = false;
20//! // Simple label
21//! let response = ListItem::new()
22//!     .selected(is_selected)
23//!     .show(ui, LabelContent::new("My Item"));
24//!
25//! // Label with icon
26//! let response = ListItem::new()
27//!     .show(ui, LabelContent::new("Settings").with_icon(&icons::SETTINGS));
28//!
29//! // Key-value property
30//! let response = ListItem::new()
31//!     .show(ui, PropertyContent::new("Price", "$1,234.56")
32//!         .value_color(Color32::GREEN));
33//!
34//! // Action button
35//! if ListItem::new()
36//!     .show(ui, ButtonContent::new(&icons::PLUS, "Add Item").primary())
37//!     .clicked()
38//! {
39//!     // add the item
40//! }
41//! # });
42//! ```
43
44use crate::theme::ThemeExt;
45mod button_content;
46mod content;
47mod label_content;
48mod property_content;
49mod visuals;
50
51pub use button_content::{ButtonContent, ButtonStyle};
52pub use content::{ContentContext, ListItem, ListItemContent};
53pub use label_content::LabelContent;
54pub use property_content::PropertyContent;
55pub use visuals::ListVisuals;
56
57use egui::{Color32, Response, Ui, Vec2};
58
59use crate::tokens::DESIGN_TOKENS;
60
61/// Default height for list items
62fn default_height() -> f32 {
63    DESIGN_TOKENS.sizing.list_item_height
64}
65
66impl ListItem {
67    /// Show the list item with the given content
68    pub fn show<C: ListItemContent>(self, ui: &mut Ui, content: C) -> Response {
69        let height = self.height.unwrap_or_else(default_height);
70        let width = ui.available_width();
71        let interactive = self.interactive && content.interactive();
72
73        // Rows sit flush: allocate without the trailing item spacing.
74        let spacing = std::mem::take(&mut ui.spacing_mut().item_spacing.y);
75        let (rect, mut response) = ui.allocate_exact_size(
76            Vec2::new(width, height),
77            if interactive {
78                egui::Sense::click()
79            } else {
80                egui::Sense::hover()
81            },
82        );
83        ui.spacing_mut().item_spacing.y = spacing;
84
85        if ui.is_rect_visible(rect) {
86            let egui_visuals = ui.style().visuals.clone();
87
88            let list_visuals = ListVisuals::new()
89                .with_hovered(response.hovered())
90                .with_active(interactive && response.is_pointer_button_down_on())
91                .with_selected(self.selected)
92                .with_interactive(interactive);
93
94            // Determine background color using ListVisuals
95            let bg_color = list_visuals
96                .bg_color(&egui_visuals)
97                .unwrap_or(Color32::TRANSPARENT);
98
99            // Draw background
100            if bg_color != Color32::TRANSPARENT {
101                ui.painter()
102                    .rect_filled(rect, ui.active_theme().radius.sm, bg_color);
103            }
104
105            // Create content context
106            let content_context = ContentContext {
107                rect: rect.shrink2(Vec2::new(ui.active_theme().spacing.lg, 0.0)),
108                bg_color,
109                selected: self.selected,
110                hovered: response.hovered(),
111                list_item: &self,
112                response: &response,
113                visuals: list_visuals,
114            };
115
116            content.ui(ui, &content_context);
117
118            // Draw separator if requested
119            if self.show_separator {
120                let sep_y = rect.bottom();
121                let sep_color = egui_visuals.widgets.noninteractive.bg_stroke.color;
122                ui.painter().hline(
123                    rect.x_range(),
124                    sep_y,
125                    egui::Stroke::new(DESIGN_TOKENS.stroke.hairline, sep_color),
126                );
127            }
128        }
129
130        // Rows touch, so keep the ring inside the row instead of under its neighbour's fill.
131        crate::styles::focus::draw_focus_ring(
132            ui,
133            &response
134                .clone()
135                .with_new_rect(rect.shrink(ui.active_theme().spacing.xs)),
136            ui.active_theme().radius.sm - ui.active_theme().spacing.xs,
137        );
138
139        // Handle selection on click
140        if response.clicked() && interactive {
141            response.mark_changed();
142        }
143
144        response
145    }
146
147    /// Show the list item with a simple text label
148    pub fn show_text(self, ui: &mut Ui, text: impl Into<String>) -> Response {
149        self.show(ui, LabelContent::new(text))
150    }
151
152    /// Show the list item with a text label and icon
153    pub fn show_with_icon(
154        self,
155        ui: &mut Ui,
156        text: impl Into<String>,
157        icon: &crate::icons::Icon,
158    ) -> Response {
159        self.show(ui, LabelContent::new(text).with_icon(icon))
160    }
161
162    /// Show the list item as a key-value property
163    pub fn show_property(
164        self,
165        ui: &mut Ui,
166        label: impl Into<String>,
167        value: impl Into<String>,
168    ) -> Response {
169        self.show(ui, PropertyContent::new(label, value))
170    }
171}
172
173/// Create a simple list of items
174///
175/// # Example
176///
177/// ```
178/// # use egui_charts::ui_kit::list_item::{ListItem, simple_list};
179/// # egui::__run_test_ui(|ui| {
180/// let items = ["AAPL", "MSFT", "NVDA"];
181/// let mut selected = None;
182/// simple_list(ui, &items, &mut selected, |ui, item, selected| {
183///     ListItem::new()
184///         .selected(selected)
185///         .show_text(ui, *item)
186/// });
187/// # });
188/// ```
189pub fn simple_list<T, F>(ui: &mut Ui, items: &[T], selected: &mut Option<usize>, mut show_item: F)
190where
191    F: FnMut(&mut Ui, &T, bool) -> Response,
192{
193    for (idx, item) in items.iter().enumerate() {
194        let is_selected = *selected == Some(idx);
195        let response = show_item(ui, item, is_selected);
196        if response.clicked() {
197            *selected = Some(idx);
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn show_advances_by_exactly_one_row() {
208        let ctx = egui::Context::default();
209        ctx.run_ui(egui::RawInput::default(), |ui| {
210            egui::CentralPanel::default().show(ui, |ui| {
211                let top = ui.cursor().top();
212                let row = ListItem::new().show_text(ui, "Row");
213                assert_eq!(row.rect.height(), default_height());
214                assert_eq!(ui.cursor().top() - top, default_height());
215            });
216        })
217        .drop_without_applying_deltas();
218    }
219
220    #[test]
221    fn subdued_and_property_labels_keep_their_weak_text_colour() {
222        for (name, theme) in crate::theme::presets() {
223            let ctx = egui::Context::default();
224            crate::theme::apply_to_egui(&ctx, &theme);
225            let output = ctx.run_ui(Default::default(), |ui| {
226                ListItem::new().show_text(ui, "Plain");
227                ListItem::new().show(ui, LabelContent::new("Subdued").subdued(true));
228                ListItem::new().show_property(ui, "Open", "19,126.06");
229            });
230            let text: Vec<_> = output
231                .shapes
232                .iter()
233                .filter_map(|s| {
234                    if let egui::Shape::Text(t) = &s.shape {
235                        Some((
236                            t.galley.job.text.clone(),
237                            t.override_text_color
238                                .unwrap_or(t.galley.job.sections[0].format.color),
239                        ))
240                    } else {
241                        None
242                    }
243                })
244                .collect();
245            output.drop_without_applying_deltas();
246            for (label, expected) in [
247                ("Plain", theme.ui.text),
248                ("Subdued", theme.ui.text_muted),
249                ("Open", theme.ui.text_muted),
250                ("19,126.06", theme.ui.text),
251            ] {
252                assert_eq!(
253                    text.iter().find(|(s, _)| s == label).unwrap().1,
254                    expected,
255                    "{name}: {label}"
256                );
257            }
258            assert_ne!(theme.ui.text_muted, theme.ui.text);
259        }
260    }
261
262    #[test]
263    fn tab_reaches_list_rows_in_visual_order_and_draws_their_ring() {
264        let ctx = egui::Context::default();
265        let frame = |events| {
266            let mut rows = Vec::new();
267            let output = ctx.run_ui(
268                egui::RawInput {
269                    events,
270                    ..Default::default()
271                },
272                |ui| {
273                    for label in ["First", "Second", "Third"] {
274                        rows.push(ListItem::new().show_text(ui, label));
275                    }
276                },
277            );
278            let rings: Vec<_> = output
279                .shapes
280                .iter()
281                .filter_map(|s| match &s.shape {
282                    egui::Shape::Rect(r) if r.stroke.width == 2.0 => Some(r.rect),
283                    _ => None,
284                })
285                .collect();
286            output.drop_without_applying_deltas();
287            (rows, rings)
288        };
289        frame(vec![]);
290        for index in 0..3 {
291            let (rows, rings) = frame(vec![egui::Event::Key {
292                key: egui::Key::Tab,
293                physical_key: None,
294                pressed: true,
295                repeat: false,
296                modifiers: egui::Modifiers::NONE,
297            }]);
298            assert!(rows[index].has_focus(), "row {index}");
299            assert_eq!(rings, vec![rows[index].rect]);
300        }
301    }
302}