Skip to main content

sequoia_elements/
dropdown.rs

1use std::cell::Cell;
2use std::rc::Rc;
3
4use sequoia_assets::{AssetRegistry, icon_key};
5use sequoia_core::{Color, Edges, Length, Rect, Size};
6use sequoia_input::{CursorIcon, Event, EventPhase, Key, PointerButton, PointerEvent};
7use sequoia_layout::{Align, Axis, ContainerStyle, LayoutCtx, LayoutStyle};
8use sequoia_reactive::Signal;
9use sequoia_style::{ButtonStyle, Theme, VisualState};
10use sequoia_text::FontWeight;
11use sequoia_view::{
12    Anchor, AnyView, Dismiss, EventCtx, OverlayEntry, OverlayId, OverlayLayer, PaintCtx, Placement,
13    Unit, View,
14};
15
16const PAD_X: f32 = 10.0;
17
18const PAD_Y: f32 = 5.0;
19
20const CHEV_GAP: f32 = 8.0;
21
22const CHEV_W: f32 = 10.0;
23
24const CHEV_H: f32 = 6.0;
25
26const ITEM_H: f32 = 28.0;
27
28const ITEM_PAD_X: f32 = 12.0;
29
30const MENU_RADIUS: f32 = 4.0;
31
32pub struct DropdownButton {
33    selected: Signal<usize>,
34    options: Vec<String>,
35    placeholder: String,
36    style: Option<ButtonStyle>,
37    background: Option<Color>,
38    hover: Option<Color>,
39    pressed: Option<Color>,
40    disabled_background: Option<Color>,
41    text_color: Option<Color>,
42    text_disabled: Option<Color>,
43    font_size: Option<f32>,
44    font_weight: Option<u16>,
45    radius: Option<f32>,
46    padding: Option<(f32, f32)>,
47    border_width: Option<f32>,
48    border_color: Option<Color>,
49    menu_background: Option<Color>,
50    menu_border: Option<Color>,
51    item_height: f32,
52    disabled: bool,
53    on_select: Option<Rc<dyn Fn(usize)>>,
54
55    chevron_icon: Option<String>,
56
57    chevron_open_icon: Option<String>,
58
59    chevron_size: Option<f32>,
60
61    chevron_color: Option<Color>,
62
63    open: Signal<bool>,
64    highlight: Signal<usize>,
65    overlay: Rc<Cell<Option<OverlayId>>>,
66    margin: Edges,
67    min_width: Option<f32>,
68    max_width: Option<f32>,
69    min_height: Option<f32>,
70    max_height: Option<f32>,
71}
72
73impl DropdownButton {
74    pub fn new(selected: Signal<usize>, options: Vec<impl Into<String>>) -> Self {
75        Self {
76            selected,
77            options: options.into_iter().map(Into::into).collect(),
78            placeholder: String::new(),
79            style: None,
80            background: None,
81            hover: None,
82            pressed: None,
83            disabled_background: None,
84            text_color: None,
85            text_disabled: None,
86            font_size: None,
87            font_weight: None,
88            radius: None,
89            padding: None,
90            border_width: None,
91            border_color: None,
92            menu_background: None,
93            menu_border: None,
94            item_height: ITEM_H,
95            disabled: false,
96            on_select: None,
97            chevron_icon: None,
98            chevron_open_icon: None,
99            chevron_size: None,
100            chevron_color: None,
101            open: Signal::new(false),
102            highlight: Signal::new(0),
103            overlay: Rc::new(Cell::new(None)),
104            margin: Edges::ZERO,
105            min_width: None,
106            max_width: None,
107            min_height: None,
108            max_height: None,
109        }
110    }
111
112    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
113        self.placeholder = placeholder.into();
114        self
115    }
116
117    pub fn style(mut self, style: ButtonStyle) -> Self {
118        self.style = Some(style);
119        self
120    }
121
122    pub fn background(mut self, color: Color) -> Self {
123        self.background = Some(color);
124        self
125    }
126
127    pub fn hover(mut self, color: Color) -> Self {
128        self.hover = Some(color);
129        self
130    }
131
132    pub fn pressed(mut self, color: Color) -> Self {
133        self.pressed = Some(color);
134        self
135    }
136
137    pub fn disabled_background(mut self, color: Color) -> Self {
138        self.disabled_background = Some(color);
139        self
140    }
141
142    pub fn text_color(mut self, color: Color) -> Self {
143        self.text_color = Some(color);
144        self
145    }
146
147    pub fn text_disabled(mut self, color: Color) -> Self {
148        self.text_disabled = Some(color);
149        self
150    }
151
152    pub fn font_size(mut self, size: f32) -> Self {
153        self.font_size = Some(size);
154        self
155    }
156
157    pub fn font_weight(mut self, weight: u16) -> Self {
158        self.font_weight = Some(weight);
159        self
160    }
161
162    pub fn radius(mut self, radius: f32) -> Self {
163        self.radius = Some(radius);
164        self
165    }
166
167    pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
168        self.padding = Some((pad_x, pad_y));
169        self
170    }
171
172    pub fn border_width(mut self, width: f32) -> Self {
173        self.border_width = Some(width);
174        self
175    }
176
177    pub fn border_color(mut self, color: Color) -> Self {
178        self.border_color = Some(color);
179        self
180    }
181
182    pub fn border(mut self, width: f32, color: Color) -> Self {
183        self.border_width = Some(width);
184        self.border_color = Some(color);
185        self
186    }
187
188    pub fn menu_background(mut self, color: Color) -> Self {
189        self.menu_background = Some(color);
190        self
191    }
192
193    pub fn menu_border(mut self, color: Color) -> Self {
194        self.menu_border = Some(color);
195        self
196    }
197
198    pub fn item_height(mut self, height: f32) -> Self {
199        self.item_height = height.max(8.0);
200        self
201    }
202
203    pub fn disabled(mut self, disabled: bool) -> Self {
204        self.disabled = disabled;
205        self
206    }
207
208    pub fn min_width(mut self, v: f32) -> Self {
209        self.min_width = Some(v.max(0.0));
210        self
211    }
212
213    pub fn max_width(mut self, v: f32) -> Self {
214        self.max_width = Some(v.max(0.0));
215        self
216    }
217
218    pub fn min_height(mut self, v: f32) -> Self {
219        self.min_height = Some(v.max(0.0));
220        self
221    }
222
223    pub fn max_height(mut self, v: f32) -> Self {
224        self.max_height = Some(v.max(0.0));
225        self
226    }
227
228    pub fn on_select(mut self, f: impl Fn(usize) + 'static) -> Self {
229        self.on_select = Some(Rc::new(f));
230        self
231    }
232
233    pub fn chevron_icon(mut self, name: impl Into<String>) -> Self {
234        self.chevron_icon = Some(name.into());
235        self
236    }
237
238    pub fn chevron_open_icon(mut self, name: impl Into<String>) -> Self {
239        self.chevron_open_icon = Some(name.into());
240        self
241    }
242
243    pub fn disclosure_icons(
244        mut self,
245        collapsed: impl Into<String>,
246        expanded: impl Into<String>,
247    ) -> Self {
248        self.chevron_icon = Some(collapsed.into());
249        self.chevron_open_icon = Some(expanded.into());
250        self
251    }
252
253    pub fn chevron_size(mut self, px: f32) -> Self {
254        self.chevron_size = Some(px.max(1.0));
255        self
256    }
257
258    pub fn chevron_color(mut self, color: Color) -> Self {
259        self.chevron_color = Some(color);
260        self
261    }
262
263    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
264        let m = margin.into();
265        self.margin = Edges::only(
266            sanitize_margin(m.left),
267            sanitize_margin(m.top),
268            sanitize_margin(m.right),
269            sanitize_margin(m.bottom),
270        );
271        self
272    }
273
274    pub fn margin_xy(self, x: f32, y: f32) -> Self {
275        self.margin(Edges::xy(x, y))
276    }
277
278    pub fn margin_x(mut self, x: f32) -> Self {
279        let x = sanitize_margin(x);
280        self.margin.left = x;
281        self.margin.right = x;
282        self
283    }
284
285    pub fn margin_y(mut self, y: f32) -> Self {
286        let y = sanitize_margin(y);
287        self.margin.top = y;
288        self.margin.bottom = y;
289        self
290    }
291
292    pub fn margin_left(mut self, v: f32) -> Self {
293        self.margin.left = sanitize_margin(v);
294        self
295    }
296
297    pub fn margin_right(mut self, v: f32) -> Self {
298        self.margin.right = sanitize_margin(v);
299        self
300    }
301
302    pub fn margin_top(mut self, v: f32) -> Self {
303        self.margin.top = sanitize_margin(v);
304        self
305    }
306
307    pub fn margin_bottom(mut self, v: f32) -> Self {
308        self.margin.bottom = sanitize_margin(v);
309        self
310    }
311
312    pub fn is_open(&self) -> bool {
313        self.open.get_untracked()
314    }
315
316    fn open_state(&self) -> Signal<bool> {
317        self.open.clone()
318    }
319
320    fn highlight_state(&self) -> Signal<usize> {
321        self.highlight.clone()
322    }
323
324    fn take_overlay(&self) -> Option<OverlayId> {
325        self.overlay.take()
326    }
327
328    fn display(&self) -> String {
329        self.options
330            .get(self.selected.get_untracked())
331            .cloned()
332            .unwrap_or_else(|| self.placeholder.clone())
333    }
334
335    fn showing_placeholder(&self) -> bool {
336        self.options.get(self.selected.get_untracked()).is_none()
337    }
338
339    fn open(&self) {
340        if self.disabled || self.options.is_empty() {
341            return;
342        }
343        let sel = self.selected.get_untracked().min(self.options.len() - 1);
344        self.highlight_state().set(sel);
345        self.open_state().set(true);
346    }
347
348    fn close_with(&self, ctx: &mut EventCtx) {
349        self.open_state().set(false);
350        if let Some(id) = self.take_overlay() {
351            ctx.hide_overlay(id);
352        }
353    }
354
355    fn select_with(&self, idx: usize, ctx: &mut EventCtx) {
356        if idx >= self.options.len() {
357            return;
358        }
359        self.selected.set(idx);
360        self.highlight_state().set(idx);
361        if let Some(f) = &self.on_select {
362            f(idx);
363        }
364        self.close_with(ctx);
365        ctx.request_focus();
366    }
367
368    fn resolved_font_size(&self) -> f32 {
369        self.font_size
370            .or_else(|| self.style.and_then(|s| s.font_size))
371            .unwrap_or(14.0)
372    }
373
374    fn resolved_weight(&self) -> FontWeight {
375        self.font_weight
376            .or_else(|| self.style.and_then(|s| s.font_weight))
377            .map(FontWeight::new)
378            .unwrap_or_default()
379    }
380
381    fn resolved_padding(&self) -> (f32, f32) {
382        self.padding
383            .or_else(|| self.style.and_then(|s| s.padding))
384            .unwrap_or((PAD_X, PAD_Y))
385    }
386}
387
388struct ResolvedButton {
389    bg: Color,
390    text: Color,
391    font_size: f32,
392    weight: FontWeight,
393    radius: f32,
394    border_width: f32,
395    border_color: Color,
396}
397
398impl DropdownButton {
399    fn resolve(&self, theme: &Theme, state: VisualState) -> ResolvedButton {
400        let style = self.style.unwrap_or_default();
401
402        let bg = if self.disabled || state.disabled {
403            self.disabled_background
404                .or(style.disabled)
405                .unwrap_or(theme.disabled)
406        } else if state.pressed {
407            self.pressed
408                .or(style.pressed)
409                .unwrap_or(Color::hex(0x3A3A3A))
410        } else if state.hovered {
411            self.hover.or(style.hover).unwrap_or(Color::hex(0x323232))
412        } else {
413            self.background
414                .or(style.background)
415                .unwrap_or(Color::hex(0x2A2A2A))
416        };
417        let placeholder = self.showing_placeholder();
418        let text = if self.disabled || state.disabled {
419            self.text_disabled
420                .or(style.text_disabled)
421                .unwrap_or(theme.text_secondary)
422        } else if placeholder {
423            self.text_color
424                .or(style.text)
425                .unwrap_or(theme.text_secondary)
426        } else {
427            self.text_color.or(style.text).unwrap_or(theme.text)
428        };
429        ResolvedButton {
430            bg,
431            text,
432            font_size: self
433                .font_size
434                .or(style.font_size)
435                .unwrap_or(theme.font_size),
436            weight: self
437                .font_weight
438                .or(style.font_weight)
439                .map(FontWeight::new)
440                .unwrap_or_default(),
441            radius: self.radius.or(style.radius).unwrap_or(theme.radius),
442            border_width: self
443                .border_width
444                .or(style.border_width)
445                .unwrap_or(1.0)
446                .max(0.0),
447            border_color: self
448                .border_color
449                .or(style.border_color)
450                .unwrap_or(theme.border),
451        }
452    }
453
454    fn sync_overlay(&self, ctx: &mut PaintCtx, theme: &Theme) {
455        let open = self.open_state();
456        if self.disabled {
457            if open.get_untracked() {
458                open.set(false);
459            }
460            if let Some(id) = self.take_overlay() {
461                ctx.hide_overlay(id);
462            }
463            return;
464        }
465        if !open.get_untracked() {
466            if let Some(id) = self.take_overlay() {
467                ctx.hide_overlay(id);
468            }
469            return;
470        }
471        if self.options.is_empty() {
472            open.set(false);
473            return;
474        }
475        if let Some(id) = self.overlay.get() {
476            if !ctx.has_overlay(id) {
477                self.take_overlay();
478                open.set(false);
479            }
480            return;
481        }
482        let menu = DropdownMenu {
483            overlay: Rc::clone(&self.overlay),
484            options: self.options.clone(),
485            selected: self.selected.clone(),
486            open: open.clone(),
487            highlight: self.highlight_state(),
488            font_size: self.resolved_font_size(),
489            weight: self.resolved_weight(),
490            item_height: self.item_height,
491            background: self.menu_background.unwrap_or(theme.surface),
492            border: self.menu_border.unwrap_or(theme.border),
493            radius: MENU_RADIUS,
494
495            menu_min_width: ctx.rect.size.width.max(0.0),
496            on_select: self.on_select.clone(),
497        };
498
499        let id = ctx.show_overlay(
500            OverlayEntry::new(menu)
501                .anchor(Anchor::View(ctx.element))
502                .placement(Placement::Bottom)
503                .layer(OverlayLayer::Popup)
504                .dismiss(Dismiss::FocusLost)
505                .offset(0.0, 4.0),
506        );
507        self.overlay.set(Some(id));
508    }
509}
510
511impl View for DropdownButton {
512    fn reuse_state(&self, old: &Self) {
513        self.open.set(old.open.get_untracked());
514        self.highlight.set(old.highlight.get_untracked());
515        self.overlay.set(old.overlay.get());
516    }
517
518    fn body(&self) -> impl View + 'static {
519        Unit
520    }
521    fn is_leaf(&self) -> bool {
522        true
523    }
524    fn focusable(&self) -> bool {
525        !self.disabled
526    }
527    fn cursor_icon(&self, _state: VisualState) -> CursorIcon {
528        if self.disabled {
529            CursorIcon::Default
530        } else {
531            CursorIcon::Pointer
532        }
533    }
534    fn has_visual_state(&self) -> bool {
535        true
536    }
537    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
538        let _ = self.selected.get();
539        let _ = self.open_state().get();
540        let _ = self.highlight_state().get();
541        Some(Vec::new())
542    }
543    fn layout_style(&self) -> LayoutStyle {
544        let mut style = LayoutStyle {
545            width: Length::Fit,
546            height: Length::Fit,
547            margin: self.margin,
548            ..LayoutStyle::default()
549        };
550        if let Some(v) = self.min_width {
551            style = style.min_width(v);
552        }
553        if let Some(v) = self.max_width {
554            style = style.max_width(v);
555        }
556        if let Some(v) = self.min_height {
557            style = style.min_height(v);
558        }
559        if let Some(v) = self.max_height {
560            style = style.max_height(v);
561        }
562        style
563    }
564    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
565        let size = self.resolved_font_size();
566        let weight = self.resolved_weight();
567        let (pad_x, pad_y) = self.resolved_padding();
568
569        let mut w = ctx.text_width_weighted(&self.placeholder, size, weight);
570        for opt in &self.options {
571            w = w.max(ctx.text_width_weighted(opt, size, weight));
572        }
573        Some(Size::new(
574            w + CHEV_GAP + CHEV_W + pad_x * 2.0,
575            LayoutCtx::text_line_height(size) + pad_y * 2.0,
576        ))
577    }
578    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
579        if self.disabled {
580            return;
581        }
582        let open = self.open_state();
583        let is_open = open.get_untracked();
584        match event {
585            Event::Pointer(PointerEvent::Up {
586                button: PointerButton::Left,
587                ..
588            }) => {
589                if ctx.phase != EventPhase::Target {
590                    return;
591                }
592                if ctx.state.pressed && ctx.hover_self {
593                    if is_open {
594                        open.set(false);
595                        if let Some(id) = self.take_overlay() {
596                            ctx.hide_overlay(id);
597                        }
598                    } else {
599                        self.open();
600                    }
601                    ctx.request_focus();
602                    ctx.handled();
603                }
604            }
605            Event::Key(key) => {
606                if ctx.phase != EventPhase::Target || !ctx.state.focused {
607                    return;
608                }
609                if self.options.is_empty() {
610                    return;
611                }
612                let len = self.options.len();
613                let hl = self.highlight_state().get_untracked().min(len - 1);
614                match key.key {
615                    Key::ESCAPE => {
616                        if is_open {
617                            open.set(false);
618                            if let Some(id) = self.take_overlay() {
619                                ctx.hide_overlay(id);
620                            }
621                            ctx.handled();
622                        }
623                    }
624                    Key::ENTER | Key::SPACE => {
625                        if is_open {
626                            self.select_with(hl, ctx);
627                        } else {
628                            self.open();
629                        }
630                        ctx.handled();
631                    }
632                    Key::ARROW_DOWN => {
633                        if is_open {
634                            self.highlight_state().set((hl + 1).min(len - 1));
635                        } else {
636                            self.open();
637                        }
638                        ctx.handled();
639                    }
640                    Key::ARROW_UP => {
641                        if is_open {
642                            self.highlight_state().set(hl.saturating_sub(1));
643                        } else {
644                            self.open();
645                        }
646                        ctx.handled();
647                    }
648                    Key::HOME => {
649                        if is_open {
650                            self.highlight_state().set(0);
651                            ctx.handled();
652                        }
653                    }
654                    Key::END if is_open => {
655                        self.highlight_state().set(len - 1);
656                        ctx.handled();
657                    }
658                    _ => {}
659                }
660            }
661            _ => {}
662        }
663    }
664    fn on_unmount(&self) {
665        self.overlay.set(None);
666    }
667    fn paint_self(&self, ctx: &mut PaintCtx) {
668        let theme = ctx.theme();
669        let mut vs = ctx.state;
670        vs.disabled = self.disabled;
671
672        if self.open_state().get_untracked() {
673            vs.pressed = true;
674        }
675        let r = self.resolve(&theme, vs);
676        if r.border_width > 0.0 && r.border_color.a > 0.0 {
677            ctx.bordered_quad(ctx.rect, r.bg, r.radius, r.border_width, r.border_color);
678        } else {
679            ctx.quad(ctx.rect, r.bg, r.radius);
680        }
681
682        let label = self.display();
683        let (pad_x, _) = self.resolved_padding();
684        let x = ctx.rect.left() + pad_x;
685        let y = ctx.rect.top() + ctx.rect.size.height * 0.5 + r.font_size * 0.35;
686        ctx.draw_text_weighted(&label, (x, y), r.font_size, r.weight, r.text);
687
688        let cx = ctx.rect.right() - pad_x - CHEV_W;
689        let cy = ctx.rect.top() + ctx.rect.size.height * 0.5;
690        let hw = CHEV_W * 0.5;
691        let hh = CHEV_H * 0.5;
692
693        let is_open = self.open_state().get_untracked();
694        let icon_name: Option<&str> = if is_open {
695            self.chevron_open_icon
696                .as_deref()
697                .or(self.chevron_icon.as_deref())
698        } else {
699            self.chevron_icon.as_deref()
700        };
701        let mut painted_icon = false;
702        if let Some(name) = icon_name {
703            let px = self.chevron_size.unwrap_or(10.0);
704            let color = self.chevron_color.unwrap_or(r.text);
705            if let Some(assets) = ctx.environment.get::<AssetRegistry>() {
706                let raster_px = ((px * ctx.scale()).round() as u32).max(1);
707                if let Ok(sprite) = assets.rasterize_icon(name, raster_px) {
708                    let dst = Rect::from_xywh(cx + hw - px * 0.5, cy - px * 0.5, px, px);
709                    ctx.draw_sprite(
710                        icon_key(name, raster_px),
711                        sprite.width,
712                        sprite.height,
713                        &sprite.alpha,
714                        dst,
715                        color,
716                    );
717                    painted_icon = true;
718                }
719            }
720        }
721        if !painted_icon {
722            ctx.polygon(
723                &[
724                    sequoia_core::Point::new(cx, cy - hh),
725                    sequoia_core::Point::new(cx + CHEV_W, cy - hh),
726                    sequoia_core::Point::new(cx + hw, cy + hh),
727                ],
728                r.text,
729            );
730        }
731
732        self.sync_overlay(ctx, &theme);
733    }
734}
735
736struct DropdownMenu {
737    overlay: Rc<Cell<Option<OverlayId>>>,
738    options: Vec<String>,
739    selected: Signal<usize>,
740    open: Signal<bool>,
741    highlight: Signal<usize>,
742    font_size: f32,
743    weight: FontWeight,
744    item_height: f32,
745    background: Color,
746    border: Color,
747    radius: f32,
748    menu_min_width: f32,
749    on_select: Option<Rc<dyn Fn(usize)>>,
750}
751
752impl View for DropdownMenu {
753    fn body(&self) -> impl View + 'static {
754        Unit
755    }
756    fn children_override(&self) -> Option<Vec<AnyView>> {
757        Some(
758            self.options
759                .iter()
760                .enumerate()
761                .map(|(i, label)| {
762                    AnyView::new(DropdownItem {
763                        overlay: Rc::clone(&self.overlay),
764                        index: i,
765                        label: label.clone(),
766                        selected: self.selected.clone(),
767                        open: self.open.clone(),
768                        highlight: self.highlight.clone(),
769                        font_size: self.font_size,
770                        weight: self.weight,
771                        item_height: self.item_height,
772                        on_select: self.on_select.clone(),
773                    })
774                })
775                .collect(),
776        )
777    }
778    fn layout_style(&self) -> LayoutStyle {
779        LayoutStyle {
780            width: Length::Fit,
781            height: Length::Fit,
782            ..LayoutStyle::default().min_width(self.menu_min_width.max(0.0))
783        }
784    }
785    fn container(&self) -> Option<ContainerStyle> {
786        Some(ContainerStyle {
787            axis: Axis::Vertical,
788            gap: 0.0,
789            cross_gap: 0.0,
790            align: Align::Stretch,
791            justify: sequoia_layout::Justify::Start,
792            wrap: false,
793            align_content: Align::Start,
794        })
795    }
796    fn paint_self(&self, ctx: &mut PaintCtx) {
797        let rect = ctx.rect;
798        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
799            return;
800        }
801        ctx.shadow(
802            rect,
803            self.radius,
804            16.0,
805            sequoia_core::Point::new(0.0, 4.0),
806            Color::rgba(0.0, 0.0, 0.0, 0.3),
807        );
808        ctx.bordered_quad(rect, self.background, self.radius, 1.0, self.border);
809    }
810}
811
812struct DropdownItem {
813    overlay: Rc<Cell<Option<OverlayId>>>,
814    index: usize,
815    label: String,
816    selected: Signal<usize>,
817    open: Signal<bool>,
818    highlight: Signal<usize>,
819    font_size: f32,
820    weight: FontWeight,
821    item_height: f32,
822    on_select: Option<Rc<dyn Fn(usize)>>,
823}
824
825impl DropdownItem {
826    fn select(&self, ctx: &mut EventCtx) {
827        self.selected.set(self.index);
828        self.highlight.set(self.index);
829        if let Some(f) = &self.on_select {
830            f(self.index);
831        }
832        self.open.set(false);
833        if let Some(id) = self.overlay.take() {
834            ctx.hide_overlay(id);
835        }
836        ctx.handled();
837    }
838}
839
840impl View for DropdownItem {
841    fn body(&self) -> impl View + 'static {
842        Unit
843    }
844    fn is_leaf(&self) -> bool {
845        true
846    }
847    fn has_visual_state(&self) -> bool {
848        true
849    }
850    fn captures_pointer(&self) -> bool {
851        true
852    }
853    fn keyed_children(&self) -> Option<Vec<(u64, AnyView)>> {
854        let _ = self.selected.get();
855        let _ = self.highlight.get();
856        Some(Vec::new())
857    }
858    fn layout_style(&self) -> LayoutStyle {
859        LayoutStyle {
860            width: Length::Fit,
861            height: Length::Px(self.item_height),
862            ..LayoutStyle::default()
863        }
864    }
865    fn intrinsic_size(&self, ctx: &mut LayoutCtx) -> Option<Size> {
866        let w = ctx.text_width_weighted(&self.label, self.font_size, self.weight);
867        Some(Size::new(w + ITEM_PAD_X * 2.0, self.item_height))
868    }
869    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
870        if ctx.phase != EventPhase::Target {
871            return;
872        }
873        match event {
874            Event::Pointer(PointerEvent::Up {
875                button: PointerButton::Left,
876                ..
877            }) => {
878                if ctx.state.pressed && ctx.hover_self {
879                    self.select(ctx);
880                }
881            }
882            Event::Pointer(PointerEvent::Move { .. }) => {
883                if ctx.hover_self {
884                    let cur = self.highlight.get_untracked();
885                    if cur != self.index {
886                        self.highlight.set(self.index);
887                    }
888                }
889            }
890            Event::Key(key) if key.key == Key::ESCAPE => {
891                self.open.set(false);
892                if let Some(id) = self.overlay.take() {
893                    ctx.hide_overlay(id);
894                }
895                ctx.handled();
896            }
897            _ => {}
898        }
899    }
900    fn paint_self(&self, ctx: &mut PaintCtx) {
901        let theme = ctx.theme();
902        let rect = ctx.rect;
903        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
904            return;
905        }
906        let is_selected = self.selected.get_untracked() == self.index;
907        let hl = self.highlight.get_untracked() == self.index;
908
909        if ctx.state.hovered || hl {
910            ctx.quad(
911                Rect::from_xywh(
912                    rect.left() + 2.0,
913                    rect.top(),
914                    (rect.size.width - 4.0).max(0.0),
915                    rect.size.height,
916                ),
917                Color::hex(0x4A4A4A),
918                4.0,
919            );
920        } else if is_selected {
921            ctx.quad(
922                Rect::from_xywh(
923                    rect.left() + 2.0,
924                    rect.top(),
925                    (rect.size.width - 4.0).max(0.0),
926                    rect.size.height,
927                ),
928                Color::hex(0x3A3A3A),
929                4.0,
930            );
931        }
932        let weight = if is_selected {
933            FontWeight::SEMIBOLD
934        } else {
935            self.weight
936        };
937        let x = rect.left() + ITEM_PAD_X;
938        let y = rect.top() + rect.size.height * 0.5 + self.font_size * 0.35;
939        ctx.draw_text_weighted(&self.label, (x, y), self.font_size, weight, theme.text);
940    }
941}
942
943fn sanitize_margin(v: f32) -> f32 {
944    if v.is_finite() { v.max(0.0) } else { 0.0 }
945}