Skip to main content

egui_components/
list.rs

1//! `ListItem` — a clickable, themed row used to build list-style UIs.
2//!
3//! State (the selected index) lives on the caller, just like
4//! [`Checkbox`](crate::checkbox::Checkbox) holds `&mut bool`. The matching
5//! pattern is:
6//!
7//! ```ignore
8//! egui::ScrollArea::vertical().show(ui, |ui| {
9//!     for (i, item) in items.iter().enumerate() {
10//!         let r = ui.add(
11//!             sc::ListItem::new(item)
12//!                 .selected(selected == Some(i)),
13//!         );
14//!         if r.clicked() {
15//!             selected = Some(i);
16//!         }
17//!     }
18//! });
19//! ```
20//!
21//! For visual grouping a [`List`] container wraps the rows in a bordered,
22//! rounded frame.
23
24use egui::{
25    pos2, vec2, Color32, FontId, Frame, InnerResponse, Margin, Response, Sense, Stroke, Ui,
26    Vec2, Widget, WidgetText,
27};
28use egui_components_theme::{mix, Theme};
29
30/// One row in a list. Stateless; the caller controls `selected`.
31pub struct ListItem {
32    label: WidgetText,
33    secondary: Option<WidgetText>,
34    selected: bool,
35    disabled: bool,
36    confirmed: bool,
37}
38
39impl ListItem {
40    pub fn new(label: impl Into<WidgetText>) -> Self {
41        Self {
42            label: label.into(),
43            secondary: None,
44            selected: false,
45            disabled: false,
46            confirmed: false,
47        }
48    }
49
50    /// Adds a muted, right-aligned secondary label (e.g. shortcut / metadata).
51    pub fn secondary(mut self, text: impl Into<WidgetText>) -> Self {
52        self.secondary = Some(text.into());
53        self
54    }
55
56    pub fn selected(mut self, b: bool) -> Self {
57        self.selected = b;
58        self
59    }
60    pub fn disabled(mut self, b: bool) -> Self {
61        self.disabled = b;
62        self
63    }
64    /// Render a small check icon on the right (e.g. for "applied" status).
65    pub fn confirmed(mut self, b: bool) -> Self {
66        self.confirmed = b;
67        self
68    }
69}
70
71impl Widget for ListItem {
72    fn ui(self, ui: &mut Ui) -> Response {
73        let theme = Theme::get(ui.ctx());
74        let m = theme.metrics;
75        let c = &theme.colors;
76        let font = FontId::proportional(m.font_size_md);
77
78        let row_h = m.button_height_sm.max(28.0);
79        let pad_x = 10.0;
80        let gap = 8.0;
81
82        let label_galley = self.label.clone().into_galley(
83            ui,
84            Some(egui::TextWrapMode::Truncate),
85            ui.available_width(),
86            font.clone(),
87        );
88        let secondary_galley = self.secondary.as_ref().map(|t| {
89            t.clone().into_galley(
90                ui,
91                Some(egui::TextWrapMode::Extend),
92                f32::INFINITY,
93                font.clone(),
94            )
95        });
96
97        let secondary_w = secondary_galley.as_ref().map(|g| g.size().x + gap).unwrap_or(0.0);
98        let check_w = if self.confirmed { row_h * 0.6 + gap } else { 0.0 };
99        let total_w = ui.available_width();
100        let desired = vec2(total_w, row_h);
101        let sense = if self.disabled { Sense::hover() } else { Sense::click() };
102        let (rect, response) = ui.allocate_exact_size(desired, sense);
103
104        if !ui.is_rect_visible(rect) {
105            return response;
106        }
107
108        let painter = ui.painter();
109
110        // Background
111        let bg = if self.disabled {
112            Color32::TRANSPARENT
113        } else if self.selected {
114            c.secondary_background
115        } else if response.is_pointer_button_down_on() {
116            c.accent_background
117        } else if response.hovered() {
118            c.accent_background
119        } else {
120            Color32::TRANSPARENT
121        };
122        if bg != Color32::TRANSPARENT {
123            painter.rect_filled(rect, theme.corner_sm(), bg);
124        }
125        if self.selected {
126            // Subtle left accent bar in primary
127            painter.rect_filled(
128                egui::Rect::from_min_size(
129                    pos2(rect.left(), rect.top() + 4.0),
130                    vec2(2.0, rect.height() - 8.0),
131                ),
132                egui::CornerRadius::same(1),
133                c.primary_background,
134            );
135        }
136
137        // Label
138        let label_color = if self.disabled {
139            mix(c.muted_foreground, Color32::TRANSPARENT, 0.3)
140        } else {
141            c.foreground
142        };
143        let label_x = rect.left() + pad_x;
144        let label_y = rect.center().y - label_galley.size().y * 0.5;
145        painter.galley_with_override_text_color(
146            pos2(label_x, label_y),
147            label_galley.clone(),
148            label_color,
149        );
150
151        // Secondary (right-aligned, before optional check)
152        let mut right = rect.right() - pad_x;
153        if self.confirmed {
154            let cx = right - check_w * 0.5 + gap * 0.5;
155            let cy = rect.center().y;
156            let size = row_h * 0.5;
157            draw_check(painter, pos2(cx, cy), size, c.primary_background);
158            right -= check_w;
159        }
160        if let Some(g) = secondary_galley {
161            let pos = pos2(right - g.size().x, rect.center().y - g.size().y * 0.5);
162            painter.galley_with_override_text_color(pos, g, c.muted_foreground);
163            let _ = secondary_w; // reserved space; consumed visually
164        }
165
166        if response.has_focus() {
167            painter.rect_stroke(
168                rect.expand(1.0),
169                theme.corner_sm(),
170                theme.focus_ring(),
171                egui::StrokeKind::Outside,
172            );
173        }
174
175        if !self.disabled && response.hovered() {
176            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
177        }
178
179        response
180    }
181}
182
183fn draw_check(painter: &egui::Painter, center: egui::Pos2, size: f32, color: Color32) {
184    let stroke = Stroke::new(1.8, color);
185    let half = size * 0.5;
186    let p1 = pos2(center.x - half * 0.6, center.y);
187    let p2 = pos2(center.x - half * 0.15, center.y + half * 0.45);
188    let p3 = pos2(center.x + half * 0.6, center.y - half * 0.5);
189    painter.line_segment([p1, p2], stroke);
190    painter.line_segment([p2, p3], stroke);
191}
192
193/// A bordered, rounded container that visually groups a list of [`ListItem`]s.
194///
195/// The container does not own the items — pass a closure that adds them.
196pub struct List {
197    id_salt: egui::Id,
198    max_height: Option<f32>,
199    padding: f32,
200}
201
202impl List {
203    /// Create a list. `id_source` is hashed into the inner [`ScrollArea`]'s id
204    /// so multiple `List`s on the same page can coexist without colliding.
205    pub fn new(id_source: impl std::hash::Hash) -> Self {
206        Self {
207            id_salt: egui::Id::new(id_source),
208            max_height: None,
209            padding: 4.0,
210        }
211    }
212    /// Cap the visible height; the list scrolls when its content exceeds it.
213    pub fn max_height(mut self, h: f32) -> Self {
214        self.max_height = Some(h);
215        self
216    }
217    pub fn padding(mut self, p: f32) -> Self {
218        self.padding = p;
219        self
220    }
221
222    pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
223        let theme = Theme::get(ui.ctx());
224        Frame::new()
225            .fill(theme.colors.background)
226            .stroke(theme.border_stroke())
227            .corner_radius(theme.corner())
228            .inner_margin(Margin::same(self.padding as i8))
229            .show(ui, |ui| {
230                if let Some(h) = self.max_height {
231                    egui::ScrollArea::vertical()
232                        .id_salt(self.id_salt)
233                        .max_height(h)
234                        .show(ui, |ui| {
235                            ui.set_width(ui.available_width());
236                            body(ui)
237                        })
238                        .inner
239                } else {
240                    body(ui)
241                }
242            })
243    }
244}
245
246// Suppress unused-warnings for helpers we expose for future variants.
247#[allow(dead_code)]
248fn _ensure_vec_used(_: Vec2) {}