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 base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
803 let style = if focused {
804 Style::new().fg(accent).bold()
805 } else if hovered {
806 Style::new().fg(accent)
807 } else {
808 Style::new().fg(base_fg)
809 };
810 let has_custom_bg = colors.bg.is_some();
811 let bg_color = if has_custom_bg || hovered || focused {
812 Some(base_bg)
813 } else {
814 None
815 };
816
817 self.commands.push(Command::BeginContainer {
818 direction: Direction::Row,
819 gap: 0,
820 align: Align::Start,
821 justify: Justify::Start,
822 border: None,
823 border_sides: BorderSides::all(),
824 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
825 bg_color,
826 padding: Padding::default(),
827 margin: Margin::default(),
828 constraints: Constraints::default(),
829 title: None,
830 grow: 0,
831 group_name: None,
832 });
833 let label_text = format!("[ {} ]", label.into());
834 self.styled(label_text, style);
835 self.commands.push(Command::EndContainer);
836 self.last_text_idx = None;
837
838 response.clicked = activated;
839 response
840 }
841
842 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
847 let focused = self.register_focusable();
848 let interaction_id = self.interaction_count;
849 self.interaction_count += 1;
850 let mut response = self.response_for(interaction_id);
851 response.focused = focused;
852
853 let mut activated = response.clicked;
854 if focused {
855 let mut consumed_indices = Vec::new();
856 for (i, event) in self.events.iter().enumerate() {
857 if let Event::Key(key) = event {
858 if key.kind != KeyEventKind::Press {
859 continue;
860 }
861 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
862 activated = true;
863 consumed_indices.push(i);
864 }
865 }
866 }
867 for index in consumed_indices {
868 self.consumed[index] = true;
869 }
870 }
871
872 let label = label.into();
873 let hover_bg = if response.hovered || focused {
874 Some(self.theme.surface_hover)
875 } else {
876 None
877 };
878 let (text, style, bg_color, border) = match variant {
879 ButtonVariant::Default => {
880 let style = if focused {
881 Style::new().fg(self.theme.primary).bold()
882 } else if response.hovered {
883 Style::new().fg(self.theme.accent)
884 } else {
885 Style::new().fg(self.theme.text)
886 };
887 (format!("[ {label} ]"), style, hover_bg, None)
888 }
889 ButtonVariant::Primary => {
890 let style = if focused {
891 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
892 } else if response.hovered {
893 Style::new().fg(self.theme.bg).bg(self.theme.accent)
894 } else {
895 Style::new().fg(self.theme.bg).bg(self.theme.primary)
896 };
897 (format!(" {label} "), style, hover_bg, None)
898 }
899 ButtonVariant::Danger => {
900 let style = if focused {
901 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
902 } else if response.hovered {
903 Style::new().fg(self.theme.bg).bg(self.theme.warning)
904 } else {
905 Style::new().fg(self.theme.bg).bg(self.theme.error)
906 };
907 (format!(" {label} "), style, hover_bg, None)
908 }
909 ButtonVariant::Outline => {
910 let border_color = if focused {
911 self.theme.primary
912 } else if response.hovered {
913 self.theme.accent
914 } else {
915 self.theme.border
916 };
917 let style = if focused {
918 Style::new().fg(self.theme.primary).bold()
919 } else if response.hovered {
920 Style::new().fg(self.theme.accent)
921 } else {
922 Style::new().fg(self.theme.text)
923 };
924 (
925 format!(" {label} "),
926 style,
927 hover_bg,
928 Some((Border::Rounded, Style::new().fg(border_color))),
929 )
930 }
931 };
932
933 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
934 self.commands.push(Command::BeginContainer {
935 direction: Direction::Row,
936 gap: 0,
937 align: Align::Center,
938 justify: Justify::Center,
939 border: if border.is_some() {
940 Some(btn_border)
941 } else {
942 None
943 },
944 border_sides: BorderSides::all(),
945 border_style: btn_border_style,
946 bg_color,
947 padding: Padding::default(),
948 margin: Margin::default(),
949 constraints: Constraints::default(),
950 title: None,
951 grow: 0,
952 group_name: None,
953 });
954 self.styled(text, style);
955 self.commands.push(Command::EndContainer);
956 self.last_text_idx = None;
957
958 response.clicked = activated;
959 response
960 }
961
962 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
967 self.checkbox_colored(label, checked, &WidgetColors::new())
968 }
969
970 pub fn checkbox_colored(
971 &mut self,
972 label: impl Into<String>,
973 checked: &mut bool,
974 colors: &WidgetColors,
975 ) -> Response {
976 let focused = self.register_focusable();
977 let interaction_id = self.interaction_count;
978 self.interaction_count += 1;
979 let mut response = self.response_for(interaction_id);
980 response.focused = focused;
981 let mut should_toggle = response.clicked;
982 let old_checked = *checked;
983
984 if focused {
985 let mut consumed_indices = Vec::new();
986 for (i, event) in self.events.iter().enumerate() {
987 if let Event::Key(key) = event {
988 if key.kind != KeyEventKind::Press {
989 continue;
990 }
991 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
992 should_toggle = true;
993 consumed_indices.push(i);
994 }
995 }
996 }
997
998 for index in consumed_indices {
999 self.consumed[index] = true;
1000 }
1001 }
1002
1003 if should_toggle {
1004 *checked = !*checked;
1005 }
1006
1007 let hover_bg = if response.hovered || focused {
1008 Some(self.theme.surface_hover)
1009 } else {
1010 None
1011 };
1012 self.commands.push(Command::BeginContainer {
1013 direction: Direction::Row,
1014 gap: 1,
1015 align: Align::Start,
1016 justify: Justify::Start,
1017 border: None,
1018 border_sides: BorderSides::all(),
1019 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1020 bg_color: hover_bg,
1021 padding: Padding::default(),
1022 margin: Margin::default(),
1023 constraints: Constraints::default(),
1024 title: None,
1025 grow: 0,
1026 group_name: None,
1027 });
1028 let marker_style = if *checked {
1029 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1030 } else {
1031 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1032 };
1033 let marker = if *checked { "[x]" } else { "[ ]" };
1034 let label_text = label.into();
1035 if focused {
1036 self.styled(format!("▸ {marker}"), marker_style.bold());
1037 self.styled(
1038 label_text,
1039 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1040 );
1041 } else {
1042 self.styled(marker, marker_style);
1043 self.styled(
1044 label_text,
1045 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1046 );
1047 }
1048 self.commands.push(Command::EndContainer);
1049 self.last_text_idx = None;
1050
1051 response.changed = *checked != old_checked;
1052 response
1053 }
1054
1055 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
1061 self.toggle_colored(label, on, &WidgetColors::new())
1062 }
1063
1064 pub fn toggle_colored(
1065 &mut self,
1066 label: impl Into<String>,
1067 on: &mut bool,
1068 colors: &WidgetColors,
1069 ) -> Response {
1070 let focused = self.register_focusable();
1071 let interaction_id = self.interaction_count;
1072 self.interaction_count += 1;
1073 let mut response = self.response_for(interaction_id);
1074 response.focused = focused;
1075 let mut should_toggle = response.clicked;
1076 let old_on = *on;
1077
1078 if focused {
1079 let mut consumed_indices = Vec::new();
1080 for (i, event) in self.events.iter().enumerate() {
1081 if let Event::Key(key) = event {
1082 if key.kind != KeyEventKind::Press {
1083 continue;
1084 }
1085 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1086 should_toggle = true;
1087 consumed_indices.push(i);
1088 }
1089 }
1090 }
1091
1092 for index in consumed_indices {
1093 self.consumed[index] = true;
1094 }
1095 }
1096
1097 if should_toggle {
1098 *on = !*on;
1099 }
1100
1101 let hover_bg = if response.hovered || focused {
1102 Some(self.theme.surface_hover)
1103 } else {
1104 None
1105 };
1106 self.commands.push(Command::BeginContainer {
1107 direction: Direction::Row,
1108 gap: 2,
1109 align: Align::Start,
1110 justify: Justify::Start,
1111 border: None,
1112 border_sides: BorderSides::all(),
1113 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1114 bg_color: hover_bg,
1115 padding: Padding::default(),
1116 margin: Margin::default(),
1117 constraints: Constraints::default(),
1118 title: None,
1119 grow: 0,
1120 group_name: None,
1121 });
1122 let label_text = label.into();
1123 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1124 let switch_style = if *on {
1125 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1126 } else {
1127 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1128 };
1129 if focused {
1130 self.styled(
1131 format!("▸ {label_text}"),
1132 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1133 );
1134 self.styled(switch, switch_style.bold());
1135 } else {
1136 self.styled(
1137 label_text,
1138 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1139 );
1140 self.styled(switch, switch_style);
1141 }
1142 self.commands.push(Command::EndContainer);
1143 self.last_text_idx = None;
1144
1145 response.changed = *on != old_on;
1146 response
1147 }
1148
1149 pub fn select(&mut self, state: &mut SelectState) -> Response {
1155 self.select_colored(state, &WidgetColors::new())
1156 }
1157
1158 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1159 if state.items.is_empty() {
1160 return Response::none();
1161 }
1162 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1163
1164 let focused = self.register_focusable();
1165 let interaction_id = self.interaction_count;
1166 self.interaction_count += 1;
1167 let mut response = self.response_for(interaction_id);
1168 response.focused = focused;
1169 let old_selected = state.selected;
1170
1171 if response.clicked {
1172 state.open = !state.open;
1173 if state.open {
1174 state.set_cursor(state.selected);
1175 }
1176 }
1177
1178 if focused {
1179 let mut consumed_indices = Vec::new();
1180 for (i, event) in self.events.iter().enumerate() {
1181 if self.consumed[i] {
1182 continue;
1183 }
1184 if let Event::Key(key) = event {
1185 if key.kind != KeyEventKind::Press {
1186 continue;
1187 }
1188 if state.open {
1189 match key.code {
1190 KeyCode::Up
1191 | KeyCode::Char('k')
1192 | KeyCode::Down
1193 | KeyCode::Char('j') => {
1194 let mut cursor = state.cursor();
1195 let _ = handle_vertical_nav(
1196 &mut cursor,
1197 state.items.len().saturating_sub(1),
1198 key.code.clone(),
1199 );
1200 state.set_cursor(cursor);
1201 consumed_indices.push(i);
1202 }
1203 KeyCode::Enter | KeyCode::Char(' ') => {
1204 state.selected = state.cursor();
1205 state.open = false;
1206 consumed_indices.push(i);
1207 }
1208 KeyCode::Esc => {
1209 state.open = false;
1210 consumed_indices.push(i);
1211 }
1212 _ => {}
1213 }
1214 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1215 state.open = true;
1216 state.set_cursor(state.selected);
1217 consumed_indices.push(i);
1218 }
1219 }
1220 }
1221 for idx in consumed_indices {
1222 self.consumed[idx] = true;
1223 }
1224 }
1225
1226 let changed = state.selected != old_selected;
1227
1228 let border_color = if focused {
1229 colors.accent.unwrap_or(self.theme.primary)
1230 } else {
1231 colors.border.unwrap_or(self.theme.border)
1232 };
1233 let display_text = state
1234 .items
1235 .get(state.selected)
1236 .cloned()
1237 .unwrap_or_else(|| state.placeholder.clone());
1238 let arrow = if state.open { "▲" } else { "▼" };
1239
1240 self.commands.push(Command::BeginContainer {
1241 direction: Direction::Column,
1242 gap: 0,
1243 align: Align::Start,
1244 justify: Justify::Start,
1245 border: None,
1246 border_sides: BorderSides::all(),
1247 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1248 bg_color: None,
1249 padding: Padding::default(),
1250 margin: Margin::default(),
1251 constraints: Constraints::default(),
1252 title: None,
1253 grow: 0,
1254 group_name: None,
1255 });
1256
1257 self.render_select_trigger(&display_text, arrow, border_color, colors);
1258
1259 if state.open {
1260 self.render_select_dropdown(state, colors);
1261 }
1262
1263 self.commands.push(Command::EndContainer);
1264 self.last_text_idx = None;
1265 response.changed = changed;
1266 response
1267 }
1268
1269 fn render_select_trigger(
1270 &mut self,
1271 display_text: &str,
1272 arrow: &str,
1273 border_color: Color,
1274 colors: &WidgetColors,
1275 ) {
1276 self.commands.push(Command::BeginContainer {
1277 direction: Direction::Row,
1278 gap: 1,
1279 align: Align::Start,
1280 justify: Justify::Start,
1281 border: Some(Border::Rounded),
1282 border_sides: BorderSides::all(),
1283 border_style: Style::new().fg(border_color),
1284 bg_color: None,
1285 padding: Padding {
1286 left: 1,
1287 right: 1,
1288 top: 0,
1289 bottom: 0,
1290 },
1291 margin: Margin::default(),
1292 constraints: Constraints::default(),
1293 title: None,
1294 grow: 0,
1295 group_name: None,
1296 });
1297 self.interaction_count += 1;
1298 self.styled(
1299 display_text,
1300 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1301 );
1302 self.styled(
1303 arrow,
1304 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1305 );
1306 self.commands.push(Command::EndContainer);
1307 self.last_text_idx = None;
1308 }
1309
1310 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1311 for (idx, item) in state.items.iter().enumerate() {
1312 let is_cursor = idx == state.cursor();
1313 let style = if is_cursor {
1314 Style::new()
1315 .bold()
1316 .fg(colors.accent.unwrap_or(self.theme.primary))
1317 } else {
1318 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1319 };
1320 let prefix = if is_cursor { "▸ " } else { " " };
1321 self.styled(format!("{prefix}{item}"), style);
1322 }
1323 }
1324
1325 pub fn radio(&mut self, state: &mut RadioState) -> Response {
1329 self.radio_colored(state, &WidgetColors::new())
1330 }
1331
1332 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1333 if state.items.is_empty() {
1334 return Response::none();
1335 }
1336 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1337 let focused = self.register_focusable();
1338 let old_selected = state.selected;
1339
1340 if focused {
1341 let mut consumed_indices = Vec::new();
1342 for (i, event) in self.events.iter().enumerate() {
1343 if self.consumed[i] {
1344 continue;
1345 }
1346 if let Event::Key(key) = event {
1347 if key.kind != KeyEventKind::Press {
1348 continue;
1349 }
1350 match key.code {
1351 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1352 let _ = handle_vertical_nav(
1353 &mut state.selected,
1354 state.items.len().saturating_sub(1),
1355 key.code.clone(),
1356 );
1357 consumed_indices.push(i);
1358 }
1359 KeyCode::Enter | KeyCode::Char(' ') => {
1360 consumed_indices.push(i);
1361 }
1362 _ => {}
1363 }
1364 }
1365 }
1366 for idx in consumed_indices {
1367 self.consumed[idx] = true;
1368 }
1369 }
1370
1371 let interaction_id = self.interaction_count;
1372 self.interaction_count += 1;
1373 let mut response = self.response_for(interaction_id);
1374 response.focused = focused;
1375
1376 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1377 for (i, event) in self.events.iter().enumerate() {
1378 if self.consumed[i] {
1379 continue;
1380 }
1381 if let Event::Mouse(mouse) = event {
1382 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1383 continue;
1384 }
1385 let in_bounds = mouse.x >= rect.x
1386 && mouse.x < rect.right()
1387 && mouse.y >= rect.y
1388 && mouse.y < rect.bottom();
1389 if !in_bounds {
1390 continue;
1391 }
1392 let clicked_idx = (mouse.y - rect.y) as usize;
1393 if clicked_idx < state.items.len() {
1394 state.selected = clicked_idx;
1395 self.consumed[i] = true;
1396 }
1397 }
1398 }
1399 }
1400
1401 self.commands.push(Command::BeginContainer {
1402 direction: Direction::Column,
1403 gap: 0,
1404 align: Align::Start,
1405 justify: Justify::Start,
1406 border: None,
1407 border_sides: BorderSides::all(),
1408 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1409 bg_color: None,
1410 padding: Padding::default(),
1411 margin: Margin::default(),
1412 constraints: Constraints::default(),
1413 title: None,
1414 grow: 0,
1415 group_name: None,
1416 });
1417
1418 for (idx, item) in state.items.iter().enumerate() {
1419 let is_selected = idx == state.selected;
1420 let marker = if is_selected { "●" } else { "○" };
1421 let style = if is_selected {
1422 if focused {
1423 Style::new()
1424 .bold()
1425 .fg(colors.accent.unwrap_or(self.theme.primary))
1426 } else {
1427 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1428 }
1429 } else {
1430 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1431 };
1432 let prefix = if focused && idx == state.selected {
1433 "▸ "
1434 } else {
1435 " "
1436 };
1437 self.styled(format!("{prefix}{marker} {item}"), style);
1438 }
1439
1440 self.commands.push(Command::EndContainer);
1441 self.last_text_idx = None;
1442 response.changed = state.selected != old_selected;
1443 response
1444 }
1445
1446 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1450 if state.items.is_empty() {
1451 return Response::none();
1452 }
1453 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1454 let focused = self.register_focusable();
1455 let old_selected = state.selected.clone();
1456
1457 if focused {
1458 let mut consumed_indices = Vec::new();
1459 for (i, event) in self.events.iter().enumerate() {
1460 if self.consumed[i] {
1461 continue;
1462 }
1463 if let Event::Key(key) = event {
1464 if key.kind != KeyEventKind::Press {
1465 continue;
1466 }
1467 match key.code {
1468 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1469 let _ = handle_vertical_nav(
1470 &mut state.cursor,
1471 state.items.len().saturating_sub(1),
1472 key.code.clone(),
1473 );
1474 consumed_indices.push(i);
1475 }
1476 KeyCode::Char(' ') | KeyCode::Enter => {
1477 state.toggle(state.cursor);
1478 consumed_indices.push(i);
1479 }
1480 _ => {}
1481 }
1482 }
1483 }
1484 for idx in consumed_indices {
1485 self.consumed[idx] = true;
1486 }
1487 }
1488
1489 let interaction_id = self.interaction_count;
1490 self.interaction_count += 1;
1491 let mut response = self.response_for(interaction_id);
1492 response.focused = focused;
1493
1494 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1495 for (i, event) in self.events.iter().enumerate() {
1496 if self.consumed[i] {
1497 continue;
1498 }
1499 if let Event::Mouse(mouse) = event {
1500 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1501 continue;
1502 }
1503 let in_bounds = mouse.x >= rect.x
1504 && mouse.x < rect.right()
1505 && mouse.y >= rect.y
1506 && mouse.y < rect.bottom();
1507 if !in_bounds {
1508 continue;
1509 }
1510 let clicked_idx = (mouse.y - rect.y) as usize;
1511 if clicked_idx < state.items.len() {
1512 state.toggle(clicked_idx);
1513 state.cursor = clicked_idx;
1514 self.consumed[i] = true;
1515 }
1516 }
1517 }
1518 }
1519
1520 self.commands.push(Command::BeginContainer {
1521 direction: Direction::Column,
1522 gap: 0,
1523 align: Align::Start,
1524 justify: Justify::Start,
1525 border: None,
1526 border_sides: BorderSides::all(),
1527 border_style: Style::new().fg(self.theme.border),
1528 bg_color: None,
1529 padding: Padding::default(),
1530 margin: Margin::default(),
1531 constraints: Constraints::default(),
1532 title: None,
1533 grow: 0,
1534 group_name: None,
1535 });
1536
1537 for (idx, item) in state.items.iter().enumerate() {
1538 let checked = state.selected.contains(&idx);
1539 let marker = if checked { "[x]" } else { "[ ]" };
1540 let is_cursor = idx == state.cursor;
1541 let style = if is_cursor && focused {
1542 Style::new().bold().fg(self.theme.primary)
1543 } else if checked {
1544 Style::new().fg(self.theme.success)
1545 } else {
1546 Style::new().fg(self.theme.text)
1547 };
1548 let prefix = if is_cursor && focused { "▸ " } else { " " };
1549 self.styled(format!("{prefix}{marker} {item}"), style);
1550 }
1551
1552 self.commands.push(Command::EndContainer);
1553 self.last_text_idx = None;
1554 response.changed = state.selected != old_selected;
1555 response
1556 }
1557
1558 pub fn tree(&mut self, state: &mut TreeState) -> Response {
1562 let entries = state.flatten();
1563 if entries.is_empty() {
1564 return Response::none();
1565 }
1566 state.selected = state.selected.min(entries.len().saturating_sub(1));
1567 let old_selected = state.selected;
1568 let focused = self.register_focusable();
1569 let interaction_id = self.interaction_count;
1570 self.interaction_count += 1;
1571 let mut response = self.response_for(interaction_id);
1572 response.focused = focused;
1573 let mut changed = false;
1574
1575 if focused {
1576 let mut consumed_indices = Vec::new();
1577 for (i, event) in self.events.iter().enumerate() {
1578 if self.consumed[i] {
1579 continue;
1580 }
1581 if let Event::Key(key) = event {
1582 if key.kind != KeyEventKind::Press {
1583 continue;
1584 }
1585 match key.code {
1586 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1587 let max_index = state.flatten().len().saturating_sub(1);
1588 let _ = handle_vertical_nav(
1589 &mut state.selected,
1590 max_index,
1591 key.code.clone(),
1592 );
1593 changed = changed || state.selected != old_selected;
1594 consumed_indices.push(i);
1595 }
1596 KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
1597 state.toggle_at(state.selected);
1598 changed = true;
1599 consumed_indices.push(i);
1600 }
1601 KeyCode::Left => {
1602 let entry = &entries[state.selected.min(entries.len() - 1)];
1603 if entry.expanded {
1604 state.toggle_at(state.selected);
1605 changed = true;
1606 }
1607 consumed_indices.push(i);
1608 }
1609 _ => {}
1610 }
1611 }
1612 }
1613 for idx in consumed_indices {
1614 self.consumed[idx] = true;
1615 }
1616 }
1617
1618 self.commands.push(Command::BeginContainer {
1619 direction: Direction::Column,
1620 gap: 0,
1621 align: Align::Start,
1622 justify: Justify::Start,
1623 border: None,
1624 border_sides: BorderSides::all(),
1625 border_style: Style::new().fg(self.theme.border),
1626 bg_color: None,
1627 padding: Padding::default(),
1628 margin: Margin::default(),
1629 constraints: Constraints::default(),
1630 title: None,
1631 grow: 0,
1632 group_name: None,
1633 });
1634
1635 let entries = state.flatten();
1636 for (idx, entry) in entries.iter().enumerate() {
1637 let indent = " ".repeat(entry.depth);
1638 let icon = if entry.is_leaf {
1639 " "
1640 } else if entry.expanded {
1641 "▾ "
1642 } else {
1643 "▸ "
1644 };
1645 let is_selected = idx == state.selected;
1646 let style = if is_selected && focused {
1647 Style::new().bold().fg(self.theme.primary)
1648 } else if is_selected {
1649 Style::new().fg(self.theme.primary)
1650 } else {
1651 Style::new().fg(self.theme.text)
1652 };
1653 let cursor = if is_selected && focused { "▸" } else { " " };
1654 self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
1655 }
1656
1657 self.commands.push(Command::EndContainer);
1658 self.last_text_idx = None;
1659 response.changed = changed || state.selected != old_selected;
1660 response
1661 }
1662
1663 pub fn virtual_list(
1670 &mut self,
1671 state: &mut ListState,
1672 visible_height: usize,
1673 f: impl Fn(&mut Context, usize),
1674 ) -> &mut Self {
1675 if state.items.is_empty() {
1676 return self;
1677 }
1678 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1679 let focused = self.register_focusable();
1680
1681 if focused {
1682 let mut consumed_indices = Vec::new();
1683 for (i, event) in self.events.iter().enumerate() {
1684 if self.consumed[i] {
1685 continue;
1686 }
1687 if let Event::Key(key) = event {
1688 if key.kind != KeyEventKind::Press {
1689 continue;
1690 }
1691 match key.code {
1692 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1693 let _ = handle_vertical_nav(
1694 &mut state.selected,
1695 state.items.len().saturating_sub(1),
1696 key.code.clone(),
1697 );
1698 consumed_indices.push(i);
1699 }
1700 KeyCode::PageUp => {
1701 state.selected = state.selected.saturating_sub(visible_height);
1702 consumed_indices.push(i);
1703 }
1704 KeyCode::PageDown => {
1705 state.selected = (state.selected + visible_height)
1706 .min(state.items.len().saturating_sub(1));
1707 consumed_indices.push(i);
1708 }
1709 KeyCode::Home => {
1710 state.selected = 0;
1711 consumed_indices.push(i);
1712 }
1713 KeyCode::End => {
1714 state.selected = state.items.len().saturating_sub(1);
1715 consumed_indices.push(i);
1716 }
1717 _ => {}
1718 }
1719 }
1720 }
1721 for idx in consumed_indices {
1722 self.consumed[idx] = true;
1723 }
1724 }
1725
1726 let start = if state.selected >= visible_height {
1727 state.selected - visible_height + 1
1728 } else {
1729 0
1730 };
1731 let end = (start + visible_height).min(state.items.len());
1732
1733 self.interaction_count += 1;
1734 self.commands.push(Command::BeginContainer {
1735 direction: Direction::Column,
1736 gap: 0,
1737 align: Align::Start,
1738 justify: Justify::Start,
1739 border: None,
1740 border_sides: BorderSides::all(),
1741 border_style: Style::new().fg(self.theme.border),
1742 bg_color: None,
1743 padding: Padding::default(),
1744 margin: Margin::default(),
1745 constraints: Constraints::default(),
1746 title: None,
1747 grow: 0,
1748 group_name: None,
1749 });
1750
1751 if start > 0 {
1752 self.styled(
1753 format!(" ↑ {} more", start),
1754 Style::new().fg(self.theme.text_dim).dim(),
1755 );
1756 }
1757
1758 for idx in start..end {
1759 f(self, idx);
1760 }
1761
1762 let remaining = state.items.len().saturating_sub(end);
1763 if remaining > 0 {
1764 self.styled(
1765 format!(" ↓ {} more", remaining),
1766 Style::new().fg(self.theme.text_dim).dim(),
1767 );
1768 }
1769
1770 self.commands.push(Command::EndContainer);
1771 self.last_text_idx = None;
1772 self
1773 }
1774
1775 pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
1779 if !state.open {
1780 return None;
1781 }
1782
1783 let filtered = state.filtered_indices();
1784 let sel = state.selected().min(filtered.len().saturating_sub(1));
1785 state.set_selected(sel);
1786
1787 let mut consumed_indices = Vec::new();
1788 let mut result: Option<usize> = None;
1789
1790 for (i, event) in self.events.iter().enumerate() {
1791 if self.consumed[i] {
1792 continue;
1793 }
1794 if let Event::Key(key) = event {
1795 if key.kind != KeyEventKind::Press {
1796 continue;
1797 }
1798 match key.code {
1799 KeyCode::Esc => {
1800 state.open = false;
1801 consumed_indices.push(i);
1802 }
1803 KeyCode::Up => {
1804 let s = state.selected();
1805 state.set_selected(s.saturating_sub(1));
1806 consumed_indices.push(i);
1807 }
1808 KeyCode::Down => {
1809 let s = state.selected();
1810 state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
1811 consumed_indices.push(i);
1812 }
1813 KeyCode::Enter => {
1814 if let Some(&cmd_idx) = filtered.get(state.selected()) {
1815 result = Some(cmd_idx);
1816 state.open = false;
1817 }
1818 consumed_indices.push(i);
1819 }
1820 KeyCode::Backspace => {
1821 if state.cursor > 0 {
1822 let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
1823 let end_idx = byte_index_for_char(&state.input, state.cursor);
1824 state.input.replace_range(byte_idx..end_idx, "");
1825 state.cursor -= 1;
1826 state.set_selected(0);
1827 }
1828 consumed_indices.push(i);
1829 }
1830 KeyCode::Char(ch) => {
1831 let byte_idx = byte_index_for_char(&state.input, state.cursor);
1832 state.input.insert(byte_idx, ch);
1833 state.cursor += 1;
1834 state.set_selected(0);
1835 consumed_indices.push(i);
1836 }
1837 _ => {}
1838 }
1839 }
1840 }
1841 for idx in consumed_indices {
1842 self.consumed[idx] = true;
1843 }
1844
1845 let filtered = state.filtered_indices();
1846
1847 self.modal(|ui| {
1848 let primary = ui.theme.primary;
1849 ui.container()
1850 .border(Border::Rounded)
1851 .border_style(Style::new().fg(primary))
1852 .pad(1)
1853 .max_w(60)
1854 .col(|ui| {
1855 let border_color = ui.theme.primary;
1856 ui.bordered(Border::Rounded)
1857 .border_style(Style::new().fg(border_color))
1858 .px(1)
1859 .col(|ui| {
1860 let display = if state.input.is_empty() {
1861 "Type to search...".to_string()
1862 } else {
1863 state.input.clone()
1864 };
1865 let style = if state.input.is_empty() {
1866 Style::new().dim().fg(ui.theme.text_dim)
1867 } else {
1868 Style::new().fg(ui.theme.text)
1869 };
1870 ui.styled(display, style);
1871 });
1872
1873 for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
1874 let cmd = &state.commands[cmd_idx];
1875 let is_selected = list_idx == state.selected();
1876 let style = if is_selected {
1877 Style::new().bold().fg(ui.theme.primary)
1878 } else {
1879 Style::new().fg(ui.theme.text)
1880 };
1881 let prefix = if is_selected { "▸ " } else { " " };
1882 let shortcut_text = cmd
1883 .shortcut
1884 .as_deref()
1885 .map(|s| format!(" ({s})"))
1886 .unwrap_or_default();
1887 ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
1888 if is_selected && !cmd.description.is_empty() {
1889 ui.styled(
1890 format!(" {}", cmd.description),
1891 Style::new().dim().fg(ui.theme.text_dim),
1892 );
1893 }
1894 }
1895
1896 if filtered.is_empty() {
1897 ui.styled(
1898 " No matching commands",
1899 Style::new().dim().fg(ui.theme.text_dim),
1900 );
1901 }
1902 });
1903 });
1904
1905 result
1906 }
1907
1908 pub fn markdown(&mut self, text: &str) -> Response {
1915 self.commands.push(Command::BeginContainer {
1916 direction: Direction::Column,
1917 gap: 0,
1918 align: Align::Start,
1919 justify: Justify::Start,
1920 border: None,
1921 border_sides: BorderSides::all(),
1922 border_style: Style::new().fg(self.theme.border),
1923 bg_color: None,
1924 padding: Padding::default(),
1925 margin: Margin::default(),
1926 constraints: Constraints::default(),
1927 title: None,
1928 grow: 0,
1929 group_name: None,
1930 });
1931 self.interaction_count += 1;
1932
1933 let text_style = Style::new().fg(self.theme.text);
1934 let bold_style = Style::new().fg(self.theme.text).bold();
1935 let code_style = Style::new().fg(self.theme.accent);
1936
1937 for line in text.lines() {
1938 let trimmed = line.trim();
1939 if trimmed.is_empty() {
1940 self.text(" ");
1941 continue;
1942 }
1943 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1944 self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
1945 continue;
1946 }
1947 if let Some(heading) = trimmed.strip_prefix("### ") {
1948 self.styled(heading, Style::new().bold().fg(self.theme.accent));
1949 } else if let Some(heading) = trimmed.strip_prefix("## ") {
1950 self.styled(heading, Style::new().bold().fg(self.theme.secondary));
1951 } else if let Some(heading) = trimmed.strip_prefix("# ") {
1952 self.styled(heading, Style::new().bold().fg(self.theme.primary));
1953 } else if let Some(item) = trimmed
1954 .strip_prefix("- ")
1955 .or_else(|| trimmed.strip_prefix("* "))
1956 {
1957 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
1958 if segs.len() <= 1 {
1959 self.styled(format!(" • {item}"), text_style);
1960 } else {
1961 self.line(|ui| {
1962 ui.styled(" • ", text_style);
1963 for (s, st) in segs {
1964 ui.styled(s, st);
1965 }
1966 });
1967 }
1968 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
1969 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
1970 if parts.len() == 2 {
1971 let segs =
1972 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
1973 if segs.len() <= 1 {
1974 self.styled(format!(" {}. {}", parts[0], parts[1]), text_style);
1975 } else {
1976 self.line(|ui| {
1977 ui.styled(format!(" {}. ", parts[0]), text_style);
1978 for (s, st) in segs {
1979 ui.styled(s, st);
1980 }
1981 });
1982 }
1983 } else {
1984 self.text(trimmed);
1985 }
1986 } else if let Some(code) = trimmed.strip_prefix("```") {
1987 let _ = code;
1988 self.styled(" ┌─code─", Style::new().fg(self.theme.border).dim());
1989 } else {
1990 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
1991 if segs.len() <= 1 {
1992 self.styled(trimmed, text_style);
1993 } else {
1994 self.line(|ui| {
1995 for (s, st) in segs {
1996 ui.styled(s, st);
1997 }
1998 });
1999 }
2000 }
2001 }
2002
2003 self.commands.push(Command::EndContainer);
2004 self.last_text_idx = None;
2005 Response::none()
2006 }
2007
2008 pub(crate) fn parse_inline_segments(
2009 text: &str,
2010 base: Style,
2011 bold: Style,
2012 code: Style,
2013 ) -> Vec<(String, Style)> {
2014 let mut segments: Vec<(String, Style)> = Vec::new();
2015 let mut current = String::new();
2016 let chars: Vec<char> = text.chars().collect();
2017 let mut i = 0;
2018 while i < chars.len() {
2019 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2020 let rest: String = chars[i + 2..].iter().collect();
2021 if let Some(end) = rest.find("**") {
2022 if !current.is_empty() {
2023 segments.push((std::mem::take(&mut current), base));
2024 }
2025 let inner: String = rest[..end].to_string();
2026 let char_count = inner.chars().count();
2027 segments.push((inner, bold));
2028 i += 2 + char_count + 2;
2029 continue;
2030 }
2031 }
2032 if chars[i] == '*'
2033 && (i + 1 >= chars.len() || chars[i + 1] != '*')
2034 && (i == 0 || chars[i - 1] != '*')
2035 {
2036 let rest: String = chars[i + 1..].iter().collect();
2037 if let Some(end) = rest.find('*') {
2038 if !current.is_empty() {
2039 segments.push((std::mem::take(&mut current), base));
2040 }
2041 let inner: String = rest[..end].to_string();
2042 let char_count = inner.chars().count();
2043 segments.push((inner, base.italic()));
2044 i += 1 + char_count + 1;
2045 continue;
2046 }
2047 }
2048 if chars[i] == '`' {
2049 let rest: String = chars[i + 1..].iter().collect();
2050 if let Some(end) = rest.find('`') {
2051 if !current.is_empty() {
2052 segments.push((std::mem::take(&mut current), base));
2053 }
2054 let inner: String = rest[..end].to_string();
2055 let char_count = inner.chars().count();
2056 segments.push((inner, code));
2057 i += 1 + char_count + 1;
2058 continue;
2059 }
2060 }
2061 current.push(chars[i]);
2062 i += 1;
2063 }
2064 if !current.is_empty() {
2065 segments.push((current, base));
2066 }
2067 segments
2068 }
2069
2070 pub fn key_seq(&self, seq: &str) -> bool {
2077 if seq.is_empty() {
2078 return false;
2079 }
2080 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2081 return false;
2082 }
2083 let target: Vec<char> = seq.chars().collect();
2084 let mut matched = 0;
2085 for (i, event) in self.events.iter().enumerate() {
2086 if self.consumed[i] {
2087 continue;
2088 }
2089 if let Event::Key(key) = event {
2090 if key.kind != KeyEventKind::Press {
2091 continue;
2092 }
2093 if let KeyCode::Char(c) = key.code {
2094 if c == target[matched] {
2095 matched += 1;
2096 if matched == target.len() {
2097 return true;
2098 }
2099 } else {
2100 matched = 0;
2101 if c == target[0] {
2102 matched = 1;
2103 }
2104 }
2105 }
2106 }
2107 }
2108 false
2109 }
2110
2111 pub fn separator(&mut self) -> Response {
2116 self.commands.push(Command::Text {
2117 content: "─".repeat(200),
2118 style: Style::new().fg(self.theme.border).dim(),
2119 grow: 0,
2120 align: Align::Start,
2121 wrap: false,
2122 margin: Margin::default(),
2123 constraints: Constraints::default(),
2124 });
2125 self.last_text_idx = Some(self.commands.len() - 1);
2126 Response::none()
2127 }
2128
2129 pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
2135 if bindings.is_empty() {
2136 return Response::none();
2137 }
2138
2139 self.interaction_count += 1;
2140 self.commands.push(Command::BeginContainer {
2141 direction: Direction::Row,
2142 gap: 2,
2143 align: Align::Start,
2144 justify: Justify::Start,
2145 border: None,
2146 border_sides: BorderSides::all(),
2147 border_style: Style::new().fg(self.theme.border),
2148 bg_color: None,
2149 padding: Padding::default(),
2150 margin: Margin::default(),
2151 constraints: Constraints::default(),
2152 title: None,
2153 grow: 0,
2154 group_name: None,
2155 });
2156 for (idx, (key, action)) in bindings.iter().enumerate() {
2157 if idx > 0 {
2158 self.styled("·", Style::new().fg(self.theme.text_dim));
2159 }
2160 self.styled(*key, Style::new().bold().fg(self.theme.primary));
2161 self.styled(*action, Style::new().fg(self.theme.text_dim));
2162 }
2163 self.commands.push(Command::EndContainer);
2164 self.last_text_idx = None;
2165
2166 Response::none()
2167 }
2168
2169 pub fn key(&self, c: char) -> bool {
2175 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2176 return false;
2177 }
2178 self.events.iter().enumerate().any(|(i, e)| {
2179 !self.consumed[i]
2180 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2181 })
2182 }
2183
2184 pub fn key_code(&self, code: KeyCode) -> bool {
2188 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2189 return false;
2190 }
2191 self.events.iter().enumerate().any(|(i, e)| {
2192 !self.consumed[i]
2193 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
2194 })
2195 }
2196
2197 pub fn key_release(&self, c: char) -> bool {
2201 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2202 return false;
2203 }
2204 self.events.iter().enumerate().any(|(i, e)| {
2205 !self.consumed[i]
2206 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
2207 })
2208 }
2209
2210 pub fn key_code_release(&self, code: KeyCode) -> bool {
2214 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2215 return false;
2216 }
2217 self.events.iter().enumerate().any(|(i, e)| {
2218 !self.consumed[i]
2219 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
2220 })
2221 }
2222
2223 pub fn consume_key(&mut self, c: char) -> bool {
2233 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2234 return false;
2235 }
2236 for (i, event) in self.events.iter().enumerate() {
2237 if self.consumed[i] {
2238 continue;
2239 }
2240 if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2241 {
2242 self.consumed[i] = true;
2243 return true;
2244 }
2245 }
2246 false
2247 }
2248
2249 pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
2259 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2260 return false;
2261 }
2262 for (i, event) in self.events.iter().enumerate() {
2263 if self.consumed[i] {
2264 continue;
2265 }
2266 if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
2267 self.consumed[i] = true;
2268 return true;
2269 }
2270 }
2271 false
2272 }
2273
2274 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2278 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2279 return false;
2280 }
2281 self.events.iter().enumerate().any(|(i, e)| {
2282 !self.consumed[i]
2283 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2284 })
2285 }
2286
2287 pub fn mouse_down(&self) -> Option<(u32, u32)> {
2291 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2292 return None;
2293 }
2294 self.events.iter().enumerate().find_map(|(i, event)| {
2295 if self.consumed[i] {
2296 return None;
2297 }
2298 if let Event::Mouse(mouse) = event {
2299 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2300 return Some((mouse.x, mouse.y));
2301 }
2302 }
2303 None
2304 })
2305 }
2306
2307 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2312 self.mouse_pos
2313 }
2314
2315 pub fn paste(&self) -> Option<&str> {
2317 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2318 return None;
2319 }
2320 self.events.iter().enumerate().find_map(|(i, event)| {
2321 if self.consumed[i] {
2322 return None;
2323 }
2324 if let Event::Paste(ref text) = event {
2325 return Some(text.as_str());
2326 }
2327 None
2328 })
2329 }
2330
2331 pub fn scroll_up(&self) -> bool {
2333 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2334 return false;
2335 }
2336 self.events.iter().enumerate().any(|(i, event)| {
2337 !self.consumed[i]
2338 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2339 })
2340 }
2341
2342 pub fn scroll_down(&self) -> bool {
2344 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2345 return false;
2346 }
2347 self.events.iter().enumerate().any(|(i, event)| {
2348 !self.consumed[i]
2349 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2350 })
2351 }
2352
2353 pub fn quit(&mut self) {
2355 self.should_quit = true;
2356 }
2357
2358 pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2366 self.clipboard_text = Some(text.into());
2367 }
2368
2369 pub fn theme(&self) -> &Theme {
2371 &self.theme
2372 }
2373
2374 pub fn set_theme(&mut self, theme: Theme) {
2378 self.theme = theme;
2379 }
2380
2381 pub fn is_dark_mode(&self) -> bool {
2383 self.dark_mode
2384 }
2385
2386 pub fn set_dark_mode(&mut self, dark: bool) {
2388 self.dark_mode = dark;
2389 }
2390
2391 pub fn width(&self) -> u32 {
2395 self.area_width
2396 }
2397
2398 pub fn breakpoint(&self) -> Breakpoint {
2422 let w = self.area_width;
2423 if w < 40 {
2424 Breakpoint::Xs
2425 } else if w < 80 {
2426 Breakpoint::Sm
2427 } else if w < 120 {
2428 Breakpoint::Md
2429 } else if w < 160 {
2430 Breakpoint::Lg
2431 } else {
2432 Breakpoint::Xl
2433 }
2434 }
2435
2436 pub fn height(&self) -> u32 {
2438 self.area_height
2439 }
2440
2441 pub fn tick(&self) -> u64 {
2446 self.tick
2447 }
2448
2449 pub fn debug_enabled(&self) -> bool {
2453 self.debug
2454 }
2455}