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 {
284 let colors = self.widget_theme.tabs;
285 self.tabs_colored(state, &colors)
286 }
287
288 pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
290 if state.labels.is_empty() {
291 state.selected = 0;
292 return Response::none();
293 }
294
295 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
296 let old_selected = state.selected;
297 let focused = self.register_focusable();
298 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
299
300 if focused {
301 let mut consumed_indices = Vec::new();
302 for (i, key) in self.available_key_presses() {
303 match key.code {
304 KeyCode::Left => {
305 state.selected = if state.selected == 0 {
306 state.labels.len().saturating_sub(1)
307 } else {
308 state.selected - 1
309 };
310 consumed_indices.push(i);
311 }
312 KeyCode::Right => {
313 if !state.labels.is_empty() {
314 state.selected = (state.selected + 1) % state.labels.len();
315 }
316 consumed_indices.push(i);
317 }
318 _ => {}
319 }
320 }
321 self.consume_indices(consumed_indices);
322 }
323
324 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
325 let mut consumed = Vec::new();
326 for (i, mouse) in clicks {
327 let mut x_offset = 0u32;
328 let rel_x = mouse.x.saturating_sub(rect.x);
329 for (idx, label) in state.labels.iter().enumerate() {
330 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
331 if rel_x >= x_offset && rel_x < x_offset + tab_width {
332 state.selected = idx;
333 consumed.push(i);
334 break;
335 }
336 x_offset += tab_width + 1;
337 }
338 }
339 self.consume_indices(consumed);
340 }
341
342 let tabs_gap = self.theme.spacing.xs();
343 self.commands
344 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
345 direction: Direction::Row,
346 gap: tabs_gap,
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 let cb_gap = self.theme.spacing.xs();
607 self.commands
608 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
609 direction: Direction::Row,
610 gap: cb_gap,
611 align: Align::Start,
612 align_self: None,
613 justify: Justify::Start,
614 border: None,
615 border_sides: BorderSides::all(),
616 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
617 bg_color: hover_bg,
618 padding: Padding::default(),
619 margin: Margin::default(),
620 constraints: Constraints::default(),
621 title: None,
622 grow: 0,
623 group_name: None,
624 })));
625 let marker_style = if *checked {
626 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
627 } else {
628 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
629 };
630 let marker = if *checked { "[x]" } else { "[ ]" };
631 let label_text = label.into();
632 if focused {
633 let mut marker_text = String::with_capacity(2 + marker.len());
634 marker_text.push_str("▸ ");
635 marker_text.push_str(marker);
636 self.styled(marker_text, marker_style.bold());
637 self.styled(
638 label_text,
639 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
640 );
641 } else {
642 self.styled(marker, marker_style);
643 self.styled(
644 label_text,
645 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
646 );
647 }
648 self.commands.push(Command::EndContainer);
649 self.rollback.last_text_idx = None;
650
651 response.changed = *checked != old_checked;
652 response
653 }
654
655 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
662 let colors = self.widget_theme.toggle;
663 self.toggle_colored(label, on, &colors)
664 }
665
666 pub fn toggle_colored(
668 &mut self,
669 label: impl Into<String>,
670 on: &mut bool,
671 colors: &WidgetColors,
672 ) -> Response {
673 let focused = self.register_focusable();
674 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
675 let mut should_toggle = response.clicked;
676 let old_on = *on;
677
678 should_toggle |= self.consume_activation_keys(focused);
679
680 if should_toggle {
681 *on = !*on;
682 }
683
684 let hover_bg = if response.hovered || focused {
685 Some(self.theme.surface_hover)
686 } else {
687 None
688 };
689 let toggle_gap = self.theme.spacing.sm();
690 self.commands
691 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
692 direction: Direction::Row,
693 gap: toggle_gap,
694 align: Align::Start,
695 align_self: None,
696 justify: Justify::Start,
697 border: None,
698 border_sides: BorderSides::all(),
699 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
700 bg_color: hover_bg,
701 padding: Padding::default(),
702 margin: Margin::default(),
703 constraints: Constraints::default(),
704 title: None,
705 grow: 0,
706 group_name: None,
707 })));
708 let label_text = label.into();
709 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
710 let switch_style = if *on {
711 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
712 } else {
713 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
714 };
715 if focused {
716 let mut focused_label = String::with_capacity(2 + label_text.len());
717 focused_label.push_str("▸ ");
718 focused_label.push_str(&label_text);
719 self.styled(
720 focused_label,
721 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
722 );
723 self.styled(switch, switch_style.bold());
724 } else {
725 self.styled(
726 label_text,
727 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
728 );
729 self.styled(switch, switch_style);
730 }
731 self.commands.push(Command::EndContainer);
732 self.rollback.last_text_idx = None;
733
734 response.changed = *on != old_on;
735 response
736 }
737
738 pub fn select(&mut self, state: &mut SelectState) -> Response {
745 let colors = self.widget_theme.select;
746 self.select_colored(state, &colors)
747 }
748
749 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
751 if state.items.is_empty() {
752 return Response::none();
753 }
754 state.selected = state.selected.min(state.items.len().saturating_sub(1));
755
756 let focused = self.register_focusable();
757 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
758 let old_selected = state.selected;
759
760 self.select_handle_events(state, focused, response.clicked);
761 self.select_render(state, focused, colors);
762 response.changed = state.selected != old_selected;
763 response
764 }
765
766 fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
767 if clicked {
768 state.open = !state.open;
769 if state.open {
770 state.set_cursor(state.selected);
771 }
772 }
773
774 if !focused {
775 return;
776 }
777
778 let mut consumed_indices = Vec::new();
779 for (i, key) in self.available_key_presses() {
780 if state.open {
781 match key.code {
782 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
783 let mut cursor = state.cursor();
784 let _ = handle_vertical_nav(
785 &mut cursor,
786 state.items.len().saturating_sub(1),
787 key.code.clone(),
788 );
789 state.set_cursor(cursor);
790 consumed_indices.push(i);
791 }
792 KeyCode::Enter | KeyCode::Char(' ') => {
793 state.selected = state.cursor();
794 state.open = false;
795 consumed_indices.push(i);
796 }
797 KeyCode::Esc => {
798 state.open = false;
799 consumed_indices.push(i);
800 }
801 _ => {}
802 }
803 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
804 state.open = true;
805 state.set_cursor(state.selected);
806 consumed_indices.push(i);
807 }
808 }
809 self.consume_indices(consumed_indices);
810 }
811
812 fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
813 let border_color = if focused {
814 colors.accent.unwrap_or(self.theme.primary)
815 } else {
816 colors.border.unwrap_or(self.theme.border)
817 };
818 let display_text = state
819 .items
820 .get(state.selected)
821 .cloned()
822 .unwrap_or_else(|| state.placeholder.clone());
823 let arrow = if state.open { "▲" } else { "▼" };
824
825 self.commands
826 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
827 direction: Direction::Column,
828 gap: 0,
829 align: Align::Start,
830 align_self: None,
831 justify: Justify::Start,
832 border: None,
833 border_sides: BorderSides::all(),
834 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
835 bg_color: None,
836 padding: Padding::default(),
837 margin: Margin::default(),
838 constraints: Constraints::default(),
839 title: None,
840 grow: 0,
841 group_name: None,
842 })));
843
844 self.render_select_trigger(&display_text, arrow, border_color, colors);
845
846 if state.open {
847 self.render_select_dropdown(state, colors);
848 }
849
850 self.commands.push(Command::EndContainer);
851 self.rollback.last_text_idx = None;
852 }
853
854 fn render_select_trigger(
855 &mut self,
856 display_text: &str,
857 arrow: &str,
858 border_color: Color,
859 colors: &WidgetColors,
860 ) {
861 let trig_gap = self.theme.spacing.xs();
862 let trig_h = self.theme.spacing.xs();
863 self.commands
864 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
865 direction: Direction::Row,
866 gap: trig_gap,
867 align: Align::Start,
868 align_self: None,
869 justify: Justify::Start,
870 border: Some(Border::Rounded),
871 border_sides: BorderSides::all(),
872 border_style: Style::new().fg(border_color),
873 bg_color: None,
874 padding: Padding {
875 left: trig_h,
876 right: trig_h,
877 top: 0,
878 bottom: 0,
879 },
880 margin: Margin::default(),
881 constraints: Constraints::default(),
882 title: None,
883 grow: 0,
884 group_name: None,
885 })));
886 self.skip_interaction_slot();
887 self.styled(
888 display_text,
889 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
890 );
891 self.styled(
892 arrow,
893 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
894 );
895 self.commands.push(Command::EndContainer);
896 self.rollback.last_text_idx = None;
897 }
898
899 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
900 for (idx, item) in state.items.iter().enumerate() {
901 let is_cursor = idx == state.cursor();
902 let style = if is_cursor {
903 Style::new()
904 .bold()
905 .fg(colors.accent.unwrap_or(self.theme.primary))
906 } else {
907 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
908 };
909 let prefix = if is_cursor { "▸ " } else { " " };
910 let mut row = String::with_capacity(prefix.len() + item.len());
911 row.push_str(prefix);
912 row.push_str(item);
913 self.styled(row, style);
914 }
915 }
916
917 pub fn radio(&mut self, state: &mut RadioState) -> Response {
922 let colors = self.widget_theme.radio;
923 self.radio_colored(state, &colors)
924 }
925
926 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
928 if state.items.is_empty() {
929 return Response::none();
930 }
931 state.selected = state.selected.min(state.items.len().saturating_sub(1));
932 let focused = self.register_focusable();
933 let old_selected = state.selected;
934
935 if focused {
936 let mut consumed_indices = Vec::new();
937 for (i, key) in self.available_key_presses() {
938 match key.code {
939 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
940 let _ = handle_vertical_nav(
941 &mut state.selected,
942 state.items.len().saturating_sub(1),
943 key.code.clone(),
944 );
945 consumed_indices.push(i);
946 }
947 KeyCode::Enter | KeyCode::Char(' ') => {
948 consumed_indices.push(i);
949 }
950 _ => {}
951 }
952 }
953 self.consume_indices(consumed_indices);
954 }
955
956 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
957
958 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
959 let mut consumed = Vec::new();
960 for (i, mouse) in clicks {
961 let clicked_idx = (mouse.y - rect.y) as usize;
962 if clicked_idx < state.items.len() {
963 state.selected = clicked_idx;
964 consumed.push(i);
965 }
966 }
967 self.consume_indices(consumed);
968 }
969
970 self.commands
971 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
972 direction: Direction::Column,
973 gap: 0,
974 align: Align::Start,
975 align_self: None,
976 justify: Justify::Start,
977 border: None,
978 border_sides: BorderSides::all(),
979 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
980 bg_color: None,
981 padding: Padding::default(),
982 margin: Margin::default(),
983 constraints: Constraints::default(),
984 title: None,
985 grow: 0,
986 group_name: None,
987 })));
988
989 for (idx, item) in state.items.iter().enumerate() {
990 let is_selected = idx == state.selected;
991 let marker = if is_selected { "●" } else { "○" };
992 let style = if is_selected {
993 if focused {
994 Style::new()
995 .bold()
996 .fg(colors.accent.unwrap_or(self.theme.primary))
997 } else {
998 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
999 }
1000 } else {
1001 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1002 };
1003 let prefix = if focused && idx == state.selected {
1004 "▸ "
1005 } else {
1006 " "
1007 };
1008 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1009 row.push_str(prefix);
1010 row.push_str(marker);
1011 row.push(' ');
1012 row.push_str(item);
1013 self.styled(row, style);
1014 }
1015
1016 self.commands.push(Command::EndContainer);
1017 self.rollback.last_text_idx = None;
1018 response.changed = state.selected != old_selected;
1019 response
1020 }
1021
1022 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1026 if state.items.is_empty() {
1027 return Response::none();
1028 }
1029 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1030 let focused = self.register_focusable();
1031 let old_selected = state.selected.clone();
1032
1033 if focused {
1034 let mut consumed_indices = Vec::new();
1035 for (i, key) in self.available_key_presses() {
1036 match key.code {
1037 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1038 let _ = handle_vertical_nav(
1039 &mut state.cursor,
1040 state.items.len().saturating_sub(1),
1041 key.code.clone(),
1042 );
1043 consumed_indices.push(i);
1044 }
1045 KeyCode::Char(' ') | KeyCode::Enter => {
1046 state.toggle(state.cursor);
1047 consumed_indices.push(i);
1048 }
1049 _ => {}
1050 }
1051 }
1052 self.consume_indices(consumed_indices);
1053 }
1054
1055 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1056
1057 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1058 let mut consumed = Vec::new();
1059 for (i, mouse) in clicks {
1060 let clicked_idx = (mouse.y - rect.y) as usize;
1061 if clicked_idx < state.items.len() {
1062 state.toggle(clicked_idx);
1063 state.cursor = clicked_idx;
1064 consumed.push(i);
1065 }
1066 }
1067 self.consume_indices(consumed);
1068 }
1069
1070 self.commands
1071 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1072 direction: Direction::Column,
1073 gap: 0,
1074 align: Align::Start,
1075 align_self: None,
1076 justify: Justify::Start,
1077 border: None,
1078 border_sides: BorderSides::all(),
1079 border_style: Style::new().fg(self.theme.border),
1080 bg_color: None,
1081 padding: Padding::default(),
1082 margin: Margin::default(),
1083 constraints: Constraints::default(),
1084 title: None,
1085 grow: 0,
1086 group_name: None,
1087 })));
1088
1089 for (idx, item) in state.items.iter().enumerate() {
1090 let checked = state.selected.contains(&idx);
1091 let marker = if checked { "[x]" } else { "[ ]" };
1092 let is_cursor = idx == state.cursor;
1093 let style = if is_cursor && focused {
1094 Style::new().bold().fg(self.theme.primary)
1095 } else if checked {
1096 Style::new().fg(self.theme.success)
1097 } else {
1098 Style::new().fg(self.theme.text)
1099 };
1100 let prefix = if is_cursor && focused { "▸ " } else { " " };
1101 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1102 row.push_str(prefix);
1103 row.push_str(marker);
1104 row.push(' ');
1105 row.push_str(item);
1106 self.styled(row, style);
1107 }
1108
1109 self.commands.push(Command::EndContainer);
1110 self.rollback.last_text_idx = None;
1111 response.changed = state.selected != old_selected;
1112 response
1113 }
1114
1115 }