Skip to main content

agg_gui/widgets/
combo_box.rs

1//! `ComboBox` — a single-selection dropdown widget.
2//!
3//! When closed the widget occupies `CLOSED_H` pixels vertically.  When open
4//! it expands downward in the layout (returning `CLOSED_H + n_items × ITEM_H`
5//! from `layout()`), so sibling widgets are pushed down.  This works naturally
6//! inside a `ScrollView` (the scroll area absorbs the extra height).
7//!
8//! Text for the selected value and dropdown items is rendered through
9//! backbuffered [`Label`] children maintained in `selected_label` and
10//! `item_labels`.  Colors are updated from `ctx.visuals()` in `paint()` so the
11//! widget responds correctly to dark / light mode switches.
12
13use std::cell::Cell;
14use std::rc::Rc;
15use std::sync::Arc;
16
17use crate::color::Color;
18use crate::event::{Event, EventResult, Key, MouseButton};
19use crate::geometry::{Point, Rect, Size};
20use crate::draw_ctx::DrawCtx;
21use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
22use crate::text::Font;
23use crate::widget::{Widget, paint_subtree};
24use crate::widgets::label::Label;
25
26const CLOSED_H: f64 = 28.0;
27const ITEM_H:   f64 = 24.0;
28const PAD_X:    f64 = 8.0;
29const ARROW_W:  f64 = 20.0;
30const CORNER_R: f64 = 4.0;
31
32/// A single-selection dropdown.
33///
34/// # Example
35/// ```ignore
36/// ComboBox::new(vec!["Option A", "Option B", "Option C"], 0, font)
37///     .on_change(|idx| println!("selected {idx}"))
38/// ```
39pub struct ComboBox {
40    bounds:   Rect,
41    children: Vec<Box<dyn Widget>>, // always empty — labels stored separately
42    base:     WidgetBase,
43
44    options:  Vec<String>,
45    selected: usize,
46    open:     bool,
47    /// Index of the item the cursor is currently over (only meaningful when open).
48    hovered_item: Option<usize>,
49
50    font:      Arc<Font>,
51    font_size: f64,
52
53    on_change: Option<Box<dyn FnMut(usize)>>,
54    /// Optional external mirror of `selected` — same bidirectional-cell
55    /// pattern as `Slider::with_value_cell` / `RadioGroup::with_selected_cell`.
56    /// `layout()` re-reads the cell every frame so a sibling ComboBox bound
57    /// to the same cell stays in lock-step; selection changes here write back.
58    selected_cell: Option<Rc<Cell<usize>>>,
59
60    // ── Backbuffered labels ──────────────────────────────────────────────────
61    /// Label for the currently selected option (shown in the closed button area).
62    selected_label: Label,
63    /// One label per option, used when the dropdown is open.
64    item_labels:    Vec<Label>,
65    /// Optional per-item font overrides, set via [`with_item_fonts`].
66    /// `None` means every entry (and the selected label) uses `self.font`
67    /// — the default.  `Some(vec)` means each entry uses `vec[i]` and
68    /// the selected label uses `vec[selected]`, ignoring the system
69    /// font override so font-preview UI stays stable.
70    item_fonts:     Option<Vec<Arc<Font>>>,
71}
72
73impl ComboBox {
74    /// Create a new `ComboBox`.
75    ///
76    /// `options` is the full list of choices; `selected` is the initial index
77    /// (clamped to a valid range).
78    pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
79        let font_size = 13.0;
80        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
81        let sel = selected.min(opts.len().saturating_sub(1));
82
83        let selected_label = Self::make_label(
84            opts.get(sel).map(|s| s.as_str()).unwrap_or(""),
85            font_size,
86            Arc::clone(&font),
87        );
88        let item_labels = opts.iter().map(|t| {
89            Self::make_label(t, font_size, Arc::clone(&font))
90        }).collect();
91
92        Self {
93            bounds:   Rect::default(),
94            children: Vec::new(),
95            base:     WidgetBase::new(),
96            options:  opts,
97            selected: sel,
98            open:     false,
99            hovered_item: None,
100            font,
101            font_size,
102            on_change: None,
103            selected_cell: None,
104            selected_label,
105            item_labels,
106            item_fonts: None,
107        }
108    }
109
110    /// Bind this combo's selection to an external `Rc<Cell<usize>>`.
111    /// `layout()` reads the cell each frame so a sibling combo (e.g. the
112    /// matching font picker in another window) sharing the same cell
113    /// stays in lock-step; user selections here write back.  Mirrors the
114    /// `Slider::with_value_cell` / `RadioGroup::with_selected_cell` pattern.
115    pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
116        let n = self.options.len();
117        let v = cell.get();
118        if n > 0 {
119            let clamped = v.min(n - 1);
120            // Initialise self.selected from the cell so the closed combo
121            // shows the right label on first paint.
122            self.set_selected(clamped);
123        }
124        self.selected_cell = Some(cell);
125        self
126    }
127
128    fn make_label(text: &str, font_size: f64, font: Arc<Font>) -> Label {
129        Label::new(text, font)
130            .with_font_size(font_size)
131    }
132
133    // ── Builder ──────────────────────────────────────────────────────────────
134
135    pub fn with_font_size(mut self, size: f64) -> Self {
136        self.font_size = size;
137        self.selected_label = Self::make_label(
138            self.options.get(self.selected).map(|s| s.as_str()).unwrap_or(""),
139            size,
140            Arc::clone(&self.font),
141        );
142        self.item_labels = self.options.iter().map(|t| {
143            Self::make_label(t, size, Arc::clone(&self.font))
144        }).collect();
145        self
146    }
147
148    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
149    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
150    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
151    pub fn with_min_size(mut self, s: Size)    -> Self { self.base.min_size = s; self }
152    pub fn with_max_size(mut self, s: Size)    -> Self { self.base.max_size = s; self }
153
154    /// Set the callback called when the user selects a new option.
155    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
156        self.on_change = Some(Box::new(cb));
157        self
158    }
159
160    /// Override the font used for EACH dropdown entry individually — one
161    /// font per option.  Intended for font-preview UI (the System window's
162    /// font picker renders each name in its own face).  Each item label
163    /// is rebuilt with the matching `Arc<Font>` and marked to ignore the
164    /// system-wide font override (otherwise changing the global font
165    /// would overwrite all the per-entry faces).
166    ///
167    /// Lengths must match: `fonts.len()` should equal the number of
168    /// options.  Extra fonts are ignored; missing entries keep the
169    /// default `self.font`.  The SELECTED label (shown when the dropdown
170    /// is closed) is also rebuilt with the currently-selected font so
171    /// the closed combo reflects the live face.
172    pub fn with_item_fonts(mut self, fonts: Vec<Arc<Font>>) -> Self {
173        self.item_fonts = Some(fonts.clone());
174        let size = self.font_size;
175        self.item_labels = self.options.iter().enumerate().map(|(i, t)| {
176            let f = fonts.get(i).cloned()
177                .unwrap_or_else(|| Arc::clone(&self.font));
178            Label::new(t, f)
179                .with_font_size(size)
180                .with_ignore_system_font(true)
181        }).collect();
182        // Rebuild the selected label with its matching font too.
183        if let Some(sel_font) = fonts.get(self.selected).cloned() {
184            self.selected_label = Label::new(
185                self.options.get(self.selected).map(|s| s.as_str()).unwrap_or(""),
186                sel_font,
187            )
188                .with_font_size(size)
189                .with_ignore_system_font(true);
190        }
191        self
192    }
193
194    // ── Accessors ────────────────────────────────────────────────────────────
195
196    pub fn selected(&self) -> usize { self.selected }
197
198    pub fn set_selected(&mut self, idx: usize) {
199        if idx < self.options.len() {
200            self.selected = idx;
201            // If per-item fonts are set, rebuild the selected label with
202            // the matching face so the closed combo shows the correct
203            // preview.  Otherwise just swap the text on the existing
204            // label.
205            if let Some(ref fonts) = self.item_fonts {
206                if let Some(f) = fonts.get(idx).cloned() {
207                    self.selected_label = Label::new(
208                        self.options[idx].as_str(), f,
209                    )
210                        .with_font_size(self.font_size)
211                        .with_ignore_system_font(true);
212                    return;
213                }
214            }
215            self.selected_label.set_text(self.options[idx].as_str());
216        }
217    }
218
219    // ── Internal helpers ─────────────────────────────────────────────────────
220
221    fn fire(&mut self) {
222        let idx = self.selected;
223        if let Some(cell) = &self.selected_cell { cell.set(idx); }
224        if let Some(cb) = self.on_change.as_mut() { cb(idx); }
225    }
226
227    /// Height returned by `layout()` — varies with open/closed state.
228    fn total_h(&self) -> f64 {
229        if self.open {
230            CLOSED_H + self.options.len() as f64 * ITEM_H
231        } else {
232            CLOSED_H
233        }
234    }
235
236    /// Local Y coordinate of the TOP of item `i` (Y-up: larger = higher on screen).
237    ///
238    /// Items are drawn below the closed button area (y < 0 from the button
239    /// bottom), but since layout expands downward in Y-up coordinates, item 0
240    /// starts just below the button, which is at `total_h - CLOSED_H`.
241    fn item_top_y(&self, i: usize) -> f64 {
242        // In local Y-up space the button occupies [total_h-CLOSED_H .. total_h].
243        // Items occupy [0 .. total_h-CLOSED_H], item 0 highest.
244        let dropdown_h = self.total_h() - CLOSED_H;
245        dropdown_h - (i as f64 * ITEM_H)
246    }
247
248    fn item_rect(&self, i: usize) -> Rect {
249        let w  = self.bounds.width;
250        let ty = self.item_top_y(i);
251        Rect::new(0.0, ty - ITEM_H, w, ITEM_H)
252    }
253
254    /// Which dropdown item (if any) contains local point `p`.
255    fn item_for_pos(&self, p: Point) -> Option<usize> {
256        if !self.open { return None; }
257        for i in 0..self.options.len() {
258            let r = self.item_rect(i);
259            if p.x >= r.x && p.x <= r.x + r.width
260                && p.y >= r.y && p.y <= r.y + r.height
261            {
262                return Some(i);
263            }
264        }
265        None
266    }
267
268    /// Whether `p` is inside the closed button area (top 28px of the widget).
269    fn in_button(&self, p: Point) -> bool {
270        let button_y = self.total_h() - CLOSED_H;
271        p.x >= 0.0 && p.x <= self.bounds.width
272            && p.y >= button_y && p.y <= self.total_h()
273    }
274}
275
276impl Widget for ComboBox {
277    fn type_name(&self) -> &'static str { "ComboBox" }
278    fn bounds(&self) -> Rect { self.bounds }
279    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
280    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
281    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
282
283    fn is_focusable(&self) -> bool { true }
284
285    fn margin(&self)   -> Insets  { self.base.margin }
286    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
287    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
288    fn min_size(&self) -> Size    { self.base.min_size }
289    fn max_size(&self) -> Size    { self.base.max_size }
290
291    fn layout(&mut self, available: Size) -> Size {
292        // Pick up external-cell writes — e.g. a sibling combo bound to
293        // the same selected_cell wrote a new index since our last paint.
294        // Skip while open so an in-progress dropdown interaction doesn't
295        // get yanked back.
296        if !self.open {
297            if let Some(cell) = &self.selected_cell {
298                let n = self.options.len();
299                if n > 0 {
300                    let v = cell.get().min(n - 1);
301                    if v != self.selected {
302                        // Use set_selected so the visible label (and the
303                        // per-item-font preview, if any) refreshes too.
304                        self.set_selected(v);
305                    }
306                }
307            }
308        }
309
310        let h = self.total_h();
311        self.bounds = Rect::new(0.0, 0.0, available.width, h);
312        let inner_w = (available.width - PAD_X * 2.0 - ARROW_W).max(0.0);
313
314        // Layout selected label.
315        let sl = self.selected_label.layout(Size::new(inner_w, CLOSED_H));
316        let sl_y = (self.total_h() - CLOSED_H) + (CLOSED_H - sl.height) * 0.5;
317        self.selected_label.set_bounds(Rect::new(PAD_X, sl_y, sl.width, sl.height));
318
319        // Layout item labels — compute ty before borrowing item_labels.
320        let dropdown_h = self.total_h() - CLOSED_H;
321        for i in 0..self.item_labels.len() {
322            let s = self.item_labels[i].layout(Size::new(inner_w, ITEM_H));
323            let ty = dropdown_h - (i as f64 * ITEM_H);
324            let ly = ty - ITEM_H + (ITEM_H - s.height) * 0.5;
325            self.item_labels[i].set_bounds(Rect::new(PAD_X, ly, s.width, s.height));
326        }
327
328        Size::new(available.width, h)
329    }
330
331    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
332        let v   = ctx.visuals();
333        let w   = self.bounds.width;
334        let h   = self.total_h();
335        // Button area (top section, Y-up).
336        let btn_y = h - CLOSED_H;
337
338        // ── Button background ─────────────────────────────────────────────────
339        ctx.set_fill_color(v.widget_bg);
340        ctx.begin_path();
341        ctx.rounded_rect(0.0, btn_y, w, CLOSED_H, CORNER_R);
342        ctx.fill();
343
344        ctx.set_stroke_color(v.widget_stroke);
345        ctx.set_line_width(1.0);
346        ctx.begin_path();
347        ctx.rounded_rect(0.0, btn_y, w, CLOSED_H, CORNER_R);
348        ctx.stroke();
349
350        // ── Dropdown arrow (▼) ────────────────────────────────────────────────
351        let arrow_x = w - ARROW_W * 0.5;
352        let arrow_cy = btn_y + CLOSED_H * 0.5;
353        let arrow_sz = 4.0;
354        ctx.set_fill_color(v.text_dim);
355        ctx.begin_path();
356        // Small downward triangle.
357        ctx.move_to(arrow_x - arrow_sz, arrow_cy + arrow_sz * 0.5);
358        ctx.line_to(arrow_x + arrow_sz, arrow_cy + arrow_sz * 0.5);
359        ctx.line_to(arrow_x,            arrow_cy - arrow_sz * 0.5);
360        ctx.close_path();
361        ctx.fill();
362
363        // ── Selected label ────────────────────────────────────────────────────
364        self.selected_label.set_color(v.text_color);
365        let sl_bounds = self.selected_label.bounds();
366
367        ctx.save();
368        ctx.translate(sl_bounds.x, sl_bounds.y);
369        paint_subtree(&mut self.selected_label, ctx);
370        ctx.restore();
371
372        // ── Open dropdown ─────────────────────────────────────────────────────
373        if self.open {
374            let dropdown_h = h - CLOSED_H;
375
376            // Dropdown panel background.
377            ctx.set_fill_color(v.widget_bg);
378            ctx.begin_path();
379            ctx.rounded_rect(0.0, 0.0, w, dropdown_h, CORNER_R);
380            ctx.fill();
381
382            ctx.set_stroke_color(v.widget_stroke);
383            ctx.set_line_width(1.0);
384            ctx.begin_path();
385            ctx.rounded_rect(0.0, 0.0, w, dropdown_h, CORNER_R);
386            ctx.stroke();
387
388            // Items.
389            for i in 0..self.options.len() {
390                let ir = self.item_rect(i);
391
392                // Hover / selected highlight.
393                let is_hovered  = self.hovered_item == Some(i);
394                let is_selected = i == self.selected;
395                if is_selected || is_hovered {
396                    let bg = if is_selected { v.accent } else { v.widget_bg_hovered };
397                    ctx.set_fill_color(bg);
398                    ctx.begin_path();
399                    ctx.rounded_rect(2.0, ir.y + 1.0, w - 4.0, ir.height - 2.0, 3.0);
400                    ctx.fill();
401                }
402
403                // Label.
404                let text_color = if is_selected { Color::white() } else { v.text_color };
405                self.item_labels[i].set_color(text_color);
406                let lb = self.item_labels[i].bounds();
407
408                ctx.save();
409                ctx.translate(lb.x, lb.y);
410                paint_subtree(&mut self.item_labels[i], ctx);
411                ctx.restore();
412            }
413        }
414    }
415
416    fn on_event(&mut self, event: &Event) -> EventResult {
417        match event {
418            Event::MouseDown { button: MouseButton::Left, pos, .. } => {
419                if self.in_button(*pos) {
420                    self.open = !self.open;
421                    self.hovered_item = None;
422                    crate::animation::request_tick();
423                    return EventResult::Consumed;
424                }
425                if self.open {
426                    if let Some(i) = self.item_for_pos(*pos) {
427                        // Route through `set_selected` so the closed
428                        // combo's preview label is rebuilt with the
429                        // newly-selected per-item font (when item_fonts
430                        // is set).  Direct `self.selected = i` would
431                        // change the index without swapping the face,
432                        // leaving the closed combo showing the new
433                        // name in the OLD typeface — the bug visible
434                        // when the LCD Subpixel demo's font picker
435                        // showed e.g. "Bangers" in Cascadia Code.
436                        self.set_selected(i);
437                        self.open = false;
438                        self.hovered_item = None;
439                        self.fire();
440                        crate::animation::request_tick();
441                        return EventResult::Consumed;
442                    }
443                    // Click outside the dropdown — close it.
444                    self.open = false;
445                    self.hovered_item = None;
446                    crate::animation::request_tick();
447                    return EventResult::Consumed;
448                }
449                EventResult::Ignored
450            }
451            Event::MouseMove { pos } => {
452                self.hovered_item = self.item_for_pos(*pos);
453                EventResult::Ignored
454            }
455            Event::KeyDown { key, .. } => {
456                let n = self.options.len();
457                match key {
458                    Key::Enter | Key::Char(' ') => {
459                        self.open = !self.open;
460                        crate::animation::request_tick();
461                        EventResult::Consumed
462                    }
463                    Key::Escape => {
464                        if self.open {
465                            self.open = false;
466                            crate::animation::request_tick();
467                            EventResult::Consumed
468                        } else {
469                            EventResult::Ignored
470                        }
471                    }
472                    Key::ArrowDown => {
473                        if self.selected + 1 < n {
474                            self.selected += 1;
475                            self.selected_label.set_text(self.options[self.selected].as_str());
476                            self.fire();
477                            crate::animation::request_tick();
478                        }
479                        EventResult::Consumed
480                    }
481                    Key::ArrowUp => {
482                        if self.selected > 0 {
483                            self.selected -= 1;
484                            self.selected_label.set_text(self.options[self.selected].as_str());
485                            self.fire();
486                            crate::animation::request_tick();
487                        }
488                        EventResult::Consumed
489                    }
490                    _ => EventResult::Ignored,
491                }
492            }
493            Event::FocusLost => {
494                let was_open = self.open;
495                self.open = false;
496                self.hovered_item = None;
497                if was_open { crate::animation::request_tick(); }
498                EventResult::Ignored
499            }
500            _ => EventResult::Ignored,
501        }
502    }
503
504    fn properties(&self) -> Vec<(&'static str, String)> {
505        vec![
506            ("selected", self.selected.to_string()),
507            ("open",     self.open.to_string()),
508            ("options",  self.options.len().to_string()),
509        ]
510    }
511}