1use std::borrow::Cow;
4
5use iced::keyboard::key::Named;
6use iced::overlay::menu;
7use iced::widget::pick_list::{self, Handle, Status};
8use iced::widget::text::LineHeight;
9use iced::widget::{container, stack, svg};
10use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme, Vector};
11
12use crate::icon::tinted;
13use crate::inert::inert;
14use crate::keys::{self, Chord, Keymap};
15use crate::theme::{Tokens, fade, mix, radius, space, text_size};
16
17pub const HEIGHT: f32 = 36.0;
19
20const CHEVRON_SIZE: f32 = 16.0;
21const LINE_HEIGHT: f32 = 20.0;
22
23pub struct Select<'a, T: Clone, Message> {
27 options: Cow<'a, [T]>,
28 selected: Option<T>,
29 placeholder: Option<String>,
30 width: Length,
31 on_select: Option<Box<dyn Fn(T) -> Message + 'a>>,
32}
33
34impl<T: Clone + std::fmt::Debug, Message> std::fmt::Debug for Select<'_, T, Message> {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 f.debug_struct("Select")
37 .field("options", &self.options)
38 .field("selected", &self.selected)
39 .field("placeholder", &self.placeholder)
40 .field("enabled", &self.on_select.is_some())
41 .finish()
42 }
43}
44
45pub fn select<'a, T, Message>(
48 options: impl Into<Cow<'a, [T]>>,
49 selected: Option<T>,
50) -> Select<'a, T, Message>
51where
52 T: ToString + PartialEq + Clone,
53{
54 Select {
55 options: options.into(),
56 selected,
57 placeholder: None,
58 width: Length::Fixed(200.0),
59 on_select: None,
60 }
61}
62
63impl<'a, T: Clone, Message> Select<'a, T, Message> {
64 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
66 self.placeholder = Some(placeholder.into());
67 self
68 }
69
70 pub fn width(mut self, width: impl Into<Length>) -> Self {
71 self.width = width.into();
72 self
73 }
74
75 pub fn on_select(mut self, on_select: impl Fn(T) -> Message + 'a) -> Self {
77 self.on_select = Some(Box::new(on_select));
78 self
79 }
80
81 pub fn on_select_maybe(mut self, on_select: Option<impl Fn(T) -> Message + 'a>) -> Self {
84 self.on_select = on_select.map(|f| Box::new(f) as Box<dyn Fn(T) -> Message + 'a>);
85 self
86 }
87
88 pub fn is_enabled(&self) -> bool {
89 self.on_select.is_some()
90 }
91}
92
93impl<'a, T, Message> From<Select<'a, T, Message>> for Element<'a, Message>
94where
95 T: ToString + PartialEq + Clone + 'a,
96 Message: Clone + 'a,
97{
98 fn from(select: Select<'a, T, Message>) -> Self {
99 let enabled = select.is_enabled();
100 let placeholder = select.placeholder.unwrap_or_default();
101 let options = select.options;
102 let selected = select.selected;
103
104 let list: Element<'a, Message> = match select.on_select {
105 Some(on_select) => field(options, selected, placeholder, on_select, true).into(),
106 None => inert(field(options, selected, placeholder, |_| (), false).into()),
107 };
108
109 let chevron = tinted(crate::lucide!(ChevronDown), CHEVRON_SIZE, None).style(
110 move |theme: &Theme, _| svg::Style {
111 color: Some(style(&Tokens::of(theme), Status::Active, enabled).handle_color),
112 },
113 );
114
115 stack![
116 container(list).width(select.width),
117 container(chevron)
118 .width(Length::Fill)
119 .height(Length::Fill)
120 .align_x(Alignment::End)
121 .align_y(Alignment::Center)
122 .padding(Padding::default().right(space::MD)),
123 ]
124 .into()
125 }
126}
127
128type PickList<'a, T, Message> =
129 iced::widget::PickList<'a, T, Cow<'a, [T]>, T, Message, Theme, iced::Renderer>;
130
131fn field<'a, T, Message>(
132 options: Cow<'a, [T]>,
133 selected: Option<T>,
134 placeholder: String,
135 on_select: impl Fn(T) -> Message + 'a,
136 enabled: bool,
137) -> PickList<'a, T, Message>
138where
139 T: ToString + PartialEq + Clone + 'a,
140 Message: Clone,
141{
142 let vertical = (HEIGHT - LINE_HEIGHT) / 2.0;
143 iced::widget::pick_list(options, selected, on_select)
144 .placeholder(placeholder)
145 .width(Length::Fill)
146 .padding(Padding {
147 top: vertical,
148 bottom: vertical,
149 left: space::MD,
150 right: space::MD + CHEVRON_SIZE + space::SM,
151 })
152 .text_size(text_size::SM)
153 .text_line_height(LineHeight::Absolute(LINE_HEIGHT.into()))
154 .handle(Handle::None)
155 .style(move |theme, status| style(&Tokens::of(theme), status, enabled))
156 .menu_style(|theme| menu_style(&Tokens::of(theme)))
157}
158
159pub fn style(tokens: &Tokens, status: Status, enabled: bool) -> pick_list::Style {
161 let border = match status {
162 Status::Active => tokens.border,
163 Status::Hovered => mix(tokens.border, tokens.foreground, 0.3),
164 Status::Opened { .. } => mix(tokens.border, tokens.foreground, 0.6),
165 };
166 let style = pick_list::Style {
167 text_color: tokens.foreground,
168 placeholder_color: tokens.muted_foreground,
169 handle_color: tokens.muted_foreground,
170 background: Background::Color(tokens.background),
171 border: Border {
172 color: border,
173 width: 1.0,
174 radius: radius::MD.into(),
175 },
176 };
177
178 if enabled {
179 return style;
180 }
181 pick_list::Style {
182 text_color: fade(tokens.foreground, 0.5),
183 placeholder_color: fade(tokens.muted_foreground, 0.5),
184 handle_color: fade(tokens.muted_foreground, 0.5),
185 background: Background::Color(tokens.muted),
186 border: Border {
187 color: fade(tokens.border, 0.5),
188 ..style.border
189 },
190 }
191}
192
193pub fn menu_style(tokens: &Tokens) -> menu::Style {
195 menu::Style {
196 background: Background::Color(tokens.background),
197 border: Border {
198 color: tokens.border,
199 width: 1.0,
200 radius: radius::MD.into(),
201 },
202 text_color: tokens.foreground,
203 selected_text_color: tokens.accent_foreground,
204 selected_background: Background::Color(tokens.accent),
205 shadow: Shadow {
206 color: fade(Color::BLACK, if tokens.is_dark { 0.5 } else { 0.1 }),
207 offset: Vector::new(0.0, 4.0),
208 blur_radius: 12.0,
209 },
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum Action {
216 Next,
217 Previous,
218}
219
220impl Action {
221 pub fn apply<T: PartialEq + Clone>(self, options: &[T], selected: Option<&T>) -> Option<T> {
226 let current = selected.and_then(|value| options.iter().position(|option| option == value));
227 let target = match (self, current) {
228 (Action::Next, None) => 0,
229 (Action::Previous, None) => options.len().checked_sub(1)?,
230 (Action::Next, Some(index)) => index + 1,
231 (Action::Previous, Some(index)) => index.checked_sub(1)?,
232 };
233 options.get(target).cloned()
234 }
235}
236
237impl keys::Action for Action {
238 const ALL: &'static [Self] = &[Action::Next, Action::Previous];
239
240 fn defaults() -> Keymap<Self> {
241 default_keymap()
242 }
243
244 fn name(self) -> &'static str {
245 match self {
246 Action::Next => "Next",
247 Action::Previous => "Previous",
248 }
249 }
250
251 fn description(self) -> &'static str {
252 match self {
253 Action::Next => "Selects the next option, stopping at the last.",
254 Action::Previous => "Selects the previous option, stopping at the first.",
255 }
256 }
257}
258
259pub fn default_keymap() -> Keymap<Action> {
270 Keymap::new()
271 .bind(Chord::named(Named::ArrowDown), Action::Next)
272 .bind(Chord::named(Named::ArrowUp), Action::Previous)
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::theme::{dark, light};
279
280 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
281 let chord: Chord = chord.parse().unwrap();
282 keymap.resolve(chord.key(), chord.modifiers())
283 }
284
285 #[test]
286 fn default_keymap_and_overrides() {
287 let keymap = default_keymap();
288 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
289 assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
290 let custom = keymap
291 .bind("J".parse().unwrap(), Action::Next)
292 .unbind(&"ArrowDown".parse().unwrap());
293 assert_eq!(press(&custom, "j"), Some(Action::Next));
294 assert_eq!(press(&custom, "ArrowDown"), None);
295 }
296
297 #[test]
298 fn next_and_previous_stop_at_the_ends() {
299 let options = ["a", "b", "c"];
300 assert_eq!(Action::Next.apply(&options, Some(&"a")), Some("b"));
301 assert_eq!(Action::Next.apply(&options, Some(&"c")), None);
302 assert_eq!(Action::Previous.apply(&options, Some(&"b")), Some("a"));
303 assert_eq!(Action::Previous.apply(&options, Some(&"a")), None);
304 }
305
306 #[test]
307 fn without_a_selection_next_and_previous_pick_an_end() {
308 let options = ["a", "b", "c"];
309 assert_eq!(Action::Next.apply(&options, None), Some("a"));
310 assert_eq!(Action::Previous.apply(&options, None), Some("c"));
311 assert_eq!(Action::Next.apply(&options, Some(&"z")), Some("a"));
312 let empty: [&str; 0] = [];
313 assert_eq!(Action::Next.apply(&empty, None), None);
314 assert_eq!(Action::Previous.apply(&empty, None), None);
315 }
316
317 const STATES: [Status; 4] = [
318 Status::Active,
319 Status::Hovered,
320 Status::Opened { is_hovered: false },
321 Status::Opened { is_hovered: true },
322 ];
323
324 #[test]
325 fn default_builder_has_no_selection_and_is_disabled() {
326 let s: Select<'_, &str, ()> = select(vec!["a", "b"], None);
327 assert_eq!(s.options.len(), 2);
328 assert!(s.selected.is_none());
329 assert!(s.placeholder.is_none());
330 assert_eq!(s.width, Length::Fixed(200.0));
331 assert!(!s.is_enabled());
332 }
333
334 #[test]
335 fn builder_sets_placeholder_and_message() {
336 const OPTIONS: &[u8] = &[1, 2, 3];
337 let s: Select<'_, u8, u8> = select(OPTIONS, Some(2))
338 .placeholder("Pick a number")
339 .on_select(|v| v);
340 assert_eq!(s.placeholder.as_deref(), Some("Pick a number"));
341 assert!(s.is_enabled());
342 assert!(matches!(s.options, Cow::Borrowed(_)));
343 }
344
345 #[test]
346 fn on_select_maybe_enables_only_with_a_message() {
347 let enabled: Select<'_, u8, u8> = select(vec![1, 2], None).on_select_maybe(Some(|v| v));
348 assert!(enabled.is_enabled());
349 let disabled: Select<'_, u8, u8> =
350 select(vec![1, 2], None).on_select_maybe(None::<fn(u8) -> u8>);
351 assert!(!disabled.is_enabled());
352 }
353
354 #[test]
355 fn hover_and_open_strengthen_the_border() {
356 for theme in [light(), dark()] {
357 let tokens = Tokens::of(&theme);
358 let borders = STATES.map(|status| style(&tokens, status, true).border.color);
359 assert_ne!(borders[0], borders[1]);
360 assert_ne!(borders[1], borders[2]);
361 assert_eq!(borders[2], borders[3]);
362 }
363 }
364
365 #[test]
366 fn placeholder_is_muted() {
367 for theme in [light(), dark()] {
368 let tokens = Tokens::of(&theme);
369 let style = style(&tokens, Status::Active, true);
370 assert_eq!(style.placeholder_color, tokens.muted_foreground);
371 assert_ne!(style.placeholder_color, style.text_color);
372 }
373 }
374
375 #[test]
376 fn disabled_fades_text_and_uses_a_muted_background() {
377 for theme in [light(), dark()] {
378 let tokens = Tokens::of(&theme);
379 for status in STATES {
380 let disabled = style(&tokens, status, false);
381 assert!((disabled.text_color.a - tokens.foreground.a * 0.5).abs() < 1e-6);
382 assert_eq!(disabled.background, Background::Color(tokens.muted));
383 }
384 }
385 }
386
387 #[test]
388 fn menu_highlights_with_accent() {
389 for theme in [light(), dark()] {
390 let tokens = Tokens::of(&theme);
391 let menu = menu_style(&tokens);
392 assert_eq!(menu.selected_background, Background::Color(tokens.accent));
393 assert_eq!(menu.border.width, 1.0);
394 }
395 }
396}