Skip to main content

slt/context/widgets_interactive/
collections.rs

1use super::*;
2
3impl Context {
4    /// Render children in a fixed grid with the given number of columns.
5    ///
6    /// Children are placed left-to-right, top-to-bottom. Each cell has equal
7    /// width (`area_width / cols`). Rows wrap automatically.
8    ///
9    /// # Example
10    ///
11    /// ```no_run
12    /// # slt::run(|ui: &mut slt::Context| {
13    /// ui.grid(3, |ui| {
14    ///     for i in 0..9 {
15    ///         ui.text(format!("Cell {i}"));
16    ///     }
17    /// });
18    /// # });
19    /// ```
20    pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
21        slt_assert(cols > 0, "grid() requires at least 1 column");
22        let interaction_id = self.next_interaction_id();
23        let border = self.theme.border;
24
25        self.commands.push(Command::BeginContainer {
26            direction: Direction::Column,
27            gap: 0,
28            align: Align::Start,
29            align_self: None,
30            justify: Justify::Start,
31            border: None,
32            border_sides: BorderSides::all(),
33            border_style: Style::new().fg(border),
34            bg_color: None,
35            padding: Padding::default(),
36            margin: Margin::default(),
37            constraints: Constraints::default(),
38            title: None,
39            grow: 0,
40            group_name: None,
41        });
42
43        let children_start = self.commands.len();
44        f(self);
45        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
46
47        let mut elements: Vec<Vec<Command>> = Vec::new();
48        let mut iter = child_commands.into_iter().peekable();
49        while let Some(cmd) = iter.next() {
50            match cmd {
51                Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
52                    let mut depth = 1_u32;
53                    let mut element = vec![cmd];
54                    for next in iter.by_ref() {
55                        match next {
56                            Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
57                                depth += 1;
58                            }
59                            Command::EndContainer => {
60                                depth = depth.saturating_sub(1);
61                            }
62                            _ => {}
63                        }
64                        let at_end = matches!(next, Command::EndContainer) && depth == 0;
65                        element.push(next);
66                        if at_end {
67                            break;
68                        }
69                    }
70                    elements.push(element);
71                }
72                Command::EndContainer => {}
73                _ => elements.push(vec![cmd]),
74            }
75        }
76
77        let cols = cols.max(1) as usize;
78        for row in elements.chunks(cols) {
79            self.skip_interaction_slot();
80            self.commands.push(Command::BeginContainer {
81                direction: Direction::Row,
82                gap: 0,
83                align: Align::Start,
84                align_self: None,
85                justify: Justify::Start,
86                border: None,
87                border_sides: BorderSides::all(),
88                border_style: Style::new().fg(border),
89                bg_color: None,
90                padding: Padding::default(),
91                margin: Margin::default(),
92                constraints: Constraints::default(),
93                title: None,
94                grow: 0,
95                group_name: None,
96            });
97
98            for element in row {
99                self.skip_interaction_slot();
100                self.commands.push(Command::BeginContainer {
101                    direction: Direction::Column,
102                    gap: 0,
103                    align: Align::Start,
104                    align_self: None,
105                    justify: Justify::Start,
106                    border: None,
107                    border_sides: BorderSides::all(),
108                    border_style: Style::new().fg(border),
109                    bg_color: None,
110                    padding: Padding::default(),
111                    margin: Margin::default(),
112                    constraints: Constraints::default(),
113                    title: None,
114                    grow: 1,
115                    group_name: None,
116                });
117                self.commands.extend(element.iter().cloned());
118                self.commands.push(Command::EndContainer);
119            }
120
121            self.commands.push(Command::EndContainer);
122        }
123
124        self.commands.push(Command::EndContainer);
125        self.rollback.last_text_idx = None;
126
127        self.response_for(interaction_id)
128    }
129
130    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
131    ///
132    /// The selected item is highlighted with the theme's primary color. If the
133    /// list is empty, nothing is rendered.
134    /// Render a navigable list widget.
135    pub fn list(&mut self, state: &mut ListState) -> Response {
136        self.list_colored(state, &WidgetColors::new())
137    }
138
139    /// Render a navigable list with custom widget colors.
140    pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
141        let visible = state.visible_indices().to_vec();
142        if visible.is_empty() && state.items.is_empty() {
143            state.selected = 0;
144            return Response::none();
145        }
146
147        if !visible.is_empty() {
148            state.selected = state.selected.min(visible.len().saturating_sub(1));
149        }
150
151        let old_selected = state.selected;
152        let focused = self.register_focusable();
153        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
154
155        if focused {
156            let mut consumed_indices = Vec::new();
157            for (i, key) in self.available_key_presses() {
158                match key.code {
159                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
160                        let _ = handle_vertical_nav(
161                            &mut state.selected,
162                            visible.len().saturating_sub(1),
163                            key.code.clone(),
164                        );
165                        consumed_indices.push(i);
166                    }
167                    _ => {}
168                }
169            }
170            self.consume_indices(consumed_indices);
171        }
172
173        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
174            let mut consumed = Vec::new();
175            for (i, mouse) in clicks {
176                let clicked_idx = (mouse.y - rect.y) as usize;
177                if clicked_idx < visible.len() {
178                    state.selected = clicked_idx;
179                    consumed.push(i);
180                }
181            }
182            self.consume_indices(consumed);
183        }
184
185        self.commands.push(Command::BeginContainer {
186            direction: Direction::Column,
187            gap: 0,
188            align: Align::Start,
189            align_self: None,
190            justify: Justify::Start,
191            border: None,
192            border_sides: BorderSides::all(),
193            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
194            bg_color: None,
195            padding: Padding::default(),
196            margin: Margin::default(),
197            constraints: Constraints::default(),
198            title: None,
199            grow: 0,
200            group_name: None,
201        });
202
203        for (view_idx, &item_idx) in visible.iter().enumerate() {
204            let item = &state.items[item_idx];
205            if view_idx == state.selected {
206                let mut selected_style = Style::new()
207                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
208                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
209                if focused {
210                    selected_style = selected_style.bold();
211                }
212                let mut row = String::with_capacity(2 + item.len());
213                row.push_str("▸ ");
214                row.push_str(item);
215                self.styled(row, selected_style);
216            } else {
217                let mut row = String::with_capacity(2 + item.len());
218                row.push_str("  ");
219                row.push_str(item);
220                self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
221            }
222        }
223
224        self.commands.push(Command::EndContainer);
225        self.rollback.last_text_idx = None;
226
227        response.changed = state.selected != old_selected;
228        response
229    }
230
231    /// Render a calendar date picker with month navigation.
232    pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
233        let focused = self.register_focusable();
234        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
235
236        let month_days = CalendarState::days_in_month(state.year, state.month);
237        state.cursor_day = state.cursor_day.clamp(1, month_days);
238        if let Some(day) = state.selected_day {
239            state.selected_day = Some(day.min(month_days));
240        }
241        let old_selected = state.selected_day;
242
243        if focused {
244            let mut consumed_indices = Vec::new();
245            for (i, key) in self.available_key_presses() {
246                match key.code {
247                    KeyCode::Left => {
248                        calendar_move_cursor_by_days(state, -1);
249                        consumed_indices.push(i);
250                    }
251                    KeyCode::Right => {
252                        calendar_move_cursor_by_days(state, 1);
253                        consumed_indices.push(i);
254                    }
255                    KeyCode::Up => {
256                        calendar_move_cursor_by_days(state, -7);
257                        consumed_indices.push(i);
258                    }
259                    KeyCode::Down => {
260                        calendar_move_cursor_by_days(state, 7);
261                        consumed_indices.push(i);
262                    }
263                    KeyCode::Char('h') => {
264                        state.prev_month();
265                        consumed_indices.push(i);
266                    }
267                    KeyCode::Char('l') => {
268                        state.next_month();
269                        consumed_indices.push(i);
270                    }
271                    KeyCode::Enter | KeyCode::Char(' ') => {
272                        state.selected_day = Some(state.cursor_day);
273                        consumed_indices.push(i);
274                    }
275                    _ => {}
276                }
277            }
278            self.consume_indices(consumed_indices);
279        }
280
281        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
282            let mut consumed = Vec::new();
283            for (i, mouse) in clicks {
284                let rel_x = mouse.x.saturating_sub(rect.x);
285                let rel_y = mouse.y.saturating_sub(rect.y);
286                if rel_y == 0 {
287                    if rel_x <= 2 {
288                        state.prev_month();
289                        consumed.push(i);
290                        continue;
291                    }
292                    if rel_x + 3 >= rect.width {
293                        state.next_month();
294                        consumed.push(i);
295                        continue;
296                    }
297                }
298
299                if !(2..8).contains(&rel_y) {
300                    continue;
301                }
302                if rel_x >= 21 {
303                    continue;
304                }
305
306                let week = rel_y - 2;
307                let col = rel_x / 3;
308                let day_index = week * 7 + col;
309                let first = CalendarState::first_weekday(state.year, state.month);
310                let days = CalendarState::days_in_month(state.year, state.month);
311                if day_index < first {
312                    continue;
313                }
314                let day = day_index - first + 1;
315                if day == 0 || day > days {
316                    continue;
317                }
318                state.cursor_day = day;
319                state.selected_day = Some(day);
320                consumed.push(i);
321            }
322            self.consume_indices(consumed);
323        }
324
325        let title = {
326            let month_name = calendar_month_name(state.month);
327            let mut s = String::with_capacity(16);
328            s.push_str(&state.year.to_string());
329            s.push(' ');
330            s.push_str(month_name);
331            s
332        };
333
334        self.commands.push(Command::BeginContainer {
335            direction: Direction::Column,
336            gap: 0,
337            align: Align::Start,
338            align_self: None,
339            justify: Justify::Start,
340            border: None,
341            border_sides: BorderSides::all(),
342            border_style: Style::new().fg(self.theme.border),
343            bg_color: None,
344            padding: Padding::default(),
345            margin: Margin::default(),
346            constraints: Constraints::default(),
347            title: None,
348            grow: 0,
349            group_name: None,
350        });
351
352        self.commands.push(Command::BeginContainer {
353            direction: Direction::Row,
354            gap: 1,
355            align: Align::Start,
356            align_self: None,
357            justify: Justify::Start,
358            border: None,
359            border_sides: BorderSides::all(),
360            border_style: Style::new().fg(self.theme.border),
361            bg_color: None,
362            padding: Padding::default(),
363            margin: Margin::default(),
364            constraints: Constraints::default(),
365            title: None,
366            grow: 0,
367            group_name: None,
368        });
369        self.styled("◀", Style::new().fg(self.theme.text));
370        self.styled(title, Style::new().bold().fg(self.theme.text));
371        self.styled("▶", Style::new().fg(self.theme.text));
372        self.commands.push(Command::EndContainer);
373
374        self.commands.push(Command::BeginContainer {
375            direction: Direction::Row,
376            gap: 0,
377            align: Align::Start,
378            align_self: None,
379            justify: Justify::Start,
380            border: None,
381            border_sides: BorderSides::all(),
382            border_style: Style::new().fg(self.theme.border),
383            bg_color: None,
384            padding: Padding::default(),
385            margin: Margin::default(),
386            constraints: Constraints::default(),
387            title: None,
388            grow: 0,
389            group_name: None,
390        });
391        for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
392            self.styled(
393                format!("{wd:>2} "),
394                Style::new().fg(self.theme.text_dim).bold(),
395            );
396        }
397        self.commands.push(Command::EndContainer);
398
399        let first = CalendarState::first_weekday(state.year, state.month);
400        let days = CalendarState::days_in_month(state.year, state.month);
401        for week in 0..6_u32 {
402            self.commands.push(Command::BeginContainer {
403                direction: Direction::Row,
404                gap: 0,
405                align: Align::Start,
406                align_self: None,
407                justify: Justify::Start,
408                border: None,
409                border_sides: BorderSides::all(),
410                border_style: Style::new().fg(self.theme.border),
411                bg_color: None,
412                padding: Padding::default(),
413                margin: Margin::default(),
414                constraints: Constraints::default(),
415                title: None,
416                grow: 0,
417                group_name: None,
418            });
419
420            for col in 0..7_u32 {
421                let idx = week * 7 + col;
422                if idx < first || idx >= first + days {
423                    self.styled("   ", Style::new().fg(self.theme.text_dim));
424                    continue;
425                }
426                let day = idx - first + 1;
427                let text = format!("{day:>2} ");
428                let style = if state.selected_day == Some(day) {
429                    Style::new()
430                        .bg(self.theme.selected_bg)
431                        .fg(self.theme.selected_fg)
432                } else if state.cursor_day == day {
433                    Style::new().fg(self.theme.primary).bold()
434                } else {
435                    Style::new().fg(self.theme.text)
436                };
437                self.styled(text, style);
438            }
439
440            self.commands.push(Command::EndContainer);
441        }
442
443        self.commands.push(Command::EndContainer);
444        self.rollback.last_text_idx = None;
445        response.changed = state.selected_day != old_selected;
446        response
447    }
448
449    /// Render a file system browser with directory navigation.
450    pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
451        if state.dirty {
452            state.refresh();
453        }
454        if !state.entries.is_empty() {
455            state.selected = state.selected.min(state.entries.len().saturating_sub(1));
456        }
457
458        let focused = self.register_focusable();
459        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
460        let mut file_selected = false;
461
462        if focused {
463            let mut consumed_indices = Vec::new();
464            for (i, key) in self.available_key_presses() {
465                match key.code {
466                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
467                        if !state.entries.is_empty() {
468                            let _ = handle_vertical_nav(
469                                &mut state.selected,
470                                state.entries.len().saturating_sub(1),
471                                key.code.clone(),
472                            );
473                        }
474                        consumed_indices.push(i);
475                    }
476                    KeyCode::Enter => {
477                        if let Some(entry) = state.entries.get(state.selected).cloned() {
478                            if entry.is_dir {
479                                state.current_dir = entry.path;
480                                state.selected = 0;
481                                state.selected_file = None;
482                                state.dirty = true;
483                            } else {
484                                state.selected_file = Some(entry.path);
485                                file_selected = true;
486                            }
487                        }
488                        consumed_indices.push(i);
489                    }
490                    KeyCode::Backspace => {
491                        if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
492                            state.current_dir = parent;
493                            state.selected = 0;
494                            state.selected_file = None;
495                            state.dirty = true;
496                        }
497                        consumed_indices.push(i);
498                    }
499                    KeyCode::Char('h') => {
500                        state.show_hidden = !state.show_hidden;
501                        state.selected = 0;
502                        state.dirty = true;
503                        consumed_indices.push(i);
504                    }
505                    KeyCode::Esc => {
506                        state.selected_file = None;
507                        consumed_indices.push(i);
508                    }
509                    _ => {}
510                }
511            }
512            self.consume_indices(consumed_indices);
513        }
514
515        if state.dirty {
516            state.refresh();
517        }
518
519        self.commands.push(Command::BeginContainer {
520            direction: Direction::Column,
521            gap: 0,
522            align: Align::Start,
523            align_self: None,
524            justify: Justify::Start,
525            border: None,
526            border_sides: BorderSides::all(),
527            border_style: Style::new().fg(self.theme.border),
528            bg_color: None,
529            padding: Padding::default(),
530            margin: Margin::default(),
531            constraints: Constraints::default(),
532            title: None,
533            grow: 0,
534            group_name: None,
535        });
536
537        let dir_text = {
538            let dir = state.current_dir.display().to_string();
539            let mut text = String::with_capacity(5 + dir.len());
540            text.push_str("Dir: ");
541            text.push_str(&dir);
542            text
543        };
544        self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
545
546        if state.entries.is_empty() {
547            self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
548        } else {
549            for (idx, entry) in state.entries.iter().enumerate() {
550                let icon = if entry.is_dir { "▸ " } else { "  " };
551                let row = if entry.is_dir {
552                    let mut row = String::with_capacity(icon.len() + entry.name.len());
553                    row.push_str(icon);
554                    row.push_str(&entry.name);
555                    row
556                } else {
557                    let size_text = entry.size.to_string();
558                    let mut row =
559                        String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
560                    row.push_str(icon);
561                    row.push_str(&entry.name);
562                    row.push_str("  ");
563                    row.push_str(&size_text);
564                    row.push_str(" B");
565                    row
566                };
567
568                let style = if idx == state.selected {
569                    if focused {
570                        Style::new().bold().fg(self.theme.primary)
571                    } else {
572                        Style::new().fg(self.theme.primary)
573                    }
574                } else {
575                    Style::new().fg(self.theme.text)
576                };
577                self.styled(row, style);
578            }
579        }
580
581        self.commands.push(Command::EndContainer);
582        self.rollback.last_text_idx = None;
583
584        response.changed = file_selected;
585        response
586    }
587}