Skip to main content

agg_gui/widgets/
combo_box.rs

1//! `ComboBox` — a single-selection dropdown widget.
2//!
3//! The widget always occupies its compact closed height.  When open, options
4//! are painted as a floating panel below the button in `paint_overlay()` so
5//! sibling widgets are not pushed down by the dropdown.
6//!
7//! Text for the selected value and dropdown items is rendered through
8//! backbuffered [`Label`] children maintained in `selected_label` and
9//! `item_labels`.  Colors are updated from `ctx.visuals()` in `paint()` so the
10//! widget responds correctly to dark / light mode switches.
11
12use std::cell::{Cell, RefCell};
13use std::rc::Rc;
14use std::sync::Arc;
15
16use crate::color::Color;
17use crate::draw_ctx::DrawCtx;
18use crate::event::{Event, EventResult, Key, MouseButton};
19use crate::geometry::{Point, Rect, Size};
20use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
21use crate::text::Font;
22use crate::widget::{paint_subtree, Widget};
23use crate::widgets::label::Label;
24
25use super::scroll_view::{current_scroll_style, current_scroll_visibility, ScrollBarStyle};
26use super::scrollbar::{
27    paint_prepared_scrollbar, PreparedScrollbar, ScrollbarAxis, ScrollbarGeometry,
28    ScrollbarOrientation, DEFAULT_GRAB_MARGIN,
29};
30
31const CLOSED_H: f64 = 24.0;
32const ITEM_H: f64 = 22.0;
33const PAD_X: f64 = 8.0;
34const ARROW_W: f64 = 20.0;
35const CORNER_R: f64 = 4.0;
36const POPUP_MARGIN: f64 = 4.0;
37const MIN_VISIBLE_ITEMS: usize = 3;
38const DEFAULT_VISIBLE_ITEMS: usize = 8;
39const SCROLLBAR_W: f64 = 6.0;
40
41struct ComboPopupRequest {
42    x: f64,
43    y: f64,
44    width: f64,
45    popup_h: f64,
46    opens_up: bool,
47    first_item: usize,
48    visible_count: usize,
49    selected: usize,
50    hovered_item: Option<usize>,
51    scrollbar: Option<PreparedScrollbar>,
52    options: Vec<String>,
53    font: Arc<Font>,
54    font_size: f64,
55    item_fonts: Option<Vec<Arc<Font>>>,
56}
57
58thread_local! {
59    static COMBO_POPUP_QUEUE: RefCell<Vec<ComboPopupRequest>> = const { RefCell::new(Vec::new()) };
60    static CURRENT_COMBO_VIEWPORT: Cell<Option<Size>> = const { Cell::new(None) };
61}
62
63/// A single-selection dropdown.
64///
65/// # Example
66/// ```ignore
67/// ComboBox::new(vec!["Option A", "Option B", "Option C"], 0, font)
68///     .on_change(|idx| println!("selected {idx}"))
69/// ```
70pub struct ComboBox {
71    bounds: Rect,
72    children: Vec<Box<dyn Widget>>, // always empty — labels stored separately
73    base: WidgetBase,
74
75    options: Vec<String>,
76    selected: usize,
77    open: bool,
78    /// Index of the item the cursor is currently over (only meaningful when open).
79    hovered_item: Option<usize>,
80
81    font: Arc<Font>,
82    font_size: f64,
83
84    on_change: Option<Box<dyn FnMut(usize)>>,
85    /// Optional external mirror of `selected` — same bidirectional-cell
86    /// pattern as `Slider::with_value_cell` / `RadioGroup::with_selected_cell`.
87    /// `layout()` re-reads the cell every frame so a sibling ComboBox bound
88    /// to the same cell stays in lock-step; selection changes here write back.
89    selected_cell: Option<Rc<Cell<usize>>>,
90
91    // ── Backbuffered labels ──────────────────────────────────────────────────
92    /// Label for the currently selected option (shown in the closed button area).
93    selected_label: Label,
94    /// One label per option, used when the dropdown is open.
95    item_labels: Vec<Label>,
96    /// Optional per-item font overrides, set via [`with_item_fonts`].
97    /// `None` means every entry (and the selected label) uses `self.font`
98    /// — the default.  `Some(vec)` means each entry uses `vec[i]` and
99    /// the selected label uses `vec[selected]`, ignoring the system
100    /// font override so font-preview UI stays stable.
101    item_fonts: Option<Vec<Arc<Font>>>,
102
103    popup_opens_up: bool,
104    popup_visible_count: usize,
105    scroll_offset: usize,
106    scrollbar: ScrollbarAxis,
107    middle_dragging: bool,
108    middle_last_pos: Point,
109}
110
111impl ComboBox {
112    /// Create a new `ComboBox`.
113    ///
114    /// `options` is the full list of choices; `selected` is the initial index
115    /// (clamped to a valid range).
116    pub fn new(options: Vec<impl Into<String>>, selected: usize, font: Arc<Font>) -> Self {
117        let font_size = 13.0;
118        let opts: Vec<String> = options.into_iter().map(|s| s.into()).collect();
119        let sel = selected.min(opts.len().saturating_sub(1));
120
121        let selected_label = Self::make_label(
122            opts.get(sel).map(|s| s.as_str()).unwrap_or(""),
123            font_size,
124            Arc::clone(&font),
125        );
126        let item_labels = opts
127            .iter()
128            .map(|t| Self::make_label(t, font_size, Arc::clone(&font)))
129            .collect();
130
131        Self {
132            bounds: Rect::default(),
133            children: Vec::new(),
134            base: WidgetBase::new(),
135            options: opts,
136            selected: sel,
137            open: false,
138            hovered_item: None,
139            font,
140            font_size,
141            on_change: None,
142            selected_cell: None,
143            selected_label,
144            item_labels,
145            item_fonts: None,
146            popup_opens_up: false,
147            popup_visible_count: DEFAULT_VISIBLE_ITEMS,
148            scroll_offset: 0,
149            scrollbar: ScrollbarAxis {
150                enabled: true,
151                ..ScrollbarAxis::default()
152            },
153            middle_dragging: false,
154            middle_last_pos: Point::ORIGIN,
155        }
156    }
157
158    /// Bind this combo's selection to an external `Rc<Cell<usize>>`.
159    /// `layout()` reads the cell each frame so a sibling combo (e.g. the
160    /// matching font picker in another window) sharing the same cell
161    /// stays in lock-step; user selections here write back.  Mirrors the
162    /// `Slider::with_value_cell` / `RadioGroup::with_selected_cell` pattern.
163    pub fn with_selected_cell(mut self, cell: Rc<Cell<usize>>) -> Self {
164        let n = self.options.len();
165        let v = cell.get();
166        if n > 0 {
167            let clamped = v.min(n - 1);
168            // Initialise self.selected from the cell so the closed combo
169            // shows the right label on first paint.
170            self.set_selected(clamped);
171        }
172        self.selected_cell = Some(cell);
173        self
174    }
175
176    fn make_label(text: &str, font_size: f64, font: Arc<Font>) -> Label {
177        Label::new(text, font).with_font_size(font_size)
178    }
179
180    // ── Builder ──────────────────────────────────────────────────────────────
181
182    pub fn with_font_size(mut self, size: f64) -> Self {
183        self.font_size = size;
184        self.selected_label = Self::make_label(
185            self.options
186                .get(self.selected)
187                .map(|s| s.as_str())
188                .unwrap_or(""),
189            size,
190            Arc::clone(&self.font),
191        );
192        self.item_labels = self
193            .options
194            .iter()
195            .map(|t| Self::make_label(t, size, Arc::clone(&self.font)))
196            .collect();
197        self
198    }
199
200    pub fn with_margin(mut self, m: Insets) -> Self {
201        self.base.margin = m;
202        self
203    }
204    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
205        self.base.h_anchor = h;
206        self
207    }
208    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
209        self.base.v_anchor = v;
210        self
211    }
212    pub fn with_min_size(mut self, s: Size) -> Self {
213        self.base.min_size = s;
214        self
215    }
216    pub fn with_max_size(mut self, s: Size) -> Self {
217        self.base.max_size = s;
218        self
219    }
220
221    /// Set the callback called when the user selects a new option.
222    pub fn on_change(mut self, cb: impl FnMut(usize) + 'static) -> Self {
223        self.on_change = Some(Box::new(cb));
224        self
225    }
226
227    /// Override the font used for EACH dropdown entry individually — one
228    /// font per option.  Intended for font-preview UI (the System window's
229    /// font picker renders each name in its own face).  Each item label
230    /// is rebuilt with the matching `Arc<Font>` and marked to ignore the
231    /// system-wide font override (otherwise changing the global font
232    /// would overwrite all the per-entry faces).
233    ///
234    /// Lengths must match: `fonts.len()` should equal the number of
235    /// options.  Extra fonts are ignored; missing entries keep the
236    /// default `self.font`.  The SELECTED label (shown when the dropdown
237    /// is closed) is also rebuilt with the currently-selected font so
238    /// the closed combo reflects the live face.
239    pub fn with_item_fonts(mut self, fonts: Vec<Arc<Font>>) -> Self {
240        self.set_item_fonts(fonts);
241        self
242    }
243
244    /// Replace per-item preview fonts after construction for lazy font UIs.
245    pub fn set_item_fonts(&mut self, fonts: Vec<Arc<Font>>) {
246        self.item_fonts = Some(fonts.clone());
247        let size = self.font_size;
248        self.item_labels = self
249            .options
250            .iter()
251            .enumerate()
252            .map(|(i, t)| {
253                let f = fonts
254                    .get(i)
255                    .cloned()
256                    .unwrap_or_else(|| Arc::clone(&self.font));
257                Label::new(t, f)
258                    .with_font_size(size)
259                    .with_ignore_system_font(true)
260            })
261            .collect();
262        if let Some(sel_font) = fonts.get(self.selected).cloned() {
263            self.selected_label = Label::new(
264                self.options
265                    .get(self.selected)
266                    .map(|s| s.as_str())
267                    .unwrap_or(""),
268                sel_font,
269            )
270            .with_font_size(size)
271            .with_ignore_system_font(true);
272        }
273    }
274
275    // ── Accessors ────────────────────────────────────────────────────────────
276
277    pub fn selected(&self) -> usize {
278        self.selected
279    }
280
281    pub fn set_selected(&mut self, idx: usize) {
282        if idx < self.options.len() {
283            self.selected = idx;
284            // If per-item fonts are set, rebuild the selected label with
285            // the matching face so the closed combo shows the correct
286            // preview.  Otherwise just swap the text on the existing
287            // label.
288            if let Some(ref fonts) = self.item_fonts {
289                if let Some(f) = fonts.get(idx).cloned() {
290                    self.selected_label = Label::new(self.options[idx].as_str(), f)
291                        .with_font_size(self.font_size)
292                        .with_ignore_system_font(true);
293                    return;
294                }
295            }
296            self.selected_label.set_text(self.options[idx].as_str());
297        }
298    }
299}
300
301mod geometry;
302
303impl Widget for ComboBox {
304    fn type_name(&self) -> &'static str {
305        "ComboBox"
306    }
307    fn bounds(&self) -> Rect {
308        self.bounds
309    }
310    fn set_bounds(&mut self, b: Rect) {
311        self.bounds = b;
312    }
313    fn children(&self) -> &[Box<dyn Widget>] {
314        &self.children
315    }
316    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
317        &mut self.children
318    }
319
320    fn is_focusable(&self) -> bool {
321        true
322    }
323
324    fn needs_draw(&self) -> bool {
325        self.scrollbar.animation_active()
326    }
327
328    fn hit_test(&self, local_pos: Point) -> bool {
329        self.in_button(local_pos) || self.pos_in_popup(local_pos)
330    }
331
332    fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
333        self.pos_in_popup(local_pos)
334    }
335
336    fn margin(&self) -> Insets {
337        self.base.margin
338    }
339    fn h_anchor(&self) -> HAnchor {
340        self.base.h_anchor
341    }
342    fn v_anchor(&self) -> VAnchor {
343        self.base.v_anchor
344    }
345    fn min_size(&self) -> Size {
346        self.base.min_size
347    }
348    fn max_size(&self) -> Size {
349        self.base.max_size
350    }
351
352    fn layout(&mut self, available: Size) -> Size {
353        // Pick up external-cell writes — e.g. a sibling combo bound to
354        // the same selected_cell wrote a new index since our last paint.
355        // Skip while open so an in-progress dropdown interaction doesn't
356        // get yanked back.
357        if !self.open {
358            if let Some(cell) = &self.selected_cell {
359                let n = self.options.len();
360                if n > 0 {
361                    let v = cell.get().min(n - 1);
362                    if v != self.selected {
363                        // Use set_selected so the visible label (and the
364                        // per-item-font preview, if any) refreshes too.
365                        self.set_selected(v);
366                    }
367                }
368            }
369        }
370
371        self.bounds = Rect::new(0.0, 0.0, available.width, CLOSED_H);
372        let inner_w = (available.width - PAD_X * 2.0 - ARROW_W).max(0.0);
373
374        // Layout selected label.
375        let sl = self.selected_label.layout(Size::new(inner_w, CLOSED_H));
376        let sl_y = (CLOSED_H - sl.height) * 0.5;
377        self.selected_label
378            .set_bounds(Rect::new(PAD_X, sl_y, sl.width, sl.height));
379
380        // Layout item labels in the floating panel. The panel may open
381        // above or below depending on available screen space.
382        for i in 0..self.item_labels.len() {
383            let s = self.item_labels[i].layout(Size::new(inner_w, ITEM_H));
384            let ir = self.item_rect(i);
385            let ly = ir.y + (ITEM_H - s.height) * 0.5;
386            self.item_labels[i].set_bounds(Rect::new(PAD_X, ly, s.width, s.height));
387        }
388
389        Size::new(available.width, CLOSED_H)
390    }
391
392    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
393        let v = ctx.visuals();
394        let w = self.bounds.width;
395
396        // ── Button background ─────────────────────────────────────────────────
397        ctx.set_fill_color(v.widget_bg);
398        ctx.begin_path();
399        ctx.rounded_rect(0.0, 0.0, w, CLOSED_H, CORNER_R);
400        ctx.fill();
401
402        ctx.set_stroke_color(v.widget_stroke);
403        ctx.set_line_width(1.0);
404        ctx.begin_path();
405        ctx.rounded_rect(0.0, 0.0, w, CLOSED_H, CORNER_R);
406        ctx.stroke();
407
408        // ── Dropdown arrow (▼) ────────────────────────────────────────────────
409        let arrow_x = w - ARROW_W * 0.5;
410        let arrow_cy = CLOSED_H * 0.5;
411        let arrow_sz = 4.0;
412        ctx.set_fill_color(v.text_dim);
413        ctx.begin_path();
414        // Small downward triangle.
415        ctx.move_to(arrow_x - arrow_sz, arrow_cy + arrow_sz * 0.5);
416        ctx.line_to(arrow_x + arrow_sz, arrow_cy + arrow_sz * 0.5);
417        ctx.line_to(arrow_x, arrow_cy - arrow_sz * 0.5);
418        ctx.close_path();
419        ctx.fill();
420
421        // ── Selected label ────────────────────────────────────────────────────
422        self.selected_label.set_color(v.text_color);
423        let sl_bounds = self.selected_label.bounds();
424
425        ctx.save();
426        ctx.translate(sl_bounds.x, sl_bounds.y);
427        paint_subtree(&mut self.selected_label, ctx);
428        ctx.restore();
429    }
430
431    fn paint_overlay(&mut self, _ctx: &mut dyn DrawCtx) {}
432
433    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
434        if self.open {
435            let mut x = 0.0;
436            let mut y = 0.0;
437            let t = ctx.root_transform();
438            t.transform(&mut x, &mut y);
439            let viewport_h = crate::widgets::combo_box::current_combo_viewport()
440                .map(|s| s.height)
441                .unwrap_or(f64::MAX / 4.0);
442            self.configure_popup_geometry(y, viewport_h);
443            let style = self.popup_scroll_style();
444            let visibility = current_scroll_visibility();
445            let viewport = self.popup_scroll_viewport();
446            let geom = self.scrollbar_geometry(style);
447            let scrollbar = self
448                .scrollbar
449                .prepare_paint(viewport, style, visibility, geom)
450                .map(|bar| bar.translated(x, y));
451            submit_combo_popup(ComboPopupRequest {
452                x,
453                y,
454                width: self.bounds.width,
455                popup_h: self.popup_h(),
456                opens_up: self.popup_opens_up,
457                first_item: self.scroll_offset,
458                visible_count: self.popup_visible_count,
459                selected: self.selected,
460                hovered_item: self.hovered_item,
461                scrollbar,
462                options: self.options.clone(),
463                font: Arc::clone(&self.font),
464                font_size: self.font_size,
465                item_fonts: self.item_fonts.clone(),
466            });
467        }
468    }
469
470    fn on_event(&mut self, event: &Event) -> EventResult {
471        match event {
472            Event::MouseDown {
473                button: MouseButton::Middle,
474                pos,
475                ..
476            } => {
477                if self.pos_in_popup(*pos) {
478                    self.middle_dragging = true;
479                    self.middle_last_pos = *pos;
480                    self.hovered_item = None;
481                    crate::animation::request_draw();
482                    return EventResult::Consumed;
483                }
484                EventResult::Ignored
485            }
486            Event::MouseDown {
487                button: MouseButton::Left,
488                pos,
489                ..
490            } => {
491                if self.in_button(*pos) {
492                    self.open = !self.open;
493                    self.hovered_item = None;
494                    self.scrollbar.hovered_bar = false;
495                    self.scrollbar.hovered_thumb = false;
496                    self.scrollbar.dragging = false;
497                    self.middle_dragging = false;
498                    if self.open {
499                        self.ensure_selected_visible();
500                    }
501                    crate::animation::request_draw();
502                    return EventResult::Consumed;
503                }
504                if self.open {
505                    if self.pos_in_scrollbar(*pos) {
506                        let style = self.popup_scroll_style();
507                        let viewport = self.popup_scroll_viewport();
508                        let geom = self.scrollbar_geometry(style);
509                        self.sync_scrollbar_from_rows();
510                        if self.scrollbar.begin_drag(*pos, viewport, style, geom) {
511                            // No visible effect until the cursor moves.
512                        } else if self.scrollbar.page_at(*pos, viewport, style, geom) {
513                            self.sync_rows_from_scrollbar();
514                        }
515                        self.hovered_item = None;
516                        self.scrollbar.hovered_thumb = self.pos_on_scroll_thumb(*pos);
517                        crate::animation::request_draw();
518                        return EventResult::Consumed;
519                    }
520                    if let Some(i) = self.item_for_pos(*pos) {
521                        // Route through `set_selected` so the closed
522                        // combo's preview label is rebuilt with the
523                        // newly-selected per-item font (when item_fonts
524                        // is set).  Direct `self.selected = i` would
525                        // change the index without swapping the face,
526                        // leaving the closed combo showing the new
527                        // name in the OLD typeface — the bug visible
528                        // when the LCD Subpixel demo's font picker
529                        // showed e.g. "Bangers" in Cascadia Code.
530                        self.set_selected(i);
531                        self.open = false;
532                        self.hovered_item = None;
533                        self.scrollbar.hovered_bar = false;
534                        self.scrollbar.hovered_thumb = false;
535                        self.scrollbar.dragging = false;
536                        self.middle_dragging = false;
537                        self.fire();
538                        crate::animation::request_draw();
539                        return EventResult::Consumed;
540                    }
541                    // Click outside the dropdown — close it.
542                    self.open = false;
543                    self.hovered_item = None;
544                    self.scrollbar.hovered_bar = false;
545                    self.scrollbar.hovered_thumb = false;
546                    self.scrollbar.dragging = false;
547                    self.middle_dragging = false;
548                    crate::animation::request_draw();
549                    return EventResult::Consumed;
550                }
551                EventResult::Ignored
552            }
553            Event::MouseMove { pos } => {
554                if self.middle_dragging {
555                    let dy = pos.y - self.middle_last_pos.y;
556                    self.middle_last_pos = *pos;
557                    self.sync_scrollbar_from_rows();
558                    if self.scrollbar.scroll_by(dy, self.popup_scroll_viewport()) {
559                        self.sync_rows_from_scrollbar();
560                        self.hovered_item = None;
561                        crate::animation::request_draw();
562                    }
563                    return EventResult::Consumed;
564                }
565                if self.scrollbar.dragging {
566                    let style = self.popup_scroll_style();
567                    let viewport = self.popup_scroll_viewport();
568                    let geom = self.scrollbar_geometry(style);
569                    if self.scrollbar.drag_to(*pos, viewport, style, geom) {
570                        self.sync_rows_from_scrollbar();
571                        self.hovered_item = None;
572                        crate::animation::request_draw();
573                    }
574                    return EventResult::Consumed;
575                }
576                let hovered_item = self.item_for_pos(*pos);
577                let style = self.popup_scroll_style();
578                let viewport = self.popup_scroll_viewport();
579                let geom = self.scrollbar_geometry(style);
580                let scroll_hover_changed = self.scrollbar.update_hover(*pos, viewport, style, geom);
581                if hovered_item != self.hovered_item || scroll_hover_changed {
582                    self.hovered_item = hovered_item;
583                    crate::animation::request_draw();
584                }
585                EventResult::Ignored
586            }
587            Event::MouseWheel { delta_y, .. } => {
588                if self.open && self.options.len() > self.popup_visible_count {
589                    self.sync_scrollbar_from_rows();
590                    if self
591                        .scrollbar
592                        .scroll_by(delta_y * 40.0, self.popup_scroll_viewport())
593                    {
594                        self.sync_rows_from_scrollbar();
595                        self.hovered_item = None;
596                        crate::animation::request_draw();
597                    }
598                    EventResult::Consumed
599                } else {
600                    EventResult::Ignored
601                }
602            }
603            Event::KeyDown { key, .. } => {
604                let n = self.options.len();
605                match key {
606                    Key::Enter | Key::Char(' ') => {
607                        self.open = !self.open;
608                        self.scrollbar.hovered_bar = false;
609                        self.scrollbar.hovered_thumb = false;
610                        self.scrollbar.dragging = false;
611                        self.middle_dragging = false;
612                        if self.open {
613                            self.ensure_selected_visible();
614                        }
615                        crate::animation::request_draw();
616                        EventResult::Consumed
617                    }
618                    Key::Escape => {
619                        if self.open {
620                            self.open = false;
621                            self.scrollbar.hovered_bar = false;
622                            self.scrollbar.hovered_thumb = false;
623                            self.scrollbar.dragging = false;
624                            self.middle_dragging = false;
625                            crate::animation::request_draw();
626                            EventResult::Consumed
627                        } else {
628                            EventResult::Ignored
629                        }
630                    }
631                    Key::ArrowDown => {
632                        if self.selected + 1 < n {
633                            self.set_selected(self.selected + 1);
634                            self.ensure_selected_visible();
635                            self.fire();
636                            crate::animation::request_draw();
637                        }
638                        EventResult::Consumed
639                    }
640                    Key::ArrowUp => {
641                        if self.selected > 0 {
642                            self.set_selected(self.selected - 1);
643                            self.ensure_selected_visible();
644                            self.fire();
645                            crate::animation::request_draw();
646                        }
647                        EventResult::Consumed
648                    }
649                    _ => EventResult::Ignored,
650                }
651            }
652            Event::FocusLost => {
653                let was_open = self.open;
654                self.open = false;
655                self.hovered_item = None;
656                self.scrollbar.hovered_bar = false;
657                self.scrollbar.hovered_thumb = false;
658                self.scrollbar.dragging = false;
659                self.middle_dragging = false;
660                if was_open {
661                    crate::animation::request_draw();
662                }
663                EventResult::Ignored
664            }
665            Event::MouseUp { button, .. } => {
666                if *button == MouseButton::Left && self.scrollbar.dragging {
667                    self.scrollbar.dragging = false;
668                    crate::animation::request_draw();
669                    EventResult::Consumed
670                } else if *button == MouseButton::Middle && self.middle_dragging {
671                    self.middle_dragging = false;
672                    crate::animation::request_draw();
673                    EventResult::Consumed
674                } else {
675                    EventResult::Ignored
676                }
677            }
678            _ => EventResult::Ignored,
679        }
680    }
681
682    fn properties(&self) -> Vec<(&'static str, String)> {
683        vec![
684            ("selected", self.selected.to_string()),
685            ("open", self.open.to_string()),
686            ("options", self.options.len().to_string()),
687            ("popup_opens_up", self.popup_opens_up.to_string()),
688            ("popup_visible_count", self.popup_visible_count.to_string()),
689            ("scroll_offset", self.scroll_offset.to_string()),
690        ]
691    }
692}
693
694fn submit_combo_popup(request: ComboPopupRequest) {
695    COMBO_POPUP_QUEUE.with(|q| q.borrow_mut().push(request));
696}
697
698fn current_combo_viewport() -> Option<Size> {
699    CURRENT_COMBO_VIEWPORT.with(|v| v.get())
700}
701
702pub(crate) fn begin_combo_popup_frame(viewport: Size) {
703    CURRENT_COMBO_VIEWPORT.with(|v| v.set(Some(viewport)));
704    COMBO_POPUP_QUEUE.with(|q| q.borrow_mut().clear());
705}
706
707pub(crate) fn paint_global_combo_popups(ctx: &mut dyn DrawCtx) {
708    let requests = COMBO_POPUP_QUEUE.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
709    if requests.is_empty() {
710        return;
711    }
712
713    ctx.save();
714    ctx.reset_clip();
715    for request in requests {
716        paint_combo_popup(ctx, request);
717    }
718    ctx.restore();
719}
720
721fn paint_combo_popup(ctx: &mut dyn DrawCtx, request: ComboPopupRequest) {
722    let v = ctx.visuals();
723    let popup_y = if request.opens_up {
724        request.y + CLOSED_H
725    } else {
726        request.y - request.popup_h
727    };
728
729    // Opaque backing first. Some widget fills are intentionally subtle; the
730    // popup itself must always obscure the content underneath.
731    ctx.set_fill_color(v.window_fill);
732    ctx.begin_path();
733    ctx.rounded_rect(request.x, popup_y, request.width, request.popup_h, CORNER_R);
734    ctx.fill();
735
736    ctx.set_fill_color(v.widget_bg);
737    ctx.begin_path();
738    ctx.rounded_rect(request.x, popup_y, request.width, request.popup_h, CORNER_R);
739    ctx.fill();
740
741    let has_scroll = request.options.len() > request.visible_count;
742    let text_w = if has_scroll {
743        (request.width - SCROLLBAR_W - 4.0).max(0.0)
744    } else {
745        request.width
746    };
747
748    for row in 0..request.visible_count {
749        let idx = request.first_item + row;
750        let Some(text) = request.options.get(idx) else {
751            break;
752        };
753        let item_y = popup_y + request.popup_h - (row as f64 + 1.0) * ITEM_H;
754        let is_selected = idx == request.selected;
755        let is_hovered = request.hovered_item == Some(idx);
756        if is_selected || is_hovered {
757            let bg = if is_selected {
758                v.accent
759            } else {
760                v.widget_bg_hovered
761            };
762            ctx.set_fill_color(bg);
763            ctx.begin_path();
764            ctx.rounded_rect(
765                request.x + 2.0,
766                item_y + 1.0,
767                text_w - 4.0,
768                ITEM_H - 2.0,
769                3.0,
770            );
771            ctx.fill();
772        }
773
774        let font = request
775            .item_fonts
776            .as_ref()
777            .and_then(|fonts| fonts.get(idx))
778            .cloned()
779            .unwrap_or_else(|| Arc::clone(&request.font));
780        ctx.set_font(font);
781        ctx.set_font_size(request.font_size);
782        ctx.set_fill_color(if is_selected {
783            Color::white()
784        } else {
785            v.text_color
786        });
787        let baseline = item_y + (ITEM_H - request.font_size) * 0.5;
788        ctx.fill_text(text, request.x + PAD_X, baseline);
789    }
790
791    if let Some(scrollbar) = request.scrollbar {
792        paint_prepared_scrollbar(ctx, scrollbar);
793    }
794
795    ctx.set_stroke_color(v.widget_stroke);
796    ctx.set_line_width(1.0);
797    ctx.begin_path();
798    ctx.rounded_rect(request.x, popup_y, request.width, request.popup_h, CORNER_R);
799    ctx.stroke();
800}