Skip to main content

elegance/
select.rs

1//! Styled select (combo-box) widget.
2//!
3//! Wraps [`egui::ComboBox`] and paints it with the elegance palette: slate
4//! input background, 1-px border, sky focus ring, and a matching chevron.
5
6use std::borrow::Cow;
7use std::hash::Hash;
8
9use egui::{
10    Color32, ComboBox, CornerRadius, Pos2, Response, Sense, Stroke, Ui, Vec2, Widget, WidgetInfo,
11    WidgetText, WidgetType,
12};
13
14use crate::theme::{with_alpha, Theme};
15
16/// A styled drop-down select.
17///
18/// Bind the selection to any `PartialEq + Clone` type — an enum, an index,
19/// or a `String` — and supply a list of `(value, label)` pairs. Labels
20/// accept `&'static str`, `String`, or any `Cow<'a, str>`, so static option
21/// lists don't allocate.
22///
23/// ```no_run
24/// # use elegance::Select;
25/// # egui::__run_test_ui(|ui| {
26/// #[derive(Clone, PartialEq)]
27/// enum Unit { Us, Ms, S }
28///
29/// let mut unit = Unit::Ms;
30/// ui.add(Select::new("unit", &mut unit).options([
31///     (Unit::Us, "μs"),
32///     (Unit::Ms, "ms"),
33///     (Unit::S,  "s"),
34/// ]));
35/// # });
36/// ```
37///
38/// For string-valued selects where each option is both the value and the
39/// label, use [`Select::strings`]:
40///
41/// ```no_run
42/// # use elegance::Select;
43/// # egui::__run_test_ui(|ui| {
44/// let mut unit = String::from("ms");
45/// ui.add(Select::strings("unit", &mut unit, ["us", "ms", "s"]));
46/// # });
47/// ```
48#[must_use = "Add with `ui.add(...)`."]
49pub struct Select<'a, T: PartialEq + Clone> {
50    id_salt: egui::Id,
51    value: &'a mut T,
52    label: Option<WidgetText>,
53    options: Vec<(T, Cow<'a, str>)>,
54    width: Option<f32>,
55}
56
57impl<'a, T: PartialEq + Clone> std::fmt::Debug for Select<'a, T> {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        let labels: Vec<&str> = self.options.iter().map(|(_, l)| l.as_ref()).collect();
60        f.debug_struct("Select")
61            .field("id_salt", &self.id_salt)
62            .field("option_labels", &labels)
63            .field("width", &self.width)
64            .finish()
65    }
66}
67
68impl<'a, T: PartialEq + Clone> Select<'a, T> {
69    /// Create a select keyed by `id_salt` and bound to `value`.
70    /// Add selectable options via [`Select::options`].
71    pub fn new(id_salt: impl Hash, value: &'a mut T) -> Self {
72        Self {
73            id_salt: egui::Id::new(id_salt),
74            value,
75            label: None,
76            options: Vec::new(),
77            width: None,
78        }
79    }
80
81    /// Show a label above the select.
82    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
83        self.label = Some(label.into());
84        self
85    }
86
87    /// Set the selectable options as `(value, label)` pairs. Labels are
88    /// carried as `Cow<'a, str>`, so `&'static str` labels never allocate.
89    pub fn options<I, S>(mut self, options: I) -> Self
90    where
91        I: IntoIterator<Item = (T, S)>,
92        S: Into<Cow<'a, str>>,
93    {
94        self.options = options.into_iter().map(|(v, l)| (v, l.into())).collect();
95        self
96    }
97
98    /// Override the select width in points. Defaults to the intrinsic
99    /// size of the selected label plus padding.
100    pub fn width(mut self, width: f32) -> Self {
101        self.width = Some(width);
102        self
103    }
104}
105
106impl<'a> Select<'a, String> {
107    /// Convenience constructor for string-valued selects. Each item is used
108    /// as both the value and the displayed label.
109    ///
110    /// ```no_run
111    /// # use elegance::Select;
112    /// # egui::__run_test_ui(|ui| {
113    /// let mut unit = String::from("ms");
114    /// ui.add(Select::strings("unit", &mut unit, ["us", "ms", "s"]));
115    /// # });
116    /// ```
117    pub fn strings<I, S>(id_salt: impl Hash, value: &'a mut String, options: I) -> Self
118    where
119        I: IntoIterator<Item = S>,
120        S: Into<Cow<'a, str>>,
121    {
122        let options: Vec<(String, Cow<'a, str>)> = options
123            .into_iter()
124            .map(|s| {
125                let label: Cow<'a, str> = s.into();
126                let value = label.as_ref().to_owned();
127                (value, label)
128            })
129            .collect();
130        Self {
131            id_salt: egui::Id::new(id_salt),
132            value,
133            label: None,
134            options,
135            width: None,
136        }
137    }
138}
139
140impl<'a, T: PartialEq + Clone> Widget for Select<'a, T> {
141    fn ui(self, ui: &mut Ui) -> Response {
142        let theme = Theme::current(ui.ctx());
143        let p = &theme.palette;
144        let t = &theme.typography;
145
146        ui.vertical(|ui| {
147            if let Some(label) = &self.label {
148                let rich = egui::RichText::new(label.text())
149                    .color(p.text_muted)
150                    .size(t.label);
151                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
152                ui.add_space(2.0);
153            }
154
155            let width = self.width.unwrap_or(160.0);
156            let chevron_color = p.text_muted;
157
158            // Resolve the displayed label for the current value. Owned so
159            // it doesn't conflict with the mutable access to `self.value`
160            // in the inner closure.
161            let selected_label: String = self
162                .options
163                .iter()
164                .find(|(v, _)| v == &*self.value)
165                .map(|(_, l)| l.as_ref().to_owned())
166                .unwrap_or_default();
167            let field_label = self.label.as_ref().map(|l| l.text().to_string());
168
169            let response = crate::theme::with_themed_visuals(ui, |ui| {
170                let v = ui.visuals_mut();
171                crate::theme::themed_input_visuals(v, &theme, p.input_bg);
172                for w in [
173                    &mut v.widgets.inactive,
174                    &mut v.widgets.hovered,
175                    &mut v.widgets.active,
176                    &mut v.widgets.open,
177                ] {
178                    w.fg_stroke = Stroke::new(1.0, p.text);
179                }
180                v.override_text_color = Some(p.text);
181
182                ComboBox::from_id_salt(self.id_salt)
183                    .width(width)
184                    .selected_text(
185                        egui::RichText::new(&selected_label)
186                            .color(p.text)
187                            .size(t.body),
188                    )
189                    .icon(move |ui, rect, _visuals, is_popup_open| {
190                        paint_chevron(ui, rect, chevron_color, is_popup_open);
191                    })
192                    .show_ui(ui, |ui| {
193                        ui.set_min_width(width);
194                        // Tight stacking. `select_option` handles its own padding.
195                        ui.spacing_mut().item_spacing.y = 2.0;
196                        for (opt_value, opt_label) in self.options.iter() {
197                            let selected = opt_value == &*self.value;
198                            if select_option(ui, opt_label.as_ref(), selected, &theme).clicked() {
199                                *self.value = opt_value.clone();
200                            }
201                        }
202                    })
203                    .response
204            });
205
206            if let Some(field_label) = field_label {
207                let selected_label = selected_label.clone();
208                response.widget_info(|| {
209                    let mut info = WidgetInfo::labeled(WidgetType::ComboBox, true, &field_label);
210                    info.current_text_value = Some(selected_label.clone());
211                    info
212                });
213            }
214
215            response
216        })
217        .inner
218    }
219}
220
221/// Paint a thin, centered chevron inside `rect`. Points down when the popup is
222/// closed (hint to open) and flips up when the popup is open (hint to close).
223fn paint_chevron(ui: &egui::Ui, rect: egui::Rect, color: Color32, is_popup_open: bool) {
224    let painter = ui.painter();
225    let stroke = Stroke::new(1.4, color);
226
227    let half_w = (rect.width() * 0.35).min(5.0);
228    let half_h = (rect.height() * 0.18).min(3.0);
229    let c = rect.center();
230
231    let (left, right, tip) = if is_popup_open {
232        (
233            egui::pos2(c.x - half_w, c.y + half_h * 0.5),
234            egui::pos2(c.x + half_w, c.y + half_h * 0.5),
235            egui::pos2(c.x, c.y - half_h * 1.5),
236        )
237    } else {
238        (
239            egui::pos2(c.x - half_w, c.y - half_h * 0.5),
240            egui::pos2(c.x + half_w, c.y - half_h * 0.5),
241            egui::pos2(c.x, c.y + half_h * 1.5),
242        )
243    };
244
245    painter.line_segment([left, tip], stroke);
246    painter.line_segment([tip, right], stroke);
247}
248
249/// Render a single option row inside the Select popup. Keeps the text anchored
250/// at a fixed offset across hover/selected/inactive states. egui's own
251/// `ui.selectable_label` goes through `Button::selectable`, whose frame's
252/// `expansion` changes between states, which shifts the text by ~1px on hover.
253fn select_option(ui: &mut Ui, label: &str, selected: bool, theme: &Theme) -> Response {
254    let p = &theme.palette;
255    let t = &theme.typography;
256
257    let pad_x = 10.0;
258    let pad_y = 6.0;
259
260    let galley = crate::theme::placeholder_galley(ui, label, t.body, false, f32::INFINITY);
261    let content_w = galley.size().x;
262    let desired = Vec2::new(
263        ui.available_width().max(content_w + pad_x * 2.0),
264        galley.size().y.max(t.body) + pad_y * 2.0,
265    );
266    let (rect, response) = ui.allocate_exact_size(desired, Sense::click());
267
268    if ui.is_rect_visible(rect) {
269        let bg = if response.hovered() {
270            with_alpha(p.sky, 60)
271        } else if selected {
272            with_alpha(p.sky, 40)
273        } else {
274            Color32::TRANSPARENT
275        };
276        if bg.a() > 0 {
277            let radius = CornerRadius::same((theme.control_radius as u8).saturating_sub(2));
278            ui.painter().rect_filled(rect, radius, bg);
279        }
280        let label_pos = Pos2::new(rect.min.x + pad_x, rect.center().y - galley.size().y * 0.5);
281        ui.painter().galley(label_pos, galley, p.text);
282    }
283    response
284}