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 mut elements: Vec<Vec<Command>> = Vec::new();
49 let mut iter = child_commands.into_iter().peekable();
50 let mut pending_markers: Vec<Command> = Vec::new();
51 while let Some(cmd) = iter.next() {
52 match cmd {
53 Command::InteractionMarker(_) => {
54 pending_markers.push(cmd);
55 }
56 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
57 let mut depth = 1_u32;
58 let mut element: Vec<Command> = std::mem::take(&mut pending_markers);
59 element.push(cmd);
60 for next in iter.by_ref() {
61 match next {
62 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
63 depth += 1;
64 }
65 Command::EndContainer => {
66 depth = depth.saturating_sub(1);
67 }
68 _ => {}
69 }
70 let at_end = matches!(next, Command::EndContainer) && depth == 0;
71 element.push(next);
72 if at_end {
73 break;
74 }
75 }
76 elements.push(element);
77 }
78 Command::EndContainer => {}
79 _ => {
80 let mut element = std::mem::take(&mut pending_markers);
81 element.push(cmd);
82 elements.push(element);
83 }
84 }
85 }
86 if !pending_markers.is_empty() {
88 elements.push(pending_markers);
89 }
90
91 let cols = cols.max(1) as usize;
92 for row in elements.chunks(cols) {
93 self.skip_interaction_slot();
94 self.commands
95 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
96 direction: Direction::Row,
97 gap: 0,
98 align: Align::Start,
99 align_self: None,
100 justify: Justify::Start,
101 border: None,
102 border_sides: BorderSides::all(),
103 border_style: Style::new().fg(border),
104 bg_color: None,
105 padding: Padding::default(),
106 margin: Margin::default(),
107 constraints: Constraints::default(),
108 title: None,
109 grow: 0,
110 group_name: None,
111 })));
112
113 for element in row {
114 self.skip_interaction_slot();
115 self.commands
116 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
117 direction: Direction::Column,
118 gap: 0,
119 align: Align::Start,
120 align_self: None,
121 justify: Justify::Start,
122 border: None,
123 border_sides: BorderSides::all(),
124 border_style: Style::new().fg(border),
125 bg_color: None,
126 padding: Padding::default(),
127 margin: Margin::default(),
128 constraints: Constraints::default(),
129 title: None,
130 grow: 1,
131 group_name: None,
132 })));
133 self.commands.extend(element.iter().cloned());
134 self.commands.push(Command::EndContainer);
135 }
136
137 self.commands.push(Command::EndContainer);
138 }
139
140 self.commands.push(Command::EndContainer);
141 self.rollback.last_text_idx = None;
142
143 self.response_for(interaction_id)
144 }
145
146 pub fn grid_with(&mut self, columns: &[GridColumn], f: impl FnOnce(&mut Context)) -> Response {
177 let cols = columns.len().max(1);
178 let interaction_id = self.next_interaction_id();
179 let border = self.theme.border;
180
181 self.commands
182 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
183 direction: Direction::Column,
184 gap: 0,
185 align: Align::Start,
186 align_self: None,
187 justify: Justify::Start,
188 border: None,
189 border_sides: BorderSides::all(),
190 border_style: Style::new().fg(border),
191 bg_color: None,
192 padding: Padding::default(),
193 margin: Margin::default(),
194 constraints: Constraints::default(),
195 title: None,
196 grow: 0,
197 group_name: None,
198 })));
199
200 let children_start = self.commands.len();
201 f(self);
202 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
203
204 let mut elements: Vec<Vec<Command>> = Vec::new();
205 let mut iter = child_commands.into_iter().peekable();
206 let mut pending_markers: Vec<Command> = Vec::new();
207 while let Some(cmd) = iter.next() {
208 match cmd {
209 Command::InteractionMarker(_) => {
210 pending_markers.push(cmd);
211 }
212 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
213 let mut depth = 1_u32;
214 let mut element: Vec<Command> = std::mem::take(&mut pending_markers);
215 element.push(cmd);
216 for next in iter.by_ref() {
217 match next {
218 Command::BeginContainer(_) | Command::BeginScrollable(_) => {
219 depth += 1;
220 }
221 Command::EndContainer => {
222 depth = depth.saturating_sub(1);
223 }
224 _ => {}
225 }
226 let at_end = matches!(next, Command::EndContainer) && depth == 0;
227 element.push(next);
228 if at_end {
229 break;
230 }
231 }
232 elements.push(element);
233 }
234 Command::EndContainer => {}
235 _ => {
236 let mut element = std::mem::take(&mut pending_markers);
237 element.push(cmd);
238 elements.push(element);
239 }
240 }
241 }
242 if !pending_markers.is_empty() {
243 elements.push(pending_markers);
244 }
245
246 for row in elements.chunks(cols) {
247 self.skip_interaction_slot();
248 self.commands
249 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
250 direction: Direction::Row,
251 gap: 0,
252 align: Align::Start,
253 align_self: None,
254 justify: Justify::Start,
255 border: None,
256 border_sides: BorderSides::all(),
257 border_style: Style::new().fg(border),
258 bg_color: None,
259 padding: Padding::default(),
260 margin: Margin::default(),
261 constraints: Constraints::default(),
262 title: None,
263 grow: 0,
264 group_name: None,
265 })));
266
267 for (col_idx, element) in row.iter().enumerate() {
268 let spec = columns.get(col_idx).copied().unwrap_or(GridColumn::Auto);
269 let (grow, constraints) = match spec {
270 GridColumn::Auto => (1, Constraints::default()),
271 GridColumn::Fixed(w) => (
272 0,
273 Constraints {
274 min_width: Some(w),
275 max_width: Some(w),
276 ..Constraints::default()
277 },
278 ),
279 GridColumn::Grow(g) => (g, Constraints::default()),
280 GridColumn::Percent(p) => (
281 0,
282 Constraints {
283 width_pct: Some(p),
284 ..Constraints::default()
285 },
286 ),
287 };
288
289 self.skip_interaction_slot();
290 self.commands
291 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
292 direction: Direction::Column,
293 gap: 0,
294 align: Align::Start,
295 align_self: None,
296 justify: Justify::Start,
297 border: None,
298 border_sides: BorderSides::all(),
299 border_style: Style::new().fg(border),
300 bg_color: None,
301 padding: Padding::default(),
302 margin: Margin::default(),
303 constraints,
304 title: None,
305 grow,
306 group_name: None,
307 })));
308 self.commands.extend(element.iter().cloned());
309 self.commands.push(Command::EndContainer);
310 }
311
312 self.commands.push(Command::EndContainer);
313 }
314
315 self.commands.push(Command::EndContainer);
316 self.rollback.last_text_idx = None;
317
318 self.response_for(interaction_id)
319 }
320
321 pub fn list(&mut self, state: &mut ListState) -> Response {
327 let colors = self.widget_theme.list;
328 self.list_colored(state, &colors)
329 }
330
331 pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
333 let visible = state.visible_indices().to_vec();
334 if visible.is_empty() && state.items.is_empty() {
335 state.selected = 0;
336 return Response::none();
337 }
338
339 if !visible.is_empty() {
340 state.selected = state.selected.min(visible.len().saturating_sub(1));
341 }
342
343 let old_selected = state.selected;
344 let focused = self.register_focusable();
345 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
346
347 if focused {
348 let mut consumed_indices = Vec::new();
349 for (i, key) in self.available_key_presses() {
350 match key.code {
351 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
352 let _ = handle_vertical_nav(
353 &mut state.selected,
354 visible.len().saturating_sub(1),
355 key.code.clone(),
356 );
357 consumed_indices.push(i);
358 }
359 _ => {}
360 }
361 }
362 self.consume_indices(consumed_indices);
363 }
364
365 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
366 let mut consumed = Vec::new();
367 for (i, mouse) in clicks {
368 let clicked_idx = (mouse.y - rect.y) as usize;
369 if clicked_idx < visible.len() {
370 state.selected = clicked_idx;
371 consumed.push(i);
372 }
373 }
374 self.consume_indices(consumed);
375 }
376
377 self.commands
378 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
379 direction: Direction::Column,
380 gap: 0,
381 align: Align::Start,
382 align_self: None,
383 justify: Justify::Start,
384 border: None,
385 border_sides: BorderSides::all(),
386 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
387 bg_color: None,
388 padding: Padding::default(),
389 margin: Margin::default(),
390 constraints: Constraints::default(),
391 title: None,
392 grow: 0,
393 group_name: None,
394 })));
395
396 for (view_idx, &item_idx) in visible.iter().enumerate() {
397 let item = &state.items[item_idx];
398 if view_idx == state.selected {
399 let mut selected_style = Style::new()
400 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
401 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
402 if focused {
403 selected_style = selected_style.bold();
404 }
405 let mut row = String::with_capacity(2 + item.len());
406 row.push_str("▸ ");
407 row.push_str(item);
408 self.styled(row, selected_style);
409 } else {
410 let mut row = String::with_capacity(2 + item.len());
411 row.push_str(" ");
412 row.push_str(item);
413 self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
414 }
415 }
416
417 self.commands.push(Command::EndContainer);
418 self.rollback.last_text_idx = None;
419
420 response.changed = state.selected != old_selected;
421 response
422 }
423
424 pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
426 let focused = self.register_focusable();
427 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
428
429 let month_days = CalendarState::days_in_month(state.year, state.month);
430 state.cursor_day = state.cursor_day.clamp(1, month_days);
431 if let Some(day) = state.selected_day {
432 state.selected_day = Some(day.min(month_days));
433 }
434 let old_selected = state.selected_day;
435
436 if focused {
437 let mut consumed_indices = Vec::new();
438 for (i, key) in self.available_key_presses() {
439 match key.code {
440 KeyCode::Left => {
441 calendar_move_cursor_by_days(state, -1);
442 consumed_indices.push(i);
443 }
444 KeyCode::Right => {
445 calendar_move_cursor_by_days(state, 1);
446 consumed_indices.push(i);
447 }
448 KeyCode::Up => {
449 calendar_move_cursor_by_days(state, -7);
450 consumed_indices.push(i);
451 }
452 KeyCode::Down => {
453 calendar_move_cursor_by_days(state, 7);
454 consumed_indices.push(i);
455 }
456 KeyCode::Char('h') => {
457 state.prev_month();
458 consumed_indices.push(i);
459 }
460 KeyCode::Char('l') => {
461 state.next_month();
462 consumed_indices.push(i);
463 }
464 KeyCode::Enter | KeyCode::Char(' ') => {
465 state.selected_day = Some(state.cursor_day);
466 consumed_indices.push(i);
467 }
468 _ => {}
469 }
470 }
471 self.consume_indices(consumed_indices);
472 }
473
474 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
475 let mut consumed = Vec::new();
476 for (i, mouse) in clicks {
477 let rel_x = mouse.x.saturating_sub(rect.x);
478 let rel_y = mouse.y.saturating_sub(rect.y);
479 if rel_y == 0 {
480 if rel_x <= 2 {
481 state.prev_month();
482 consumed.push(i);
483 continue;
484 }
485 if rel_x + 3 >= rect.width {
486 state.next_month();
487 consumed.push(i);
488 continue;
489 }
490 }
491
492 if !(2..8).contains(&rel_y) {
493 continue;
494 }
495 if rel_x >= 21 {
496 continue;
497 }
498
499 let week = rel_y - 2;
500 let col = rel_x / 3;
501 let day_index = week * 7 + col;
502 let first = CalendarState::first_weekday(state.year, state.month);
503 let days = CalendarState::days_in_month(state.year, state.month);
504 if day_index < first {
505 continue;
506 }
507 let day = day_index - first + 1;
508 if day == 0 || day > days {
509 continue;
510 }
511 state.cursor_day = day;
512 state.selected_day = Some(day);
513 consumed.push(i);
514 }
515 self.consume_indices(consumed);
516 }
517
518 let title = {
519 let month_name = calendar_month_name(state.month);
520 let mut s = String::with_capacity(16);
521 s.push_str(&state.year.to_string());
522 s.push(' ');
523 s.push_str(month_name);
524 s
525 };
526
527 self.commands
528 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
529 direction: Direction::Column,
530 gap: 0,
531 align: Align::Start,
532 align_self: None,
533 justify: Justify::Start,
534 border: None,
535 border_sides: BorderSides::all(),
536 border_style: Style::new().fg(self.theme.border),
537 bg_color: None,
538 padding: Padding::default(),
539 margin: Margin::default(),
540 constraints: Constraints::default(),
541 title: None,
542 grow: 0,
543 group_name: None,
544 })));
545
546 self.commands
547 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
548 direction: Direction::Row,
549 gap: 1,
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 self.styled("◀", Style::new().fg(self.theme.text));
565 self.styled(title, Style::new().bold().fg(self.theme.text));
566 self.styled("▶", Style::new().fg(self.theme.text));
567 self.commands.push(Command::EndContainer);
568
569 self.commands
570 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
571 direction: Direction::Row,
572 gap: 0,
573 align: Align::Start,
574 align_self: None,
575 justify: Justify::Start,
576 border: None,
577 border_sides: BorderSides::all(),
578 border_style: Style::new().fg(self.theme.border),
579 bg_color: None,
580 padding: Padding::default(),
581 margin: Margin::default(),
582 constraints: Constraints::default(),
583 title: None,
584 grow: 0,
585 group_name: None,
586 })));
587 for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
588 self.styled(
589 format!("{wd:>2} "),
590 Style::new().fg(self.theme.text_dim).bold(),
591 );
592 }
593 self.commands.push(Command::EndContainer);
594
595 let first = CalendarState::first_weekday(state.year, state.month);
596 let days = CalendarState::days_in_month(state.year, state.month);
597 for week in 0..6_u32 {
598 self.commands
599 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
600 direction: Direction::Row,
601 gap: 0,
602 align: Align::Start,
603 align_self: None,
604 justify: Justify::Start,
605 border: None,
606 border_sides: BorderSides::all(),
607 border_style: Style::new().fg(self.theme.border),
608 bg_color: None,
609 padding: Padding::default(),
610 margin: Margin::default(),
611 constraints: Constraints::default(),
612 title: None,
613 grow: 0,
614 group_name: None,
615 })));
616
617 for col in 0..7_u32 {
618 let idx = week * 7 + col;
619 if idx < first || idx >= first + days {
620 self.styled(" ", Style::new().fg(self.theme.text_dim));
621 continue;
622 }
623 let day = idx - first + 1;
624 let text = format!("{day:>2} ");
625 let style = if state.selected_day == Some(day) {
626 Style::new()
627 .bg(self.theme.selected_bg)
628 .fg(self.theme.selected_fg)
629 } else if state.cursor_day == day {
630 Style::new().fg(self.theme.primary).bold()
631 } else {
632 Style::new().fg(self.theme.text)
633 };
634 self.styled(text, style);
635 }
636
637 self.commands.push(Command::EndContainer);
638 }
639
640 self.commands.push(Command::EndContainer);
641 self.rollback.last_text_idx = None;
642 response.changed = state.selected_day != old_selected;
643 response
644 }
645
646 pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
648 if state.dirty {
649 state.refresh();
650 }
651 if !state.entries.is_empty() {
652 state.selected = state.selected.min(state.entries.len().saturating_sub(1));
653 }
654
655 let focused = self.register_focusable();
656 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
657 let mut file_selected = false;
658
659 if focused {
660 let mut consumed_indices = Vec::new();
661 for (i, key) in self.available_key_presses() {
662 match key.code {
663 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
664 if !state.entries.is_empty() {
665 let _ = handle_vertical_nav(
666 &mut state.selected,
667 state.entries.len().saturating_sub(1),
668 key.code.clone(),
669 );
670 }
671 consumed_indices.push(i);
672 }
673 KeyCode::Enter => {
674 if let Some(entry) = state.entries.get(state.selected).cloned() {
675 if entry.is_dir {
676 state.current_dir = entry.path;
677 state.selected = 0;
678 state.selected_file = None;
679 state.dirty = true;
680 } else {
681 state.selected_file = Some(entry.path);
682 file_selected = true;
683 }
684 }
685 consumed_indices.push(i);
686 }
687 KeyCode::Backspace => {
688 if let Some(parent) = state.current_dir.parent().map(|p| p.to_path_buf()) {
689 state.current_dir = parent;
690 state.selected = 0;
691 state.selected_file = None;
692 state.dirty = true;
693 }
694 consumed_indices.push(i);
695 }
696 KeyCode::Char('h') => {
697 state.show_hidden = !state.show_hidden;
698 state.selected = 0;
699 state.dirty = true;
700 consumed_indices.push(i);
701 }
702 KeyCode::Esc => {
703 state.selected_file = None;
704 consumed_indices.push(i);
705 }
706 _ => {}
707 }
708 }
709 self.consume_indices(consumed_indices);
710 }
711
712 if state.dirty {
713 state.refresh();
714 }
715
716 self.commands
717 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
718 direction: Direction::Column,
719 gap: 0,
720 align: Align::Start,
721 align_self: None,
722 justify: Justify::Start,
723 border: None,
724 border_sides: BorderSides::all(),
725 border_style: Style::new().fg(self.theme.border),
726 bg_color: None,
727 padding: Padding::default(),
728 margin: Margin::default(),
729 constraints: Constraints::default(),
730 title: None,
731 grow: 0,
732 group_name: None,
733 })));
734
735 let dir_text = {
736 let dir = state.current_dir.display().to_string();
737 let mut text = String::with_capacity(5 + dir.len());
738 text.push_str("Dir: ");
739 text.push_str(&dir);
740 text
741 };
742 self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
743
744 if state.entries.is_empty() {
745 self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
746 } else {
747 for (idx, entry) in state.entries.iter().enumerate() {
748 let icon = if entry.is_dir { "▸ " } else { " " };
749 let row = if entry.is_dir {
750 let mut row = String::with_capacity(icon.len() + entry.name.len());
751 row.push_str(icon);
752 row.push_str(&entry.name);
753 row
754 } else {
755 let size_text = entry.size.to_string();
756 let mut row =
757 String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
758 row.push_str(icon);
759 row.push_str(&entry.name);
760 row.push_str(" ");
761 row.push_str(&size_text);
762 row.push_str(" B");
763 row
764 };
765
766 let style = if idx == state.selected {
767 if focused {
768 Style::new().bold().fg(self.theme.primary)
769 } else {
770 Style::new().fg(self.theme.primary)
771 }
772 } else {
773 Style::new().fg(self.theme.text)
774 };
775 self.styled(row, style);
776 }
777 }
778
779 self.commands.push(Command::EndContainer);
780 self.rollback.last_text_idx = None;
781
782 response.changed = file_selected;
783 response
784 }
785}