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 selectable list that supports keyboard reordering of items.
330    ///
331    /// Behaves exactly like [`list`](Context::list) for navigation (Up/Down and
332    /// `k`/`j` move the selection) and click selection, but additionally lets the
333    /// focused user reorder the selected item with `Shift+Up`/`Shift+Down` or
334    /// `Alt+Up`/`Alt+Down`. Reordering operates on the underlying item order via
335    /// [`ListState::move_item`], keeping the selection on the moved item.
336    ///
337    /// Returns a [`ListResponse`](crate::ListResponse) which derefs to the
338    /// standard [`Response`] and exposes
339    /// [`reordered`](crate::ListResponse::reordered) — `Some((from, to))` with
340    /// the data indices when an item moved this frame, otherwise `None`.
341    ///
342    /// The plain [`list`](Context::list) entry point is unchanged; opt into
343    /// reordering by calling this method instead.
344    ///
345    /// # Example
346    ///
347    /// ```no_run
348    /// # use slt::widgets::ListState;
349    /// # let mut list = ListState::new(vec!["First", "Second", "Third"]);
350    /// # slt::run(move |ui: &mut slt::Context| {
351    /// let r = ui.list_reorderable(&mut list);
352    /// if let Some((from, to)) = r.reordered {
353    ///     let _ = (from, to); // persist new order
354    /// }
355    /// # });
356    /// ```
357    ///
358    /// Available since `0.21.1`.
359    pub fn list_reorderable(&mut self, state: &mut ListState) -> crate::widgets::ListResponse {
360        let colors = self.widget_theme.list;
361        self.list_reorderable_colored(state, &colors)
362    }
363
364    /// Render a reorderable list with custom widget colors.
365    ///
366    /// See [`list_reorderable`](Context::list_reorderable) for the reorder
367    /// keybindings and return semantics.
368    ///
369    /// Available since `0.21.1`.
370    pub fn list_reorderable_colored(
371        &mut self,
372        state: &mut ListState,
373        colors: &WidgetColors,
374    ) -> crate::widgets::ListResponse {
375        let visible = state.visible_indices().to_vec();
376        if visible.is_empty() && state.items.is_empty() {
377            state.selected = 0;
378            return crate::widgets::ListResponse::default();
379        }
380
381        if !visible.is_empty() {
382            state.selected = state.selected.min(visible.len().saturating_sub(1));
383        }
384
385        let old_selected = state.selected;
386        let focused = self.register_focusable();
387        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
388
389        let mut reordered: Option<(usize, usize)> = None;
390
391        if focused {
392            let mut consumed_indices = Vec::new();
393            for (i, key) in self.available_key_presses() {
394                // Reorder takes precedence over navigation when a Shift/Alt
395                // modifier is held with a vertical-movement key.
396                let modded = key.modifiers.contains(KeyModifiers::SHIFT)
397                    || key.modifiers.contains(KeyModifiers::ALT);
398                // Direction of the move: -1 (up) or +1 (down) for the selected
399                // view row, `None` for non-movement keys.
400                let dir: Option<isize> = match key.code {
401                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => Some(-1),
402                    KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => Some(1),
403                    _ => None,
404                };
405
406                if modded {
407                    if let Some(delta) = dir {
408                        let cur_view = state.selected;
409                        let target_view = if delta < 0 {
410                            cur_view.checked_sub(1)
411                        } else {
412                            let next = cur_view + 1;
413                            (next < visible.len()).then_some(next)
414                        };
415                        // Map both endpoints from view positions to data indices
416                        // so reordering survives an active filter.
417                        if let Some(target_view) = target_view
418                            && let (Some(&from), Some(&to)) =
419                                (visible.get(cur_view), visible.get(target_view))
420                            && state.move_item(from, to)
421                        {
422                            reordered = Some((from, to));
423                        }
424                        // Consume regardless so a held modifier never also
425                        // triggers a plain navigation step on the same key.
426                        consumed_indices.push(i);
427                    }
428                    continue;
429                }
430
431                if dir.is_some() {
432                    let _ = handle_vertical_nav(
433                        &mut state.selected,
434                        visible.len().saturating_sub(1),
435                        key.code.clone(),
436                    );
437                    consumed_indices.push(i);
438                }
439            }
440            self.consume_indices(consumed_indices);
441        }
442
443        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
444            let mut consumed = Vec::new();
445            // `visible` may be stale after a reorder rebuilt the view; re-read
446            // the current visible count for bounds.
447            let visible_len = state.visible_indices().len();
448            for (i, mouse) in clicks {
449                let clicked_idx = (mouse.y - rect.y) as usize;
450                if clicked_idx < visible_len {
451                    state.selected = clicked_idx;
452                    consumed.push(i);
453                }
454            }
455            self.consume_indices(consumed);
456        }
457
458        // Re-read the (possibly reordered) view for rendering.
459        let visible = state.visible_indices().to_vec();
460
461        self.commands
462            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
463                direction: Direction::Column,
464                gap: 0,
465                align: Align::Start,
466                align_self: None,
467                justify: Justify::Start,
468                border: None,
469                border_sides: BorderSides::all(),
470                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
471                bg_color: None,
472                padding: Padding::default(),
473                margin: Margin::default(),
474                constraints: Constraints::default(),
475                title: None,
476                grow: 0,
477                group_name: None,
478            })));
479
480        for (view_idx, &item_idx) in visible.iter().enumerate() {
481            let item = &state.items[item_idx];
482            if view_idx == state.selected {
483                let mut selected_style = Style::new()
484                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
485                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
486                if focused {
487                    selected_style = selected_style.bold();
488                }
489                let mut row = String::with_capacity(2 + item.len());
490                row.push_str("▸ ");
491                row.push_str(item);
492                self.styled(row, selected_style);
493            } else {
494                let mut row = String::with_capacity(2 + item.len());
495                row.push_str("  ");
496                row.push_str(item);
497                self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
498            }
499        }
500
501        self.commands.push(Command::EndContainer);
502        self.rollback.last_text_idx = None;
503
504        response.changed = state.selected != old_selected || reordered.is_some();
505        crate::widgets::ListResponse {
506            response,
507            reordered,
508        }
509    }
510
511    /// Render a calendar date picker with month navigation.
512    ///
513    /// Single-date mode is the default. Opt into range selection with
514    /// [`CalendarState::with_range`] and an optional `HH:MM` time row with
515    /// [`CalendarState::with_time`].
516    ///
517    /// # Keybindings (when focused)
518    ///
519    /// | Key | Action |
520    /// |-----|--------|
521    /// | `Left` / `h` | Previous day |
522    /// | `Right` / `l` | Next day |
523    /// | `Up` | Previous week (−7 days) |
524    /// | `Down` | Next week (+7 days) |
525    /// | `[` | Previous month |
526    /// | `]` | Next month |
527    /// | `Enter` / `Space` | Select cursor day (range: set anchor) |
528    /// | `Shift+Left` / `Shift+H` | Extend range −1 day |
529    /// | `Shift+Right` / `Shift+L` | Extend range +1 day |
530    /// | `Shift+Up` | Extend range −7 days |
531    /// | `Shift+Down` | Extend range +7 days |
532    /// | `Shift+Enter` / `Shift+Space` | Set range extent at cursor |
533    ///
534    /// `h`/`l` follow vim convention (cursor by one day). Use `[`/`]` for
535    /// month navigation. Mouse clicks on the title row navigate months and
536    /// clicks inside the day grid select that day; in range mode a
537    /// `Shift`+left-click sets the range extent endpoint.
538    ///
539    /// # Example
540    ///
541    /// ```no_run
542    /// # slt::run(|ui: &mut slt::Context| {
543    /// # let mut cal = slt::CalendarState::from_ym(2024, 3);
544    /// cal.with_range();
545    /// let resp = ui.calendar(&mut cal);
546    /// if resp.changed {
547    ///     if let Some((start, end)) = cal.selected_range() {
548    ///         ui.text(format!("{}-{:02}-{:02} → {}-{:02}-{:02}",
549    ///             start.year, start.month, start.day,
550    ///             end.year, end.month, end.day));
551    ///     }
552    /// }
553    /// # });
554    /// ```
555    pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
556        let focused = self.register_focusable();
557        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
558
559        let month_days = CalendarState::days_in_month(state.year, state.month);
560        state.cursor_day = state.cursor_day.clamp(1, month_days);
561        if let Some(day) = state.selected_day {
562            state.selected_day = Some(day.min(month_days));
563        }
564        let old_selected = state.selected_day;
565        let old_anchor = state.anchor;
566        let old_extent = state.extent;
567        let old_time = (state.hour, state.minute);
568
569        if focused {
570            let mut consumed_indices = Vec::new();
571            for (i, key) in self.available_key_presses() {
572                let shift = key.modifiers.contains(KeyModifiers::SHIFT);
573                let range = state.mode == CalendarSelect::Range;
574                // Day delta for cursor-movement keys; `None` for non-movement keys.
575                let movement_delta = match key.code {
576                    KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') => Some(-1),
577                    KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => Some(1),
578                    KeyCode::Up => Some(-7),
579                    KeyCode::Down => Some(7),
580                    _ => None,
581                };
582
583                if let Some(delta) = movement_delta {
584                    calendar_move_cursor_by_days(state, delta);
585                    if range && shift {
586                        // Shift-extend: move the extent endpoint with the cursor.
587                        state.extend_to_cursor();
588                    }
589                    consumed_indices.push(i);
590                    continue;
591                }
592
593                match key.code {
594                    KeyCode::Char('[') => {
595                        state.prev_month();
596                        consumed_indices.push(i);
597                    }
598                    KeyCode::Char(']') => {
599                        state.next_month();
600                        consumed_indices.push(i);
601                    }
602                    KeyCode::Enter | KeyCode::Char(' ') => {
603                        if range {
604                            if shift {
605                                // Set the range extent endpoint at the cursor.
606                                state.extend_to_cursor();
607                            } else {
608                                // Set / reset the anchor at the cursor.
609                                state.set_anchor_to_cursor();
610                            }
611                        } else {
612                            state.selected_day = Some(state.cursor_day);
613                        }
614                        consumed_indices.push(i);
615                    }
616                    _ => {}
617                }
618            }
619            self.consume_indices(consumed_indices);
620        }
621
622        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
623            let mut consumed = Vec::new();
624            for (i, mouse) in clicks {
625                let rel_x = mouse.x.saturating_sub(rect.x);
626                let rel_y = mouse.y.saturating_sub(rect.y);
627                if rel_y == 0 {
628                    if rel_x <= 2 {
629                        state.prev_month();
630                        consumed.push(i);
631                        continue;
632                    }
633                    if rel_x + 3 >= rect.width {
634                        state.next_month();
635                        consumed.push(i);
636                        continue;
637                    }
638                }
639
640                if !(2..8).contains(&rel_y) {
641                    continue;
642                }
643                if rel_x >= 21 {
644                    continue;
645                }
646
647                let week = rel_y - 2;
648                let col = rel_x / 3;
649                let day_index = week * 7 + col;
650                let first = CalendarState::first_weekday(state.year, state.month);
651                let days = CalendarState::days_in_month(state.year, state.month);
652                if day_index < first {
653                    continue;
654                }
655                let day = day_index - first + 1;
656                if day == 0 || day > days {
657                    continue;
658                }
659                state.cursor_day = day;
660                if state.mode == CalendarSelect::Range {
661                    if mouse.modifiers.contains(KeyModifiers::SHIFT) {
662                        // Shift+click sets the range extent endpoint.
663                        state.extend_to_cursor();
664                    } else {
665                        // Plain click (re)sets the anchor.
666                        state.set_anchor_to_cursor();
667                    }
668                } else {
669                    state.selected_day = Some(day);
670                }
671                consumed.push(i);
672            }
673            self.consume_indices(consumed);
674        }
675
676        let title = {
677            let month_name = calendar_month_name(state.month);
678            let mut s = String::with_capacity(16);
679            s.push_str(&state.year.to_string());
680            s.push(' ');
681            s.push_str(month_name);
682            s
683        };
684
685        self.commands
686            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
687                direction: Direction::Column,
688                gap: 0,
689                align: Align::Start,
690                align_self: None,
691                justify: Justify::Start,
692                border: None,
693                border_sides: BorderSides::all(),
694                border_style: Style::new().fg(self.theme.border),
695                bg_color: None,
696                padding: Padding::default(),
697                margin: Margin::default(),
698                constraints: Constraints::default(),
699                title: None,
700                grow: 0,
701                group_name: None,
702            })));
703
704        let cal_gap = self.theme.spacing.xs();
705        self.commands
706            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
707                direction: Direction::Row,
708                gap: cal_gap as i32,
709                align: Align::Start,
710                align_self: None,
711                justify: Justify::Start,
712                border: None,
713                border_sides: BorderSides::all(),
714                border_style: Style::new().fg(self.theme.border),
715                bg_color: None,
716                padding: Padding::default(),
717                margin: Margin::default(),
718                constraints: Constraints::default(),
719                title: None,
720                grow: 0,
721                group_name: None,
722            })));
723        self.styled("◀", Style::new().fg(self.theme.text));
724        self.styled(title, Style::new().bold().fg(self.theme.text));
725        self.styled("▶", Style::new().fg(self.theme.text));
726        self.commands.push(Command::EndContainer);
727
728        self.commands
729            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
730                direction: Direction::Row,
731                gap: 0,
732                align: Align::Start,
733                align_self: None,
734                justify: Justify::Start,
735                border: None,
736                border_sides: BorderSides::all(),
737                border_style: Style::new().fg(self.theme.border),
738                bg_color: None,
739                padding: Padding::default(),
740                margin: Margin::default(),
741                constraints: Constraints::default(),
742                title: None,
743                grow: 0,
744                group_name: None,
745            })));
746        for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
747            self.styled(
748                format!("{wd:>2} "),
749                Style::new().fg(self.theme.text_dim).bold(),
750            );
751        }
752        self.commands.push(Command::EndContainer);
753
754        let first = CalendarState::first_weekday(state.year, state.month);
755        let days = CalendarState::days_in_month(state.year, state.month);
756        for week in 0..6_u32 {
757            self.commands
758                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
759                    direction: Direction::Row,
760                    gap: 0,
761                    align: Align::Start,
762                    align_self: None,
763                    justify: Justify::Start,
764                    border: None,
765                    border_sides: BorderSides::all(),
766                    border_style: Style::new().fg(self.theme.border),
767                    bg_color: None,
768                    padding: Padding::default(),
769                    margin: Margin::default(),
770                    constraints: Constraints::default(),
771                    title: None,
772                    grow: 0,
773                    group_name: None,
774                })));
775
776            for col in 0..7_u32 {
777                let idx = week * 7 + col;
778                if idx < first || idx >= first + days {
779                    self.styled("   ", Style::new().fg(self.theme.text_dim));
780                    continue;
781                }
782                let day = idx - first + 1;
783                let text = format!("{day:>2} ");
784                let cell = CalDate {
785                    year: state.year,
786                    month: state.month,
787                    day,
788                };
789                let style = if state.mode == CalendarSelect::Range {
790                    if state.is_range_endpoint(cell) {
791                        // Endpoints get the strong selected highlight.
792                        Style::new()
793                            .bg(self.theme.selected_bg)
794                            .fg(self.theme.selected_fg)
795                    } else if state.in_range(cell) {
796                        // Interior band: subtler surface fill, distinct from endpoints.
797                        Style::new().bg(self.theme.surface).fg(self.theme.text)
798                    } else if state.cursor_day == day {
799                        Style::new().fg(self.theme.primary).bold()
800                    } else {
801                        Style::new().fg(self.theme.text)
802                    }
803                } else if state.selected_day == Some(day) {
804                    Style::new()
805                        .bg(self.theme.selected_bg)
806                        .fg(self.theme.selected_fg)
807                } else if state.cursor_day == day {
808                    Style::new().fg(self.theme.primary).bold()
809                } else {
810                    Style::new().fg(self.theme.text)
811                };
812                self.styled(text, style);
813            }
814
815            self.commands.push(Command::EndContainer);
816        }
817
818        if state.time_enabled {
819            let time_text = format!("{:02}:{:02}", state.hour, state.minute);
820            self.styled(time_text, Style::new().fg(self.theme.text).bold());
821        }
822
823        self.commands.push(Command::EndContainer);
824        self.rollback.last_text_idx = None;
825        response.changed = state.selected_day != old_selected
826            || state.anchor != old_anchor
827            || state.extent != old_extent
828            || (state.hour, state.minute) != old_time;
829        response
830    }
831
832    /// Render a file system browser with directory navigation.
833    pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
834        if state.dirty {
835            state.refresh();
836        }
837        if !state.entries.is_empty() {
838            state.selected = state.selected.min(state.entries.len().saturating_sub(1));
839        }
840
841        let focused = self.register_focusable();
842        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
843        let mut file_selected = false;
844
845        if focused {
846            let mut consumed_indices = Vec::new();
847            for (i, key) in self.available_key_presses() {
848                match key.code {
849                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
850                        if !state.entries.is_empty() {
851                            let _ = handle_vertical_nav(
852                                &mut state.selected,
853                                state.entries.len().saturating_sub(1),
854                                key.code.clone(),
855                            );
856                        }
857                        consumed_indices.push(i);
858                    }
859                    KeyCode::Enter => {
860                        if let Some(entry) = state.entries.get(state.selected).cloned() {
861                            if entry.is_dir {
862                                state.current_dir = entry.path;
863                                state.selected = 0;
864                                state.selected_file = None;
865                                state.dirty = true;
866                            } else {
867                                state.selected_file = Some(entry.path);
868                                file_selected = true;
869                            }
870                        }
871                        consumed_indices.push(i);
872                    }
873                    KeyCode::Backspace => {
874                        if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
875                            state.current_dir = parent;
876                            state.selected = 0;
877                            state.selected_file = None;
878                            state.dirty = true;
879                        }
880                        consumed_indices.push(i);
881                    }
882                    KeyCode::Char('h') => {
883                        state.show_hidden = !state.show_hidden;
884                        state.selected = 0;
885                        state.dirty = true;
886                        consumed_indices.push(i);
887                    }
888                    KeyCode::Esc => {
889                        state.selected_file = None;
890                        consumed_indices.push(i);
891                    }
892                    _ => {}
893                }
894            }
895            self.consume_indices(consumed_indices);
896        }
897
898        if state.dirty {
899            state.refresh();
900        }
901
902        self.commands
903            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
904                direction: Direction::Column,
905                gap: 0,
906                align: Align::Start,
907                align_self: None,
908                justify: Justify::Start,
909                border: None,
910                border_sides: BorderSides::all(),
911                border_style: Style::new().fg(self.theme.border),
912                bg_color: None,
913                padding: Padding::default(),
914                margin: Margin::default(),
915                constraints: Constraints::default(),
916                title: None,
917                grow: 0,
918                group_name: None,
919            })));
920
921        let dir_text = {
922            let dir = state.current_dir.display().to_string();
923            let mut text = String::with_capacity(5 + dir.len());
924            text.push_str("Dir: ");
925            text.push_str(&dir);
926            text
927        };
928        self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
929
930        if state.entries.is_empty() {
931            self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
932        } else {
933            for (idx, entry) in state.entries.iter().enumerate() {
934                let icon = if entry.is_dir { "▸ " } else { "  " };
935                let row = if entry.is_dir {
936                    let mut row = String::with_capacity(icon.len() + entry.name.len());
937                    row.push_str(icon);
938                    row.push_str(&entry.name);
939                    row
940                } else {
941                    let size_text = entry.size.to_string();
942                    let mut row =
943                        String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
944                    row.push_str(icon);
945                    row.push_str(&entry.name);
946                    row.push_str("  ");
947                    row.push_str(&size_text);
948                    row.push_str(" B");
949                    row
950                };
951
952                let style = if idx == state.selected {
953                    if focused {
954                        Style::new().bold().fg(self.theme.primary)
955                    } else {
956                        Style::new().fg(self.theme.primary)
957                    }
958                } else {
959                    Style::new().fg(self.theme.text)
960                };
961                self.styled(row, style);
962            }
963        }
964
965        self.commands.push(Command::EndContainer);
966        self.rollback.last_text_idx = None;
967
968        response.changed = file_selected;
969        response
970    }
971}
972
973/// Group `child_commands` into per-cell command vectors for `grid()` / `grid_with()`.
974///
975/// Each container subtree (`BeginContainer`/`BeginScrollable` … matching `EndContainer`)
976/// becomes one element. Bare `InteractionMarker`s are flushed onto the next element so
977/// hit-testing slots stay attached to the cell that owns them. Trailing markers with
978/// no following command form their own (empty-content) element.
979fn collect_grid_elements(child_commands: Vec<Command>) -> Vec<Vec<Command>> {
980    let mut elements: Vec<Vec<Command>> = Vec::new();
981    let mut iter = child_commands.into_iter().peekable();
982    let mut pending_markers: Vec<Command> = Vec::new();
983    while let Some(cmd) = iter.next() {
984        match cmd {
985            Command::InteractionMarker(_) => {
986                pending_markers.push(cmd);
987            }
988            Command::BeginContainer(_) | Command::BeginScrollable(_) => {
989                let mut depth = 1_u32;
990                let mut element: Vec<Command> = std::mem::take(&mut pending_markers);
991                element.push(cmd);
992                for next in iter.by_ref() {
993                    match next {
994                        Command::BeginContainer(_) | Command::BeginScrollable(_) => {
995                            depth += 1;
996                        }
997                        Command::EndContainer => {
998                            depth = depth.saturating_sub(1);
999                        }
1000                        _ => {}
1001                    }
1002                    let at_end = matches!(next, Command::EndContainer) && depth == 0;
1003                    element.push(next);
1004                    if at_end {
1005                        break;
1006                    }
1007                }
1008                elements.push(element);
1009            }
1010            Command::EndContainer => {}
1011            _ => {
1012                let mut element = std::mem::take(&mut pending_markers);
1013                element.push(cmd);
1014                elements.push(element);
1015            }
1016        }
1017    }
1018    // Flush any trailing markers (edge case: marker with no following command)
1019    if !pending_markers.is_empty() {
1020        elements.push(pending_markers);
1021    }
1022    elements
1023}
1024
1025#[cfg(test)]
1026mod list_reorder_render_tests {
1027    use crate::widgets::ListState;
1028    use crate::{EventBuilder, KeyCode, KeyModifiers, TestBackend};
1029
1030    #[test]
1031    fn shift_down_reorders_selected_item() {
1032        let mut backend = TestBackend::new(20, 6);
1033        let mut state = ListState::new(vec!["alpha", "beta", "gamma"]);
1034        state.selected = 0; // "alpha"
1035
1036        let events = EventBuilder::new()
1037            .key_with(KeyCode::Down, KeyModifiers::SHIFT)
1038            .build();
1039
1040        let mut reordered = None;
1041        backend.run_with_events(events, |ui| {
1042            let r = ui.list_reorderable(&mut state);
1043            reordered = r.reordered;
1044        });
1045
1046        // "alpha" (data 0) swapped down with "beta" (data 1).
1047        assert_eq!(reordered, Some((0, 1)));
1048        assert_eq!(state.items, vec!["beta", "alpha", "gamma"]);
1049        // Selection follows the moved item to its new position.
1050        assert_eq!(state.selected, 1);
1051        assert_eq!(state.selected_item(), Some("alpha"));
1052    }
1053
1054    #[test]
1055    fn alt_up_reorders_selected_item() {
1056        let mut backend = TestBackend::new(20, 6);
1057        let mut state = ListState::new(vec!["one", "two", "three"]);
1058        state.selected = 2; // "three"
1059
1060        let events = EventBuilder::new()
1061            .key_with(KeyCode::Up, KeyModifiers::ALT)
1062            .build();
1063
1064        let mut reordered = None;
1065        backend.run_with_events(events, |ui| {
1066            reordered = ui.list_reorderable(&mut state).reordered;
1067        });
1068
1069        assert_eq!(reordered, Some((2, 1)));
1070        assert_eq!(state.items, vec!["one", "three", "two"]);
1071        assert_eq!(state.selected, 1);
1072    }
1073
1074    #[test]
1075    fn shift_up_at_top_is_a_noop() {
1076        let mut backend = TestBackend::new(20, 6);
1077        let mut state = ListState::new(vec!["a", "b", "c"]);
1078        state.selected = 0;
1079
1080        let events = EventBuilder::new()
1081            .key_with(KeyCode::Up, KeyModifiers::SHIFT)
1082            .build();
1083
1084        let mut reordered = Some((9, 9));
1085        backend.run_with_events(events, |ui| {
1086            reordered = ui.list_reorderable(&mut state).reordered;
1087        });
1088
1089        // No room to move up from the top: nothing reordered.
1090        assert_eq!(reordered, None);
1091        assert_eq!(state.items, vec!["a", "b", "c"]);
1092        assert_eq!(state.selected, 0);
1093    }
1094
1095    #[test]
1096    fn plain_down_navigates_without_reordering() {
1097        let mut backend = TestBackend::new(20, 6);
1098        let mut state = ListState::new(vec!["a", "b", "c"]);
1099        state.selected = 0;
1100
1101        let events = EventBuilder::new().key_code(KeyCode::Down).build();
1102
1103        let mut reordered = Some((9, 9));
1104        backend.run_with_events(events, |ui| {
1105            reordered = ui.list_reorderable(&mut state).reordered;
1106        });
1107
1108        // Without a modifier, Down moves the selection but never reorders.
1109        assert_eq!(reordered, None);
1110        assert_eq!(state.items, vec!["a", "b", "c"]);
1111        assert_eq!(state.selected, 1);
1112    }
1113}