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::{Color32, ComboBox, Response, Stroke, Ui, Widget, WidgetInfo, WidgetText, WidgetType};
10
11use crate::theme::Theme;
12
13/// A styled drop-down select.
14///
15/// Bind the selection to any `PartialEq + Clone` type — an enum, an index,
16/// or a `String` — and supply a list of `(value, label)` pairs. Labels
17/// accept `&'static str`, `String`, or any `Cow<'a, str>`, so static option
18/// lists don't allocate.
19///
20/// ```no_run
21/// # use elegance::Select;
22/// # egui::__run_test_ui(|ui| {
23/// #[derive(Clone, PartialEq)]
24/// enum Unit { Us, Ms, S }
25///
26/// let mut unit = Unit::Ms;
27/// ui.add(Select::new("unit", &mut unit).options([
28///     (Unit::Us, "μs"),
29///     (Unit::Ms, "ms"),
30///     (Unit::S,  "s"),
31/// ]));
32/// # });
33/// ```
34///
35/// For string-valued selects where each option is both the value and the
36/// label, use [`Select::strings`]:
37///
38/// ```no_run
39/// # use elegance::Select;
40/// # egui::__run_test_ui(|ui| {
41/// let mut unit = String::from("ms");
42/// ui.add(Select::strings("unit", &mut unit, ["us", "ms", "s"]));
43/// # });
44/// ```
45#[must_use = "Add with `ui.add(...)`."]
46pub struct Select<'a, T: PartialEq + Clone> {
47    id_salt: egui::Id,
48    value: &'a mut T,
49    label: Option<WidgetText>,
50    options: Vec<(T, Cow<'a, str>)>,
51    width: Option<f32>,
52}
53
54impl<'a, T: PartialEq + Clone> std::fmt::Debug for Select<'a, T> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        let labels: Vec<&str> = self.options.iter().map(|(_, l)| l.as_ref()).collect();
57        f.debug_struct("Select")
58            .field("id_salt", &self.id_salt)
59            .field("option_labels", &labels)
60            .field("width", &self.width)
61            .finish()
62    }
63}
64
65impl<'a, T: PartialEq + Clone> Select<'a, T> {
66    /// Create a select keyed by `id_salt` and bound to `value`.
67    /// Add selectable options via [`Select::options`].
68    pub fn new(id_salt: impl Hash, value: &'a mut T) -> Self {
69        Self {
70            id_salt: egui::Id::new(id_salt),
71            value,
72            label: None,
73            options: Vec::new(),
74            width: None,
75        }
76    }
77
78    /// Show a label above the select.
79    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
80        self.label = Some(label.into());
81        self
82    }
83
84    /// Set the selectable options as `(value, label)` pairs. Labels are
85    /// carried as `Cow<'a, str>`, so `&'static str` labels never allocate.
86    pub fn options<I, S>(mut self, options: I) -> Self
87    where
88        I: IntoIterator<Item = (T, S)>,
89        S: Into<Cow<'a, str>>,
90    {
91        self.options = options.into_iter().map(|(v, l)| (v, l.into())).collect();
92        self
93    }
94
95    /// Override the select width in points. Defaults to the intrinsic
96    /// size of the selected label plus padding.
97    pub fn width(mut self, width: f32) -> Self {
98        self.width = Some(width);
99        self
100    }
101}
102
103impl<'a> Select<'a, String> {
104    /// Convenience constructor for string-valued selects. Each item is used
105    /// as both the value and the displayed label.
106    ///
107    /// ```no_run
108    /// # use elegance::Select;
109    /// # egui::__run_test_ui(|ui| {
110    /// let mut unit = String::from("ms");
111    /// ui.add(Select::strings("unit", &mut unit, ["us", "ms", "s"]));
112    /// # });
113    /// ```
114    pub fn strings<I, S>(id_salt: impl Hash, value: &'a mut String, options: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: Into<Cow<'a, str>>,
118    {
119        let options: Vec<(String, Cow<'a, str>)> = options
120            .into_iter()
121            .map(|s| {
122                let label: Cow<'a, str> = s.into();
123                let value = label.as_ref().to_owned();
124                (value, label)
125            })
126            .collect();
127        Self {
128            id_salt: egui::Id::new(id_salt),
129            value,
130            label: None,
131            options,
132            width: None,
133        }
134    }
135}
136
137impl<'a, T: PartialEq + Clone> Widget for Select<'a, T> {
138    fn ui(self, ui: &mut Ui) -> Response {
139        let theme = Theme::current(ui.ctx());
140        let p = &theme.palette;
141        let t = &theme.typography;
142
143        ui.vertical(|ui| {
144            if let Some(label) = &self.label {
145                let rich = egui::RichText::new(label.text())
146                    .color(p.text_muted)
147                    .size(t.label);
148                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
149                ui.add_space(2.0);
150            }
151
152            let width = self.width.unwrap_or(160.0);
153            let chevron_color = p.text_muted;
154
155            // Resolve the displayed label for the current value. Owned so
156            // it doesn't conflict with the mutable access to `self.value`
157            // in the inner closure.
158            let selected_label: String = self
159                .options
160                .iter()
161                .find(|(v, _)| v == &*self.value)
162                .map(|(_, l)| l.as_ref().to_owned())
163                .unwrap_or_default();
164            let field_label = self.label.as_ref().map(|l| l.text().to_string());
165
166            let response = crate::theme::with_themed_visuals(ui, |ui| {
167                let v = ui.visuals_mut();
168                crate::theme::themed_input_visuals(v, &theme, p.input_bg);
169                for w in [
170                    &mut v.widgets.inactive,
171                    &mut v.widgets.hovered,
172                    &mut v.widgets.active,
173                    &mut v.widgets.open,
174                ] {
175                    w.fg_stroke = Stroke::new(1.0, p.text);
176                }
177                v.override_text_color = Some(p.text);
178
179                ComboBox::from_id_salt(self.id_salt)
180                    .width(width)
181                    .selected_text(
182                        egui::RichText::new(&selected_label)
183                            .color(p.text)
184                            .size(t.body),
185                    )
186                    .icon(move |ui, rect, _visuals, is_popup_open| {
187                        paint_chevron(ui, rect, chevron_color, is_popup_open);
188                    })
189                    .show_ui(ui, |ui| {
190                        ui.set_min_width(width);
191                        for (opt_value, opt_label) in self.options.iter() {
192                            let label = egui::RichText::new(opt_label.as_ref())
193                                .color(p.text)
194                                .size(t.body);
195                            if ui
196                                .selectable_label(opt_value == &*self.value, label)
197                                .clicked()
198                            {
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}