1use super::*;
2
3impl Context {
4 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 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 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 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 pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
374 let focused = self.register_focusable();
375 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
376
377 let month_days = CalendarState::days_in_month(state.year, state.month);
378 state.cursor_day = state.cursor_day.clamp(1, month_days);
379 if let Some(day) = state.selected_day {
380 state.selected_day = Some(day.min(month_days));
381 }
382 let old_selected = state.selected_day;
383 let old_anchor = state.anchor;
384 let old_extent = state.extent;
385 let old_time = (state.hour, state.minute);
386
387 if focused {
388 let mut consumed_indices = Vec::new();
389 for (i, key) in self.available_key_presses() {
390 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
391 let range = state.mode == CalendarSelect::Range;
392 let movement_delta = match key.code {
394 KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H') => Some(-1),
395 KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L') => Some(1),
396 KeyCode::Up => Some(-7),
397 KeyCode::Down => Some(7),
398 _ => None,
399 };
400
401 if let Some(delta) = movement_delta {
402 calendar_move_cursor_by_days(state, delta);
403 if range && shift {
404 state.extend_to_cursor();
406 }
407 consumed_indices.push(i);
408 continue;
409 }
410
411 match key.code {
412 KeyCode::Char('[') => {
413 state.prev_month();
414 consumed_indices.push(i);
415 }
416 KeyCode::Char(']') => {
417 state.next_month();
418 consumed_indices.push(i);
419 }
420 KeyCode::Enter | KeyCode::Char(' ') => {
421 if range {
422 if shift {
423 state.extend_to_cursor();
425 } else {
426 state.set_anchor_to_cursor();
428 }
429 } else {
430 state.selected_day = Some(state.cursor_day);
431 }
432 consumed_indices.push(i);
433 }
434 _ => {}
435 }
436 }
437 self.consume_indices(consumed_indices);
438 }
439
440 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
441 let mut consumed = Vec::new();
442 for (i, mouse) in clicks {
443 let rel_x = mouse.x.saturating_sub(rect.x);
444 let rel_y = mouse.y.saturating_sub(rect.y);
445 if rel_y == 0 {
446 if rel_x <= 2 {
447 state.prev_month();
448 consumed.push(i);
449 continue;
450 }
451 if rel_x + 3 >= rect.width {
452 state.next_month();
453 consumed.push(i);
454 continue;
455 }
456 }
457
458 if !(2..8).contains(&rel_y) {
459 continue;
460 }
461 if rel_x >= 21 {
462 continue;
463 }
464
465 let week = rel_y - 2;
466 let col = rel_x / 3;
467 let day_index = week * 7 + col;
468 let first = CalendarState::first_weekday(state.year, state.month);
469 let days = CalendarState::days_in_month(state.year, state.month);
470 if day_index < first {
471 continue;
472 }
473 let day = day_index - first + 1;
474 if day == 0 || day > days {
475 continue;
476 }
477 state.cursor_day = day;
478 if state.mode == CalendarSelect::Range {
479 if mouse.modifiers.contains(KeyModifiers::SHIFT) {
480 state.extend_to_cursor();
482 } else {
483 state.set_anchor_to_cursor();
485 }
486 } else {
487 state.selected_day = Some(day);
488 }
489 consumed.push(i);
490 }
491 self.consume_indices(consumed);
492 }
493
494 let title = {
495 let month_name = calendar_month_name(state.month);
496 let mut s = String::with_capacity(16);
497 s.push_str(&state.year.to_string());
498 s.push(' ');
499 s.push_str(month_name);
500 s
501 };
502
503 self.commands
504 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
505 direction: Direction::Column,
506 gap: 0,
507 align: Align::Start,
508 align_self: None,
509 justify: Justify::Start,
510 border: None,
511 border_sides: BorderSides::all(),
512 border_style: Style::new().fg(self.theme.border),
513 bg_color: None,
514 padding: Padding::default(),
515 margin: Margin::default(),
516 constraints: Constraints::default(),
517 title: None,
518 grow: 0,
519 group_name: None,
520 })));
521
522 let cal_gap = self.theme.spacing.xs();
523 self.commands
524 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
525 direction: Direction::Row,
526 gap: cal_gap as i32,
527 align: Align::Start,
528 align_self: None,
529 justify: Justify::Start,
530 border: None,
531 border_sides: BorderSides::all(),
532 border_style: Style::new().fg(self.theme.border),
533 bg_color: None,
534 padding: Padding::default(),
535 margin: Margin::default(),
536 constraints: Constraints::default(),
537 title: None,
538 grow: 0,
539 group_name: None,
540 })));
541 self.styled("◀", Style::new().fg(self.theme.text));
542 self.styled(title, Style::new().bold().fg(self.theme.text));
543 self.styled("▶", Style::new().fg(self.theme.text));
544 self.commands.push(Command::EndContainer);
545
546 self.commands
547 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
548 direction: Direction::Row,
549 gap: 0,
550 align: Align::Start,
551 align_self: None,
552 justify: Justify::Start,
553 border: None,
554 border_sides: BorderSides::all(),
555 border_style: Style::new().fg(self.theme.border),
556 bg_color: None,
557 padding: Padding::default(),
558 margin: Margin::default(),
559 constraints: Constraints::default(),
560 title: None,
561 grow: 0,
562 group_name: None,
563 })));
564 for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
565 self.styled(
566 format!("{wd:>2} "),
567 Style::new().fg(self.theme.text_dim).bold(),
568 );
569 }
570 self.commands.push(Command::EndContainer);
571
572 let first = CalendarState::first_weekday(state.year, state.month);
573 let days = CalendarState::days_in_month(state.year, state.month);
574 for week in 0..6_u32 {
575 self.commands
576 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
577 direction: Direction::Row,
578 gap: 0,
579 align: Align::Start,
580 align_self: None,
581 justify: Justify::Start,
582 border: None,
583 border_sides: BorderSides::all(),
584 border_style: Style::new().fg(self.theme.border),
585 bg_color: None,
586 padding: Padding::default(),
587 margin: Margin::default(),
588 constraints: Constraints::default(),
589 title: None,
590 grow: 0,
591 group_name: None,
592 })));
593
594 for col in 0..7_u32 {
595 let idx = week * 7 + col;
596 if idx < first || idx >= first + days {
597 self.styled(" ", Style::new().fg(self.theme.text_dim));
598 continue;
599 }
600 let day = idx - first + 1;
601 let text = format!("{day:>2} ");
602 let cell = CalDate {
603 year: state.year,
604 month: state.month,
605 day,
606 };
607 let style = if state.mode == CalendarSelect::Range {
608 if state.is_range_endpoint(cell) {
609 Style::new()
611 .bg(self.theme.selected_bg)
612 .fg(self.theme.selected_fg)
613 } else if state.in_range(cell) {
614 Style::new().bg(self.theme.surface).fg(self.theme.text)
616 } else if state.cursor_day == day {
617 Style::new().fg(self.theme.primary).bold()
618 } else {
619 Style::new().fg(self.theme.text)
620 }
621 } else if state.selected_day == Some(day) {
622 Style::new()
623 .bg(self.theme.selected_bg)
624 .fg(self.theme.selected_fg)
625 } else if state.cursor_day == day {
626 Style::new().fg(self.theme.primary).bold()
627 } else {
628 Style::new().fg(self.theme.text)
629 };
630 self.styled(text, style);
631 }
632
633 self.commands.push(Command::EndContainer);
634 }
635
636 if state.time_enabled {
637 let time_text = format!("{:02}:{:02}", state.hour, state.minute);
638 self.styled(time_text, Style::new().fg(self.theme.text).bold());
639 }
640
641 self.commands.push(Command::EndContainer);
642 self.rollback.last_text_idx = None;
643 response.changed = state.selected_day != old_selected
644 || state.anchor != old_anchor
645 || state.extent != old_extent
646 || (state.hour, state.minute) != old_time;
647 response
648 }
649
650 pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
652 if state.dirty {
653 state.refresh();
654 }
655 if !state.entries.is_empty() {
656 state.selected = state.selected.min(state.entries.len().saturating_sub(1));
657 }
658
659 let focused = self.register_focusable();
660 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
661 let mut file_selected = false;
662
663 if focused {
664 let mut consumed_indices = Vec::new();
665 for (i, key) in self.available_key_presses() {
666 match key.code {
667 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
668 if !state.entries.is_empty() {
669 let _ = handle_vertical_nav(
670 &mut state.selected,
671 state.entries.len().saturating_sub(1),
672 key.code.clone(),
673 );
674 }
675 consumed_indices.push(i);
676 }
677 KeyCode::Enter => {
678 if let Some(entry) = state.entries.get(state.selected).cloned() {
679 if entry.is_dir {
680 state.current_dir = entry.path;
681 state.selected = 0;
682 state.selected_file = None;
683 state.dirty = true;
684 } else {
685 state.selected_file = Some(entry.path);
686 file_selected = true;
687 }
688 }
689 consumed_indices.push(i);
690 }
691 KeyCode::Backspace => {
692 if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
693 state.current_dir = parent;
694 state.selected = 0;
695 state.selected_file = None;
696 state.dirty = true;
697 }
698 consumed_indices.push(i);
699 }
700 KeyCode::Char('h') => {
701 state.show_hidden = !state.show_hidden;
702 state.selected = 0;
703 state.dirty = true;
704 consumed_indices.push(i);
705 }
706 KeyCode::Esc => {
707 state.selected_file = None;
708 consumed_indices.push(i);
709 }
710 _ => {}
711 }
712 }
713 self.consume_indices(consumed_indices);
714 }
715
716 if state.dirty {
717 state.refresh();
718 }
719
720 self.commands
721 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
722 direction: Direction::Column,
723 gap: 0,
724 align: Align::Start,
725 align_self: None,
726 justify: Justify::Start,
727 border: None,
728 border_sides: BorderSides::all(),
729 border_style: Style::new().fg(self.theme.border),
730 bg_color: None,
731 padding: Padding::default(),
732 margin: Margin::default(),
733 constraints: Constraints::default(),
734 title: None,
735 grow: 0,
736 group_name: None,
737 })));
738
739 let dir_text = {
740 let dir = state.current_dir.display().to_string();
741 let mut text = String::with_capacity(5 + dir.len());
742 text.push_str("Dir: ");
743 text.push_str(&dir);
744 text
745 };
746 self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
747
748 if state.entries.is_empty() {
749 self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
750 } else {
751 for (idx, entry) in state.entries.iter().enumerate() {
752 let icon = if entry.is_dir { "▸ " } else { " " };
753 let row = if entry.is_dir {
754 let mut row = String::with_capacity(icon.len() + entry.name.len());
755 row.push_str(icon);
756 row.push_str(&entry.name);
757 row
758 } else {
759 let size_text = entry.size.to_string();
760 let mut row =
761 String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
762 row.push_str(icon);
763 row.push_str(&entry.name);
764 row.push_str(" ");
765 row.push_str(&size_text);
766 row.push_str(" B");
767 row
768 };
769
770 let style = if idx == state.selected {
771 if focused {
772 Style::new().bold().fg(self.theme.primary)
773 } else {
774 Style::new().fg(self.theme.primary)
775 }
776 } else {
777 Style::new().fg(self.theme.text)
778 };
779 self.styled(row, style);
780 }
781 }
782
783 self.commands.push(Command::EndContainer);
784 self.rollback.last_text_idx = None;
785
786 response.changed = file_selected;
787 response
788 }
789}
790
791fn collect_grid_elements(child_commands: Vec<Command>) -> Vec<Vec<Command>> {
798 let mut elements: Vec<Vec<Command>> = Vec::new();
799 let mut iter = child_commands.into_iter().peekable();
800 let mut pending_markers: Vec<Command> = Vec::new();
801 while let Some(cmd) = iter.next() {
802 match cmd {
803 Command::InteractionMarker(_) => {
804 pending_markers.push(cmd);
805 }
806 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
807 let mut depth = 1_u32;
808 let mut element: Vec<Command> = std::mem::take(&mut pending_markers);
809 element.push(cmd);
810 for next in iter.by_ref() {
811 match next {
812 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
813 depth += 1;
814 }
815 Command::EndContainer => {
816 depth = depth.saturating_sub(1);
817 }
818 _ => {}
819 }
820 let at_end = matches!(next, Command::EndContainer) && depth == 0;
821 element.push(next);
822 if at_end {
823 break;
824 }
825 }
826 elements.push(element);
827 }
828 Command::EndContainer => {}
829 _ => {
830 let mut element = std::mem::take(&mut pending_markers);
831 element.push(cmd);
832 elements.push(element);
833 }
834 }
835 }
836 if !pending_markers.is_empty() {
838 elements.push(pending_markers);
839 }
840 elements
841}