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