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
26            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
27                direction: Direction::Column,
28                gap: 0,
29                align: Align::Start,
30                align_self: None,
31                justify: Justify::Start,
32                border: None,
33                border_sides: BorderSides::all(),
34                border_style: Style::new().fg(border),
35                bg_color: None,
36                padding: Padding::default(),
37                margin: Margin::default(),
38                constraints: Constraints::default(),
39                title: None,
40                grow: 0,
41                group_name: None,
42            })));
43
44        let children_start = self.commands.len();
45        f(self);
46        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
47
48        let elements = collect_grid_elements(child_commands);
49
50        let cols = cols.max(1) as usize;
51        for row in elements.chunks(cols) {
52            self.skip_interaction_slot();
53            self.commands
54                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
55                    direction: Direction::Row,
56                    gap: 0,
57                    align: Align::Start,
58                    align_self: None,
59                    justify: Justify::Start,
60                    border: None,
61                    border_sides: BorderSides::all(),
62                    border_style: Style::new().fg(border),
63                    bg_color: None,
64                    padding: Padding::default(),
65                    margin: Margin::default(),
66                    constraints: Constraints::default(),
67                    title: None,
68                    grow: 0,
69                    group_name: None,
70                })));
71
72            for element in row {
73                self.skip_interaction_slot();
74                self.commands
75                    .push(Command::BeginContainer(Box::new(BeginContainerArgs {
76                        direction: Direction::Column,
77                        gap: 0,
78                        align: Align::Start,
79                        align_self: None,
80                        justify: Justify::Start,
81                        border: None,
82                        border_sides: BorderSides::all(),
83                        border_style: Style::new().fg(border),
84                        bg_color: None,
85                        padding: Padding::default(),
86                        margin: Margin::default(),
87                        constraints: Constraints::default(),
88                        title: None,
89                        grow: 1,
90                        group_name: None,
91                    })));
92                self.commands.extend(element.iter().cloned());
93                self.commands.push(Command::EndContainer);
94            }
95
96            self.commands.push(Command::EndContainer);
97        }
98
99        self.commands.push(Command::EndContainer);
100        self.rollback.last_text_idx = None;
101
102        self.response_for(interaction_id)
103    }
104
105    /// Render children in a grid with per-column width specifications.
106    ///
107    /// The number of columns is determined by the length of `columns`. Children
108    /// are placed left-to-right, top-to-bottom, wrapping into rows
109    /// automatically.
110    ///
111    /// # Column specifications
112    ///
113    /// - [`GridColumn::Auto`] — equal-width flex column (same as `grid()`)
114    /// - [`GridColumn::Fixed(n)`](GridColumn::Fixed) — exactly `n` character cells wide
115    /// - [`GridColumn::Grow(w)`](GridColumn::Grow) — flexible with grow weight `w`
116    /// - [`GridColumn::Percent(p)`](GridColumn::Percent) — `p`% of the grid width
117    ///
118    /// # Example
119    ///
120    /// ```no_run
121    /// use slt::GridColumn;
122    /// # slt::run(|ui: &mut slt::Context| {
123    /// ui.grid_with(&[
124    ///     GridColumn::Fixed(8),
125    ///     GridColumn::Grow(1),
126    ///     GridColumn::Grow(1),
127    ///     GridColumn::Fixed(4),
128    /// ], |ui| {
129    ///     for i in 0..8 {
130    ///         ui.text(format!("Cell {i}"));
131    ///     }
132    /// });
133    /// # });
134    /// ```
135    pub fn grid_with(&mut self, columns: &[GridColumn], f: impl FnOnce(&mut Context)) -> Response {
136        let cols = columns.len().max(1);
137        let interaction_id = self.next_interaction_id();
138        let border = self.theme.border;
139
140        self.commands
141            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
142                direction: Direction::Column,
143                gap: 0,
144                align: Align::Start,
145                align_self: None,
146                justify: Justify::Start,
147                border: None,
148                border_sides: BorderSides::all(),
149                border_style: Style::new().fg(border),
150                bg_color: None,
151                padding: Padding::default(),
152                margin: Margin::default(),
153                constraints: Constraints::default(),
154                title: None,
155                grow: 0,
156                group_name: None,
157            })));
158
159        let children_start = self.commands.len();
160        f(self);
161        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
162
163        let elements = collect_grid_elements(child_commands);
164
165        for row in elements.chunks(cols) {
166            self.skip_interaction_slot();
167            self.commands
168                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
169                    direction: Direction::Row,
170                    gap: 0,
171                    align: Align::Start,
172                    align_self: None,
173                    justify: Justify::Start,
174                    border: None,
175                    border_sides: BorderSides::all(),
176                    border_style: Style::new().fg(border),
177                    bg_color: None,
178                    padding: Padding::default(),
179                    margin: Margin::default(),
180                    constraints: Constraints::default(),
181                    title: None,
182                    grow: 0,
183                    group_name: None,
184                })));
185
186            for (col_idx, element) in row.iter().enumerate() {
187                let spec = columns.get(col_idx).copied().unwrap_or(GridColumn::Auto);
188                let (grow, constraints) = match spec {
189                    GridColumn::Auto => (1, Constraints::default()),
190                    GridColumn::Fixed(w) => (0, Constraints::default().w(w)),
191                    GridColumn::Grow(g) => (g, Constraints::default()),
192                    GridColumn::Percent(p) => (0, Constraints::default().w_pct(p)),
193                };
194
195                self.skip_interaction_slot();
196                self.commands
197                    .push(Command::BeginContainer(Box::new(BeginContainerArgs {
198                        direction: Direction::Column,
199                        gap: 0,
200                        align: Align::Start,
201                        align_self: None,
202                        justify: Justify::Start,
203                        border: None,
204                        border_sides: BorderSides::all(),
205                        border_style: Style::new().fg(border),
206                        bg_color: None,
207                        padding: Padding::default(),
208                        margin: Margin::default(),
209                        constraints,
210                        title: None,
211                        grow,
212                        group_name: None,
213                    })));
214                self.commands.extend(element.iter().cloned());
215                self.commands.push(Command::EndContainer);
216            }
217
218            self.commands.push(Command::EndContainer);
219        }
220
221        self.commands.push(Command::EndContainer);
222        self.rollback.last_text_idx = None;
223
224        self.response_for(interaction_id)
225    }
226
227    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
228    ///
229    /// The selected item is highlighted with the theme's primary color. If the
230    /// list is empty, nothing is rendered.
231    pub fn list(&mut self, state: &mut ListState) -> Response {
232        let colors = self.widget_theme.list;
233        self.list_colored(state, &colors)
234    }
235
236    /// Render a navigable list with custom widget colors.
237    pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
238        let visible = state.visible_indices().to_vec();
239        if visible.is_empty() && state.items.is_empty() {
240            state.selected = 0;
241            return Response::none();
242        }
243
244        if !visible.is_empty() {
245            state.selected = state.selected.min(visible.len().saturating_sub(1));
246        }
247
248        let old_selected = state.selected;
249        let focused = self.register_focusable();
250        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
251
252        if focused {
253            let mut consumed_indices = Vec::new();
254            for (i, key) in self.available_key_presses() {
255                match key.code {
256                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
257                        let _ = handle_vertical_nav(
258                            &mut state.selected,
259                            visible.len().saturating_sub(1),
260                            key.code.clone(),
261                        );
262                        consumed_indices.push(i);
263                    }
264                    _ => {}
265                }
266            }
267            self.consume_indices(consumed_indices);
268        }
269
270        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
271            let mut consumed = Vec::new();
272            for (i, mouse) in clicks {
273                let clicked_idx = (mouse.y - rect.y) as usize;
274                if clicked_idx < visible.len() {
275                    state.selected = clicked_idx;
276                    consumed.push(i);
277                }
278            }
279            self.consume_indices(consumed);
280        }
281
282        self.commands
283            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
284                direction: Direction::Column,
285                gap: 0,
286                align: Align::Start,
287                align_self: None,
288                justify: Justify::Start,
289                border: None,
290                border_sides: BorderSides::all(),
291                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
292                bg_color: None,
293                padding: Padding::default(),
294                margin: Margin::default(),
295                constraints: Constraints::default(),
296                title: None,
297                grow: 0,
298                group_name: None,
299            })));
300
301        for (view_idx, &item_idx) in visible.iter().enumerate() {
302            let item = &state.items[item_idx];
303            if view_idx == state.selected {
304                let mut selected_style = Style::new()
305                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
306                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
307                if focused {
308                    selected_style = selected_style.bold();
309                }
310                let mut row = String::with_capacity(2 + item.len());
311                row.push_str("▸ ");
312                row.push_str(item);
313                self.styled(row, selected_style);
314            } else {
315                let mut row = String::with_capacity(2 + item.len());
316                row.push_str("  ");
317                row.push_str(item);
318                self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
319            }
320        }
321
322        self.commands.push(Command::EndContainer);
323        self.rollback.last_text_idx = None;
324
325        response.changed = state.selected != old_selected;
326        response
327    }
328
329    /// Render a calendar date picker with month navigation.
330    ///
331    /// Single-date mode is the default. Opt into range selection with
332    /// [`CalendarState::with_range`] and an optional `HH:MM` time row with
333    /// [`CalendarState::with_time`].
334    ///
335    /// # Keybindings (when focused)
336    ///
337    /// | Key | Action |
338    /// |-----|--------|
339    /// | `Left` / `h` | Previous day |
340    /// | `Right` / `l` | Next day |
341    /// | `Up` | Previous week (−7 days) |
342    /// | `Down` | Next week (+7 days) |
343    /// | `[` | Previous month |
344    /// | `]` | Next month |
345    /// | `Enter` / `Space` | Select cursor day (range: set anchor) |
346    /// | `Shift+Left` / `Shift+H` | Extend range −1 day |
347    /// | `Shift+Right` / `Shift+L` | Extend range +1 day |
348    /// | `Shift+Up` | Extend range −7 days |
349    /// | `Shift+Down` | Extend range +7 days |
350    /// | `Shift+Enter` / `Shift+Space` | Set range extent at cursor |
351    ///
352    /// `h`/`l` follow vim convention (cursor by one day). Use `[`/`]` for
353    /// month navigation. Mouse clicks on the title row navigate months and
354    /// clicks inside the day grid select that day; in range mode a
355    /// `Shift`+left-click sets the range extent endpoint.
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// # slt::run(|ui: &mut slt::Context| {
361    /// # let mut cal = slt::CalendarState::from_ym(2024, 3);
362    /// cal.with_range();
363    /// let resp = ui.calendar(&mut cal);
364    /// if resp.changed {
365    ///     if let Some((start, end)) = cal.selected_range() {
366    ///         ui.text(format!("{}-{:02}-{:02} → {}-{:02}-{:02}",
367    ///             start.year, start.month, start.day,
368    ///             end.year, end.month, end.day));
369    ///     }
370    /// }
371    /// # });
372    /// ```
373    pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
374        let focused = self.register_focusable();
375        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
376
377        let month_days = CalendarState::days_in_month(state.year, state.month);
378        state.cursor_day = state.cursor_day.clamp(1, month_days);
379        if let Some(day) = state.selected_day {
380            state.selected_day = Some(day.min(month_days));
381        }
382        let old_selected = state.selected_day;
383        let old_anchor = state.anchor;
384        let old_extent = state.extent;
385        let old_time = (state.hour, state.minute);
386
387        if focused {
388            let mut consumed_indices = Vec::new();
389            for (i, key) in self.available_key_presses() {
390                let shift = key.modifiers.contains(KeyModifiers::SHIFT);
391                let range = state.mode == CalendarSelect::Range;
392                // Day delta for cursor-movement keys; `None` for non-movement keys.
393                let movement_delta = match key.code {
394                    KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') => Some(-1),
395                    KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => Some(1),
396                    KeyCode::Up => Some(-7),
397                    KeyCode::Down => Some(7),
398                    _ => None,
399                };
400
401                if let Some(delta) = movement_delta {
402                    calendar_move_cursor_by_days(state, delta);
403                    if range && shift {
404                        // Shift-extend: move the extent endpoint with the cursor.
405                        state.extend_to_cursor();
406                    }
407                    consumed_indices.push(i);
408                    continue;
409                }
410
411                match key.code {
412                    KeyCode::Char('[') => {
413                        state.prev_month();
414                        consumed_indices.push(i);
415                    }
416                    KeyCode::Char(']') => {
417                        state.next_month();
418                        consumed_indices.push(i);
419                    }
420                    KeyCode::Enter | KeyCode::Char(' ') => {
421                        if range {
422                            if shift {
423                                // Set the range extent endpoint at the cursor.
424                                state.extend_to_cursor();
425                            } else {
426                                // Set / reset the anchor at the cursor.
427                                state.set_anchor_to_cursor();
428                            }
429                        } else {
430                            state.selected_day = Some(state.cursor_day);
431                        }
432                        consumed_indices.push(i);
433                    }
434                    _ => {}
435                }
436            }
437            self.consume_indices(consumed_indices);
438        }
439
440        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
441            let mut consumed = Vec::new();
442            for (i, mouse) in clicks {
443                let rel_x = mouse.x.saturating_sub(rect.x);
444                let rel_y = mouse.y.saturating_sub(rect.y);
445                if rel_y == 0 {
446                    if rel_x <= 2 {
447                        state.prev_month();
448                        consumed.push(i);
449                        continue;
450                    }
451                    if rel_x + 3 >= rect.width {
452                        state.next_month();
453                        consumed.push(i);
454                        continue;
455                    }
456                }
457
458                if !(2..8).contains(&rel_y) {
459                    continue;
460                }
461                if rel_x >= 21 {
462                    continue;
463                }
464
465                let week = rel_y - 2;
466                let col = rel_x / 3;
467                let day_index = week * 7 + col;
468                let first = CalendarState::first_weekday(state.year, state.month);
469                let days = CalendarState::days_in_month(state.year, state.month);
470                if day_index < first {
471                    continue;
472                }
473                let day = day_index - first + 1;
474                if day == 0 || day > days {
475                    continue;
476                }
477                state.cursor_day = day;
478                if state.mode == CalendarSelect::Range {
479                    if mouse.modifiers.contains(KeyModifiers::SHIFT) {
480                        // Shift+click sets the range extent endpoint.
481                        state.extend_to_cursor();
482                    } else {
483                        // Plain click (re)sets the anchor.
484                        state.set_anchor_to_cursor();
485                    }
486                } else {
487                    state.selected_day = Some(day);
488                }
489                consumed.push(i);
490            }
491            self.consume_indices(consumed);
492        }
493
494        let title = {
495            let month_name = calendar_month_name(state.month);
496            let mut s = String::with_capacity(16);
497            s.push_str(&state.year.to_string());
498            s.push(' ');
499            s.push_str(month_name);
500            s
501        };
502
503        self.commands
504            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
505                direction: Direction::Column,
506                gap: 0,
507                align: Align::Start,
508                align_self: None,
509                justify: Justify::Start,
510                border: None,
511                border_sides: BorderSides::all(),
512                border_style: Style::new().fg(self.theme.border),
513                bg_color: None,
514                padding: Padding::default(),
515                margin: Margin::default(),
516                constraints: Constraints::default(),
517                title: None,
518                grow: 0,
519                group_name: None,
520            })));
521
522        let cal_gap = self.theme.spacing.xs();
523        self.commands
524            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
525                direction: Direction::Row,
526                gap: cal_gap as i32,
527                align: Align::Start,
528                align_self: None,
529                justify: Justify::Start,
530                border: None,
531                border_sides: BorderSides::all(),
532                border_style: Style::new().fg(self.theme.border),
533                bg_color: None,
534                padding: Padding::default(),
535                margin: Margin::default(),
536                constraints: Constraints::default(),
537                title: None,
538                grow: 0,
539                group_name: None,
540            })));
541        self.styled("◀", Style::new().fg(self.theme.text));
542        self.styled(title, Style::new().bold().fg(self.theme.text));
543        self.styled("▶", Style::new().fg(self.theme.text));
544        self.commands.push(Command::EndContainer);
545
546        self.commands
547            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
548                direction: Direction::Row,
549                gap: 0,
550                align: Align::Start,
551                align_self: None,
552                justify: Justify::Start,
553                border: None,
554                border_sides: BorderSides::all(),
555                border_style: Style::new().fg(self.theme.border),
556                bg_color: None,
557                padding: Padding::default(),
558                margin: Margin::default(),
559                constraints: Constraints::default(),
560                title: None,
561                grow: 0,
562                group_name: None,
563            })));
564        for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
565            self.styled(
566                format!("{wd:>2} "),
567                Style::new().fg(self.theme.text_dim).bold(),
568            );
569        }
570        self.commands.push(Command::EndContainer);
571
572        let first = CalendarState::first_weekday(state.year, state.month);
573        let days = CalendarState::days_in_month(state.year, state.month);
574        for week in 0..6_u32 {
575            self.commands
576                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
577                    direction: Direction::Row,
578                    gap: 0,
579                    align: Align::Start,
580                    align_self: None,
581                    justify: Justify::Start,
582                    border: None,
583                    border_sides: BorderSides::all(),
584                    border_style: Style::new().fg(self.theme.border),
585                    bg_color: None,
586                    padding: Padding::default(),
587                    margin: Margin::default(),
588                    constraints: Constraints::default(),
589                    title: None,
590                    grow: 0,
591                    group_name: None,
592                })));
593
594            for col in 0..7_u32 {
595                let idx = week * 7 + col;
596                if idx < first || idx >= first + days {
597                    self.styled("   ", Style::new().fg(self.theme.text_dim));
598                    continue;
599                }
600                let day = idx - first + 1;
601                let text = format!("{day:>2} ");
602                let cell = CalDate {
603                    year: state.year,
604                    month: state.month,
605                    day,
606                };
607                let style = if state.mode == CalendarSelect::Range {
608                    if state.is_range_endpoint(cell) {
609                        // Endpoints get the strong selected highlight.
610                        Style::new()
611                            .bg(self.theme.selected_bg)
612                            .fg(self.theme.selected_fg)
613                    } else if state.in_range(cell) {
614                        // Interior band: subtler surface fill, distinct from endpoints.
615                        Style::new().bg(self.theme.surface).fg(self.theme.text)
616                    } else if state.cursor_day == day {
617                        Style::new().fg(self.theme.primary).bold()
618                    } else {
619                        Style::new().fg(self.theme.text)
620                    }
621                } else if state.selected_day == Some(day) {
622                    Style::new()
623                        .bg(self.theme.selected_bg)
624                        .fg(self.theme.selected_fg)
625                } else if state.cursor_day == day {
626                    Style::new().fg(self.theme.primary).bold()
627                } else {
628                    Style::new().fg(self.theme.text)
629                };
630                self.styled(text, style);
631            }
632
633            self.commands.push(Command::EndContainer);
634        }
635
636        if state.time_enabled {
637            let time_text = format!("{:02}:{:02}", state.hour, state.minute);
638            self.styled(time_text, Style::new().fg(self.theme.text).bold());
639        }
640
641        self.commands.push(Command::EndContainer);
642        self.rollback.last_text_idx = None;
643        response.changed = state.selected_day != old_selected
644            || state.anchor != old_anchor
645            || state.extent != old_extent
646            || (state.hour, state.minute) != old_time;
647        response
648    }
649
650    /// Render a file system browser with directory navigation.
651    pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
652        if state.dirty {
653            state.refresh();
654        }
655        if !state.entries.is_empty() {
656            state.selected = state.selected.min(state.entries.len().saturating_sub(1));
657        }
658
659        let focused = self.register_focusable();
660        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
661        let mut file_selected = false;
662
663        if focused {
664            let mut consumed_indices = Vec::new();
665            for (i, key) in self.available_key_presses() {
666                match key.code {
667                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
668                        if !state.entries.is_empty() {
669                            let _ = handle_vertical_nav(
670                                &mut state.selected,
671                                state.entries.len().saturating_sub(1),
672                                key.code.clone(),
673                            );
674                        }
675                        consumed_indices.push(i);
676                    }
677                    KeyCode::Enter => {
678                        if let Some(entry) = state.entries.get(state.selected).cloned() {
679                            if entry.is_dir {
680                                state.current_dir = entry.path;
681                                state.selected = 0;
682                                state.selected_file = None;
683                                state.dirty = true;
684                            } else {
685                                state.selected_file = Some(entry.path);
686                                file_selected = true;
687                            }
688                        }
689                        consumed_indices.push(i);
690                    }
691                    KeyCode::Backspace => {
692                        if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
693                            state.current_dir = parent;
694                            state.selected = 0;
695                            state.selected_file = None;
696                            state.dirty = true;
697                        }
698                        consumed_indices.push(i);
699                    }
700                    KeyCode::Char('h') => {
701                        state.show_hidden = !state.show_hidden;
702                        state.selected = 0;
703                        state.dirty = true;
704                        consumed_indices.push(i);
705                    }
706                    KeyCode::Esc => {
707                        state.selected_file = None;
708                        consumed_indices.push(i);
709                    }
710                    _ => {}
711                }
712            }
713            self.consume_indices(consumed_indices);
714        }
715
716        if state.dirty {
717            state.refresh();
718        }
719
720        self.commands
721            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
722                direction: Direction::Column,
723                gap: 0,
724                align: Align::Start,
725                align_self: None,
726                justify: Justify::Start,
727                border: None,
728                border_sides: BorderSides::all(),
729                border_style: Style::new().fg(self.theme.border),
730                bg_color: None,
731                padding: Padding::default(),
732                margin: Margin::default(),
733                constraints: Constraints::default(),
734                title: None,
735                grow: 0,
736                group_name: None,
737            })));
738
739        let dir_text = {
740            let dir = state.current_dir.display().to_string();
741            let mut text = String::with_capacity(5 + dir.len());
742            text.push_str("Dir: ");
743            text.push_str(&dir);
744            text
745        };
746        self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
747
748        if state.entries.is_empty() {
749            self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
750        } else {
751            for (idx, entry) in state.entries.iter().enumerate() {
752                let icon = if entry.is_dir { "▸ " } else { "  " };
753                let row = if entry.is_dir {
754                    let mut row = String::with_capacity(icon.len() + entry.name.len());
755                    row.push_str(icon);
756                    row.push_str(&entry.name);
757                    row
758                } else {
759                    let size_text = entry.size.to_string();
760                    let mut row =
761                        String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
762                    row.push_str(icon);
763                    row.push_str(&entry.name);
764                    row.push_str("  ");
765                    row.push_str(&size_text);
766                    row.push_str(" B");
767                    row
768                };
769
770                let style = if idx == state.selected {
771                    if focused {
772                        Style::new().bold().fg(self.theme.primary)
773                    } else {
774                        Style::new().fg(self.theme.primary)
775                    }
776                } else {
777                    Style::new().fg(self.theme.text)
778                };
779                self.styled(row, style);
780            }
781        }
782
783        self.commands.push(Command::EndContainer);
784        self.rollback.last_text_idx = None;
785
786        response.changed = file_selected;
787        response
788    }
789}
790
791/// Group `child_commands` into per-cell command vectors for `grid()` / `grid_with()`.
792///
793/// Each container subtree (`BeginContainer`/`BeginScrollable` … matching `EndContainer`)
794/// becomes one element. Bare `InteractionMarker`s are flushed onto the next element so
795/// hit-testing slots stay attached to the cell that owns them. Trailing markers with
796/// no following command form their own (empty-content) element.
797fn collect_grid_elements(child_commands: Vec<Command>) -> Vec<Vec<Command>> {
798    let mut elements: Vec<Vec<Command>> = Vec::new();
799    let mut iter = child_commands.into_iter().peekable();
800    let mut pending_markers: Vec<Command> = Vec::new();
801    while let Some(cmd) = iter.next() {
802        match cmd {
803            Command::InteractionMarker(_) => {
804                pending_markers.push(cmd);
805            }
806            Command::BeginContainer(_) | Command::BeginScrollable(_) => {
807                let mut depth = 1_u32;
808                let mut element: Vec<Command> = std::mem::take(&mut pending_markers);
809                element.push(cmd);
810                for next in iter.by_ref() {
811                    match next {
812                        Command::BeginContainer(_) | Command::BeginScrollable(_) => {
813                            depth += 1;
814                        }
815                        Command::EndContainer => {
816                            depth = depth.saturating_sub(1);
817                        }
818                        _ => {}
819                    }
820                    let at_end = matches!(next, Command::EndContainer) && depth == 0;
821                    element.push(next);
822                    if at_end {
823                        break;
824                    }
825                }
826                elements.push(element);
827            }
828            Command::EndContainer => {}
829            _ => {
830                let mut element = std::mem::take(&mut pending_markers);
831                element.push(cmd);
832                elements.push(element);
833            }
834        }
835    }
836    // Flush any trailing markers (edge case: marker with no following command)
837    if !pending_markers.is_empty() {
838        elements.push(pending_markers);
839    }
840    elements
841}