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