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