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        if !self.is_visible() {
362            return false;
363        }
364        self.scrollbar.animation_active() || self.children().iter().any(|c| c.needs_draw())
365    }
366
367    fn hit_test(&self, local_pos: Point) -> bool {
368        self.in_button(local_pos) || self.pos_in_popup(local_pos)
369    }
370
371    fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
372        self.pos_in_popup(local_pos)
373    }
374
375    fn margin(&self) -> Insets {
376        self.base.margin
377    }
378    fn widget_base(&self) -> Option<&WidgetBase> {
379        Some(&self.base)
380    }
381    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
382        Some(&mut self.base)
383    }
384    fn h_anchor(&self) -> HAnchor {
385        self.base.h_anchor
386    }
387    fn v_anchor(&self) -> VAnchor {
388        self.base.v_anchor
389    }
390    fn min_size(&self) -> Size {
391        self.base.min_size
392    }
393    fn max_size(&self) -> Size {
394        self.base.max_size
395    }
396
397    fn layout(&mut self, available: Size) -> Size {
398        // Pick up external-cell writes — e.g. a sibling combo bound to
399        // the same selected_cell wrote a new index since our last paint.
400        // Skip while open so an in-progress dropdown interaction doesn't
401        // get yanked back.
402        if !self.open {
403            if let Some(cell) = &self.selected_cell {
404                let n = self.options.len();
405                if n > 0 {
406                    let v = cell.get().min(n - 1);
407                    if v != self.selected {
408                        // Use set_selected so the visible label (and the
409                        // per-item-font preview, if any) refreshes too.
410                        self.set_selected(v);
411                    }
412                }
413            }
414        }
415
416        self.bounds = Rect::new(0.0, 0.0, available.width, CLOSED_H);
417        let inner_w = (available.width - PAD_X * 2.0 - ARROW_W).max(0.0);
418
419        // Layout selected label.
420        let sl = self.selected_label.layout(Size::new(inner_w, CLOSED_H));
421        let sl_y = (CLOSED_H - sl.height) * 0.5;
422        self.selected_label
423            .set_bounds(Rect::new(PAD_X, sl_y, sl.width, sl.height));
424
425        // Layout item labels in the floating panel. The panel may open
426        // above or below depending on available screen space.
427        let mut labels = self.item_labels.borrow_mut();
428        for i in 0..labels.len() {
429            let s = labels[i].layout(Size::new(inner_w, ITEM_H));
430            let ir = self.item_rect(i);
431            let ly = ir.y + (ITEM_H - s.height) * 0.5;
432            labels[i].set_bounds(Rect::new(PAD_X, ly, s.width, s.height));
433        }
434        drop(labels);
435
436        Size::new(available.width, CLOSED_H)
437    }
438
439    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
440        let v = ctx.visuals();
441        let w = self.bounds.width;
442
443        // ── Button background ─────────────────────────────────────────────────
444        ctx.set_fill_color(v.widget_bg);
445        ctx.begin_path();
446        ctx.rounded_rect(0.0, 0.0, w, CLOSED_H, CORNER_R);
447        ctx.fill();
448
449        ctx.set_stroke_color(v.widget_stroke);
450        ctx.set_line_width(1.0);
451        ctx.begin_path();
452        ctx.rounded_rect(0.0, 0.0, w, CLOSED_H, CORNER_R);
453        ctx.stroke();
454
455        // ── Dropdown arrow (▼) ────────────────────────────────────────────────
456        let arrow_x = w - ARROW_W * 0.5;
457        let arrow_cy = CLOSED_H * 0.5;
458        let arrow_sz = 4.0;
459        ctx.set_fill_color(v.text_dim);
460        ctx.begin_path();
461        // Small downward triangle.
462        ctx.move_to(arrow_x - arrow_sz, arrow_cy + arrow_sz * 0.5);
463        ctx.line_to(arrow_x + arrow_sz, arrow_cy + arrow_sz * 0.5);
464        ctx.line_to(arrow_x, arrow_cy - arrow_sz * 0.5);
465        ctx.close_path();
466        ctx.fill();
467
468        // ── Selected label ────────────────────────────────────────────────────
469        self.selected_label.set_color(v.text_color);
470        let sl_bounds = self.selected_label.bounds();
471
472        ctx.save();
473        ctx.translate(sl_bounds.x, sl_bounds.y);
474        paint_subtree(&mut self.selected_label, ctx);
475        ctx.restore();
476    }
477
478    fn paint_overlay(&mut self, _ctx: &mut dyn DrawCtx) {}
479
480    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
481        if self.open {
482            let mut x = 0.0;
483            let mut y = 0.0;
484            let t = ctx.root_transform();
485            t.transform(&mut x, &mut y);
486            // `root_transform` includes the outer device-scale multiplier, but
487            // the popup queue is drained while that same scale is still active
488            // on the ctx and `viewport_h` (and the rest of the popup geometry)
489            // are in logical units.  Strip the scale here so request coords
490            // stay in logical root space — otherwise on HiDPI mobile (DPR 2-3)
491            // the popup paints at scale²-magnified position while hit-testing
492            // (which is purely logical) stays adjacent to the closed button.
493            let scale = crate::device_scale::device_scale().max(1e-6);
494            let x = x / scale;
495            let y = y / scale;
496            let viewport_h = crate::widgets::combo_box::current_combo_viewport()
497                .map(|s| s.height)
498                .unwrap_or(f64::MAX / 4.0);
499            self.configure_popup_geometry(y, viewport_h);
500            let style = self.popup_scroll_style();
501            let visibility = current_scroll_visibility();
502            let viewport = self.popup_scroll_viewport();
503            let geom = self.scrollbar_geometry(style);
504            let scrollbar = self
505                .scrollbar
506                .prepare_paint(viewport, style, visibility, geom)
507                .map(|bar| bar.translated(x, y));
508            submit_combo_popup(ComboPopupRequest {
509                x,
510                y,
511                width: self.bounds.width,
512                popup_h: self.popup_h(),
513                opens_up: self.popup_opens_up,
514                first_item: self.scroll_offset,
515                visible_count: self.popup_visible_count,
516                selected: self.selected,
517                hovered_item: self.hovered_item,
518                scrollbar,
519                item_count: self.options.len(),
520                item_labels: Rc::clone(&self.item_labels),
521                hover_tooltip: self.hovered_item.and_then(|idx| {
522                    let text = self.item_tooltips.as_ref()?.get(idx)?;
523                    if text.is_empty() {
524                        return None;
525                    }
526                    Some((text.clone(), Arc::clone(&self.font)))
527                }),
528            });
529        }
530    }
531
532    fn on_event(&mut self, event: &Event) -> EventResult {
533        match event {
534            Event::MouseDown {
535                button: MouseButton::Middle,
536                pos,
537                ..
538            } => {
539                if self.pos_in_popup(*pos) {
540                    self.middle_dragging = true;
541                    self.middle_last_pos = *pos;
542                    self.hovered_item = None;
543                    crate::animation::request_draw();
544                    return EventResult::Consumed;
545                }
546                EventResult::Ignored
547            }
548            Event::MouseDown {
549                button: MouseButton::Left,
550                pos,
551                ..
552            } => {
553                if self.in_button(*pos) {
554                    self.open = !self.open;
555                    self.hovered_item = None;
556                    self.scrollbar.hovered_bar = false;
557                    self.scrollbar.hovered_thumb = false;
558                    self.scrollbar.dragging = false;
559                    self.middle_dragging = false;
560                    if self.open {
561                        self.ensure_selected_visible();
562                    }
563                    crate::animation::request_draw();
564                    return EventResult::Consumed;
565                }
566                if self.open {
567                    if self.pos_in_scrollbar(*pos) {
568                        let style = self.popup_scroll_style();
569                        let viewport = self.popup_scroll_viewport();
570                        let geom = self.scrollbar_geometry(style);
571                        self.sync_scrollbar_from_rows();
572                        if self.scrollbar.begin_drag(*pos, viewport, style, geom) {
573                            // No visible effect until the cursor moves.
574                        } else if self.scrollbar.page_at(*pos, viewport, style, geom) {
575                            self.sync_rows_from_scrollbar();
576                        }
577                        self.hovered_item = None;
578                        self.scrollbar.hovered_thumb = self.pos_on_scroll_thumb(*pos);
579                        crate::animation::request_draw();
580                        return EventResult::Consumed;
581                    }
582                    if let Some(i) = self.item_for_pos(*pos) {
583                        // Route through `set_selected` so the closed
584                        // combo's preview label is rebuilt with the
585                        // newly-selected per-item font (when item_fonts
586                        // is set).  Direct `self.selected = i` would
587                        // change the index without swapping the face,
588                        // leaving the closed combo showing the new
589                        // name in the OLD typeface — the bug visible
590                        // when the System window's font picker showed
591                        // e.g. "Bangers" in Cascadia Code.
592                        self.set_selected(i);
593                        self.open = false;
594                        self.hovered_item = None;
595                        self.scrollbar.hovered_bar = false;
596                        self.scrollbar.hovered_thumb = false;
597                        self.scrollbar.dragging = false;
598                        self.middle_dragging = false;
599                        self.fire();
600                        crate::animation::request_draw();
601                        return EventResult::Consumed;
602                    }
603                    // Click outside the dropdown — close it.
604                    self.open = false;
605                    self.hovered_item = None;
606                    self.scrollbar.hovered_bar = false;
607                    self.scrollbar.hovered_thumb = false;
608                    self.scrollbar.dragging = false;
609                    self.middle_dragging = false;
610                    crate::animation::request_draw();
611                    return EventResult::Consumed;
612                }
613                EventResult::Ignored
614            }
615            Event::MouseMove { pos } => {
616                if self.middle_dragging {
617                    let dy = pos.y - self.middle_last_pos.y;
618                    self.middle_last_pos = *pos;
619                    self.sync_scrollbar_from_rows();
620                    if self.scrollbar.scroll_by(dy, self.popup_scroll_viewport()) {
621                        self.sync_rows_from_scrollbar();
622                        self.hovered_item = None;
623                        crate::animation::request_draw();
624                    }
625                    return EventResult::Consumed;
626                }
627                if self.scrollbar.dragging {
628                    let style = self.popup_scroll_style();
629                    let viewport = self.popup_scroll_viewport();
630                    let geom = self.scrollbar_geometry(style);
631                    if self.scrollbar.drag_to(*pos, viewport, style, geom) {
632                        self.sync_rows_from_scrollbar();
633                        self.hovered_item = None;
634                        crate::animation::request_draw();
635                    }
636                    return EventResult::Consumed;
637                }
638                let hovered_item = self.item_for_pos(*pos);
639                let style = self.popup_scroll_style();
640                let viewport = self.popup_scroll_viewport();
641                let geom = self.scrollbar_geometry(style);
642                let scroll_hover_changed = self.scrollbar.update_hover(*pos, viewport, style, geom);
643                if hovered_item != self.hovered_item || scroll_hover_changed {
644                    self.hovered_item = hovered_item;
645                    crate::animation::request_draw();
646                }
647                EventResult::Ignored
648            }
649            Event::MouseWheel { delta_y, .. } => {
650                if self.open && self.options.len() > self.popup_visible_count {
651                    self.sync_scrollbar_from_rows();
652                    // Negate delta_y so positive (= wheel forward,
653                    // see content above) DECREASES the scrollbar
654                    // offset, matching ScrollView and TreeView.
655                    if self
656                        .scrollbar
657                        .scroll_by(-delta_y * 40.0, self.popup_scroll_viewport())
658                    {
659                        self.sync_rows_from_scrollbar();
660                        self.hovered_item = None;
661                        crate::animation::request_draw();
662                    }
663                    EventResult::Consumed
664                } else {
665                    EventResult::Ignored
666                }
667            }
668            Event::KeyDown { key, .. } => {
669                let n = self.options.len();
670                match key {
671                    Key::Enter | Key::Char(' ') => {
672                        self.open = !self.open;
673                        self.scrollbar.hovered_bar = false;
674                        self.scrollbar.hovered_thumb = false;
675                        self.scrollbar.dragging = false;
676                        self.middle_dragging = false;
677                        if self.open {
678                            self.ensure_selected_visible();
679                        }
680                        crate::animation::request_draw();
681                        EventResult::Consumed
682                    }
683                    Key::Escape => {
684                        if self.open {
685                            self.open = false;
686                            self.scrollbar.hovered_bar = false;
687                            self.scrollbar.hovered_thumb = false;
688                            self.scrollbar.dragging = false;
689                            self.middle_dragging = false;
690                            crate::animation::request_draw();
691                            EventResult::Consumed
692                        } else {
693                            EventResult::Ignored
694                        }
695                    }
696                    Key::ArrowDown => {
697                        if self.selected + 1 < n {
698                            self.set_selected(self.selected + 1);
699                            self.ensure_selected_visible();
700                            self.fire();
701                            crate::animation::request_draw();
702                        }
703                        EventResult::Consumed
704                    }
705                    Key::ArrowUp => {
706                        if self.selected > 0 {
707                            self.set_selected(self.selected - 1);
708                            self.ensure_selected_visible();
709                            self.fire();
710                            crate::animation::request_draw();
711                        }
712                        EventResult::Consumed
713                    }
714                    _ => EventResult::Ignored,
715                }
716            }
717            Event::FocusLost => {
718                let was_open = self.open;
719                self.open = false;
720                self.hovered_item = None;
721                self.scrollbar.hovered_bar = false;
722                self.scrollbar.hovered_thumb = false;
723                self.scrollbar.dragging = false;
724                self.middle_dragging = false;
725                if was_open {
726                    crate::animation::request_draw();
727                }
728                EventResult::Ignored
729            }
730            Event::MouseUp { button, .. } => {
731                if *button == MouseButton::Left && self.scrollbar.dragging {
732                    self.scrollbar.dragging = false;
733                    crate::animation::request_draw();
734                    EventResult::Consumed
735                } else if *button == MouseButton::Middle && self.middle_dragging {
736                    self.middle_dragging = false;
737                    crate::animation::request_draw();
738                    EventResult::Consumed
739                } else {
740                    EventResult::Ignored
741                }
742            }
743            _ => EventResult::Ignored,
744        }
745    }
746
747    fn properties(&self) -> Vec<(&'static str, String)> {
748        vec![
749            ("selected", self.selected.to_string()),
750            ("open", self.open.to_string()),
751            ("options", self.options.len().to_string()),
752            ("popup_opens_up", self.popup_opens_up.to_string()),
753            ("popup_visible_count", self.popup_visible_count.to_string()),
754            ("scroll_offset", self.scroll_offset.to_string()),
755        ]
756    }
757}
758
759fn submit_combo_popup_internal(request: ComboPopupRequest) {
760    COMBO_POPUP_QUEUE.with(|q| q.borrow_mut().push(request));
761}
762
763mod popup_paint;
764pub(crate) use popup_paint::{begin_combo_popup_frame, paint_global_combo_popups};
765pub(super) use popup_paint::{current_combo_viewport, submit_combo_popup};