1use 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#[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 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 pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
83 self.label = Some(label.into());
84 self
85 }
86
87 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 pub fn width(mut self, width: f32) -> Self {
101 self.width = Some(width);
102 self
103 }
104}
105
106impl<'a> Select<'a, String> {
107 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 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 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
221fn 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
249fn 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}