1use super::*;
2
3impl Context {
4 pub fn table(&mut self, state: &mut TableState) -> Response {
10 self.table_colored(state, &WidgetColors::new())
11 }
12
13 pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
15 if state.is_dirty() {
16 state.recompute_widths();
17 }
18
19 let old_selected = state.selected;
20 let old_sort_column = state.sort_column;
21 let old_sort_ascending = state.sort_ascending;
22 let old_page = state.page;
23 let old_filter = state.filter.clone();
24
25 let focused = self.register_focusable();
26 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
27
28 self.table_handle_events(state, focused, interaction_id);
29
30 if state.is_dirty() {
31 state.recompute_widths();
32 }
33
34 self.table_render(state, focused, colors);
35
36 response.changed = state.selected != old_selected
37 || state.sort_column != old_sort_column
38 || state.sort_ascending != old_sort_ascending
39 || state.page != old_page
40 || state.filter != old_filter;
41 response
42 }
43
44 fn table_handle_events(
45 &mut self,
46 state: &mut TableState,
47 focused: bool,
48 interaction_id: usize,
49 ) {
50 self.handle_table_keys(state, focused);
51
52 if state.visible_indices().is_empty() && state.headers.is_empty() {
53 return;
54 }
55
56 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
57 let mut consumed = Vec::new();
58 for (i, mouse) in clicks {
59 if mouse.y == rect.y {
60 let rel_x = mouse.x.saturating_sub(rect.x);
61 let mut x_offset = 0u32;
62 for (col_idx, width) in state.column_widths().iter().enumerate() {
63 if rel_x >= x_offset && rel_x < x_offset + *width {
64 state.toggle_sort(col_idx);
65 state.selected = 0;
66 consumed.push(i);
67 break;
68 }
69 x_offset += *width;
70 if col_idx + 1 < state.column_widths().len() {
71 x_offset += 3;
72 }
73 }
74 continue;
75 }
76
77 if mouse.y < rect.y + 2 {
78 continue;
79 }
80
81 let visible_len = if state.page_size > 0 {
82 let start = state
83 .page
84 .saturating_mul(state.page_size)
85 .min(state.visible_indices().len());
86 let end = (start + state.page_size).min(state.visible_indices().len());
87 end.saturating_sub(start)
88 } else {
89 state.visible_indices().len()
90 };
91 let clicked_idx = (mouse.y - rect.y - 2) as usize;
92 if clicked_idx < visible_len {
93 state.selected = clicked_idx;
94 consumed.push(i);
95 }
96 }
97 self.consume_indices(consumed);
98 }
99 }
100
101 fn table_render(&mut self, state: &mut TableState, focused: bool, colors: &WidgetColors) {
102 let total_visible = state.visible_indices().len();
103 let page_start = if state.page_size > 0 {
104 state
105 .page
106 .saturating_mul(state.page_size)
107 .min(total_visible)
108 } else {
109 0
110 };
111 let page_end = if state.page_size > 0 {
112 (page_start + state.page_size).min(total_visible)
113 } else {
114 total_visible
115 };
116 let visible_len = page_end.saturating_sub(page_start);
117 state.selected = state.selected.min(visible_len.saturating_sub(1));
118
119 self.commands.push(Command::BeginContainer {
120 direction: Direction::Column,
121 gap: 0,
122 align: Align::Start,
123 align_self: None,
124 justify: Justify::Start,
125 border: None,
126 border_sides: BorderSides::all(),
127 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
128 bg_color: None,
129 padding: Padding::default(),
130 margin: Margin::default(),
131 constraints: Constraints::default(),
132 title: None,
133 grow: 0,
134 group_name: None,
135 });
136
137 self.render_table_header(state, colors);
138 self.render_table_rows(state, focused, page_start, visible_len, colors);
139
140 if state.page_size > 0 && state.total_pages() > 1 {
141 let current_page = (state.page + 1).to_string();
142 let total_pages = state.total_pages().to_string();
143 let mut page_text = String::with_capacity(current_page.len() + total_pages.len() + 6);
144 page_text.push_str("Page ");
145 page_text.push_str(¤t_page);
146 page_text.push('/');
147 page_text.push_str(&total_pages);
148 self.styled(
149 page_text,
150 Style::new()
151 .dim()
152 .fg(colors.fg.unwrap_or(self.theme.text_dim)),
153 );
154 }
155
156 self.commands.push(Command::EndContainer);
157 self.rollback.last_text_idx = None;
158 }
159
160 fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
161 if !focused || state.visible_indices().is_empty() {
162 return;
163 }
164
165 let mut consumed_indices = Vec::new();
166 for (i, key) in self.available_key_presses() {
167 match key.code {
168 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
169 let visible_len = table_visible_len(state);
170 state.selected = state.selected.min(visible_len.saturating_sub(1));
171 let _ = handle_vertical_nav(
172 &mut state.selected,
173 visible_len.saturating_sub(1),
174 key.code.clone(),
175 );
176 consumed_indices.push(i);
177 }
178 KeyCode::PageUp => {
179 let old_page = state.page;
180 state.prev_page();
181 if state.page != old_page {
182 state.selected = 0;
183 }
184 consumed_indices.push(i);
185 }
186 KeyCode::PageDown => {
187 let old_page = state.page;
188 state.next_page();
189 if state.page != old_page {
190 state.selected = 0;
191 }
192 consumed_indices.push(i);
193 }
194 _ => {}
195 }
196 }
197 self.consume_indices(consumed_indices);
198 }
199
200 fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
201 let header_cells = state
202 .headers
203 .iter()
204 .enumerate()
205 .map(|(i, header)| {
206 if state.sort_column == Some(i) {
207 if state.sort_ascending {
208 let mut sorted_header = String::with_capacity(header.len() + 2);
209 sorted_header.push_str(header);
210 sorted_header.push_str(" ▲");
211 sorted_header
212 } else {
213 let mut sorted_header = String::with_capacity(header.len() + 2);
214 sorted_header.push_str(header);
215 sorted_header.push_str(" ▼");
216 sorted_header
217 }
218 } else {
219 header.clone()
220 }
221 })
222 .collect::<Vec<_>>();
223 let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
224 self.styled(
225 header_line,
226 Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
227 );
228
229 let separator = state
230 .column_widths()
231 .iter()
232 .map(|w| "─".repeat(*w as usize))
233 .collect::<Vec<_>>()
234 .join("─┼─");
235 self.text(separator);
236 }
237
238 fn render_table_rows(
239 &mut self,
240 state: &TableState,
241 focused: bool,
242 page_start: usize,
243 visible_len: usize,
244 colors: &WidgetColors,
245 ) {
246 for idx in 0..visible_len {
247 let data_idx = state.visible_indices()[page_start + idx];
248 let Some(row) = state.rows.get(data_idx) else {
249 continue;
250 };
251 let line = format_table_row(row, state.column_widths(), " │ ");
252 if idx == state.selected {
253 let mut style = Style::new()
254 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
255 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
256 if focused {
257 style = style.bold();
258 }
259 self.styled(line, style);
260 } else {
261 let mut style = Style::new().fg(colors.fg.unwrap_or(self.theme.text));
262 if state.zebra {
263 let zebra_bg = colors.bg.unwrap_or({
264 if idx % 2 == 0 {
265 self.theme.surface
266 } else {
267 self.theme.surface_hover
268 }
269 });
270 style = style.bg(zebra_bg);
271 }
272 self.styled(line, style);
273 }
274 }
275 }
276
277 pub fn tabs(&mut self, state: &mut TabsState) -> Response {
283 self.tabs_colored(state, &WidgetColors::new())
284 }
285
286 pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
288 if state.labels.is_empty() {
289 state.selected = 0;
290 return Response::none();
291 }
292
293 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
294 let old_selected = state.selected;
295 let focused = self.register_focusable();
296 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
297
298 if focused {
299 let mut consumed_indices = Vec::new();
300 for (i, key) in self.available_key_presses() {
301 match key.code {
302 KeyCode::Left => {
303 state.selected = if state.selected == 0 {
304 state.labels.len().saturating_sub(1)
305 } else {
306 state.selected - 1
307 };
308 consumed_indices.push(i);
309 }
310 KeyCode::Right => {
311 if !state.labels.is_empty() {
312 state.selected = (state.selected + 1) % state.labels.len();
313 }
314 consumed_indices.push(i);
315 }
316 _ => {}
317 }
318 }
319 self.consume_indices(consumed_indices);
320 }
321
322 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
323 let mut consumed = Vec::new();
324 for (i, mouse) in clicks {
325 let mut x_offset = 0u32;
326 let rel_x = mouse.x - rect.x;
327 for (idx, label) in state.labels.iter().enumerate() {
328 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
329 if rel_x >= x_offset && rel_x < x_offset + tab_width {
330 state.selected = idx;
331 consumed.push(i);
332 break;
333 }
334 x_offset += tab_width + 1;
335 }
336 }
337 self.consume_indices(consumed);
338 }
339
340 self.commands.push(Command::BeginContainer {
341 direction: Direction::Row,
342 gap: 1,
343 align: Align::Start,
344 align_self: None,
345 justify: Justify::Start,
346 border: None,
347 border_sides: BorderSides::all(),
348 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
349 bg_color: None,
350 padding: Padding::default(),
351 margin: Margin::default(),
352 constraints: Constraints::default(),
353 title: None,
354 grow: 0,
355 group_name: None,
356 });
357 for (idx, label) in state.labels.iter().enumerate() {
358 let style = if idx == state.selected {
359 let s = Style::new()
360 .fg(colors.accent.unwrap_or(self.theme.primary))
361 .bold();
362 if focused {
363 s.underline()
364 } else {
365 s
366 }
367 } else {
368 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
369 };
370 let mut tab = String::with_capacity(label.len() + 4);
371 tab.push_str("[ ");
372 tab.push_str(label);
373 tab.push_str(" ]");
374 self.styled(tab, style);
375 }
376 self.commands.push(Command::EndContainer);
377 self.rollback.last_text_idx = None;
378
379 response.changed = state.selected != old_selected;
380 response
381 }
382
383 pub fn button(&mut self, label: impl Into<String>) -> Response {
389 self.button_colored(label, &WidgetColors::new())
390 }
391
392 pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
394 let focused = self.register_focusable();
395 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
396
397 let activated = response.clicked || self.consume_activation_keys(focused);
398
399 let hovered = response.hovered;
400 let base_fg = colors.fg.unwrap_or(self.theme.text);
401 let accent = colors.accent.unwrap_or(self.theme.accent);
402 let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
403 let style = if focused {
404 Style::new().fg(accent).bold()
405 } else if hovered {
406 Style::new().fg(accent)
407 } else {
408 Style::new().fg(base_fg)
409 };
410 let has_custom_bg = colors.bg.is_some();
411 let bg_color = if has_custom_bg || hovered || focused {
412 Some(base_bg)
413 } else {
414 None
415 };
416
417 self.commands.push(Command::BeginContainer {
418 direction: Direction::Row,
419 gap: 0,
420 align: Align::Start,
421 align_self: None,
422 justify: Justify::Start,
423 border: None,
424 border_sides: BorderSides::all(),
425 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
426 bg_color,
427 padding: Padding::default(),
428 margin: Margin::default(),
429 constraints: Constraints::default(),
430 title: None,
431 grow: 0,
432 group_name: None,
433 });
434 let raw_label = label.into();
435 let mut label_text = String::with_capacity(raw_label.len() + 4);
436 label_text.push_str("[ ");
437 label_text.push_str(&raw_label);
438 label_text.push_str(" ]");
439 self.styled(label_text, style);
440 self.commands.push(Command::EndContainer);
441 self.rollback.last_text_idx = None;
442
443 response.clicked = activated;
444 response
445 }
446
447 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
452 let focused = self.register_focusable();
453 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
454
455 let activated = response.clicked || self.consume_activation_keys(focused);
456
457 let label = label.into();
458 let hover_bg = if response.hovered || focused {
459 Some(self.theme.surface_hover)
460 } else {
461 None
462 };
463 let (text, style, bg_color, border) = match variant {
464 ButtonVariant::Default => {
465 let style = if focused {
466 Style::new().fg(self.theme.primary).bold()
467 } else if response.hovered {
468 Style::new().fg(self.theme.accent)
469 } else {
470 Style::new().fg(self.theme.text)
471 };
472 let mut text = String::with_capacity(label.len() + 4);
473 text.push_str("[ ");
474 text.push_str(&label);
475 text.push_str(" ]");
476 (text, style, hover_bg, None)
477 }
478 ButtonVariant::Primary => {
479 let style = if focused {
480 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
481 } else if response.hovered {
482 Style::new().fg(self.theme.bg).bg(self.theme.accent)
483 } else {
484 Style::new().fg(self.theme.bg).bg(self.theme.primary)
485 };
486 let mut text = String::with_capacity(label.len() + 2);
487 text.push(' ');
488 text.push_str(&label);
489 text.push(' ');
490 (text, style, hover_bg, None)
491 }
492 ButtonVariant::Danger => {
493 let style = if focused {
494 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
495 } else if response.hovered {
496 Style::new().fg(self.theme.bg).bg(self.theme.warning)
497 } else {
498 Style::new().fg(self.theme.bg).bg(self.theme.error)
499 };
500 let mut text = String::with_capacity(label.len() + 2);
501 text.push(' ');
502 text.push_str(&label);
503 text.push(' ');
504 (text, style, hover_bg, None)
505 }
506 ButtonVariant::Outline => {
507 let border_color = if focused {
508 self.theme.primary
509 } else if response.hovered {
510 self.theme.accent
511 } else {
512 self.theme.border
513 };
514 let style = if focused {
515 Style::new().fg(self.theme.primary).bold()
516 } else if response.hovered {
517 Style::new().fg(self.theme.accent)
518 } else {
519 Style::new().fg(self.theme.text)
520 };
521 (
522 {
523 let mut text = String::with_capacity(label.len() + 2);
524 text.push(' ');
525 text.push_str(&label);
526 text.push(' ');
527 text
528 },
529 style,
530 hover_bg,
531 Some((Border::Rounded, Style::new().fg(border_color))),
532 )
533 }
534 };
535
536 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
537 self.commands.push(Command::BeginContainer {
538 direction: Direction::Row,
539 gap: 0,
540 align: Align::Center,
541 align_self: None,
542 justify: Justify::Center,
543 border: if border.is_some() {
544 Some(btn_border)
545 } else {
546 None
547 },
548 border_sides: BorderSides::all(),
549 border_style: btn_border_style,
550 bg_color,
551 padding: Padding::default(),
552 margin: Margin::default(),
553 constraints: Constraints::default(),
554 title: None,
555 grow: 0,
556 group_name: None,
557 });
558 self.styled(text, style);
559 self.commands.push(Command::EndContainer);
560 self.rollback.last_text_idx = None;
561
562 response.clicked = activated;
563 response
564 }
565
566 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
572 self.checkbox_colored(label, checked, &WidgetColors::new())
573 }
574
575 pub fn checkbox_colored(
577 &mut self,
578 label: impl Into<String>,
579 checked: &mut bool,
580 colors: &WidgetColors,
581 ) -> Response {
582 let focused = self.register_focusable();
583 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
584 let mut should_toggle = response.clicked;
585 let old_checked = *checked;
586
587 should_toggle |= self.consume_activation_keys(focused);
588
589 if should_toggle {
590 *checked = !*checked;
591 }
592
593 let hover_bg = if response.hovered || focused {
594 Some(self.theme.surface_hover)
595 } else {
596 None
597 };
598 self.commands.push(Command::BeginContainer {
599 direction: Direction::Row,
600 gap: 1,
601 align: Align::Start,
602 align_self: None,
603 justify: Justify::Start,
604 border: None,
605 border_sides: BorderSides::all(),
606 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
607 bg_color: hover_bg,
608 padding: Padding::default(),
609 margin: Margin::default(),
610 constraints: Constraints::default(),
611 title: None,
612 grow: 0,
613 group_name: None,
614 });
615 let marker_style = if *checked {
616 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
617 } else {
618 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
619 };
620 let marker = if *checked { "[x]" } else { "[ ]" };
621 let label_text = label.into();
622 if focused {
623 let mut marker_text = String::with_capacity(2 + marker.len());
624 marker_text.push_str("▸ ");
625 marker_text.push_str(marker);
626 self.styled(marker_text, marker_style.bold());
627 self.styled(
628 label_text,
629 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
630 );
631 } else {
632 self.styled(marker, marker_style);
633 self.styled(
634 label_text,
635 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
636 );
637 }
638 self.commands.push(Command::EndContainer);
639 self.rollback.last_text_idx = None;
640
641 response.changed = *checked != old_checked;
642 response
643 }
644
645 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
652 self.toggle_colored(label, on, &WidgetColors::new())
653 }
654
655 pub fn toggle_colored(
657 &mut self,
658 label: impl Into<String>,
659 on: &mut bool,
660 colors: &WidgetColors,
661 ) -> Response {
662 let focused = self.register_focusable();
663 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
664 let mut should_toggle = response.clicked;
665 let old_on = *on;
666
667 should_toggle |= self.consume_activation_keys(focused);
668
669 if should_toggle {
670 *on = !*on;
671 }
672
673 let hover_bg = if response.hovered || focused {
674 Some(self.theme.surface_hover)
675 } else {
676 None
677 };
678 self.commands.push(Command::BeginContainer {
679 direction: Direction::Row,
680 gap: 2,
681 align: Align::Start,
682 align_self: None,
683 justify: Justify::Start,
684 border: None,
685 border_sides: BorderSides::all(),
686 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
687 bg_color: hover_bg,
688 padding: Padding::default(),
689 margin: Margin::default(),
690 constraints: Constraints::default(),
691 title: None,
692 grow: 0,
693 group_name: None,
694 });
695 let label_text = label.into();
696 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
697 let switch_style = if *on {
698 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
699 } else {
700 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
701 };
702 if focused {
703 let mut focused_label = String::with_capacity(2 + label_text.len());
704 focused_label.push_str("▸ ");
705 focused_label.push_str(&label_text);
706 self.styled(
707 focused_label,
708 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
709 );
710 self.styled(switch, switch_style.bold());
711 } else {
712 self.styled(
713 label_text,
714 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
715 );
716 self.styled(switch, switch_style);
717 }
718 self.commands.push(Command::EndContainer);
719 self.rollback.last_text_idx = None;
720
721 response.changed = *on != old_on;
722 response
723 }
724
725 pub fn select(&mut self, state: &mut SelectState) -> Response {
732 self.select_colored(state, &WidgetColors::new())
733 }
734
735 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
737 if state.items.is_empty() {
738 return Response::none();
739 }
740 state.selected = state.selected.min(state.items.len().saturating_sub(1));
741
742 let focused = self.register_focusable();
743 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
744 let old_selected = state.selected;
745
746 self.select_handle_events(state, focused, response.clicked);
747 self.select_render(state, focused, colors);
748 response.changed = state.selected != old_selected;
749 response
750 }
751
752 fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
753 if clicked {
754 state.open = !state.open;
755 if state.open {
756 state.set_cursor(state.selected);
757 }
758 }
759
760 if !focused {
761 return;
762 }
763
764 let mut consumed_indices = Vec::new();
765 for (i, key) in self.available_key_presses() {
766 if state.open {
767 match key.code {
768 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
769 let mut cursor = state.cursor();
770 let _ = handle_vertical_nav(
771 &mut cursor,
772 state.items.len().saturating_sub(1),
773 key.code.clone(),
774 );
775 state.set_cursor(cursor);
776 consumed_indices.push(i);
777 }
778 KeyCode::Enter | KeyCode::Char(' ') => {
779 state.selected = state.cursor();
780 state.open = false;
781 consumed_indices.push(i);
782 }
783 KeyCode::Esc => {
784 state.open = false;
785 consumed_indices.push(i);
786 }
787 _ => {}
788 }
789 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
790 state.open = true;
791 state.set_cursor(state.selected);
792 consumed_indices.push(i);
793 }
794 }
795 self.consume_indices(consumed_indices);
796 }
797
798 fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
799 let border_color = if focused {
800 colors.accent.unwrap_or(self.theme.primary)
801 } else {
802 colors.border.unwrap_or(self.theme.border)
803 };
804 let display_text = state
805 .items
806 .get(state.selected)
807 .cloned()
808 .unwrap_or_else(|| state.placeholder.clone());
809 let arrow = if state.open { "▲" } else { "▼" };
810
811 self.commands.push(Command::BeginContainer {
812 direction: Direction::Column,
813 gap: 0,
814 align: Align::Start,
815 align_self: None,
816 justify: Justify::Start,
817 border: None,
818 border_sides: BorderSides::all(),
819 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
820 bg_color: None,
821 padding: Padding::default(),
822 margin: Margin::default(),
823 constraints: Constraints::default(),
824 title: None,
825 grow: 0,
826 group_name: None,
827 });
828
829 self.render_select_trigger(&display_text, arrow, border_color, colors);
830
831 if state.open {
832 self.render_select_dropdown(state, colors);
833 }
834
835 self.commands.push(Command::EndContainer);
836 self.rollback.last_text_idx = None;
837 }
838
839 fn render_select_trigger(
840 &mut self,
841 display_text: &str,
842 arrow: &str,
843 border_color: Color,
844 colors: &WidgetColors,
845 ) {
846 self.commands.push(Command::BeginContainer {
847 direction: Direction::Row,
848 gap: 1,
849 align: Align::Start,
850 align_self: None,
851 justify: Justify::Start,
852 border: Some(Border::Rounded),
853 border_sides: BorderSides::all(),
854 border_style: Style::new().fg(border_color),
855 bg_color: None,
856 padding: Padding {
857 left: 1,
858 right: 1,
859 top: 0,
860 bottom: 0,
861 },
862 margin: Margin::default(),
863 constraints: Constraints::default(),
864 title: None,
865 grow: 0,
866 group_name: None,
867 });
868 self.skip_interaction_slot();
869 self.styled(
870 display_text,
871 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
872 );
873 self.styled(
874 arrow,
875 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
876 );
877 self.commands.push(Command::EndContainer);
878 self.rollback.last_text_idx = None;
879 }
880
881 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
882 for (idx, item) in state.items.iter().enumerate() {
883 let is_cursor = idx == state.cursor();
884 let style = if is_cursor {
885 Style::new()
886 .bold()
887 .fg(colors.accent.unwrap_or(self.theme.primary))
888 } else {
889 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
890 };
891 let prefix = if is_cursor { "▸ " } else { " " };
892 let mut row = String::with_capacity(prefix.len() + item.len());
893 row.push_str(prefix);
894 row.push_str(item);
895 self.styled(row, style);
896 }
897 }
898
899 pub fn radio(&mut self, state: &mut RadioState) -> Response {
904 self.radio_colored(state, &WidgetColors::new())
905 }
906
907 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
909 if state.items.is_empty() {
910 return Response::none();
911 }
912 state.selected = state.selected.min(state.items.len().saturating_sub(1));
913 let focused = self.register_focusable();
914 let old_selected = state.selected;
915
916 if focused {
917 let mut consumed_indices = Vec::new();
918 for (i, key) in self.available_key_presses() {
919 match key.code {
920 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
921 let _ = handle_vertical_nav(
922 &mut state.selected,
923 state.items.len().saturating_sub(1),
924 key.code.clone(),
925 );
926 consumed_indices.push(i);
927 }
928 KeyCode::Enter | KeyCode::Char(' ') => {
929 consumed_indices.push(i);
930 }
931 _ => {}
932 }
933 }
934 self.consume_indices(consumed_indices);
935 }
936
937 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
938
939 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
940 let mut consumed = Vec::new();
941 for (i, mouse) in clicks {
942 let clicked_idx = (mouse.y - rect.y) as usize;
943 if clicked_idx < state.items.len() {
944 state.selected = clicked_idx;
945 consumed.push(i);
946 }
947 }
948 self.consume_indices(consumed);
949 }
950
951 self.commands.push(Command::BeginContainer {
952 direction: Direction::Column,
953 gap: 0,
954 align: Align::Start,
955 align_self: None,
956 justify: Justify::Start,
957 border: None,
958 border_sides: BorderSides::all(),
959 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
960 bg_color: None,
961 padding: Padding::default(),
962 margin: Margin::default(),
963 constraints: Constraints::default(),
964 title: None,
965 grow: 0,
966 group_name: None,
967 });
968
969 for (idx, item) in state.items.iter().enumerate() {
970 let is_selected = idx == state.selected;
971 let marker = if is_selected { "●" } else { "○" };
972 let style = if is_selected {
973 if focused {
974 Style::new()
975 .bold()
976 .fg(colors.accent.unwrap_or(self.theme.primary))
977 } else {
978 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
979 }
980 } else {
981 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
982 };
983 let prefix = if focused && idx == state.selected {
984 "▸ "
985 } else {
986 " "
987 };
988 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
989 row.push_str(prefix);
990 row.push_str(marker);
991 row.push(' ');
992 row.push_str(item);
993 self.styled(row, style);
994 }
995
996 self.commands.push(Command::EndContainer);
997 self.rollback.last_text_idx = None;
998 response.changed = state.selected != old_selected;
999 response
1000 }
1001
1002 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1006 if state.items.is_empty() {
1007 return Response::none();
1008 }
1009 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1010 let focused = self.register_focusable();
1011 let old_selected = state.selected.clone();
1012
1013 if focused {
1014 let mut consumed_indices = Vec::new();
1015 for (i, key) in self.available_key_presses() {
1016 match key.code {
1017 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1018 let _ = handle_vertical_nav(
1019 &mut state.cursor,
1020 state.items.len().saturating_sub(1),
1021 key.code.clone(),
1022 );
1023 consumed_indices.push(i);
1024 }
1025 KeyCode::Char(' ') | KeyCode::Enter => {
1026 state.toggle(state.cursor);
1027 consumed_indices.push(i);
1028 }
1029 _ => {}
1030 }
1031 }
1032 self.consume_indices(consumed_indices);
1033 }
1034
1035 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1036
1037 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1038 let mut consumed = Vec::new();
1039 for (i, mouse) in clicks {
1040 let clicked_idx = (mouse.y - rect.y) as usize;
1041 if clicked_idx < state.items.len() {
1042 state.toggle(clicked_idx);
1043 state.cursor = clicked_idx;
1044 consumed.push(i);
1045 }
1046 }
1047 self.consume_indices(consumed);
1048 }
1049
1050 self.commands.push(Command::BeginContainer {
1051 direction: Direction::Column,
1052 gap: 0,
1053 align: Align::Start,
1054 align_self: None,
1055 justify: Justify::Start,
1056 border: None,
1057 border_sides: BorderSides::all(),
1058 border_style: Style::new().fg(self.theme.border),
1059 bg_color: None,
1060 padding: Padding::default(),
1061 margin: Margin::default(),
1062 constraints: Constraints::default(),
1063 title: None,
1064 grow: 0,
1065 group_name: None,
1066 });
1067
1068 for (idx, item) in state.items.iter().enumerate() {
1069 let checked = state.selected.contains(&idx);
1070 let marker = if checked { "[x]" } else { "[ ]" };
1071 let is_cursor = idx == state.cursor;
1072 let style = if is_cursor && focused {
1073 Style::new().bold().fg(self.theme.primary)
1074 } else if checked {
1075 Style::new().fg(self.theme.success)
1076 } else {
1077 Style::new().fg(self.theme.text)
1078 };
1079 let prefix = if is_cursor && focused { "▸ " } else { " " };
1080 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1081 row.push_str(prefix);
1082 row.push_str(marker);
1083 row.push(' ');
1084 row.push_str(item);
1085 self.styled(row, style);
1086 }
1087
1088 self.commands.push(Command::EndContainer);
1089 self.rollback.last_text_idx = None;
1090 response.changed = state.selected != old_selected;
1091 response
1092 }
1093
1094 }