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