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