Skip to main content

jag_ui/elements/
select.rs

1//! Dropdown select element with options list.
2
3use jag_draw::{Brush, Color, ColorLinPremul, Rect, RoundedRadii, RoundedRect};
4use jag_surface::Canvas;
5
6use crate::event::{
7    ElementState, EventHandler, EventResult, KeyCode, KeyboardEvent, MouseButton, MouseClickEvent,
8    MouseMoveEvent, ScrollEvent,
9};
10use crate::focus::FocusId;
11
12use super::Element;
13
14/// A dropdown select element with an options list.
15#[derive(Clone)]
16pub struct Select {
17    /// Bounding rect of the closed select field.
18    pub rect: Rect,
19    /// Currently displayed label text.
20    pub label: String,
21    /// Placeholder text shown when no option is selected.
22    pub placeholder: String,
23    /// Label font size.
24    pub label_size: f32,
25    /// Label text color.
26    pub label_color: ColorLinPremul,
27    /// Placeholder text color.
28    pub placeholder_color: ColorLinPremul,
29    /// Whether the placeholder is currently shown.
30    pub is_placeholder: bool,
31    /// Whether the dropdown is open.
32    pub open: bool,
33    /// Whether this select is focused.
34    pub focused: bool,
35    /// List of option strings.
36    pub options: Vec<String>,
37    /// Index of the currently selected option.
38    pub selected_index: Option<usize>,
39    /// Padding values.
40    pub padding: [f32; 4],
41    /// Background color.
42    pub bg_color: ColorLinPremul,
43    /// Border color.
44    pub border_color: ColorLinPremul,
45    /// Border width.
46    pub border_width: f32,
47    /// Corner radius.
48    pub radius: f32,
49    /// Validation error message.
50    pub validation_error: Option<String>,
51    /// Focus identifier.
52    pub focus_id: FocusId,
53}
54
55impl Select {
56    /// Height of each option row in the dropdown overlay.
57    const OPTION_HEIGHT: f32 = 36.0;
58    /// Internal padding in the overlay.
59    const OVERLAY_PADDING: f32 = 4.0;
60
61    /// Create a select with sensible defaults.
62    pub fn new(placeholder: impl Into<String>) -> Self {
63        let ph = placeholder.into();
64        Self {
65            rect: Rect {
66                x: 0.0,
67                y: 0.0,
68                w: 200.0,
69                h: 36.0,
70            },
71            label: ph.clone(),
72            placeholder: ph,
73            label_size: 14.0,
74            label_color: ColorLinPremul::from_srgba_u8([255, 255, 255, 255]),
75            placeholder_color: ColorLinPremul::from_srgba_u8([160, 160, 160, 255]),
76            is_placeholder: true,
77            open: false,
78            focused: false,
79            options: Vec::new(),
80            selected_index: None,
81            padding: [8.0, 12.0, 8.0, 12.0],
82            bg_color: ColorLinPremul::from_srgba_u8([40, 40, 40, 255]),
83            border_color: ColorLinPremul::from_srgba_u8([80, 80, 80, 255]),
84            border_width: 1.0,
85            radius: 6.0,
86            validation_error: None,
87            focus_id: FocusId(0),
88        }
89    }
90
91    /// Toggle the dropdown open/closed.
92    pub fn toggle_open(&mut self) {
93        self.open = !self.open;
94    }
95
96    /// Close the dropdown.
97    pub fn close(&mut self) {
98        self.open = false;
99    }
100
101    /// Get the overlay bounds (if open).
102    pub fn get_overlay_bounds(&self) -> Option<Rect> {
103        if !self.open || self.options.is_empty() {
104            return None;
105        }
106        let overlay_height =
107            (self.options.len() as f32 * Self::OPTION_HEIGHT) + (Self::OVERLAY_PADDING * 2.0);
108        Some(Rect {
109            x: self.rect.x,
110            y: self.rect.y + self.rect.h + 4.0,
111            w: self.rect.w,
112            h: overlay_height,
113        })
114    }
115
116    /// Get the selected option text.
117    pub fn selected_option(&self) -> Option<&String> {
118        self.selected_index.and_then(|idx| self.options.get(idx))
119    }
120
121    /// Set the selected index and update the label.
122    pub fn set_selected_index(&mut self, index: Option<usize>) {
123        self.selected_index = index;
124        if let Some(idx) = index {
125            if idx < self.options.len() {
126                self.label = self.options[idx].clone();
127                self.is_placeholder = false;
128            }
129        } else {
130            self.label = self.placeholder.clone();
131            self.is_placeholder = true;
132        }
133    }
134
135    /// Handle click on the field (toggle dropdown).
136    fn handle_field_click(&mut self, x: f32, y: f32) -> bool {
137        if x >= self.rect.x
138            && x <= self.rect.x + self.rect.w
139            && y >= self.rect.y
140            && y <= self.rect.y + self.rect.h
141        {
142            self.toggle_open();
143            true
144        } else {
145            false
146        }
147    }
148
149    /// Handle click on the overlay options.
150    fn handle_overlay_click(&mut self, x: f32, y: f32) -> bool {
151        if !self.open || self.options.is_empty() {
152            return false;
153        }
154        let overlay_bounds = match self.get_overlay_bounds() {
155            Some(b) => b,
156            None => return false,
157        };
158
159        if x < overlay_bounds.x
160            || x > overlay_bounds.x + overlay_bounds.w
161            || y < overlay_bounds.y
162            || y > overlay_bounds.y + overlay_bounds.h
163        {
164            return false;
165        }
166
167        let local_y = y - overlay_bounds.y - Self::OVERLAY_PADDING;
168        if local_y >= 0.0 {
169            let idx = (local_y / Self::OPTION_HEIGHT) as usize;
170            if idx < self.options.len() {
171                self.selected_index = Some(idx);
172                self.label = self.options[idx].clone();
173                self.is_placeholder = false;
174                self.open = false;
175                return true;
176            }
177        }
178        false
179    }
180
181    /// Render the dropdown overlay.
182    fn render_dropdown_overlay(&self, canvas: &mut Canvas, z: i32) {
183        let overlay_bounds = match self.get_overlay_bounds() {
184            Some(b) => b,
185            None => return,
186        };
187
188        let radius = 6.0;
189        let overlay_rrect = RoundedRect {
190            rect: overlay_bounds,
191            radii: RoundedRadii {
192                tl: radius,
193                tr: radius,
194                br: radius,
195                bl: radius,
196            },
197        };
198
199        // Background
200        let overlay_bg = Color::rgba(255, 255, 255, 255);
201        canvas.rounded_rect(overlay_rrect, Brush::Solid(overlay_bg), z);
202
203        // Border
204        jag_surface::shapes::draw_rounded_rectangle(
205            canvas,
206            overlay_rrect,
207            None,
208            Some(1.0),
209            Some(Brush::Solid(self.border_color)),
210            z + 1,
211        );
212
213        // Options
214        let pad_left = self.padding[3];
215        for (idx, option) in self.options.iter().enumerate() {
216            let option_y =
217                overlay_bounds.y + Self::OVERLAY_PADDING + (idx as f32 * Self::OPTION_HEIGHT);
218
219            let is_selected = self.selected_index == Some(idx);
220            if is_selected {
221                let highlight_bg = Color::rgba(220, 220, 224, 255);
222                canvas.fill_rect(
223                    overlay_bounds.x,
224                    option_y,
225                    overlay_bounds.w,
226                    Self::OPTION_HEIGHT,
227                    Brush::Solid(highlight_bg),
228                    z + 2,
229                );
230            }
231
232            let text_x = overlay_bounds.x + Self::OVERLAY_PADDING + pad_left;
233            let text_y = option_y + Self::OPTION_HEIGHT * 0.5 + self.label_size * 0.35;
234            let text_color = if is_selected {
235                Color::rgba(20, 24, 30, 255)
236            } else {
237                Color::rgba(34, 42, 52, 255)
238            };
239
240            canvas.draw_text_run_weighted(
241                [text_x, text_y],
242                option.clone(),
243                self.label_size,
244                400.0,
245                text_color,
246                z + 3,
247            );
248        }
249    }
250}
251
252impl Default for Select {
253    fn default() -> Self {
254        Self::new("Select...")
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Element trait
260// ---------------------------------------------------------------------------
261
262impl Element for Select {
263    fn rect(&self) -> Rect {
264        self.rect
265    }
266
267    fn set_rect(&mut self, rect: Rect) {
268        self.rect = rect;
269    }
270
271    fn render(&self, canvas: &mut Canvas, z: i32) {
272        let rrect = RoundedRect {
273            rect: self.rect,
274            radii: RoundedRadii {
275                tl: self.radius,
276                tr: self.radius,
277                br: self.radius,
278                bl: self.radius,
279            },
280        };
281
282        let has_error = self.validation_error.is_some();
283        let border_color = if has_error {
284            Color::rgba(220, 38, 38, 255)
285        } else if self.focused {
286            Color::rgba(63, 130, 246, 255)
287        } else {
288            self.border_color
289        };
290        let border_width = if has_error {
291            self.border_width.max(2.0)
292        } else if self.focused {
293            (self.border_width + 1.0).max(2.0)
294        } else {
295            self.border_width.max(1.0)
296        };
297
298        jag_surface::shapes::draw_snapped_rounded_rectangle(
299            canvas,
300            rrect,
301            Some(Brush::Solid(self.bg_color)),
302            Some(border_width),
303            Some(Brush::Solid(border_color)),
304            z,
305        );
306
307        // Label text
308        let pad_top = self.padding[0];
309        let pad_left = self.padding[3];
310        let pad_bottom = self.padding[2];
311        let content_h = (self.rect.h - pad_top - pad_bottom).max(0.0);
312        let tp = [
313            self.rect.x + pad_left,
314            self.rect.y + pad_top + content_h * 0.5 + self.label_size * 0.35,
315        ];
316        let (text, color) = if self.is_placeholder {
317            (&self.placeholder, self.placeholder_color)
318        } else {
319            (&self.label, self.label_color)
320        };
321        canvas.draw_text_run_weighted(tp, text.clone(), self.label_size, 400.0, color, z + 2);
322
323        // Chevron indicator (simple text)
324        let pad_right = self.padding[1];
325        let chevron = if self.open { "\u{25B2}" } else { "\u{25BC}" };
326        let chevron_x = self.rect.x + self.rect.w - pad_right - 12.0;
327        let chevron_y = tp[1];
328        canvas.draw_text_run_weighted(
329            [chevron_x, chevron_y],
330            chevron.to_string(),
331            self.label_size * 0.7,
332            400.0,
333            self.label_color,
334            z + 3,
335        );
336
337        // Dropdown overlay
338        if self.open && !self.options.is_empty() {
339            self.render_dropdown_overlay(canvas, z + 1000);
340        }
341
342        // Validation error
343        if let Some(ref error_msg) = self.validation_error {
344            let error_size = (self.label_size * 0.9).max(12.0);
345            let baseline_offset = error_size * 0.8;
346            let top_gap = 3.0;
347            let error_y = self.rect.y + self.rect.h + top_gap + baseline_offset;
348            let error_color = ColorLinPremul::from_srgba_u8([220, 38, 38, 255]);
349
350            canvas.draw_text_run_weighted(
351                [self.rect.x + pad_left, error_y],
352                error_msg.clone(),
353                error_size,
354                400.0,
355                error_color,
356                z + 5,
357            );
358        }
359    }
360
361    fn focus_id(&self) -> Option<FocusId> {
362        Some(self.focus_id)
363    }
364}
365
366// ---------------------------------------------------------------------------
367// EventHandler trait
368// ---------------------------------------------------------------------------
369
370impl EventHandler for Select {
371    fn handle_mouse_click(&mut self, event: &MouseClickEvent) -> EventResult {
372        if event.button != MouseButton::Left || event.state != ElementState::Pressed {
373            return EventResult::Ignored;
374        }
375        if self.open && self.handle_overlay_click(event.x, event.y) {
376            return EventResult::Handled;
377        }
378        if self.handle_field_click(event.x, event.y) {
379            return EventResult::Handled;
380        }
381        EventResult::Ignored
382    }
383
384    fn handle_keyboard(&mut self, event: &KeyboardEvent) -> EventResult {
385        if event.state != ElementState::Pressed {
386            return EventResult::Ignored;
387        }
388        if !self.focused && !self.open {
389            return EventResult::Ignored;
390        }
391        match event.key {
392            KeyCode::ArrowDown => {
393                if !self.open {
394                    self.open = true;
395                } else if !self.options.is_empty() {
396                    let new_idx = match self.selected_index {
397                        Some(idx) if idx + 1 < self.options.len() => idx + 1,
398                        Some(idx) => idx,
399                        None => 0,
400                    };
401                    self.set_selected_index(Some(new_idx));
402                }
403                EventResult::Handled
404            }
405            KeyCode::ArrowUp => {
406                if self.open && !self.options.is_empty() {
407                    let new_idx = match self.selected_index {
408                        Some(idx) if idx > 0 => idx - 1,
409                        Some(idx) => idx,
410                        None => 0,
411                    };
412                    self.set_selected_index(Some(new_idx));
413                    EventResult::Handled
414                } else {
415                    EventResult::Ignored
416                }
417            }
418            KeyCode::Enter => {
419                self.open = !self.open;
420                EventResult::Handled
421            }
422            KeyCode::Escape => {
423                if self.open {
424                    self.open = false;
425                    EventResult::Handled
426                } else {
427                    EventResult::Ignored
428                }
429            }
430            KeyCode::Space => {
431                self.toggle_open();
432                EventResult::Handled
433            }
434            _ => EventResult::Ignored,
435        }
436    }
437
438    fn handle_mouse_move(&mut self, _event: &MouseMoveEvent) -> EventResult {
439        EventResult::Ignored
440    }
441
442    fn handle_scroll(&mut self, _event: &ScrollEvent) -> EventResult {
443        EventResult::Ignored
444    }
445
446    fn is_focused(&self) -> bool {
447        self.focused
448    }
449
450    fn set_focused(&mut self, focused: bool) {
451        self.focused = focused;
452    }
453
454    fn contains_point(&self, x: f32, y: f32) -> bool {
455        // Field area
456        if x >= self.rect.x
457            && x <= self.rect.x + self.rect.w
458            && y >= self.rect.y
459            && y <= self.rect.y + self.rect.h
460        {
461            return true;
462        }
463        // Overlay area (if open)
464        if let Some(ob) = self.get_overlay_bounds()
465            && x >= ob.x
466            && x <= ob.x + ob.w
467            && y >= ob.y
468            && y <= ob.y + ob.h
469        {
470            return true;
471        }
472        false
473    }
474}
475
476// ---------------------------------------------------------------------------
477// Tests
478// ---------------------------------------------------------------------------
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn select_new_defaults() {
486        let sel = Select::new("Choose...");
487        assert_eq!(sel.placeholder, "Choose...");
488        assert!(sel.is_placeholder);
489        assert!(!sel.open);
490        assert!(sel.selected_index.is_none());
491    }
492
493    #[test]
494    fn select_set_selected_index() {
495        let mut sel = Select::new("Pick");
496        sel.options = vec!["A".into(), "B".into(), "C".into()];
497        sel.set_selected_index(Some(1));
498        assert_eq!(sel.selected_index, Some(1));
499        assert_eq!(sel.label, "B");
500        assert!(!sel.is_placeholder);
501
502        sel.set_selected_index(None);
503        assert_eq!(sel.label, "Pick");
504        assert!(sel.is_placeholder);
505    }
506
507    #[test]
508    fn select_toggle_open() {
509        let mut sel = Select::new("Pick");
510        assert!(!sel.open);
511        sel.toggle_open();
512        assert!(sel.open);
513        sel.toggle_open();
514        assert!(!sel.open);
515    }
516
517    #[test]
518    fn select_contains_point_field() {
519        let mut sel = Select::new("Pick");
520        sel.rect = Rect {
521            x: 10.0,
522            y: 10.0,
523            w: 200.0,
524            h: 36.0,
525        };
526        assert!(sel.contains_point(100.0, 25.0));
527        assert!(!sel.contains_point(0.0, 0.0));
528    }
529
530    #[test]
531    fn select_keyboard_open_close() {
532        let mut sel = Select::new("Pick");
533        sel.focused = true;
534        sel.options = vec!["X".into()];
535
536        let down = KeyboardEvent {
537            key: KeyCode::ArrowDown,
538            state: ElementState::Pressed,
539            modifiers: Default::default(),
540            text: None,
541        };
542        assert_eq!(sel.handle_keyboard(&down), EventResult::Handled);
543        assert!(sel.open);
544
545        let esc = KeyboardEvent {
546            key: KeyCode::Escape,
547            state: ElementState::Pressed,
548            modifiers: Default::default(),
549            text: None,
550        };
551        assert_eq!(sel.handle_keyboard(&esc), EventResult::Handled);
552        assert!(!sel.open);
553    }
554}