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.interaction_count += 1;
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.interaction_count += 1;
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.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 = self.next_interaction_id();
154        let mut response = self.response_for(interaction_id);
155        response.focused = focused;
156
157        if focused {
158            let mut consumed_indices = Vec::new();
159            for (i, event) in self.events.iter().enumerate() {
160                if let Event::Key(key) = event {
161                    if key.kind != KeyEventKind::Press {
162                        continue;
163                    }
164                    match key.code {
165                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
166                            let _ = handle_vertical_nav(
167                                &mut state.selected,
168                                visible.len().saturating_sub(1),
169                                key.code.clone(),
170                            );
171                            consumed_indices.push(i);
172                        }
173                        _ => {}
174                    }
175                }
176            }
177
178            for index in consumed_indices {
179                self.consumed[index] = true;
180            }
181        }
182
183        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
184            for (i, event) in self.events.iter().enumerate() {
185                if self.consumed[i] {
186                    continue;
187                }
188                if let Event::Mouse(mouse) = event {
189                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
190                        continue;
191                    }
192                    let in_bounds = mouse.x >= rect.x
193                        && mouse.x < rect.right()
194                        && mouse.y >= rect.y
195                        && mouse.y < rect.bottom();
196                    if !in_bounds {
197                        continue;
198                    }
199                    let clicked_idx = (mouse.y - rect.y) as usize;
200                    if clicked_idx < visible.len() {
201                        state.selected = clicked_idx;
202                        self.consumed[i] = true;
203                    }
204                }
205            }
206        }
207
208        self.commands.push(Command::BeginContainer {
209            direction: Direction::Column,
210            gap: 0,
211            align: Align::Start,
212            align_self: None,
213            justify: Justify::Start,
214            border: None,
215            border_sides: BorderSides::all(),
216            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
217            bg_color: None,
218            padding: Padding::default(),
219            margin: Margin::default(),
220            constraints: Constraints::default(),
221            title: None,
222            grow: 0,
223            group_name: None,
224        });
225
226        for (view_idx, &item_idx) in visible.iter().enumerate() {
227            let item = &state.items[item_idx];
228            if view_idx == state.selected {
229                let mut selected_style = Style::new()
230                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
231                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
232                if focused {
233                    selected_style = selected_style.bold();
234                }
235                let mut row = String::with_capacity(2 + item.len());
236                row.push_str("▸ ");
237                row.push_str(item);
238                self.styled(row, selected_style);
239            } else {
240                let mut row = String::with_capacity(2 + item.len());
241                row.push_str("  ");
242                row.push_str(item);
243                self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
244            }
245        }
246
247        self.commands.push(Command::EndContainer);
248        self.last_text_idx = None;
249
250        response.changed = state.selected != old_selected;
251        response
252    }
253
254    /// Render a calendar date picker with month navigation.
255    pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
256        let focused = self.register_focusable();
257        let interaction_id = self.next_interaction_id();
258        let mut response = self.response_for(interaction_id);
259        response.focused = focused;
260
261        let month_days = CalendarState::days_in_month(state.year, state.month);
262        state.cursor_day = state.cursor_day.clamp(1, month_days);
263        if let Some(day) = state.selected_day {
264            state.selected_day = Some(day.min(month_days));
265        }
266        let old_selected = state.selected_day;
267
268        if focused {
269            let mut consumed_indices = Vec::new();
270            for (i, event) in self.events.iter().enumerate() {
271                if self.consumed[i] {
272                    continue;
273                }
274                if let Event::Key(key) = event {
275                    if key.kind != KeyEventKind::Press {
276                        continue;
277                    }
278                    match key.code {
279                        KeyCode::Left => {
280                            calendar_move_cursor_by_days(state, -1);
281                            consumed_indices.push(i);
282                        }
283                        KeyCode::Right => {
284                            calendar_move_cursor_by_days(state, 1);
285                            consumed_indices.push(i);
286                        }
287                        KeyCode::Up => {
288                            calendar_move_cursor_by_days(state, -7);
289                            consumed_indices.push(i);
290                        }
291                        KeyCode::Down => {
292                            calendar_move_cursor_by_days(state, 7);
293                            consumed_indices.push(i);
294                        }
295                        KeyCode::Char('h') => {
296                            state.prev_month();
297                            consumed_indices.push(i);
298                        }
299                        KeyCode::Char('l') => {
300                            state.next_month();
301                            consumed_indices.push(i);
302                        }
303                        KeyCode::Enter | KeyCode::Char(' ') => {
304                            state.selected_day = Some(state.cursor_day);
305                            consumed_indices.push(i);
306                        }
307                        _ => {}
308                    }
309                }
310            }
311
312            for index in consumed_indices {
313                self.consumed[index] = true;
314            }
315        }
316
317        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
318            for (i, event) in self.events.iter().enumerate() {
319                if self.consumed[i] {
320                    continue;
321                }
322                if let Event::Mouse(mouse) = event {
323                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
324                        continue;
325                    }
326                    let in_bounds = mouse.x >= rect.x
327                        && mouse.x < rect.right()
328                        && mouse.y >= rect.y
329                        && mouse.y < rect.bottom();
330                    if !in_bounds {
331                        continue;
332                    }
333
334                    let rel_x = mouse.x.saturating_sub(rect.x);
335                    let rel_y = mouse.y.saturating_sub(rect.y);
336                    if rel_y == 0 {
337                        if rel_x <= 2 {
338                            state.prev_month();
339                            self.consumed[i] = true;
340                            continue;
341                        }
342                        if rel_x + 3 >= rect.width {
343                            state.next_month();
344                            self.consumed[i] = true;
345                            continue;
346                        }
347                    }
348
349                    if !(2..8).contains(&rel_y) {
350                        continue;
351                    }
352                    if rel_x >= 21 {
353                        continue;
354                    }
355
356                    let week = rel_y - 2;
357                    let col = rel_x / 3;
358                    let day_index = week * 7 + col;
359                    let first = CalendarState::first_weekday(state.year, state.month);
360                    let days = CalendarState::days_in_month(state.year, state.month);
361                    if day_index < first {
362                        continue;
363                    }
364                    let day = day_index - first + 1;
365                    if day == 0 || day > days {
366                        continue;
367                    }
368                    state.cursor_day = day;
369                    state.selected_day = Some(day);
370                    self.consumed[i] = true;
371                }
372            }
373        }
374
375        let title = {
376            let month_name = calendar_month_name(state.month);
377            let mut s = String::with_capacity(16);
378            s.push_str(&state.year.to_string());
379            s.push(' ');
380            s.push_str(month_name);
381            s
382        };
383
384        self.commands.push(Command::BeginContainer {
385            direction: Direction::Column,
386            gap: 0,
387            align: Align::Start,
388            align_self: None,
389            justify: Justify::Start,
390            border: None,
391            border_sides: BorderSides::all(),
392            border_style: Style::new().fg(self.theme.border),
393            bg_color: None,
394            padding: Padding::default(),
395            margin: Margin::default(),
396            constraints: Constraints::default(),
397            title: None,
398            grow: 0,
399            group_name: None,
400        });
401
402        self.commands.push(Command::BeginContainer {
403            direction: Direction::Row,
404            gap: 1,
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        self.styled("◀", Style::new().fg(self.theme.text));
420        self.styled(title, Style::new().bold().fg(self.theme.text));
421        self.styled("▶", Style::new().fg(self.theme.text));
422        self.commands.push(Command::EndContainer);
423
424        self.commands.push(Command::BeginContainer {
425            direction: Direction::Row,
426            gap: 0,
427            align: Align::Start,
428            align_self: None,
429            justify: Justify::Start,
430            border: None,
431            border_sides: BorderSides::all(),
432            border_style: Style::new().fg(self.theme.border),
433            bg_color: None,
434            padding: Padding::default(),
435            margin: Margin::default(),
436            constraints: Constraints::default(),
437            title: None,
438            grow: 0,
439            group_name: None,
440        });
441        for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
442            self.styled(
443                format!("{wd:>2} "),
444                Style::new().fg(self.theme.text_dim).bold(),
445            );
446        }
447        self.commands.push(Command::EndContainer);
448
449        let first = CalendarState::first_weekday(state.year, state.month);
450        let days = CalendarState::days_in_month(state.year, state.month);
451        for week in 0..6_u32 {
452            self.commands.push(Command::BeginContainer {
453                direction: Direction::Row,
454                gap: 0,
455                align: Align::Start,
456                align_self: None,
457                justify: Justify::Start,
458                border: None,
459                border_sides: BorderSides::all(),
460                border_style: Style::new().fg(self.theme.border),
461                bg_color: None,
462                padding: Padding::default(),
463                margin: Margin::default(),
464                constraints: Constraints::default(),
465                title: None,
466                grow: 0,
467                group_name: None,
468            });
469
470            for col in 0..7_u32 {
471                let idx = week * 7 + col;
472                if idx < first || idx >= first + days {
473                    self.styled("   ", Style::new().fg(self.theme.text_dim));
474                    continue;
475                }
476                let day = idx - first + 1;
477                let text = format!("{day:>2} ");
478                let style = if state.selected_day == Some(day) {
479                    Style::new()
480                        .bg(self.theme.selected_bg)
481                        .fg(self.theme.selected_fg)
482                } else if state.cursor_day == day {
483                    Style::new().fg(self.theme.primary).bold()
484                } else {
485                    Style::new().fg(self.theme.text)
486                };
487                self.styled(text, style);
488            }
489
490            self.commands.push(Command::EndContainer);
491        }
492
493        self.commands.push(Command::EndContainer);
494        self.last_text_idx = None;
495        response.changed = state.selected_day != old_selected;
496        response
497    }
498
499    /// Render a file system browser with directory navigation.
500    pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
501        if state.dirty {
502            state.refresh();
503        }
504        if !state.entries.is_empty() {
505            state.selected = state.selected.min(state.entries.len().saturating_sub(1));
506        }
507
508        let focused = self.register_focusable();
509        let interaction_id = self.next_interaction_id();
510        let mut response = self.response_for(interaction_id);
511        response.focused = focused;
512        let mut file_selected = false;
513
514        if focused {
515            let mut consumed_indices = Vec::new();
516            for (i, event) in self.events.iter().enumerate() {
517                if self.consumed[i] {
518                    continue;
519                }
520                if let Event::Key(key) = event {
521                    if key.kind != KeyEventKind::Press {
522                        continue;
523                    }
524                    match key.code {
525                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
526                            if !state.entries.is_empty() {
527                                let _ = handle_vertical_nav(
528                                    &mut state.selected,
529                                    state.entries.len().saturating_sub(1),
530                                    key.code.clone(),
531                                );
532                            }
533                            consumed_indices.push(i);
534                        }
535                        KeyCode::Enter => {
536                            if let Some(entry) = state.entries.get(state.selected).cloned() {
537                                if entry.is_dir {
538                                    state.current_dir = entry.path;
539                                    state.selected = 0;
540                                    state.selected_file = None;
541                                    state.dirty = true;
542                                } else {
543                                    state.selected_file = Some(entry.path);
544                                    file_selected = true;
545                                }
546                            }
547                            consumed_indices.push(i);
548                        }
549                        KeyCode::Backspace => {
550                            if let Some(parent) =
551                                state.current_dir.parent().map(|p| p.to_path_buf())
552                            {
553                                state.current_dir = parent;
554                                state.selected = 0;
555                                state.selected_file = None;
556                                state.dirty = true;
557                            }
558                            consumed_indices.push(i);
559                        }
560                        KeyCode::Char('h') => {
561                            state.show_hidden = !state.show_hidden;
562                            state.selected = 0;
563                            state.dirty = true;
564                            consumed_indices.push(i);
565                        }
566                        KeyCode::Esc => {
567                            state.selected_file = None;
568                            consumed_indices.push(i);
569                        }
570                        _ => {}
571                    }
572                }
573            }
574
575            for index in consumed_indices {
576                self.consumed[index] = true;
577            }
578        }
579
580        if state.dirty {
581            state.refresh();
582        }
583
584        self.commands.push(Command::BeginContainer {
585            direction: Direction::Column,
586            gap: 0,
587            align: Align::Start,
588            align_self: None,
589            justify: Justify::Start,
590            border: None,
591            border_sides: BorderSides::all(),
592            border_style: Style::new().fg(self.theme.border),
593            bg_color: None,
594            padding: Padding::default(),
595            margin: Margin::default(),
596            constraints: Constraints::default(),
597            title: None,
598            grow: 0,
599            group_name: None,
600        });
601
602        let dir_text = {
603            let dir = state.current_dir.display().to_string();
604            let mut text = String::with_capacity(5 + dir.len());
605            text.push_str("Dir: ");
606            text.push_str(&dir);
607            text
608        };
609        self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
610
611        if state.entries.is_empty() {
612            self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
613        } else {
614            for (idx, entry) in state.entries.iter().enumerate() {
615                let icon = if entry.is_dir { "▸ " } else { "  " };
616                let row = if entry.is_dir {
617                    let mut row = String::with_capacity(icon.len() + entry.name.len());
618                    row.push_str(icon);
619                    row.push_str(&entry.name);
620                    row
621                } else {
622                    let size_text = entry.size.to_string();
623                    let mut row =
624                        String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
625                    row.push_str(icon);
626                    row.push_str(&entry.name);
627                    row.push_str("  ");
628                    row.push_str(&size_text);
629                    row.push_str(" B");
630                    row
631                };
632
633                let style = if idx == state.selected {
634                    if focused {
635                        Style::new().bold().fg(self.theme.primary)
636                    } else {
637                        Style::new().fg(self.theme.primary)
638                    }
639                } else {
640                    Style::new().fg(self.theme.text)
641                };
642                self.styled(row, style);
643            }
644        }
645
646        self.commands.push(Command::EndContainer);
647        self.last_text_idx = None;
648
649        response.changed = file_selected;
650        response
651    }
652}