Skip to main content

ashlar_ui/overlay/
select.rs

1//! A button that opens a list of options and shows the chosen one.
2
3use egui::{Align2, EventFilter, Id, Key, Modifiers, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
4
5use super::options::OptionList;
6use super::popover::Popover;
7use crate::automation::{self, Details, Role};
8use crate::tokens::{self, Tokens};
9
10#[derive(Clone, Copy, Debug, Default)]
11struct Memory {
12    open: bool,
13    cursor: usize,
14}
15
16/// Choose one of a fixed list of options. `current` is an index into `options`; an index out of
17/// range shows the placeholder.
18///
19/// Keyboard: Space, Enter or the arrows open it; the arrows, Home and End move; Enter or Space
20/// chooses; Escape closes without choosing. The focus stays on the button while the list is
21/// open. `changed()` on the response reports a choice.
22pub struct Select<'a, T: AsRef<str>> {
23    id: &'a str,
24    options: &'a [T],
25    current: &'a mut usize,
26    width: Option<f32>,
27    label: &'a str,
28    placeholder: &'a str,
29}
30
31impl<'a, T: AsRef<str>> Select<'a, T> {
32    /// `id` is the automation id; options register as `<id>.option.<n>`.
33    pub fn new(id: &'a str, options: &'a [T], current: &'a mut usize) -> Self {
34        Self { id, options, current, width: None, label: "", placeholder: "Choose…" }
35    }
36
37    /// Width of the button in points (default: 12 control heights).
38    pub fn width(mut self, width: f32) -> Self {
39        self.width = width.is_finite().then_some(width.clamp(40.0, 4000.0));
40        self
41    }
42
43    /// The accessible name (what the select chooses: "Font size").
44    pub fn label(mut self, label: &'a str) -> Self {
45        self.label = label;
46        self
47    }
48
49    /// Shown when `current` is out of range.
50    pub fn placeholder(mut self, text: &'a str) -> Self {
51        self.placeholder = text;
52        self
53    }
54
55    pub fn show(self, ui: &mut Ui) -> Response {
56        let t = tokens::current(ui.ctx());
57        let id = Id::new(("ashlar_ui::select", self.id));
58        let mem_id = id.with("memory");
59        let mut mem: Memory = ui.data(|d| d.get_temp(mem_id)).unwrap_or_default();
60        let n = self.options.len();
61        let label_of = |i: usize| self.options.get(i).map_or("", |o| o.as_ref());
62        let current = (*self.current < n).then_some(*self.current);
63        let shown = current.map_or(self.placeholder, label_of);
64
65        let mut choose = None;
66        let mut moved = false;
67        let focused = ui.memory(|m| m.has_focus(id));
68        if focused {
69            // arrows drive the list instead of moving the focus; Escape closes instead of blurring
70            let filter = EventFilter { tab: false, horizontal_arrows: false, vertical_arrows: true, escape: mem.open };
71            ui.memory_mut(|m| m.set_focus_lock_filter(id, filter));
72            let (up, down, home, end, enter, space) = ui.input_mut(|i| {
73                (
74                    i.consume_key(Modifiers::NONE, Key::ArrowUp),
75                    i.consume_key(Modifiers::NONE, Key::ArrowDown),
76                    mem.open && i.consume_key(Modifiers::NONE, Key::Home),
77                    mem.open && i.consume_key(Modifiers::NONE, Key::End),
78                    i.consume_key(Modifiers::NONE, Key::Enter),
79                    i.consume_key(Modifiers::NONE, Key::Space),
80                )
81            });
82            let last = n.saturating_sub(1);
83            if !mem.open {
84                if (up || down || enter || space) && n > 0 {
85                    mem.open = true;
86                    mem.cursor = current.unwrap_or(0);
87                    moved = true;
88                }
89            } else {
90                if up {
91                    mem.cursor = mem.cursor.saturating_sub(1);
92                }
93                if down {
94                    mem.cursor = mem.cursor.saturating_add(1).min(last);
95                }
96                if home {
97                    mem.cursor = 0;
98                }
99                if end {
100                    mem.cursor = last;
101                }
102                moved = up || down || home || end;
103                if (enter || space) && mem.cursor < n {
104                    choose = Some(mem.cursor);
105                }
106            }
107        }
108
109        let width = self.width.unwrap_or(t.control_height * 12.0).min(ui.available_width().max(40.0));
110        let (rect, _) = ui.allocate_exact_size(vec2(width, t.control_height), Sense::hover());
111        let mut response = ui.interact(rect, id, Sense::click());
112        if response.clicked() {
113            // keep the keyboard working after a pointer open
114            response.request_focus();
115            mem.open = !mem.open && n > 0;
116            mem.cursor = current.unwrap_or(0);
117            moved = true;
118        }
119        if mem.open && response.lost_focus() && !ui.input(|i| i.pointer.any_down() || i.pointer.any_released()) {
120            // tabbed away
121            mem.open = false;
122        }
123        paint_button(ui, &t, &response, shown, current.is_none(), focused);
124
125        let cursor = mem.cursor.min(n.saturating_sub(1));
126        let option_of = |row: usize| (row < n).then_some(row);
127        let id_str = self.id;
128        let clicked = Popover::attached(id.with("popup")).width(width).show(&response, &mut mem.open, |ui| {
129            OptionList {
130                id: id_str,
131                rows: n,
132                option_of: &option_of,
133                label: &label_of,
134                cursor: Some(cursor),
135                chosen: current,
136                scroll_to: moved.then_some(cursor),
137            }
138            .show(ui)
139        });
140        if let Some(Some(row)) = clicked {
141            choose = Some(row);
142        }
143        if let Some(i) = choose
144            && i < n
145        {
146            mem.open = false;
147            mem.cursor = i;
148            if *self.current != i {
149                *self.current = i;
150                response.mark_changed();
151            }
152        }
153        ui.data_mut(|d| d.insert_temp(mem_id, mem));
154
155        let chosen_label = if *self.current < n { label_of(*self.current) } else { "" };
156        response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, self.label));
157        ui.ctx().accesskit_node_builder(id, |node| {
158            node.set_role(egui::accesskit::Role::ComboBox);
159            node.set_expanded(mem.open);
160            node.set_value(chosen_label);
161        });
162        automation::register_with(
163            ui,
164            &response,
165            self.id,
166            self.label,
167            Role::ComboBox,
168            Details { value: Some(chosen_label), selected: Some(mem.open) },
169        );
170        response
171    }
172}
173
174fn paint_button(ui: &Ui, t: &Tokens, response: &Response, text: &str, placeholder: bool, focused: bool) {
175    let rect = response.rect;
176    let fill = if response.is_pointer_button_down_on() {
177        t.surface.pressed
178    } else if response.hovered() {
179        t.surface.hover
180    } else {
181        t.surface.sunken
182    };
183    let p = ui.painter();
184    p.rect(rect, t.radius.md, fill, Stroke::new(t.stroke.hairline, t.border), StrokeKind::Inside);
185    if focused {
186        p.rect_stroke(rect.expand(1.0), t.radius.md, Stroke::new(t.stroke.focus, t.focus), StrokeKind::Outside);
187    }
188    let color = if placeholder { t.text.secondary } else { t.text.primary };
189    let text_rect = rect.shrink2(vec2(t.space.md, 0.0)).with_max_x(rect.right() - t.space.md * 2.0 - 8.0);
190    let clip = p.with_clip_rect(text_rect.intersect(p.clip_rect()));
191    clip.text(pos2(text_rect.left(), rect.center().y), Align2::LEFT_CENTER, text, Tokens::font(t.type_scale.body), color);
192    // chevron
193    let c = pos2(rect.right() - t.space.md - 3.0, rect.center().y);
194    let s = Stroke::new(1.4, t.text.secondary);
195    p.line_segment([c + vec2(-4.0, -2.0), c + vec2(0.0, 2.0)], s);
196    p.line_segment([c + vec2(0.0, 2.0), c + vec2(4.0, -2.0)], s);
197}