pix_engine/gui/widgets/
select.rs

1//! Select widget rendering methods.
2//!
3//! Provided [`PixState`] methods:
4//!
5//! - [`PixState::select_box`]
6//! - [`PixState::select_list`]
7//!
8//! # Example
9//!
10//! ```
11//! # use pix_engine::prelude::*;
12//! # struct App { selected_box: usize, selected_list: usize };
13//! # impl PixEngine for App {
14//! fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
15//!     let items = ["Item 1", "Item 2", "Item 3"];
16//!     let displayed_count = 4;
17//!     s.select_box("Select Box", &mut self.selected_box, &items, displayed_count)?;
18//!     s.select_list("Select List", &mut self.selected_list, &items, displayed_count)?;
19//!     Ok(())
20//! }
21//! # }
22//! ```
23
24use crate::{
25    gui::{state::ElementId, Direction},
26    ops::clamp_size,
27    prelude::*,
28};
29use std::cmp;
30
31/// The maximum number of select elements that can be displayed at once.
32pub const MAX_DISPLAYED: usize = 100;
33const SELECT_POP_LABEL: &str = "##select_pop";
34
35impl PixState {
36    /// Draw a select box the current canvas that returns `true` when selection is changed.
37    ///
38    /// Maximum displayed count of 100.
39    ///
40    /// # Errors
41    ///
42    /// If the renderer fails to draw to the current render target, or if `displayed_count` is
43    /// greater than [`MAX_DISPLAYED`] then an error is returned.
44    ///
45    /// # Example
46    ///
47    /// ```
48    /// # use pix_engine::prelude::*;
49    /// # struct App { select_box: usize };
50    /// # impl PixEngine for App {
51    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
52    ///     let items = ["Item 1", "Item 2", "Item 3"];
53    ///     let displayed_count = 4;
54    ///     if s.select_box("Select Box", &mut self.select_box, &items, displayed_count)? {
55    ///         // selection changed
56    ///     }
57    ///     Ok(())
58    /// }
59    /// # }
60    /// ```
61    pub fn select_box<S, I>(
62        &mut self,
63        label: S,
64        selected: &mut usize,
65        items: &[I],
66        mut displayed_count: usize,
67    ) -> PixResult<bool>
68    where
69        S: AsRef<str>,
70        I: AsRef<str>,
71    {
72        let label = label.as_ref();
73
74        if displayed_count > MAX_DISPLAYED {
75            displayed_count = MAX_DISPLAYED;
76        }
77        if *selected >= items.len() {
78            *selected = items.len() - 1;
79        }
80
81        let s = self;
82        let id = s.ui.get_id(&label);
83        let label = s.ui.get_label(label);
84        let pos = s.cursor_pos();
85        let font_size = clamp_size(s.theme.font_size);
86        let spacing = s.theme.spacing;
87        let fpad = spacing.frame_pad;
88        let ipad = spacing.item_pad;
89
90        // Calculate rect
91        let (item_width, item_height) = s.text_size(items.get(0).map_or("", AsRef::as_ref))?;
92        let width = s.ui.next_width.take().unwrap_or(item_width);
93        let (label_width, label_height) = s.text_size(label)?;
94        let [mut x, y] = pos.coords();
95        if !label.is_empty() {
96            x += label_width + ipad.x();
97        }
98        let select_box = rect![x, y, width, item_height].offset_size(2 * fpad);
99
100        // Check hover/active/keyboard focus
101        let hovered = s.focused() && s.ui.try_hover(id, &select_box);
102        let focused = s.focused() && s.ui.try_focus(id);
103
104        s.push();
105        s.ui.push_cursor();
106
107        // Label
108        if !label.is_empty() {
109            s.set_cursor_pos([
110                pos.x(),
111                pos.y() + select_box.height() / 2 - label_height / 2,
112            ]);
113            s.text(label)?;
114        }
115
116        // Select Box
117        s.rect_mode(RectMode::Corner);
118        if hovered {
119            s.frame_cursor(&Cursor::hand())?;
120        }
121        let [stroke, bg, fg] = s.widget_colors(id, ColorType::Background);
122        s.stroke(stroke);
123        s.fill(bg);
124        s.rect(select_box)?;
125
126        // Arrow
127        let arrow_width = font_size + 2 * fpad.y();
128        let arrow_x = cmp::max(select_box.left(), select_box.right() - arrow_width);
129
130        let [_, select_y, _, select_height] = select_box.coords();
131        let arrow_box = rect![arrow_x, select_y, arrow_width, select_height];
132        s.rect(arrow_box)?;
133
134        if arrow_x + arrow_width - fpad.x() <= select_box.right() {
135            s.stroke(None);
136            s.fill(fg);
137            s.clip(arrow_box)?;
138            s.arrow(
139                [
140                    arrow_x + fpad.y(),
141                    select_y + arrow_box.height() / 2 - arrow_width / 4,
142                ],
143                Direction::Down,
144                f64::from(fpad.y()) / 8.0,
145            )?;
146        }
147
148        // Item
149        s.clip(rect![
150            select_box.top_left(),
151            select_box.width() - arrow_box.width(),
152            select_box.height()
153        ])?;
154
155        s.wrap(None);
156        s.set_cursor_pos(select_box.top_left() + fpad);
157        s.stroke(None);
158        s.fill(fg);
159        s.text(&items[*selected])?;
160
161        s.clip(None)?;
162        s.ui.pop_cursor();
163        s.pop();
164        s.advance_cursor([select_box.right() - pos.x(), select_box.height()]);
165
166        let line_height = font_size + 2 * ipad.y();
167        let expanded_list = rect![
168            select_box.left(),
169            select_box.bottom() + 1,
170            select_box.width(),
171            displayed_count as i32 * line_height + 2 * fpad.y(),
172        ];
173        let original_selected = *selected;
174        s.select_list_popup(id, selected, items, displayed_count, expanded_list)?;
175
176        // Process input
177        s.push_id(id);
178        let list_id = s.ui.get_id(&SELECT_POP_LABEL);
179        let scroll = s.ui.scroll(list_id);
180        s.pop_id();
181        let expanded = s.ui.expanded(id);
182        if focused {
183            s.ui.set_expanded(id, true);
184            if let Some(key) = s.ui.key_entered() {
185                if let Key::Escape | Key::Return = key {
186                    s.ui.set_expanded(id, !expanded);
187                    s.ui.clear_entered();
188                } else {
189                    let new_selected = match key {
190                        Key::Up => Some(selected.saturating_sub(1)),
191                        Key::Down => Some(cmp::min(items.len() - 1, selected.saturating_add(1))),
192                        _ => None,
193                    };
194                    if let Some(selection) = new_selected {
195                        *selected = selection;
196                        s.ui.clear_entered();
197                        let sel_y = *selected as i32 * line_height;
198                        let mut new_scroll = scroll;
199                        let height = expanded_list.height();
200                        if sel_y < scroll.y() {
201                            // Snap scroll to top of the window
202                            new_scroll.set_y(sel_y);
203                        } else if sel_y + line_height > scroll.y() + height {
204                            // Snap scroll to bottom of the window
205                            new_scroll
206                                .set_y((sel_y + line_height) - (height - font_size - ipad.y()));
207                        }
208                        if new_scroll != scroll {
209                            s.ui.set_scroll(list_id, new_scroll);
210                        }
211                    }
212                }
213            }
214        }
215        let clicked_outside = s.mouse_down(Mouse::Left)
216            && !select_box.contains(s.mouse_pos())
217            && !expanded_list.contains(s.mouse_pos());
218        if (expanded && clicked_outside) || (!focused && !s.mouse_down(Mouse::Left)) {
219            s.ui.set_expanded(id, false);
220        }
221
222        s.ui.handle_focus(id);
223
224        Ok(original_selected != *selected)
225    }
226
227    /// Draw a select list to the current canvas with a scrollable region that returns `true` when
228    /// selection is changed.
229    ///
230    /// # Errors
231    ///
232    /// If the renderer fails to draw to the current render target, or if `displayed_count` is
233    /// greater than [`MAX_DISPLAYED`] then an error is returned.
234    ///
235    /// # Example
236    ///
237    /// ```
238    /// # use pix_engine::prelude::*;
239    /// # struct App { select_list: usize };
240    /// # impl PixEngine for App {
241    /// fn on_update(&mut self, s: &mut PixState) -> PixResult<()> {
242    ///     let items = ["Item 1", "Item 2", "Item 3"];
243    ///     let displayed_count = 4;
244    ///     if s.select_list("Select List", &mut self.select_list, &items, displayed_count)? {
245    ///         // Selection  changed
246    ///     }
247    ///     Ok(())
248    /// }
249    /// # }
250    /// ```
251    pub fn select_list<S, I>(
252        &mut self,
253        label: S,
254        selected: &mut usize,
255        items: &[I],
256        mut displayed_count: usize,
257    ) -> PixResult<bool>
258    where
259        S: AsRef<str>,
260        I: AsRef<str>,
261    {
262        let label = label.as_ref();
263
264        if displayed_count > MAX_DISPLAYED {
265            displayed_count = MAX_DISPLAYED;
266        }
267        if *selected >= items.len() {
268            *selected = items.len() - 1;
269        }
270
271        let s = self;
272        let id = s.ui.get_id(&label);
273        let label = s.ui.get_label(label);
274        let pos = s.cursor_pos();
275        let font_size = clamp_size(s.theme.font_size);
276        let spacing = s.theme.spacing;
277        let fpad = spacing.frame_pad;
278        let ipad = spacing.item_pad;
279
280        // Calculate rect
281        let (label_width, label_height) = s.text_size(label)?;
282        let width = s.ui.next_width.take().unwrap_or(label_width);
283        let [x, mut y] = pos.coords();
284        if !label.is_empty() {
285            y += label_height + ipad.y();
286        }
287        let line_height = font_size + 2 * ipad.y();
288        let select_list = rect![
289            x,
290            y,
291            width,
292            displayed_count as i32 * line_height + 2 * fpad.y() + 2
293        ];
294
295        // Check hover/active/keyboard focus
296        let focused = s.focused() && s.ui.try_focus(id);
297
298        s.push();
299        s.ui.push_cursor();
300
301        // Select List
302        s.rect_mode(RectMode::Corner);
303        s.text(label)?;
304
305        let original_selected = *selected;
306        s.select_list_items(id, selected, items, displayed_count, select_list)?;
307
308        s.ui.pop_cursor();
309        s.pop();
310
311        // Process input
312        let scroll = s.ui.scroll(id);
313        let line_height = font_size + 2 * ipad.y();
314        if focused {
315            if let Some(key) = s.ui.key_entered() {
316                let new_selected = match key {
317                    Key::Up => Some(selected.saturating_sub(1)),
318                    Key::Down => Some(cmp::min(items.len() - 1, selected.saturating_add(1))),
319                    _ => None,
320                };
321                if let Some(selection) = new_selected {
322                    *selected = selection;
323                    s.ui.clear_entered();
324                    let sel_y = *selected as i32 * line_height;
325                    let mut new_scroll = scroll;
326                    let height = select_list.height();
327                    if sel_y < scroll.y() {
328                        // Snap scroll to top of the window
329                        new_scroll.set_y(sel_y);
330                    } else if sel_y + line_height > scroll.y() + height {
331                        // Snap scroll to bottom of the window
332                        new_scroll.set_y((sel_y + line_height) - (height - font_size - ipad.y()));
333                    }
334                    if new_scroll != scroll {
335                        s.ui.set_scroll(id, new_scroll);
336                    }
337                }
338            }
339        }
340        s.ui.handle_focus(id);
341
342        // Scrollbars
343        let total_height = items.len() as i32 * line_height + 2;
344        let total_width = items.iter().fold(0, |max_width, item| {
345            let (w, _) = s.text_size(item.as_ref()).unwrap_or((0, 0));
346            cmp::max(w, max_width)
347        });
348
349        let rect = s.scroll(
350            id,
351            select_list,
352            total_width + 2 * fpad.x(),
353            total_height + 2 * fpad.y(),
354        )?;
355        s.advance_cursor([rect.width().max(label_width), rect.bottom() - pos.y()]);
356
357        Ok(original_selected != *selected)
358    }
359}
360
361impl PixState {
362    #[inline]
363    fn select_list_popup<I>(
364        &mut self,
365        id: ElementId,
366        selected: &mut usize,
367        items: &[I],
368        displayed_count: usize,
369        size: Rect<i32>,
370    ) -> PixResult<bool>
371    where
372        I: AsRef<str>,
373    {
374        let s = self;
375        let font_size = clamp_size(s.theme.font_size);
376        let spacing = s.theme.spacing;
377        let fpad = spacing.frame_pad;
378        let ipad = spacing.item_pad;
379
380        let line_height = font_size + 2 * ipad.y();
381        let height = displayed_count as i32 * line_height + 2 * fpad.y();
382
383        let expanded = s.ui.expanded(id);
384        if expanded {
385            // Pop select list
386            let total_height = items.len() as i32 * line_height + 2 * fpad.y();
387            let texture_id = s.get_or_create_texture(id, None, size)?;
388
389            s.ui.offset_mouse(size.top_left());
390
391            s.set_texture_target(texture_id)?;
392            s.clear()?;
393            s.set_cursor_pos([0, 0]);
394            if total_height > height {
395                s.next_width((size.width() - spacing.scroll_size) as u32);
396            } else {
397                s.next_width(size.width() as u32);
398            }
399            s.ui.disable_focus();
400            s.push_id(id);
401            let changed = s.select_list(SELECT_POP_LABEL, selected, items, displayed_count)?;
402            s.pop_id();
403            s.ui.enable_focus();
404            s.clear_texture_target();
405
406            s.ui.clear_mouse_offset();
407            if changed {
408                s.ui.set_expanded(id, false);
409            }
410            Ok(changed)
411        } else {
412            Ok(false)
413        }
414    }
415
416    #[inline]
417    fn select_list_items<I>(
418        &mut self,
419        id: ElementId,
420        selected: &mut usize,
421        items: &[I],
422        displayed_count: usize,
423        select_list: Rect<i32>,
424    ) -> PixResult<()>
425    where
426        I: AsRef<str>,
427    {
428        let s = self;
429        let font_size = clamp_size(s.theme.font_size);
430        let spacing = s.theme.spacing;
431        let colors = s.theme.colors;
432        let fpad = spacing.frame_pad;
433        let ipad = spacing.item_pad;
434
435        // Check hover/active/keyboard focus
436        let hovered = s.focused() && s.ui.try_hover(id, &select_list);
437        let active = s.ui.is_active(id);
438        let disabled = s.ui.disabled;
439
440        let [stroke, bg, fg] = s.widget_colors(id, ColorType::Background);
441        s.stroke(stroke);
442        s.fill(colors.background);
443        s.rect(select_list)?;
444
445        // Items
446        let mpos = s.mouse_pos();
447
448        let border_clip = select_list.shrink([1, 1]);
449        s.clip(border_clip)?;
450        let content_clip = border_clip.shrink(fpad);
451        let item_clip = rect![
452            select_list.x() + 1,
453            content_clip.y(),
454            select_list.width() - 2,
455            content_clip.height(),
456        ];
457
458        let scroll = s.ui.scroll(id);
459        let line_height = font_size + ipad.y() * 2;
460        let skip_count = (scroll.y() / line_height) as usize;
461        let displayed_items = items
462            .iter()
463            .enumerate()
464            .skip(skip_count)
465            .take(displayed_count + 1); // Display extra items for scrolling overflow
466
467        let x = select_list.x() + fpad.x() - scroll.x();
468        let mut y = content_clip.y() - scroll.y() + (skip_count as i32 * line_height);
469        for (i, item) in displayed_items {
470            let item_rect = rect!(select_list.x(), y, select_list.width(), line_height);
471            let clickable =
472                item_rect.bottom() > content_clip.y() || item_rect.top() < select_list.height();
473            s.push();
474            s.clip(item_clip)?;
475            if hovered && clickable && item_rect.contains(mpos) {
476                s.frame_cursor(&Cursor::hand())?;
477                s.stroke(None);
478                s.fill(bg);
479                s.rect([item_clip.x(), y, item_clip.width(), line_height])?;
480                if active && s.mouse_clicked(Mouse::Left) {
481                    *selected = i;
482                }
483            }
484            if *selected == i {
485                s.stroke(None);
486                if disabled {
487                    s.fill(colors.primary.blended(colors.background, 0.38));
488                } else {
489                    s.fill(colors.primary);
490                }
491                s.rect([item_clip.x(), y, item_clip.width(), line_height])?;
492            }
493            s.pop();
494            s.clip(content_clip)?;
495            s.set_cursor_pos([x, y + ipad.y()]);
496            s.stroke(None);
497            if *selected == i {
498                s.fill(colors.on_primary);
499            } else {
500                s.fill(fg);
501            }
502            s.text(item)?;
503            s.clip(border_clip)?;
504            y += line_height;
505        }
506
507        s.clip(None)?;
508
509        Ok(())
510    }
511}