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