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