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.interaction_count;
23 self.interaction_count += 1;
24 let border = self.theme.border;
25
26 self.commands.push(Command::BeginContainer {
27 direction: Direction::Column,
28 gap: 0,
29 align: Align::Start,
30 justify: Justify::Start,
31 border: None,
32 border_sides: BorderSides::all(),
33 border_style: Style::new().fg(border),
34 bg_color: None,
35 padding: Padding::default(),
36 margin: Margin::default(),
37 constraints: Constraints::default(),
38 title: None,
39 grow: 0,
40 group_name: None,
41 });
42
43 let children_start = self.commands.len();
44 f(self);
45 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
46
47 let mut elements: Vec<Vec<Command>> = Vec::new();
48 let mut iter = child_commands.into_iter().peekable();
49 while let Some(cmd) = iter.next() {
50 match cmd {
51 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
52 let mut depth = 1_u32;
53 let mut element = vec![cmd];
54 for next in iter.by_ref() {
55 match next {
56 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
57 depth += 1;
58 }
59 Command::EndContainer => {
60 depth = depth.saturating_sub(1);
61 }
62 _ => {}
63 }
64 let at_end = matches!(next, Command::EndContainer) && depth == 0;
65 element.push(next);
66 if at_end {
67 break;
68 }
69 }
70 elements.push(element);
71 }
72 Command::EndContainer => {}
73 _ => elements.push(vec![cmd]),
74 }
75 }
76
77 let cols = cols.max(1) as usize;
78 for row in elements.chunks(cols) {
79 self.interaction_count += 1;
80 self.commands.push(Command::BeginContainer {
81 direction: Direction::Row,
82 gap: 0,
83 align: Align::Start,
84 justify: Justify::Start,
85 border: None,
86 border_sides: BorderSides::all(),
87 border_style: Style::new().fg(border),
88 bg_color: None,
89 padding: Padding::default(),
90 margin: Margin::default(),
91 constraints: Constraints::default(),
92 title: None,
93 grow: 0,
94 group_name: None,
95 });
96
97 for element in row {
98 self.interaction_count += 1;
99 self.commands.push(Command::BeginContainer {
100 direction: Direction::Column,
101 gap: 0,
102 align: Align::Start,
103 justify: Justify::Start,
104 border: None,
105 border_sides: BorderSides::all(),
106 border_style: Style::new().fg(border),
107 bg_color: None,
108 padding: Padding::default(),
109 margin: Margin::default(),
110 constraints: Constraints::default(),
111 title: None,
112 grow: 1,
113 group_name: None,
114 });
115 self.commands.extend(element.iter().cloned());
116 self.commands.push(Command::EndContainer);
117 }
118
119 self.commands.push(Command::EndContainer);
120 }
121
122 self.commands.push(Command::EndContainer);
123 self.last_text_idx = None;
124
125 self.response_for(interaction_id)
126 }
127
128 pub fn list(&mut self, state: &mut ListState) -> Response {
133 self.list_colored(state, &WidgetColors::new())
134 }
135
136 pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
137 let visible = state.visible_indices().to_vec();
138 if visible.is_empty() && state.items.is_empty() {
139 state.selected = 0;
140 return Response::none();
141 }
142
143 if !visible.is_empty() {
144 state.selected = state.selected.min(visible.len().saturating_sub(1));
145 }
146
147 let old_selected = state.selected;
148 let focused = self.register_focusable();
149 let interaction_id = self.interaction_count;
150 self.interaction_count += 1;
151 let mut response = self.response_for(interaction_id);
152 response.focused = focused;
153
154 if focused {
155 let mut consumed_indices = Vec::new();
156 for (i, event) in self.events.iter().enumerate() {
157 if let Event::Key(key) = event {
158 if key.kind != KeyEventKind::Press {
159 continue;
160 }
161 match key.code {
162 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
163 let _ = handle_vertical_nav(
164 &mut state.selected,
165 visible.len().saturating_sub(1),
166 key.code.clone(),
167 );
168 consumed_indices.push(i);
169 }
170 _ => {}
171 }
172 }
173 }
174
175 for index in consumed_indices {
176 self.consumed[index] = true;
177 }
178 }
179
180 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
181 for (i, event) in self.events.iter().enumerate() {
182 if self.consumed[i] {
183 continue;
184 }
185 if let Event::Mouse(mouse) = event {
186 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
187 continue;
188 }
189 let in_bounds = mouse.x >= rect.x
190 && mouse.x < rect.right()
191 && mouse.y >= rect.y
192 && mouse.y < rect.bottom();
193 if !in_bounds {
194 continue;
195 }
196 let clicked_idx = (mouse.y - rect.y) as usize;
197 if clicked_idx < visible.len() {
198 state.selected = clicked_idx;
199 self.consumed[i] = true;
200 }
201 }
202 }
203 }
204
205 self.commands.push(Command::BeginContainer {
206 direction: Direction::Column,
207 gap: 0,
208 align: Align::Start,
209 justify: Justify::Start,
210 border: None,
211 border_sides: BorderSides::all(),
212 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
213 bg_color: None,
214 padding: Padding::default(),
215 margin: Margin::default(),
216 constraints: Constraints::default(),
217 title: None,
218 grow: 0,
219 group_name: None,
220 });
221
222 for (view_idx, &item_idx) in visible.iter().enumerate() {
223 let item = &state.items[item_idx];
224 if view_idx == state.selected {
225 let mut selected_style = Style::new()
226 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
227 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
228 if focused {
229 selected_style = selected_style.bold();
230 }
231 self.styled(format!("▸ {item}"), selected_style);
232 } else {
233 self.styled(
234 format!(" {item}"),
235 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
236 );
237 }
238 }
239
240 self.commands.push(Command::EndContainer);
241 self.last_text_idx = None;
242
243 response.changed = state.selected != old_selected;
244 response
245 }
246
247 pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
248 if state.dirty {
249 state.refresh();
250 }
251 if !state.entries.is_empty() {
252 state.selected = state.selected.min(state.entries.len().saturating_sub(1));
253 }
254
255 let focused = self.register_focusable();
256 let interaction_id = self.interaction_count;
257 self.interaction_count += 1;
258 let mut response = self.response_for(interaction_id);
259 response.focused = focused;
260 let mut file_selected = false;
261
262 if focused {
263 let mut consumed_indices = Vec::new();
264 for (i, event) in self.events.iter().enumerate() {
265 if self.consumed[i] {
266 continue;
267 }
268 if let Event::Key(key) = event {
269 if key.kind != KeyEventKind::Press {
270 continue;
271 }
272 match key.code {
273 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
274 if !state.entries.is_empty() {
275 let _ = handle_vertical_nav(
276 &mut state.selected,
277 state.entries.len().saturating_sub(1),
278 key.code.clone(),
279 );
280 }
281 consumed_indices.push(i);
282 }
283 KeyCode::Enter => {
284 if let Some(entry) = state.entries.get(state.selected).cloned() {
285 if entry.is_dir {
286 state.current_dir = entry.path;
287 state.selected = 0;
288 state.selected_file = None;
289 state.dirty = true;
290 } else {
291 state.selected_file = Some(entry.path);
292 file_selected = true;
293 }
294 }
295 consumed_indices.push(i);
296 }
297 KeyCode::Backspace => {
298 if let Some(parent) =
299 state.current_dir.parent().map(|p| p.to_path_buf())
300 {
301 state.current_dir = parent;
302 state.selected = 0;
303 state.selected_file = None;
304 state.dirty = true;
305 }
306 consumed_indices.push(i);
307 }
308 KeyCode::Char('h') => {
309 state.show_hidden = !state.show_hidden;
310 state.selected = 0;
311 state.dirty = true;
312 consumed_indices.push(i);
313 }
314 KeyCode::Esc => {
315 state.selected_file = None;
316 consumed_indices.push(i);
317 }
318 _ => {}
319 }
320 }
321 }
322
323 for index in consumed_indices {
324 self.consumed[index] = true;
325 }
326 }
327
328 if state.dirty {
329 state.refresh();
330 }
331
332 self.commands.push(Command::BeginContainer {
333 direction: Direction::Column,
334 gap: 0,
335 align: Align::Start,
336 justify: Justify::Start,
337 border: None,
338 border_sides: BorderSides::all(),
339 border_style: Style::new().fg(self.theme.border),
340 bg_color: None,
341 padding: Padding::default(),
342 margin: Margin::default(),
343 constraints: Constraints::default(),
344 title: None,
345 grow: 0,
346 group_name: None,
347 });
348
349 self.styled(
350 format!("Dir: {}", state.current_dir.display()),
351 Style::new().fg(self.theme.text_dim).dim(),
352 );
353
354 if state.entries.is_empty() {
355 self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
356 } else {
357 for (idx, entry) in state.entries.iter().enumerate() {
358 let icon = if entry.is_dir { "▸ " } else { " " };
359 let row = if entry.is_dir {
360 format!("{icon}{}", entry.name)
361 } else {
362 format!("{icon}{} {} B", entry.name, entry.size)
363 };
364
365 let style = if idx == state.selected {
366 if focused {
367 Style::new().bold().fg(self.theme.primary)
368 } else {
369 Style::new().fg(self.theme.primary)
370 }
371 } else {
372 Style::new().fg(self.theme.text)
373 };
374 self.styled(row, style);
375 }
376 }
377
378 self.commands.push(Command::EndContainer);
379 self.last_text_idx = None;
380
381 response.changed = file_selected;
382 response
383 }
384
385 pub fn table(&mut self, state: &mut TableState) -> Response {
390 self.table_colored(state, &WidgetColors::new())
391 }
392
393 pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
394 if state.is_dirty() {
395 state.recompute_widths();
396 }
397
398 let old_selected = state.selected;
399 let old_sort_column = state.sort_column;
400 let old_sort_ascending = state.sort_ascending;
401 let old_page = state.page;
402 let old_filter = state.filter.clone();
403
404 let focused = self.register_focusable();
405 let interaction_id = self.interaction_count;
406 self.interaction_count += 1;
407 let mut response = self.response_for(interaction_id);
408 response.focused = focused;
409
410 self.handle_table_keys(state, focused);
411
412 if !state.visible_indices().is_empty() || !state.headers.is_empty() {
413 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
414 for (i, event) in self.events.iter().enumerate() {
415 if self.consumed[i] {
416 continue;
417 }
418 if let Event::Mouse(mouse) = event {
419 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
420 continue;
421 }
422 let in_bounds = mouse.x >= rect.x
423 && mouse.x < rect.right()
424 && mouse.y >= rect.y
425 && mouse.y < rect.bottom();
426 if !in_bounds {
427 continue;
428 }
429
430 if mouse.y == rect.y {
431 let rel_x = mouse.x.saturating_sub(rect.x);
432 let mut x_offset = 0u32;
433 for (col_idx, width) in state.column_widths().iter().enumerate() {
434 if rel_x >= x_offset && rel_x < x_offset + *width {
435 state.toggle_sort(col_idx);
436 state.selected = 0;
437 self.consumed[i] = true;
438 break;
439 }
440 x_offset += *width;
441 if col_idx + 1 < state.column_widths().len() {
442 x_offset += 3;
443 }
444 }
445 continue;
446 }
447
448 if mouse.y < rect.y + 2 {
449 continue;
450 }
451
452 let visible_len = if state.page_size > 0 {
453 let start = state
454 .page
455 .saturating_mul(state.page_size)
456 .min(state.visible_indices().len());
457 let end = (start + state.page_size).min(state.visible_indices().len());
458 end.saturating_sub(start)
459 } else {
460 state.visible_indices().len()
461 };
462 let clicked_idx = (mouse.y - rect.y - 2) as usize;
463 if clicked_idx < visible_len {
464 state.selected = clicked_idx;
465 self.consumed[i] = true;
466 }
467 }
468 }
469 }
470 }
471
472 if state.is_dirty() {
473 state.recompute_widths();
474 }
475
476 let total_visible = state.visible_indices().len();
477 let page_start = if state.page_size > 0 {
478 state
479 .page
480 .saturating_mul(state.page_size)
481 .min(total_visible)
482 } else {
483 0
484 };
485 let page_end = if state.page_size > 0 {
486 (page_start + state.page_size).min(total_visible)
487 } else {
488 total_visible
489 };
490 let visible_len = page_end.saturating_sub(page_start);
491 state.selected = state.selected.min(visible_len.saturating_sub(1));
492
493 self.commands.push(Command::BeginContainer {
494 direction: Direction::Column,
495 gap: 0,
496 align: Align::Start,
497 justify: Justify::Start,
498 border: None,
499 border_sides: BorderSides::all(),
500 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
501 bg_color: None,
502 padding: Padding::default(),
503 margin: Margin::default(),
504 constraints: Constraints::default(),
505 title: None,
506 grow: 0,
507 group_name: None,
508 });
509
510 self.render_table_header(state, colors);
511 self.render_table_rows(state, focused, page_start, visible_len, colors);
512
513 if state.page_size > 0 && state.total_pages() > 1 {
514 self.styled(
515 format!("Page {}/{}", state.page + 1, state.total_pages()),
516 Style::new()
517 .dim()
518 .fg(colors.fg.unwrap_or(self.theme.text_dim)),
519 );
520 }
521
522 self.commands.push(Command::EndContainer);
523 self.last_text_idx = None;
524
525 response.changed = state.selected != old_selected
526 || state.sort_column != old_sort_column
527 || state.sort_ascending != old_sort_ascending
528 || state.page != old_page
529 || state.filter != old_filter;
530 response
531 }
532
533 fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
534 if !focused || state.visible_indices().is_empty() {
535 return;
536 }
537
538 let mut consumed_indices = Vec::new();
539 for (i, event) in self.events.iter().enumerate() {
540 if let Event::Key(key) = event {
541 if key.kind != KeyEventKind::Press {
542 continue;
543 }
544 match key.code {
545 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
546 let visible_len = table_visible_len(state);
547 state.selected = state.selected.min(visible_len.saturating_sub(1));
548 let _ = handle_vertical_nav(
549 &mut state.selected,
550 visible_len.saturating_sub(1),
551 key.code.clone(),
552 );
553 consumed_indices.push(i);
554 }
555 KeyCode::PageUp => {
556 let old_page = state.page;
557 state.prev_page();
558 if state.page != old_page {
559 state.selected = 0;
560 }
561 consumed_indices.push(i);
562 }
563 KeyCode::PageDown => {
564 let old_page = state.page;
565 state.next_page();
566 if state.page != old_page {
567 state.selected = 0;
568 }
569 consumed_indices.push(i);
570 }
571 _ => {}
572 }
573 }
574 }
575 for index in consumed_indices {
576 self.consumed[index] = true;
577 }
578 }
579
580 fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
581 let header_cells = state
582 .headers
583 .iter()
584 .enumerate()
585 .map(|(i, header)| {
586 if state.sort_column == Some(i) {
587 if state.sort_ascending {
588 format!("{header} ▲")
589 } else {
590 format!("{header} ▼")
591 }
592 } else {
593 header.clone()
594 }
595 })
596 .collect::<Vec<_>>();
597 let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
598 self.styled(
599 header_line,
600 Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
601 );
602
603 let separator = state
604 .column_widths()
605 .iter()
606 .map(|w| "─".repeat(*w as usize))
607 .collect::<Vec<_>>()
608 .join("─┼─");
609 self.text(separator);
610 }
611
612 fn render_table_rows(
613 &mut self,
614 state: &TableState,
615 focused: bool,
616 page_start: usize,
617 visible_len: usize,
618 colors: &WidgetColors,
619 ) {
620 for idx in 0..visible_len {
621 let data_idx = state.visible_indices()[page_start + idx];
622 let Some(row) = state.rows.get(data_idx) else {
623 continue;
624 };
625 let line = format_table_row(row, state.column_widths(), " │ ");
626 if idx == state.selected {
627 let mut style = Style::new()
628 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
629 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
630 if focused {
631 style = style.bold();
632 }
633 self.styled(line, style);
634 } else {
635 self.styled(line, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
636 }
637 }
638 }
639
640 pub fn tabs(&mut self, state: &mut TabsState) -> Response {
645 self.tabs_colored(state, &WidgetColors::new())
646 }
647
648 pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
649 if state.labels.is_empty() {
650 state.selected = 0;
651 return Response::none();
652 }
653
654 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
655 let old_selected = state.selected;
656 let focused = self.register_focusable();
657 let interaction_id = self.interaction_count;
658 self.interaction_count += 1;
659 let mut response = self.response_for(interaction_id);
660 response.focused = focused;
661
662 if focused {
663 let mut consumed_indices = Vec::new();
664 for (i, event) in self.events.iter().enumerate() {
665 if let Event::Key(key) = event {
666 if key.kind != KeyEventKind::Press {
667 continue;
668 }
669 match key.code {
670 KeyCode::Left => {
671 state.selected = if state.selected == 0 {
672 state.labels.len().saturating_sub(1)
673 } else {
674 state.selected - 1
675 };
676 consumed_indices.push(i);
677 }
678 KeyCode::Right => {
679 if !state.labels.is_empty() {
680 state.selected = (state.selected + 1) % state.labels.len();
681 }
682 consumed_indices.push(i);
683 }
684 _ => {}
685 }
686 }
687 }
688
689 for index in consumed_indices {
690 self.consumed[index] = true;
691 }
692 }
693
694 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
695 for (i, event) in self.events.iter().enumerate() {
696 if self.consumed[i] {
697 continue;
698 }
699 if let Event::Mouse(mouse) = event {
700 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
701 continue;
702 }
703 let in_bounds = mouse.x >= rect.x
704 && mouse.x < rect.right()
705 && mouse.y >= rect.y
706 && mouse.y < rect.bottom();
707 if !in_bounds {
708 continue;
709 }
710
711 let mut x_offset = 0u32;
712 let rel_x = mouse.x - rect.x;
713 for (idx, label) in state.labels.iter().enumerate() {
714 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
715 if rel_x >= x_offset && rel_x < x_offset + tab_width {
716 state.selected = idx;
717 self.consumed[i] = true;
718 break;
719 }
720 x_offset += tab_width + 1;
721 }
722 }
723 }
724 }
725
726 self.commands.push(Command::BeginContainer {
727 direction: Direction::Row,
728 gap: 1,
729 align: Align::Start,
730 justify: Justify::Start,
731 border: None,
732 border_sides: BorderSides::all(),
733 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
734 bg_color: None,
735 padding: Padding::default(),
736 margin: Margin::default(),
737 constraints: Constraints::default(),
738 title: None,
739 grow: 0,
740 group_name: None,
741 });
742 for (idx, label) in state.labels.iter().enumerate() {
743 let style = if idx == state.selected {
744 let s = Style::new()
745 .fg(colors.accent.unwrap_or(self.theme.primary))
746 .bold();
747 if focused {
748 s.underline()
749 } else {
750 s
751 }
752 } else {
753 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
754 };
755 self.styled(format!("[ {label} ]"), style);
756 }
757 self.commands.push(Command::EndContainer);
758 self.last_text_idx = None;
759
760 response.changed = state.selected != old_selected;
761 response
762 }
763
764 pub fn button(&mut self, label: impl Into<String>) -> Response {
769 self.button_colored(label, &WidgetColors::new())
770 }
771
772 pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
773 let focused = self.register_focusable();
774 let interaction_id = self.interaction_count;
775 self.interaction_count += 1;
776 let mut response = self.response_for(interaction_id);
777 response.focused = focused;
778
779 let mut activated = response.clicked;
780 if focused {
781 let mut consumed_indices = Vec::new();
782 for (i, event) in self.events.iter().enumerate() {
783 if let Event::Key(key) = event {
784 if key.kind != KeyEventKind::Press {
785 continue;
786 }
787 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
788 activated = true;
789 consumed_indices.push(i);
790 }
791 }
792 }
793
794 for index in consumed_indices {
795 self.consumed[index] = true;
796 }
797 }
798
799 let hovered = response.hovered;
800 let base_fg = colors.fg.unwrap_or(self.theme.text);
801 let accent = colors.accent.unwrap_or(self.theme.accent);
802 let style = if focused {
803 Style::new().fg(accent).bold()
804 } else if hovered {
805 Style::new().fg(accent)
806 } else {
807 Style::new().fg(base_fg)
808 };
809 let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
810 let hover_bg = if hovered || focused {
811 Some(base_bg)
812 } else {
813 None
814 };
815
816 self.commands.push(Command::BeginContainer {
817 direction: Direction::Row,
818 gap: 0,
819 align: Align::Start,
820 justify: Justify::Start,
821 border: None,
822 border_sides: BorderSides::all(),
823 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
824 bg_color: hover_bg,
825 padding: Padding::default(),
826 margin: Margin::default(),
827 constraints: Constraints::default(),
828 title: None,
829 grow: 0,
830 group_name: None,
831 });
832 self.styled(format!("[ {} ]", label.into()), style);
833 self.commands.push(Command::EndContainer);
834 self.last_text_idx = None;
835
836 response.clicked = activated;
837 response
838 }
839
840 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
845 let focused = self.register_focusable();
846 let interaction_id = self.interaction_count;
847 self.interaction_count += 1;
848 let mut response = self.response_for(interaction_id);
849 response.focused = focused;
850
851 let mut activated = response.clicked;
852 if focused {
853 let mut consumed_indices = Vec::new();
854 for (i, event) in self.events.iter().enumerate() {
855 if let Event::Key(key) = event {
856 if key.kind != KeyEventKind::Press {
857 continue;
858 }
859 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
860 activated = true;
861 consumed_indices.push(i);
862 }
863 }
864 }
865 for index in consumed_indices {
866 self.consumed[index] = true;
867 }
868 }
869
870 let label = label.into();
871 let hover_bg = if response.hovered || focused {
872 Some(self.theme.surface_hover)
873 } else {
874 None
875 };
876 let (text, style, bg_color, border) = match variant {
877 ButtonVariant::Default => {
878 let style = if focused {
879 Style::new().fg(self.theme.primary).bold()
880 } else if response.hovered {
881 Style::new().fg(self.theme.accent)
882 } else {
883 Style::new().fg(self.theme.text)
884 };
885 (format!("[ {label} ]"), style, hover_bg, None)
886 }
887 ButtonVariant::Primary => {
888 let style = if focused {
889 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
890 } else if response.hovered {
891 Style::new().fg(self.theme.bg).bg(self.theme.accent)
892 } else {
893 Style::new().fg(self.theme.bg).bg(self.theme.primary)
894 };
895 (format!(" {label} "), style, hover_bg, None)
896 }
897 ButtonVariant::Danger => {
898 let style = if focused {
899 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
900 } else if response.hovered {
901 Style::new().fg(self.theme.bg).bg(self.theme.warning)
902 } else {
903 Style::new().fg(self.theme.bg).bg(self.theme.error)
904 };
905 (format!(" {label} "), style, hover_bg, None)
906 }
907 ButtonVariant::Outline => {
908 let border_color = if focused {
909 self.theme.primary
910 } else if response.hovered {
911 self.theme.accent
912 } else {
913 self.theme.border
914 };
915 let style = if focused {
916 Style::new().fg(self.theme.primary).bold()
917 } else if response.hovered {
918 Style::new().fg(self.theme.accent)
919 } else {
920 Style::new().fg(self.theme.text)
921 };
922 (
923 format!(" {label} "),
924 style,
925 hover_bg,
926 Some((Border::Rounded, Style::new().fg(border_color))),
927 )
928 }
929 };
930
931 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
932 self.commands.push(Command::BeginContainer {
933 direction: Direction::Row,
934 gap: 0,
935 align: Align::Center,
936 justify: Justify::Center,
937 border: if border.is_some() {
938 Some(btn_border)
939 } else {
940 None
941 },
942 border_sides: BorderSides::all(),
943 border_style: btn_border_style,
944 bg_color,
945 padding: Padding::default(),
946 margin: Margin::default(),
947 constraints: Constraints::default(),
948 title: None,
949 grow: 0,
950 group_name: None,
951 });
952 self.styled(text, style);
953 self.commands.push(Command::EndContainer);
954 self.last_text_idx = None;
955
956 response.clicked = activated;
957 response
958 }
959
960 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
965 self.checkbox_colored(label, checked, &WidgetColors::new())
966 }
967
968 pub fn checkbox_colored(
969 &mut self,
970 label: impl Into<String>,
971 checked: &mut bool,
972 colors: &WidgetColors,
973 ) -> Response {
974 let focused = self.register_focusable();
975 let interaction_id = self.interaction_count;
976 self.interaction_count += 1;
977 let mut response = self.response_for(interaction_id);
978 response.focused = focused;
979 let mut should_toggle = response.clicked;
980 let old_checked = *checked;
981
982 if focused {
983 let mut consumed_indices = Vec::new();
984 for (i, event) in self.events.iter().enumerate() {
985 if let Event::Key(key) = event {
986 if key.kind != KeyEventKind::Press {
987 continue;
988 }
989 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
990 should_toggle = true;
991 consumed_indices.push(i);
992 }
993 }
994 }
995
996 for index in consumed_indices {
997 self.consumed[index] = true;
998 }
999 }
1000
1001 if should_toggle {
1002 *checked = !*checked;
1003 }
1004
1005 let hover_bg = if response.hovered || focused {
1006 Some(self.theme.surface_hover)
1007 } else {
1008 None
1009 };
1010 self.commands.push(Command::BeginContainer {
1011 direction: Direction::Row,
1012 gap: 1,
1013 align: Align::Start,
1014 justify: Justify::Start,
1015 border: None,
1016 border_sides: BorderSides::all(),
1017 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1018 bg_color: hover_bg,
1019 padding: Padding::default(),
1020 margin: Margin::default(),
1021 constraints: Constraints::default(),
1022 title: None,
1023 grow: 0,
1024 group_name: None,
1025 });
1026 let marker_style = if *checked {
1027 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1028 } else {
1029 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1030 };
1031 let marker = if *checked { "[x]" } else { "[ ]" };
1032 let label_text = label.into();
1033 if focused {
1034 self.styled(format!("▸ {marker}"), marker_style.bold());
1035 self.styled(
1036 label_text,
1037 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1038 );
1039 } else {
1040 self.styled(marker, marker_style);
1041 self.styled(
1042 label_text,
1043 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1044 );
1045 }
1046 self.commands.push(Command::EndContainer);
1047 self.last_text_idx = None;
1048
1049 response.changed = *checked != old_checked;
1050 response
1051 }
1052
1053 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
1059 self.toggle_colored(label, on, &WidgetColors::new())
1060 }
1061
1062 pub fn toggle_colored(
1063 &mut self,
1064 label: impl Into<String>,
1065 on: &mut bool,
1066 colors: &WidgetColors,
1067 ) -> Response {
1068 let focused = self.register_focusable();
1069 let interaction_id = self.interaction_count;
1070 self.interaction_count += 1;
1071 let mut response = self.response_for(interaction_id);
1072 response.focused = focused;
1073 let mut should_toggle = response.clicked;
1074 let old_on = *on;
1075
1076 if focused {
1077 let mut consumed_indices = Vec::new();
1078 for (i, event) in self.events.iter().enumerate() {
1079 if let Event::Key(key) = event {
1080 if key.kind != KeyEventKind::Press {
1081 continue;
1082 }
1083 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1084 should_toggle = true;
1085 consumed_indices.push(i);
1086 }
1087 }
1088 }
1089
1090 for index in consumed_indices {
1091 self.consumed[index] = true;
1092 }
1093 }
1094
1095 if should_toggle {
1096 *on = !*on;
1097 }
1098
1099 let hover_bg = if response.hovered || focused {
1100 Some(self.theme.surface_hover)
1101 } else {
1102 None
1103 };
1104 self.commands.push(Command::BeginContainer {
1105 direction: Direction::Row,
1106 gap: 2,
1107 align: Align::Start,
1108 justify: Justify::Start,
1109 border: None,
1110 border_sides: BorderSides::all(),
1111 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1112 bg_color: hover_bg,
1113 padding: Padding::default(),
1114 margin: Margin::default(),
1115 constraints: Constraints::default(),
1116 title: None,
1117 grow: 0,
1118 group_name: None,
1119 });
1120 let label_text = label.into();
1121 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1122 let switch_style = if *on {
1123 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1124 } else {
1125 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1126 };
1127 if focused {
1128 self.styled(
1129 format!("▸ {label_text}"),
1130 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1131 );
1132 self.styled(switch, switch_style.bold());
1133 } else {
1134 self.styled(
1135 label_text,
1136 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1137 );
1138 self.styled(switch, switch_style);
1139 }
1140 self.commands.push(Command::EndContainer);
1141 self.last_text_idx = None;
1142
1143 response.changed = *on != old_on;
1144 response
1145 }
1146
1147 pub fn select(&mut self, state: &mut SelectState) -> Response {
1153 self.select_colored(state, &WidgetColors::new())
1154 }
1155
1156 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1157 if state.items.is_empty() {
1158 return Response::none();
1159 }
1160 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1161
1162 let focused = self.register_focusable();
1163 let interaction_id = self.interaction_count;
1164 self.interaction_count += 1;
1165 let mut response = self.response_for(interaction_id);
1166 response.focused = focused;
1167 let old_selected = state.selected;
1168
1169 if response.clicked {
1170 state.open = !state.open;
1171 if state.open {
1172 state.set_cursor(state.selected);
1173 }
1174 }
1175
1176 if focused {
1177 let mut consumed_indices = Vec::new();
1178 for (i, event) in self.events.iter().enumerate() {
1179 if self.consumed[i] {
1180 continue;
1181 }
1182 if let Event::Key(key) = event {
1183 if key.kind != KeyEventKind::Press {
1184 continue;
1185 }
1186 if state.open {
1187 match key.code {
1188 KeyCode::Up
1189 | KeyCode::Char('k')
1190 | KeyCode::Down
1191 | KeyCode::Char('j') => {
1192 let mut cursor = state.cursor();
1193 let _ = handle_vertical_nav(
1194 &mut cursor,
1195 state.items.len().saturating_sub(1),
1196 key.code.clone(),
1197 );
1198 state.set_cursor(cursor);
1199 consumed_indices.push(i);
1200 }
1201 KeyCode::Enter | KeyCode::Char(' ') => {
1202 state.selected = state.cursor();
1203 state.open = false;
1204 consumed_indices.push(i);
1205 }
1206 KeyCode::Esc => {
1207 state.open = false;
1208 consumed_indices.push(i);
1209 }
1210 _ => {}
1211 }
1212 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1213 state.open = true;
1214 state.set_cursor(state.selected);
1215 consumed_indices.push(i);
1216 }
1217 }
1218 }
1219 for idx in consumed_indices {
1220 self.consumed[idx] = true;
1221 }
1222 }
1223
1224 let changed = state.selected != old_selected;
1225
1226 let border_color = if focused {
1227 colors.accent.unwrap_or(self.theme.primary)
1228 } else {
1229 colors.border.unwrap_or(self.theme.border)
1230 };
1231 let display_text = state
1232 .items
1233 .get(state.selected)
1234 .cloned()
1235 .unwrap_or_else(|| state.placeholder.clone());
1236 let arrow = if state.open { "▲" } else { "▼" };
1237
1238 self.commands.push(Command::BeginContainer {
1239 direction: Direction::Column,
1240 gap: 0,
1241 align: Align::Start,
1242 justify: Justify::Start,
1243 border: None,
1244 border_sides: BorderSides::all(),
1245 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1246 bg_color: None,
1247 padding: Padding::default(),
1248 margin: Margin::default(),
1249 constraints: Constraints::default(),
1250 title: None,
1251 grow: 0,
1252 group_name: None,
1253 });
1254
1255 self.render_select_trigger(&display_text, arrow, border_color, colors);
1256
1257 if state.open {
1258 self.render_select_dropdown(state, colors);
1259 }
1260
1261 self.commands.push(Command::EndContainer);
1262 self.last_text_idx = None;
1263 response.changed = changed;
1264 response
1265 }
1266
1267 fn render_select_trigger(
1268 &mut self,
1269 display_text: &str,
1270 arrow: &str,
1271 border_color: Color,
1272 colors: &WidgetColors,
1273 ) {
1274 self.commands.push(Command::BeginContainer {
1275 direction: Direction::Row,
1276 gap: 1,
1277 align: Align::Start,
1278 justify: Justify::Start,
1279 border: Some(Border::Rounded),
1280 border_sides: BorderSides::all(),
1281 border_style: Style::new().fg(border_color),
1282 bg_color: None,
1283 padding: Padding {
1284 left: 1,
1285 right: 1,
1286 top: 0,
1287 bottom: 0,
1288 },
1289 margin: Margin::default(),
1290 constraints: Constraints::default(),
1291 title: None,
1292 grow: 0,
1293 group_name: None,
1294 });
1295 self.interaction_count += 1;
1296 self.styled(
1297 display_text,
1298 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1299 );
1300 self.styled(
1301 arrow,
1302 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1303 );
1304 self.commands.push(Command::EndContainer);
1305 self.last_text_idx = None;
1306 }
1307
1308 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1309 for (idx, item) in state.items.iter().enumerate() {
1310 let is_cursor = idx == state.cursor();
1311 let style = if is_cursor {
1312 Style::new()
1313 .bold()
1314 .fg(colors.accent.unwrap_or(self.theme.primary))
1315 } else {
1316 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1317 };
1318 let prefix = if is_cursor { "▸ " } else { " " };
1319 self.styled(format!("{prefix}{item}"), style);
1320 }
1321 }
1322
1323 pub fn radio(&mut self, state: &mut RadioState) -> Response {
1327 self.radio_colored(state, &WidgetColors::new())
1328 }
1329
1330 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1331 if state.items.is_empty() {
1332 return Response::none();
1333 }
1334 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1335 let focused = self.register_focusable();
1336 let old_selected = state.selected;
1337
1338 if focused {
1339 let mut consumed_indices = Vec::new();
1340 for (i, event) in self.events.iter().enumerate() {
1341 if self.consumed[i] {
1342 continue;
1343 }
1344 if let Event::Key(key) = event {
1345 if key.kind != KeyEventKind::Press {
1346 continue;
1347 }
1348 match key.code {
1349 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1350 let _ = handle_vertical_nav(
1351 &mut state.selected,
1352 state.items.len().saturating_sub(1),
1353 key.code.clone(),
1354 );
1355 consumed_indices.push(i);
1356 }
1357 KeyCode::Enter | KeyCode::Char(' ') => {
1358 consumed_indices.push(i);
1359 }
1360 _ => {}
1361 }
1362 }
1363 }
1364 for idx in consumed_indices {
1365 self.consumed[idx] = true;
1366 }
1367 }
1368
1369 let interaction_id = self.interaction_count;
1370 self.interaction_count += 1;
1371 let mut response = self.response_for(interaction_id);
1372 response.focused = focused;
1373
1374 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1375 for (i, event) in self.events.iter().enumerate() {
1376 if self.consumed[i] {
1377 continue;
1378 }
1379 if let Event::Mouse(mouse) = event {
1380 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1381 continue;
1382 }
1383 let in_bounds = mouse.x >= rect.x
1384 && mouse.x < rect.right()
1385 && mouse.y >= rect.y
1386 && mouse.y < rect.bottom();
1387 if !in_bounds {
1388 continue;
1389 }
1390 let clicked_idx = (mouse.y - rect.y) as usize;
1391 if clicked_idx < state.items.len() {
1392 state.selected = clicked_idx;
1393 self.consumed[i] = true;
1394 }
1395 }
1396 }
1397 }
1398
1399 self.commands.push(Command::BeginContainer {
1400 direction: Direction::Column,
1401 gap: 0,
1402 align: Align::Start,
1403 justify: Justify::Start,
1404 border: None,
1405 border_sides: BorderSides::all(),
1406 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1407 bg_color: None,
1408 padding: Padding::default(),
1409 margin: Margin::default(),
1410 constraints: Constraints::default(),
1411 title: None,
1412 grow: 0,
1413 group_name: None,
1414 });
1415
1416 for (idx, item) in state.items.iter().enumerate() {
1417 let is_selected = idx == state.selected;
1418 let marker = if is_selected { "●" } else { "○" };
1419 let style = if is_selected {
1420 if focused {
1421 Style::new()
1422 .bold()
1423 .fg(colors.accent.unwrap_or(self.theme.primary))
1424 } else {
1425 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1426 }
1427 } else {
1428 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1429 };
1430 let prefix = if focused && idx == state.selected {
1431 "▸ "
1432 } else {
1433 " "
1434 };
1435 self.styled(format!("{prefix}{marker} {item}"), style);
1436 }
1437
1438 self.commands.push(Command::EndContainer);
1439 self.last_text_idx = None;
1440 response.changed = state.selected != old_selected;
1441 response
1442 }
1443
1444 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1448 if state.items.is_empty() {
1449 return Response::none();
1450 }
1451 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1452 let focused = self.register_focusable();
1453 let old_selected = state.selected.clone();
1454
1455 if focused {
1456 let mut consumed_indices = Vec::new();
1457 for (i, event) in self.events.iter().enumerate() {
1458 if self.consumed[i] {
1459 continue;
1460 }
1461 if let Event::Key(key) = event {
1462 if key.kind != KeyEventKind::Press {
1463 continue;
1464 }
1465 match key.code {
1466 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1467 let _ = handle_vertical_nav(
1468 &mut state.cursor,
1469 state.items.len().saturating_sub(1),
1470 key.code.clone(),
1471 );
1472 consumed_indices.push(i);
1473 }
1474 KeyCode::Char(' ') | KeyCode::Enter => {
1475 state.toggle(state.cursor);
1476 consumed_indices.push(i);
1477 }
1478 _ => {}
1479 }
1480 }
1481 }
1482 for idx in consumed_indices {
1483 self.consumed[idx] = true;
1484 }
1485 }
1486
1487 let interaction_id = self.interaction_count;
1488 self.interaction_count += 1;
1489 let mut response = self.response_for(interaction_id);
1490 response.focused = focused;
1491
1492 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1493 for (i, event) in self.events.iter().enumerate() {
1494 if self.consumed[i] {
1495 continue;
1496 }
1497 if let Event::Mouse(mouse) = event {
1498 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1499 continue;
1500 }
1501 let in_bounds = mouse.x >= rect.x
1502 && mouse.x < rect.right()
1503 && mouse.y >= rect.y
1504 && mouse.y < rect.bottom();
1505 if !in_bounds {
1506 continue;
1507 }
1508 let clicked_idx = (mouse.y - rect.y) as usize;
1509 if clicked_idx < state.items.len() {
1510 state.toggle(clicked_idx);
1511 state.cursor = clicked_idx;
1512 self.consumed[i] = true;
1513 }
1514 }
1515 }
1516 }
1517
1518 self.commands.push(Command::BeginContainer {
1519 direction: Direction::Column,
1520 gap: 0,
1521 align: Align::Start,
1522 justify: Justify::Start,
1523 border: None,
1524 border_sides: BorderSides::all(),
1525 border_style: Style::new().fg(self.theme.border),
1526 bg_color: None,
1527 padding: Padding::default(),
1528 margin: Margin::default(),
1529 constraints: Constraints::default(),
1530 title: None,
1531 grow: 0,
1532 group_name: None,
1533 });
1534
1535 for (idx, item) in state.items.iter().enumerate() {
1536 let checked = state.selected.contains(&idx);
1537 let marker = if checked { "[x]" } else { "[ ]" };
1538 let is_cursor = idx == state.cursor;
1539 let style = if is_cursor && focused {
1540 Style::new().bold().fg(self.theme.primary)
1541 } else if checked {
1542 Style::new().fg(self.theme.success)
1543 } else {
1544 Style::new().fg(self.theme.text)
1545 };
1546 let prefix = if is_cursor && focused { "▸ " } else { " " };
1547 self.styled(format!("{prefix}{marker} {item}"), style);
1548 }
1549
1550 self.commands.push(Command::EndContainer);
1551 self.last_text_idx = None;
1552 response.changed = state.selected != old_selected;
1553 response
1554 }
1555
1556 pub fn tree(&mut self, state: &mut TreeState) -> Response {
1560 let entries = state.flatten();
1561 if entries.is_empty() {
1562 return Response::none();
1563 }
1564 state.selected = state.selected.min(entries.len().saturating_sub(1));
1565 let old_selected = state.selected;
1566 let focused = self.register_focusable();
1567 let interaction_id = self.interaction_count;
1568 self.interaction_count += 1;
1569 let mut response = self.response_for(interaction_id);
1570 response.focused = focused;
1571 let mut changed = false;
1572
1573 if focused {
1574 let mut consumed_indices = Vec::new();
1575 for (i, event) in self.events.iter().enumerate() {
1576 if self.consumed[i] {
1577 continue;
1578 }
1579 if let Event::Key(key) = event {
1580 if key.kind != KeyEventKind::Press {
1581 continue;
1582 }
1583 match key.code {
1584 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1585 let max_index = state.flatten().len().saturating_sub(1);
1586 let _ = handle_vertical_nav(
1587 &mut state.selected,
1588 max_index,
1589 key.code.clone(),
1590 );
1591 changed = changed || state.selected != old_selected;
1592 consumed_indices.push(i);
1593 }
1594 KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
1595 state.toggle_at(state.selected);
1596 changed = true;
1597 consumed_indices.push(i);
1598 }
1599 KeyCode::Left => {
1600 let entry = &entries[state.selected.min(entries.len() - 1)];
1601 if entry.expanded {
1602 state.toggle_at(state.selected);
1603 changed = true;
1604 }
1605 consumed_indices.push(i);
1606 }
1607 _ => {}
1608 }
1609 }
1610 }
1611 for idx in consumed_indices {
1612 self.consumed[idx] = true;
1613 }
1614 }
1615
1616 self.commands.push(Command::BeginContainer {
1617 direction: Direction::Column,
1618 gap: 0,
1619 align: Align::Start,
1620 justify: Justify::Start,
1621 border: None,
1622 border_sides: BorderSides::all(),
1623 border_style: Style::new().fg(self.theme.border),
1624 bg_color: None,
1625 padding: Padding::default(),
1626 margin: Margin::default(),
1627 constraints: Constraints::default(),
1628 title: None,
1629 grow: 0,
1630 group_name: None,
1631 });
1632
1633 let entries = state.flatten();
1634 for (idx, entry) in entries.iter().enumerate() {
1635 let indent = " ".repeat(entry.depth);
1636 let icon = if entry.is_leaf {
1637 " "
1638 } else if entry.expanded {
1639 "▾ "
1640 } else {
1641 "▸ "
1642 };
1643 let is_selected = idx == state.selected;
1644 let style = if is_selected && focused {
1645 Style::new().bold().fg(self.theme.primary)
1646 } else if is_selected {
1647 Style::new().fg(self.theme.primary)
1648 } else {
1649 Style::new().fg(self.theme.text)
1650 };
1651 let cursor = if is_selected && focused { "▸" } else { " " };
1652 self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
1653 }
1654
1655 self.commands.push(Command::EndContainer);
1656 self.last_text_idx = None;
1657 response.changed = changed || state.selected != old_selected;
1658 response
1659 }
1660
1661 pub fn virtual_list(
1668 &mut self,
1669 state: &mut ListState,
1670 visible_height: usize,
1671 f: impl Fn(&mut Context, usize),
1672 ) -> &mut Self {
1673 if state.items.is_empty() {
1674 return self;
1675 }
1676 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1677 let focused = self.register_focusable();
1678
1679 if focused {
1680 let mut consumed_indices = Vec::new();
1681 for (i, event) in self.events.iter().enumerate() {
1682 if self.consumed[i] {
1683 continue;
1684 }
1685 if let Event::Key(key) = event {
1686 if key.kind != KeyEventKind::Press {
1687 continue;
1688 }
1689 match key.code {
1690 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1691 let _ = handle_vertical_nav(
1692 &mut state.selected,
1693 state.items.len().saturating_sub(1),
1694 key.code.clone(),
1695 );
1696 consumed_indices.push(i);
1697 }
1698 KeyCode::PageUp => {
1699 state.selected = state.selected.saturating_sub(visible_height);
1700 consumed_indices.push(i);
1701 }
1702 KeyCode::PageDown => {
1703 state.selected = (state.selected + visible_height)
1704 .min(state.items.len().saturating_sub(1));
1705 consumed_indices.push(i);
1706 }
1707 KeyCode::Home => {
1708 state.selected = 0;
1709 consumed_indices.push(i);
1710 }
1711 KeyCode::End => {
1712 state.selected = state.items.len().saturating_sub(1);
1713 consumed_indices.push(i);
1714 }
1715 _ => {}
1716 }
1717 }
1718 }
1719 for idx in consumed_indices {
1720 self.consumed[idx] = true;
1721 }
1722 }
1723
1724 let start = if state.selected >= visible_height {
1725 state.selected - visible_height + 1
1726 } else {
1727 0
1728 };
1729 let end = (start + visible_height).min(state.items.len());
1730
1731 self.interaction_count += 1;
1732 self.commands.push(Command::BeginContainer {
1733 direction: Direction::Column,
1734 gap: 0,
1735 align: Align::Start,
1736 justify: Justify::Start,
1737 border: None,
1738 border_sides: BorderSides::all(),
1739 border_style: Style::new().fg(self.theme.border),
1740 bg_color: None,
1741 padding: Padding::default(),
1742 margin: Margin::default(),
1743 constraints: Constraints::default(),
1744 title: None,
1745 grow: 0,
1746 group_name: None,
1747 });
1748
1749 if start > 0 {
1750 self.styled(
1751 format!(" ↑ {} more", start),
1752 Style::new().fg(self.theme.text_dim).dim(),
1753 );
1754 }
1755
1756 for idx in start..end {
1757 f(self, idx);
1758 }
1759
1760 let remaining = state.items.len().saturating_sub(end);
1761 if remaining > 0 {
1762 self.styled(
1763 format!(" ↓ {} more", remaining),
1764 Style::new().fg(self.theme.text_dim).dim(),
1765 );
1766 }
1767
1768 self.commands.push(Command::EndContainer);
1769 self.last_text_idx = None;
1770 self
1771 }
1772
1773 pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
1777 if !state.open {
1778 return None;
1779 }
1780
1781 let filtered = state.filtered_indices();
1782 let sel = state.selected().min(filtered.len().saturating_sub(1));
1783 state.set_selected(sel);
1784
1785 let mut consumed_indices = Vec::new();
1786 let mut result: Option<usize> = None;
1787
1788 for (i, event) in self.events.iter().enumerate() {
1789 if self.consumed[i] {
1790 continue;
1791 }
1792 if let Event::Key(key) = event {
1793 if key.kind != KeyEventKind::Press {
1794 continue;
1795 }
1796 match key.code {
1797 KeyCode::Esc => {
1798 state.open = false;
1799 consumed_indices.push(i);
1800 }
1801 KeyCode::Up => {
1802 let s = state.selected();
1803 state.set_selected(s.saturating_sub(1));
1804 consumed_indices.push(i);
1805 }
1806 KeyCode::Down => {
1807 let s = state.selected();
1808 state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
1809 consumed_indices.push(i);
1810 }
1811 KeyCode::Enter => {
1812 if let Some(&cmd_idx) = filtered.get(state.selected()) {
1813 result = Some(cmd_idx);
1814 state.open = false;
1815 }
1816 consumed_indices.push(i);
1817 }
1818 KeyCode::Backspace => {
1819 if state.cursor > 0 {
1820 let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
1821 let end_idx = byte_index_for_char(&state.input, state.cursor);
1822 state.input.replace_range(byte_idx..end_idx, "");
1823 state.cursor -= 1;
1824 state.set_selected(0);
1825 }
1826 consumed_indices.push(i);
1827 }
1828 KeyCode::Char(ch) => {
1829 let byte_idx = byte_index_for_char(&state.input, state.cursor);
1830 state.input.insert(byte_idx, ch);
1831 state.cursor += 1;
1832 state.set_selected(0);
1833 consumed_indices.push(i);
1834 }
1835 _ => {}
1836 }
1837 }
1838 }
1839 for idx in consumed_indices {
1840 self.consumed[idx] = true;
1841 }
1842
1843 let filtered = state.filtered_indices();
1844
1845 self.modal(|ui| {
1846 let primary = ui.theme.primary;
1847 ui.container()
1848 .border(Border::Rounded)
1849 .border_style(Style::new().fg(primary))
1850 .pad(1)
1851 .max_w(60)
1852 .col(|ui| {
1853 let border_color = ui.theme.primary;
1854 ui.bordered(Border::Rounded)
1855 .border_style(Style::new().fg(border_color))
1856 .px(1)
1857 .col(|ui| {
1858 let display = if state.input.is_empty() {
1859 "Type to search...".to_string()
1860 } else {
1861 state.input.clone()
1862 };
1863 let style = if state.input.is_empty() {
1864 Style::new().dim().fg(ui.theme.text_dim)
1865 } else {
1866 Style::new().fg(ui.theme.text)
1867 };
1868 ui.styled(display, style);
1869 });
1870
1871 for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
1872 let cmd = &state.commands[cmd_idx];
1873 let is_selected = list_idx == state.selected();
1874 let style = if is_selected {
1875 Style::new().bold().fg(ui.theme.primary)
1876 } else {
1877 Style::new().fg(ui.theme.text)
1878 };
1879 let prefix = if is_selected { "▸ " } else { " " };
1880 let shortcut_text = cmd
1881 .shortcut
1882 .as_deref()
1883 .map(|s| format!(" ({s})"))
1884 .unwrap_or_default();
1885 ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
1886 if is_selected && !cmd.description.is_empty() {
1887 ui.styled(
1888 format!(" {}", cmd.description),
1889 Style::new().dim().fg(ui.theme.text_dim),
1890 );
1891 }
1892 }
1893
1894 if filtered.is_empty() {
1895 ui.styled(
1896 " No matching commands",
1897 Style::new().dim().fg(ui.theme.text_dim),
1898 );
1899 }
1900 });
1901 });
1902
1903 result
1904 }
1905
1906 pub fn markdown(&mut self, text: &str) -> Response {
1913 self.commands.push(Command::BeginContainer {
1914 direction: Direction::Column,
1915 gap: 0,
1916 align: Align::Start,
1917 justify: Justify::Start,
1918 border: None,
1919 border_sides: BorderSides::all(),
1920 border_style: Style::new().fg(self.theme.border),
1921 bg_color: None,
1922 padding: Padding::default(),
1923 margin: Margin::default(),
1924 constraints: Constraints::default(),
1925 title: None,
1926 grow: 0,
1927 group_name: None,
1928 });
1929 self.interaction_count += 1;
1930
1931 let text_style = Style::new().fg(self.theme.text);
1932 let bold_style = Style::new().fg(self.theme.text).bold();
1933 let code_style = Style::new().fg(self.theme.accent);
1934
1935 for line in text.lines() {
1936 let trimmed = line.trim();
1937 if trimmed.is_empty() {
1938 self.text(" ");
1939 continue;
1940 }
1941 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1942 self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
1943 continue;
1944 }
1945 if let Some(heading) = trimmed.strip_prefix("### ") {
1946 self.styled(heading, Style::new().bold().fg(self.theme.accent));
1947 } else if let Some(heading) = trimmed.strip_prefix("## ") {
1948 self.styled(heading, Style::new().bold().fg(self.theme.secondary));
1949 } else if let Some(heading) = trimmed.strip_prefix("# ") {
1950 self.styled(heading, Style::new().bold().fg(self.theme.primary));
1951 } else if let Some(item) = trimmed
1952 .strip_prefix("- ")
1953 .or_else(|| trimmed.strip_prefix("* "))
1954 {
1955 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
1956 if segs.len() <= 1 {
1957 self.styled(format!(" • {item}"), text_style);
1958 } else {
1959 self.line(|ui| {
1960 ui.styled(" • ", text_style);
1961 for (s, st) in segs {
1962 ui.styled(s, st);
1963 }
1964 });
1965 }
1966 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
1967 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
1968 if parts.len() == 2 {
1969 let segs =
1970 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
1971 if segs.len() <= 1 {
1972 self.styled(format!(" {}. {}", parts[0], parts[1]), text_style);
1973 } else {
1974 self.line(|ui| {
1975 ui.styled(format!(" {}. ", parts[0]), text_style);
1976 for (s, st) in segs {
1977 ui.styled(s, st);
1978 }
1979 });
1980 }
1981 } else {
1982 self.text(trimmed);
1983 }
1984 } else if let Some(code) = trimmed.strip_prefix("```") {
1985 let _ = code;
1986 self.styled(" ┌─code─", Style::new().fg(self.theme.border).dim());
1987 } else {
1988 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
1989 if segs.len() <= 1 {
1990 self.styled(trimmed, text_style);
1991 } else {
1992 self.line(|ui| {
1993 for (s, st) in segs {
1994 ui.styled(s, st);
1995 }
1996 });
1997 }
1998 }
1999 }
2000
2001 self.commands.push(Command::EndContainer);
2002 self.last_text_idx = None;
2003 Response::none()
2004 }
2005
2006 pub(crate) fn parse_inline_segments(
2007 text: &str,
2008 base: Style,
2009 bold: Style,
2010 code: Style,
2011 ) -> Vec<(String, Style)> {
2012 let mut segments: Vec<(String, Style)> = Vec::new();
2013 let mut current = String::new();
2014 let chars: Vec<char> = text.chars().collect();
2015 let mut i = 0;
2016 while i < chars.len() {
2017 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2018 let rest: String = chars[i + 2..].iter().collect();
2019 if let Some(end) = rest.find("**") {
2020 if !current.is_empty() {
2021 segments.push((std::mem::take(&mut current), base));
2022 }
2023 let inner: String = rest[..end].to_string();
2024 let char_count = inner.chars().count();
2025 segments.push((inner, bold));
2026 i += 2 + char_count + 2;
2027 continue;
2028 }
2029 }
2030 if chars[i] == '*'
2031 && (i + 1 >= chars.len() || chars[i + 1] != '*')
2032 && (i == 0 || chars[i - 1] != '*')
2033 {
2034 let rest: String = chars[i + 1..].iter().collect();
2035 if let Some(end) = rest.find('*') {
2036 if !current.is_empty() {
2037 segments.push((std::mem::take(&mut current), base));
2038 }
2039 let inner: String = rest[..end].to_string();
2040 let char_count = inner.chars().count();
2041 segments.push((inner, base.italic()));
2042 i += 1 + char_count + 1;
2043 continue;
2044 }
2045 }
2046 if chars[i] == '`' {
2047 let rest: String = chars[i + 1..].iter().collect();
2048 if let Some(end) = rest.find('`') {
2049 if !current.is_empty() {
2050 segments.push((std::mem::take(&mut current), base));
2051 }
2052 let inner: String = rest[..end].to_string();
2053 let char_count = inner.chars().count();
2054 segments.push((inner, code));
2055 i += 1 + char_count + 1;
2056 continue;
2057 }
2058 }
2059 current.push(chars[i]);
2060 i += 1;
2061 }
2062 if !current.is_empty() {
2063 segments.push((current, base));
2064 }
2065 segments
2066 }
2067
2068 pub fn key_seq(&self, seq: &str) -> bool {
2075 if seq.is_empty() {
2076 return false;
2077 }
2078 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2079 return false;
2080 }
2081 let target: Vec<char> = seq.chars().collect();
2082 let mut matched = 0;
2083 for (i, event) in self.events.iter().enumerate() {
2084 if self.consumed[i] {
2085 continue;
2086 }
2087 if let Event::Key(key) = event {
2088 if key.kind != KeyEventKind::Press {
2089 continue;
2090 }
2091 if let KeyCode::Char(c) = key.code {
2092 if c == target[matched] {
2093 matched += 1;
2094 if matched == target.len() {
2095 return true;
2096 }
2097 } else {
2098 matched = 0;
2099 if c == target[0] {
2100 matched = 1;
2101 }
2102 }
2103 }
2104 }
2105 }
2106 false
2107 }
2108
2109 pub fn separator(&mut self) -> Response {
2114 self.commands.push(Command::Text {
2115 content: "─".repeat(200),
2116 style: Style::new().fg(self.theme.border).dim(),
2117 grow: 0,
2118 align: Align::Start,
2119 wrap: false,
2120 margin: Margin::default(),
2121 constraints: Constraints::default(),
2122 });
2123 self.last_text_idx = Some(self.commands.len() - 1);
2124 Response::none()
2125 }
2126
2127 pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
2133 if bindings.is_empty() {
2134 return Response::none();
2135 }
2136
2137 self.interaction_count += 1;
2138 self.commands.push(Command::BeginContainer {
2139 direction: Direction::Row,
2140 gap: 2,
2141 align: Align::Start,
2142 justify: Justify::Start,
2143 border: None,
2144 border_sides: BorderSides::all(),
2145 border_style: Style::new().fg(self.theme.border),
2146 bg_color: None,
2147 padding: Padding::default(),
2148 margin: Margin::default(),
2149 constraints: Constraints::default(),
2150 title: None,
2151 grow: 0,
2152 group_name: None,
2153 });
2154 for (idx, (key, action)) in bindings.iter().enumerate() {
2155 if idx > 0 {
2156 self.styled("·", Style::new().fg(self.theme.text_dim));
2157 }
2158 self.styled(*key, Style::new().bold().fg(self.theme.primary));
2159 self.styled(*action, Style::new().fg(self.theme.text_dim));
2160 }
2161 self.commands.push(Command::EndContainer);
2162 self.last_text_idx = None;
2163
2164 Response::none()
2165 }
2166
2167 pub fn key(&self, c: char) -> bool {
2173 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2174 return false;
2175 }
2176 self.events.iter().enumerate().any(|(i, e)| {
2177 !self.consumed[i]
2178 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2179 })
2180 }
2181
2182 pub fn key_code(&self, code: KeyCode) -> bool {
2186 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2187 return false;
2188 }
2189 self.events.iter().enumerate().any(|(i, e)| {
2190 !self.consumed[i]
2191 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
2192 })
2193 }
2194
2195 pub fn key_release(&self, c: char) -> bool {
2199 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2200 return false;
2201 }
2202 self.events.iter().enumerate().any(|(i, e)| {
2203 !self.consumed[i]
2204 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
2205 })
2206 }
2207
2208 pub fn key_code_release(&self, code: KeyCode) -> bool {
2212 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2213 return false;
2214 }
2215 self.events.iter().enumerate().any(|(i, e)| {
2216 !self.consumed[i]
2217 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
2218 })
2219 }
2220
2221 pub fn consume_key(&mut self, c: char) -> bool {
2231 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2232 return false;
2233 }
2234 for (i, event) in self.events.iter().enumerate() {
2235 if self.consumed[i] {
2236 continue;
2237 }
2238 if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2239 {
2240 self.consumed[i] = true;
2241 return true;
2242 }
2243 }
2244 false
2245 }
2246
2247 pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
2257 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2258 return false;
2259 }
2260 for (i, event) in self.events.iter().enumerate() {
2261 if self.consumed[i] {
2262 continue;
2263 }
2264 if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
2265 self.consumed[i] = true;
2266 return true;
2267 }
2268 }
2269 false
2270 }
2271
2272 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2276 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2277 return false;
2278 }
2279 self.events.iter().enumerate().any(|(i, e)| {
2280 !self.consumed[i]
2281 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2282 })
2283 }
2284
2285 pub fn mouse_down(&self) -> Option<(u32, u32)> {
2289 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2290 return None;
2291 }
2292 self.events.iter().enumerate().find_map(|(i, event)| {
2293 if self.consumed[i] {
2294 return None;
2295 }
2296 if let Event::Mouse(mouse) = event {
2297 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2298 return Some((mouse.x, mouse.y));
2299 }
2300 }
2301 None
2302 })
2303 }
2304
2305 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2310 self.mouse_pos
2311 }
2312
2313 pub fn paste(&self) -> Option<&str> {
2315 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2316 return None;
2317 }
2318 self.events.iter().enumerate().find_map(|(i, event)| {
2319 if self.consumed[i] {
2320 return None;
2321 }
2322 if let Event::Paste(ref text) = event {
2323 return Some(text.as_str());
2324 }
2325 None
2326 })
2327 }
2328
2329 pub fn scroll_up(&self) -> bool {
2331 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2332 return false;
2333 }
2334 self.events.iter().enumerate().any(|(i, event)| {
2335 !self.consumed[i]
2336 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2337 })
2338 }
2339
2340 pub fn scroll_down(&self) -> bool {
2342 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2343 return false;
2344 }
2345 self.events.iter().enumerate().any(|(i, event)| {
2346 !self.consumed[i]
2347 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2348 })
2349 }
2350
2351 pub fn quit(&mut self) {
2353 self.should_quit = true;
2354 }
2355
2356 pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2364 self.clipboard_text = Some(text.into());
2365 }
2366
2367 pub fn theme(&self) -> &Theme {
2369 &self.theme
2370 }
2371
2372 pub fn set_theme(&mut self, theme: Theme) {
2376 self.theme = theme;
2377 }
2378
2379 pub fn is_dark_mode(&self) -> bool {
2381 self.dark_mode
2382 }
2383
2384 pub fn set_dark_mode(&mut self, dark: bool) {
2386 self.dark_mode = dark;
2387 }
2388
2389 pub fn width(&self) -> u32 {
2393 self.area_width
2394 }
2395
2396 pub fn breakpoint(&self) -> Breakpoint {
2420 let w = self.area_width;
2421 if w < 40 {
2422 Breakpoint::Xs
2423 } else if w < 80 {
2424 Breakpoint::Sm
2425 } else if w < 120 {
2426 Breakpoint::Md
2427 } else if w < 160 {
2428 Breakpoint::Lg
2429 } else {
2430 Breakpoint::Xl
2431 }
2432 }
2433
2434 pub fn height(&self) -> u32 {
2436 self.area_height
2437 }
2438
2439 pub fn tick(&self) -> u64 {
2444 self.tick
2445 }
2446
2447 pub fn debug_enabled(&self) -> bool {
2451 self.debug
2452 }
2453}