1use super::*;
2
3const PAGINATOR_MAX_DOTS: usize = 12;
6
7type TableCellRenderer = Box<dyn Fn(usize, usize, &str) -> (String, Style)>;
10
11impl Context {
12 pub fn table(&mut self, state: &mut TableState) -> Response {
18 let colors = self.widget_theme.table;
19 self.table_colored(state, &colors)
20 }
21
22 pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
24 self.table_inner(state, colors, None)
25 }
26
27 pub fn table_with(
63 &mut self,
64 state: &mut TableState,
65 cell: impl Fn(usize, usize, &str) -> (String, Style) + 'static,
66 ) -> Response {
67 let colors = self.widget_theme.table;
68 self.table_inner(state, &colors, Some(Box::new(cell)))
69 }
70
71 fn table_inner(
72 &mut self,
73 state: &mut TableState,
74 colors: &WidgetColors,
75 cell: Option<TableCellRenderer>,
76 ) -> Response {
77 if state.is_dirty() {
78 state.recompute_widths();
79 }
80
81 let old_selected = state.selected;
82 let old_sort_column = state.sort_column;
83 let old_sort_ascending = state.sort_ascending;
84 let old_page = state.page;
85 let old_filter = state.filter.clone();
86 let old_multi = state.multi_selected.clone();
87
88 let focused = self.register_focusable();
89 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
90
91 self.table_handle_events(state, focused, interaction_id);
92
93 if state.is_dirty() {
94 state.recompute_widths();
95 }
96 state.resolve_column_widths(self.area_width);
97
98 self.table_render(state, focused, colors, cell);
99
100 response.changed = state.selected != old_selected
101 || state.sort_column != old_sort_column
102 || state.sort_ascending != old_sort_ascending
103 || state.page != old_page
104 || state.filter != old_filter
105 || state.multi_selected != old_multi;
106 response
107 }
108
109 fn table_handle_events(
110 &mut self,
111 state: &mut TableState,
112 focused: bool,
113 interaction_id: usize,
114 ) {
115 self.handle_table_keys(state, focused);
116
117 if state.visible_indices().is_empty() && state.headers.is_empty() {
118 return;
119 }
120
121 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
122 let mut consumed = Vec::new();
123 for (i, mouse) in clicks {
124 if mouse.y == rect.y {
125 let rel_x = mouse.x.saturating_sub(rect.x);
126 let mut x_offset = 0u32;
127 for (col_idx, width) in state.column_widths().iter().enumerate() {
128 if rel_x >= x_offset && rel_x < x_offset + *width {
129 state.toggle_sort(col_idx);
130 state.selected = 0;
131 consumed.push(i);
132 break;
133 }
134 x_offset += *width;
135 if col_idx + 1 < state.column_widths().len() {
136 x_offset += 3;
137 }
138 }
139 continue;
140 }
141
142 if mouse.y < rect.y + 2 {
143 continue;
144 }
145
146 let visible_len = if state.page_size > 0 {
147 let start = state
148 .page
149 .saturating_mul(state.page_size)
150 .min(state.visible_indices().len());
151 let end = (start + state.page_size).min(state.visible_indices().len());
152 end.saturating_sub(start)
153 } else {
154 state.visible_indices().len()
155 };
156 let clicked_idx = (mouse.y - rect.y - 2) as usize;
157 if clicked_idx < visible_len {
158 state.selected = clicked_idx;
159 if mouse.modifiers.contains(KeyModifiers::SHIFT) {
160 let anchor = state.selection_anchor.unwrap_or(clicked_idx);
161 state.select_range(anchor, clicked_idx);
162 } else if mouse.modifiers.contains(KeyModifiers::CONTROL) {
163 state.toggle_row(clicked_idx);
164 } else {
165 state.select_single(clicked_idx);
166 }
167 consumed.push(i);
168 }
169 }
170 self.consume_indices(consumed);
171 }
172 }
173
174 fn table_render(
175 &mut self,
176 state: &mut TableState,
177 focused: bool,
178 colors: &WidgetColors,
179 cell: Option<TableCellRenderer>,
180 ) {
181 let total_visible = state.visible_indices().len();
182 let page_start = if state.page_size > 0 {
183 state
184 .page
185 .saturating_mul(state.page_size)
186 .min(total_visible)
187 } else {
188 0
189 };
190 let page_end = if state.page_size > 0 {
191 (page_start + state.page_size).min(total_visible)
192 } else {
193 total_visible
194 };
195 let visible_len = page_end.saturating_sub(page_start);
196 state.selected = state.selected.min(visible_len.saturating_sub(1));
197
198 self.commands
199 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
200 direction: Direction::Column,
201 gap: 0,
202 align: Align::Start,
203 align_self: None,
204 justify: Justify::Start,
205 border: None,
206 border_sides: BorderSides::all(),
207 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
208 bg_color: None,
209 padding: Padding::default(),
210 margin: Margin::default(),
211 constraints: Constraints::default(),
212 title: None,
213 grow: 0,
214 group_name: None,
215 })));
216
217 self.render_table_header(state, colors);
218 self.render_table_rows(state, focused, page_start, visible_len, colors, cell);
219
220 if state.page_size > 0 && state.total_pages() > 1 {
221 let current_page = (state.page + 1).to_string();
222 let total_pages = state.total_pages().to_string();
223 let mut page_text = String::with_capacity(current_page.len() + total_pages.len() + 6);
224 page_text.push_str("Page ");
225 page_text.push_str(¤t_page);
226 page_text.push('/');
227 page_text.push_str(&total_pages);
228 self.styled(
229 page_text,
230 Style::new()
231 .dim()
232 .fg(colors.fg.unwrap_or(self.theme.text_dim)),
233 );
234 }
235
236 self.commands.push(Command::EndContainer);
237 self.rollback.last_text_idx = None;
238 }
239
240 fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
241 if !focused || state.visible_indices().is_empty() {
242 return;
243 }
244
245 let mut consumed_indices = Vec::new();
246 for (i, key) in self.available_key_presses() {
247 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
248 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
249 match key.code {
250 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') if shift => {
252 let visible_len = table_visible_len(state);
253 state.selected = state.selected.min(visible_len.saturating_sub(1));
254 let anchor = *state.selection_anchor.get_or_insert(state.selected);
255 handle_vertical_nav(
256 &mut state.selected,
257 visible_len.saturating_sub(1),
258 key.code.clone(),
259 );
260 state.select_range(anchor, state.selected);
261 consumed_indices.push(i);
262 }
263 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
265 let visible_len = table_visible_len(state);
266 state.selected = state.selected.min(visible_len.saturating_sub(1));
267 let _ = handle_vertical_nav(
268 &mut state.selected,
269 visible_len.saturating_sub(1),
270 key.code.clone(),
271 );
272 consumed_indices.push(i);
273 }
274 KeyCode::Char(' ') if ctrl => {
277 state.toggle_row(state.selected);
278 consumed_indices.push(i);
279 }
280 KeyCode::Char(' ') => {
281 state.toggle_row(state.selected);
282 consumed_indices.push(i);
283 }
284 KeyCode::PageUp => {
285 let old_page = state.page;
286 state.prev_page();
287 if state.page != old_page {
288 state.selected = 0;
289 }
290 consumed_indices.push(i);
291 }
292 KeyCode::PageDown => {
293 let old_page = state.page;
294 state.next_page();
295 if state.page != old_page {
296 state.selected = 0;
297 }
298 consumed_indices.push(i);
299 }
300 _ => {}
301 }
302 }
303 self.consume_indices(consumed_indices);
304 }
305
306 fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
307 let header_cells = state
308 .headers
309 .iter()
310 .enumerate()
311 .map(|(i, header)| {
312 if state.sort_column == Some(i) {
313 if state.sort_ascending {
314 let mut sorted_header = String::with_capacity(header.len() + 2);
315 sorted_header.push_str(header);
316 sorted_header.push_str(" ▲");
317 sorted_header
318 } else {
319 let mut sorted_header = String::with_capacity(header.len() + 2);
320 sorted_header.push_str(header);
321 sorted_header.push_str(" ▼");
322 sorted_header
323 }
324 } else {
325 header.clone()
326 }
327 })
328 .collect::<Vec<_>>();
329 let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
330 self.styled(
331 header_line,
332 Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
333 );
334
335 let separator = state
336 .column_widths()
337 .iter()
338 .map(|w| "─".repeat(*w as usize))
339 .collect::<Vec<_>>()
340 .join("─┼─");
341 self.text(separator);
342 }
343
344 fn render_table_rows(
345 &mut self,
346 state: &TableState,
347 focused: bool,
348 page_start: usize,
349 visible_len: usize,
350 colors: &WidgetColors,
351 cell: Option<TableCellRenderer>,
352 ) {
353 for idx in 0..visible_len {
354 let view_idx = page_start + idx;
355 let data_idx = state.visible_indices()[view_idx];
356 let Some(row) = state.rows.get(data_idx) else {
357 continue;
358 };
359
360 let base = if idx == state.selected {
365 let mut style = Style::new()
366 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
367 .fg(colors.fg.unwrap_or(self.theme.selected_fg));
368 if focused {
369 style = style.bold();
370 }
371 style
372 } else if state.is_row_selected(view_idx) {
373 Style::new()
376 .bg(colors.accent.unwrap_or(self.theme.selected_bg))
377 .fg(colors.fg.unwrap_or(self.theme.selected_fg))
378 .dim()
379 } else {
380 let mut style = Style::new().fg(colors.fg.unwrap_or(self.theme.text));
381 if state.zebra {
382 let zebra_bg = colors.bg.unwrap_or({
383 if idx % 2 == 0 {
384 self.theme.surface
385 } else {
386 self.theme.surface_hover
387 }
388 });
389 style = style.bg(zebra_bg);
390 }
391 style
392 };
393
394 match &cell {
395 None => {
396 let line = format_table_row(row, state.column_widths(), " │ ");
397 self.styled(line, base);
398 }
399 Some(render) => {
400 let widths = state.column_widths();
401 let mut segments: Vec<(String, Style)> =
402 Vec::with_capacity(widths.len().saturating_mul(2));
403 for (col, width) in widths.iter().enumerate() {
404 if col > 0 {
405 segments.push((" │ ".to_string(), base));
406 }
407 let raw = row.get(col).map(String::as_str).unwrap_or("");
408 let (content, cell_style) = render(view_idx, col, raw);
409 let mut merged = base;
414 if cell_style.fg.is_some() {
415 merged.fg = cell_style.fg;
416 }
417 if cell_style.bg.is_some() {
418 merged.bg = cell_style.bg;
419 }
420 merged.modifiers |= cell_style.modifiers;
421 let padded = clamp_table_cell(&content, *width);
422 segments.push((padded, merged));
423 }
424 self.line(move |ui| {
425 for (text, style) in segments {
426 ui.styled(text, style);
427 }
428 });
429 }
430 }
431 }
432 }
433
434 pub fn tabs(&mut self, state: &mut TabsState) -> Response {
439 let colors = self.widget_theme.tabs;
440 self.tabs_colored(state, &colors)
441 }
442
443 pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
445 if state.labels.is_empty() {
446 state.selected = 0;
447 return Response::none();
448 }
449
450 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
451 let old_selected = state.selected;
452 let focused = self.register_focusable();
453 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
454
455 if focused {
456 let mut consumed_indices = Vec::new();
457 for (i, key) in self.available_key_presses() {
458 match key.code {
459 KeyCode::Left => {
460 state.selected = if state.selected == 0 {
461 state.labels.len().saturating_sub(1)
462 } else {
463 state.selected - 1
464 };
465 consumed_indices.push(i);
466 }
467 KeyCode::Right => {
468 if !state.labels.is_empty() {
469 state.selected = (state.selected + 1) % state.labels.len();
470 }
471 consumed_indices.push(i);
472 }
473 _ => {}
474 }
475 }
476 self.consume_indices(consumed_indices);
477 }
478
479 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
480 let mut consumed = Vec::new();
481 for (i, mouse) in clicks {
482 let mut x_offset = 0u32;
483 let rel_x = mouse.x.saturating_sub(rect.x);
484 for (idx, label) in state.labels.iter().enumerate() {
485 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
486 if rel_x >= x_offset && rel_x < x_offset + tab_width {
487 state.selected = idx;
488 consumed.push(i);
489 break;
490 }
491 x_offset += tab_width + 1;
492 }
493 }
494 self.consume_indices(consumed);
495 }
496
497 let tabs_gap = self.theme.spacing.xs();
498 self.commands
499 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
500 direction: Direction::Row,
501 gap: tabs_gap as i32,
502 align: Align::Start,
503 align_self: None,
504 justify: Justify::Start,
505 border: None,
506 border_sides: BorderSides::all(),
507 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
508 bg_color: None,
509 padding: Padding::default(),
510 margin: Margin::default(),
511 constraints: Constraints::default(),
512 title: None,
513 grow: 0,
514 group_name: None,
515 })));
516 for (idx, label) in state.labels.iter().enumerate() {
517 let style = if idx == state.selected {
518 let s = Style::new()
519 .fg(colors.accent.unwrap_or(self.theme.primary))
520 .bold();
521 if focused {
522 s.underline()
523 } else {
524 s
525 }
526 } else {
527 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
528 };
529 let mut tab = String::with_capacity(label.len() + 4);
530 tab.push_str("[ ");
531 tab.push_str(label);
532 tab.push_str(" ]");
533 self.styled(tab, style);
534 }
535 self.commands.push(Command::EndContainer);
536 self.rollback.last_text_idx = None;
537
538 response.changed = state.selected != old_selected;
539 response
540 }
541
542 pub fn paginator(&mut self, state: &mut PaginatorState) -> Response {
565 let colors = self.widget_theme.tabs;
567 self.paginator_colored(state, &colors)
568 }
569
570 pub fn paginator_colored(
590 &mut self,
591 state: &mut PaginatorState,
592 colors: &WidgetColors,
593 ) -> Response {
594 state.page = state.page.min(state.total_pages().saturating_sub(1));
595 let old_page = state.page;
596
597 let focused = self.register_focusable();
598 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
599
600 if focused {
601 let mut consumed_indices = Vec::new();
602 for (i, key) in self.available_key_presses() {
603 match key.code {
604 KeyCode::Left | KeyCode::Char('h') | KeyCode::PageUp => {
605 state.prev_page();
606 consumed_indices.push(i);
607 }
608 KeyCode::Right | KeyCode::Char('l') | KeyCode::PageDown => {
609 state.next_page();
610 consumed_indices.push(i);
611 }
612 _ => {}
613 }
614 }
615 self.consume_indices(consumed_indices);
616 }
617
618 let total_pages = state.total_pages();
619 let use_dots =
621 matches!(state.style, PaginatorStyle::Dots) && total_pages <= PAGINATOR_MAX_DOTS;
622
623 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
624 let mut consumed = Vec::new();
625 for (i, mouse) in clicks {
626 if mouse.y != rect.y {
627 continue;
628 }
629 let rel_x = mouse.x.saturating_sub(rect.x);
630 if use_dots {
631 let target = rel_x as usize;
633 if target < total_pages {
634 state.set_page(target);
635 consumed.push(i);
636 }
637 } else {
638 let label = format!("{}/{}", state.page + 1, total_pages);
640 let width = UnicodeWidthStr::width(label.as_str()) as u32;
641 if rel_x < width {
642 if rel_x < width / 2 {
643 state.prev_page();
644 } else {
645 state.next_page();
646 }
647 consumed.push(i);
648 }
649 }
650 }
651 self.consume_indices(consumed);
652 }
653
654 self.commands
655 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
656 direction: Direction::Row,
657 gap: 0,
658 align: Align::Start,
659 align_self: None,
660 justify: Justify::Start,
661 border: None,
662 border_sides: BorderSides::all(),
663 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
664 bg_color: None,
665 padding: Padding::default(),
666 margin: Margin::default(),
667 constraints: Constraints::default(),
668 title: None,
669 grow: 0,
670 group_name: None,
671 })));
672
673 if use_dots {
674 let active_color = colors.accent.unwrap_or(self.theme.primary);
675 let inactive_color = colors.fg.unwrap_or(self.theme.text_dim);
676 for page in 0..total_pages {
677 let (glyph, color) = if page == state.page {
678 ("●", active_color)
679 } else {
680 ("○", inactive_color)
681 };
682 let style = if page == state.page && focused {
683 Style::new().fg(color).bold()
684 } else {
685 Style::new().fg(color)
686 };
687 self.styled(glyph, style);
688 }
689 } else {
690 let label = format!("{}/{}", state.page + 1, total_pages);
691 let style = Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim));
692 self.styled(label, style);
693 }
694
695 self.commands.push(Command::EndContainer);
696 self.rollback.last_text_idx = None;
697
698 response.changed = state.page != old_page;
699 response
700 }
701
702 pub fn button(&mut self, label: impl Into<String>) -> Response {
708 let colors = self.widget_theme.button;
709 self.button_colored(label, &colors)
710 }
711
712 pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
714 let focused = self.register_focusable();
715 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
716
717 let activated = response.clicked || self.consume_activation_keys(focused);
718
719 let hovered = response.hovered;
720 let base_fg = colors.fg.unwrap_or(self.theme.text);
721 let accent = colors.accent.unwrap_or(self.theme.accent);
722 let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
723 let style = if focused {
724 Style::new().fg(accent).bold()
725 } else if hovered {
726 Style::new().fg(accent)
727 } else {
728 Style::new().fg(base_fg)
729 };
730 let has_custom_bg = colors.bg.is_some();
731 let bg_color = if has_custom_bg || hovered || focused {
732 Some(base_bg)
733 } else {
734 None
735 };
736
737 self.commands
738 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
739 direction: Direction::Row,
740 gap: 0,
741 align: Align::Start,
742 align_self: None,
743 justify: Justify::Start,
744 border: None,
745 border_sides: BorderSides::all(),
746 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
747 bg_color,
748 padding: Padding::default(),
749 margin: Margin::default(),
750 constraints: Constraints::default(),
751 title: None,
752 grow: 0,
753 group_name: None,
754 })));
755 let raw_label = label.into();
756 let mut label_text = String::with_capacity(raw_label.len() + 4);
757 label_text.push_str("[ ");
758 label_text.push_str(&raw_label);
759 label_text.push_str(" ]");
760 self.styled(label_text, style);
761 self.commands.push(Command::EndContainer);
762 self.rollback.last_text_idx = None;
763
764 response.clicked = activated;
765 response
766 }
767
768 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
773 let focused = self.register_focusable();
774 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
775
776 let activated = response.clicked || self.consume_activation_keys(focused);
777
778 let label = label.into();
779 let hover_bg = if response.hovered || focused {
780 Some(self.theme.surface_hover)
781 } else {
782 None
783 };
784 let (text, style, bg_color, border) = match variant {
785 ButtonVariant::Default => {
786 let style = if focused {
787 Style::new().fg(self.theme.primary).bold()
788 } else if response.hovered {
789 Style::new().fg(self.theme.accent)
790 } else {
791 Style::new().fg(self.theme.text)
792 };
793 let mut text = String::with_capacity(label.len() + 4);
794 text.push_str("[ ");
795 text.push_str(&label);
796 text.push_str(" ]");
797 (text, style, hover_bg, None)
798 }
799 ButtonVariant::Primary => {
800 let style = if focused {
801 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
802 } else if response.hovered {
803 Style::new().fg(self.theme.bg).bg(self.theme.accent)
804 } else {
805 Style::new().fg(self.theme.bg).bg(self.theme.primary)
806 };
807 let mut text = String::with_capacity(label.len() + 2);
808 text.push(' ');
809 text.push_str(&label);
810 text.push(' ');
811 (text, style, hover_bg, None)
812 }
813 ButtonVariant::Danger => {
814 let style = if focused {
815 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
816 } else if response.hovered {
817 Style::new().fg(self.theme.bg).bg(self.theme.warning)
818 } else {
819 Style::new().fg(self.theme.bg).bg(self.theme.error)
820 };
821 let mut text = String::with_capacity(label.len() + 2);
822 text.push(' ');
823 text.push_str(&label);
824 text.push(' ');
825 (text, style, hover_bg, None)
826 }
827 ButtonVariant::Outline => {
828 let border_color = if focused {
829 self.theme.primary
830 } else if response.hovered {
831 self.theme.accent
832 } else {
833 self.theme.border
834 };
835 let style = if focused {
836 Style::new().fg(self.theme.primary).bold()
837 } else if response.hovered {
838 Style::new().fg(self.theme.accent)
839 } else {
840 Style::new().fg(self.theme.text)
841 };
842 (
843 {
844 let mut text = String::with_capacity(label.len() + 2);
845 text.push(' ');
846 text.push_str(&label);
847 text.push(' ');
848 text
849 },
850 style,
851 hover_bg,
852 Some((Border::Rounded, Style::new().fg(border_color))),
853 )
854 }
855 };
856
857 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
858 self.commands
859 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
860 direction: Direction::Row,
861 gap: 0,
862 align: Align::Center,
863 align_self: None,
864 justify: Justify::Center,
865 border: if border.is_some() {
866 Some(btn_border)
867 } else {
868 None
869 },
870 border_sides: BorderSides::all(),
871 border_style: btn_border_style,
872 bg_color,
873 padding: Padding::default(),
874 margin: Margin::default(),
875 constraints: Constraints::default(),
876 title: None,
877 grow: 0,
878 group_name: None,
879 })));
880 self.styled(text, style);
881 self.commands.push(Command::EndContainer);
882 self.rollback.last_text_idx = None;
883
884 response.clicked = activated;
885 response
886 }
887
888 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
894 let colors = self.widget_theme.checkbox;
895 self.checkbox_colored(label, checked, &colors)
896 }
897
898 pub fn checkbox_colored(
900 &mut self,
901 label: impl Into<String>,
902 checked: &mut bool,
903 colors: &WidgetColors,
904 ) -> Response {
905 let focused = self.register_focusable();
906 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
907 let mut should_toggle = response.clicked;
908 let old_checked = *checked;
909
910 should_toggle |= self.consume_activation_keys(focused);
911
912 if should_toggle {
913 *checked = !*checked;
914 }
915
916 let hover_bg = if response.hovered || focused {
917 Some(self.theme.surface_hover)
918 } else {
919 None
920 };
921 let cb_gap = self.theme.spacing.xs();
922 self.commands
923 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
924 direction: Direction::Row,
925 gap: cb_gap as i32,
926 align: Align::Start,
927 align_self: None,
928 justify: Justify::Start,
929 border: None,
930 border_sides: BorderSides::all(),
931 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
932 bg_color: hover_bg,
933 padding: Padding::default(),
934 margin: Margin::default(),
935 constraints: Constraints::default(),
936 title: None,
937 grow: 0,
938 group_name: None,
939 })));
940 let marker_style = if *checked {
941 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
942 } else {
943 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
944 };
945 let marker = if *checked { "[x]" } else { "[ ]" };
946 let label_text = label.into();
947 if focused {
948 let mut marker_text = String::with_capacity(2 + marker.len());
949 marker_text.push_str("▸ ");
950 marker_text.push_str(marker);
951 self.styled(marker_text, marker_style.bold());
952 self.styled(
953 label_text,
954 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
955 );
956 } else {
957 self.styled(marker, marker_style);
958 self.styled(
959 label_text,
960 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
961 );
962 }
963 self.commands.push(Command::EndContainer);
964 self.rollback.last_text_idx = None;
965
966 response.changed = *checked != old_checked;
967 response
968 }
969
970 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
977 let colors = self.widget_theme.toggle;
978 self.toggle_colored(label, on, &colors)
979 }
980
981 pub fn toggle_colored(
983 &mut self,
984 label: impl Into<String>,
985 on: &mut bool,
986 colors: &WidgetColors,
987 ) -> Response {
988 let focused = self.register_focusable();
989 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
990 let mut should_toggle = response.clicked;
991 let old_on = *on;
992
993 should_toggle |= self.consume_activation_keys(focused);
994
995 if should_toggle {
996 *on = !*on;
997 }
998
999 let hover_bg = if response.hovered || focused {
1000 Some(self.theme.surface_hover)
1001 } else {
1002 None
1003 };
1004 let toggle_gap = self.theme.spacing.sm();
1005 self.commands
1006 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1007 direction: Direction::Row,
1008 gap: toggle_gap as i32,
1009 align: Align::Start,
1010 align_self: None,
1011 justify: Justify::Start,
1012 border: None,
1013 border_sides: BorderSides::all(),
1014 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1015 bg_color: hover_bg,
1016 padding: Padding::default(),
1017 margin: Margin::default(),
1018 constraints: Constraints::default(),
1019 title: None,
1020 grow: 0,
1021 group_name: None,
1022 })));
1023 let label_text = label.into();
1024 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1025 let switch_style = if *on {
1026 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1027 } else {
1028 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1029 };
1030 if focused {
1031 let mut focused_label = String::with_capacity(2 + label_text.len());
1032 focused_label.push_str("▸ ");
1033 focused_label.push_str(&label_text);
1034 self.styled(
1035 focused_label,
1036 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1037 );
1038 self.styled(switch, switch_style.bold());
1039 } else {
1040 self.styled(
1041 label_text,
1042 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1043 );
1044 self.styled(switch, switch_style);
1045 }
1046 self.commands.push(Command::EndContainer);
1047 self.rollback.last_text_idx = None;
1048
1049 response.changed = *on != old_on;
1050 response
1051 }
1052
1053 pub fn select(&mut self, state: &mut SelectState) -> Response {
1060 let colors = self.widget_theme.select;
1061 self.select_colored(state, &colors)
1062 }
1063
1064 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1066 if state.items.is_empty() {
1067 return Response::none();
1068 }
1069 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1070
1071 let focused = self.register_focusable();
1072 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
1073 let old_selected = state.selected;
1074
1075 self.select_handle_events(state, focused, response.clicked);
1076 if state.open {
1078 let flen = state.filtered_indices().len();
1079 let cur = state.cursor();
1080 if flen == 0 {
1081 state.set_cursor(0);
1082 } else if cur >= flen {
1083 state.set_cursor(flen - 1);
1084 }
1085 }
1086 self.select_render(state, focused, colors);
1087 response.changed = state.selected != old_selected;
1088 response
1089 }
1090
1091 fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
1092 if clicked {
1093 state.open = !state.open;
1094 if state.open {
1095 state.filter.clear();
1096 state.set_cursor(state.selected);
1097 }
1098 }
1099
1100 if !focused {
1101 return;
1102 }
1103
1104 let mut consumed_indices = Vec::new();
1105 for (i, key) in self.available_key_presses() {
1106 if state.open {
1107 let filtered_len = state.filtered_indices().len();
1110 match key.code {
1111 KeyCode::Up => {
1112 state.set_cursor(state.cursor().saturating_sub(1));
1113 consumed_indices.push(i);
1114 }
1115 KeyCode::Down => {
1116 if filtered_len > 0 {
1117 let next = (state.cursor() + 1).min(filtered_len - 1);
1118 state.set_cursor(next);
1119 }
1120 consumed_indices.push(i);
1121 }
1122 KeyCode::Enter => {
1123 if let Some(&real) = state.filtered_indices().get(state.cursor()) {
1124 state.selected = real;
1125 }
1126 state.open = false;
1127 state.filter.clear();
1128 consumed_indices.push(i);
1129 }
1130 KeyCode::Esc => {
1131 if state.filter.is_empty() {
1133 state.open = false;
1134 } else {
1135 state.filter.clear();
1136 state.set_cursor(0);
1137 }
1138 consumed_indices.push(i);
1139 }
1140 KeyCode::Backspace => {
1141 state.filter.pop();
1142 state.set_cursor(0);
1143 consumed_indices.push(i);
1144 }
1145 KeyCode::Char(c) => {
1146 state.filter.push(c);
1149 state.set_cursor(0);
1150 consumed_indices.push(i);
1151 }
1152 _ => {}
1153 }
1154 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1155 state.open = true;
1156 state.filter.clear();
1157 state.set_cursor(state.selected);
1158 consumed_indices.push(i);
1159 }
1160 }
1161 self.consume_indices(consumed_indices);
1162 }
1163
1164 fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
1165 let border_color = if focused {
1166 colors.accent.unwrap_or(self.theme.primary)
1167 } else {
1168 colors.border.unwrap_or(self.theme.border)
1169 };
1170 let display_text = state
1171 .items
1172 .get(state.selected)
1173 .cloned()
1174 .unwrap_or_else(|| state.placeholder.clone());
1175 let arrow = if state.open { "▲" } else { "▼" };
1176
1177 self.commands
1178 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1179 direction: Direction::Column,
1180 gap: 0,
1181 align: Align::Start,
1182 align_self: None,
1183 justify: Justify::Start,
1184 border: None,
1185 border_sides: BorderSides::all(),
1186 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1187 bg_color: None,
1188 padding: Padding::default(),
1189 margin: Margin::default(),
1190 constraints: Constraints::default(),
1191 title: None,
1192 grow: 0,
1193 group_name: None,
1194 })));
1195
1196 self.render_select_trigger(&display_text, arrow, border_color, colors);
1197
1198 if state.open {
1199 self.render_select_dropdown(state, colors);
1200 }
1201
1202 self.commands.push(Command::EndContainer);
1203 self.rollback.last_text_idx = None;
1204 }
1205
1206 fn render_select_trigger(
1207 &mut self,
1208 display_text: &str,
1209 arrow: &str,
1210 border_color: Color,
1211 colors: &WidgetColors,
1212 ) {
1213 let trig_gap = self.theme.spacing.xs();
1214 let trig_h = self.theme.spacing.xs();
1215 self.commands
1216 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1217 direction: Direction::Row,
1218 gap: trig_gap as i32,
1219 align: Align::Start,
1220 align_self: None,
1221 justify: Justify::Start,
1222 border: Some(Border::Rounded),
1223 border_sides: BorderSides::all(),
1224 border_style: Style::new().fg(border_color),
1225 bg_color: None,
1226 padding: Padding {
1227 left: trig_h,
1228 right: trig_h,
1229 top: 0,
1230 bottom: 0,
1231 },
1232 margin: Margin::default(),
1233 constraints: Constraints::default(),
1234 title: None,
1235 grow: 0,
1236 group_name: None,
1237 })));
1238 self.skip_interaction_slot();
1239 self.styled(
1240 display_text,
1241 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1242 );
1243 self.styled(
1244 arrow,
1245 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1246 );
1247 self.commands.push(Command::EndContainer);
1248 self.rollback.last_text_idx = None;
1249 }
1250
1251 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1252 let filtered = state.filtered_indices();
1253
1254 if !state.filter.is_empty() {
1256 let dim = self.theme.text_dim;
1257 let mut q = String::with_capacity(state.filter.len() + 1);
1258 q.push('/');
1259 q.push_str(&state.filter);
1260 self.styled(q, Style::new().fg(dim).italic());
1261 }
1262
1263 if filtered.is_empty() {
1264 let dim = self.theme.text_dim;
1265 self.styled(" (no matches)".to_string(), Style::new().fg(dim).dim());
1266 return;
1267 }
1268
1269 let cursor = state.cursor();
1270 for (pos, &idx) in filtered.iter().enumerate() {
1271 let item = &state.items[idx];
1272 let is_cursor = pos == cursor;
1273 let style = if is_cursor {
1274 Style::new()
1275 .bold()
1276 .fg(colors.accent.unwrap_or(self.theme.primary))
1277 } else {
1278 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1279 };
1280 let prefix = if is_cursor { "▸ " } else { " " };
1281 let mut row = String::with_capacity(prefix.len() + item.len());
1282 row.push_str(prefix);
1283 row.push_str(item);
1284 self.styled(row, style);
1285 }
1286 }
1287
1288 pub fn radio(&mut self, state: &mut RadioState) -> Response {
1293 let colors = self.widget_theme.radio;
1294 self.radio_colored(state, &colors)
1295 }
1296
1297 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1299 if state.items.is_empty() {
1300 return Response::none();
1301 }
1302 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1303 let focused = self.register_focusable();
1304 let old_selected = state.selected;
1305
1306 if focused {
1307 let mut consumed_indices = Vec::new();
1308 for (i, key) in self.available_key_presses() {
1309 match key.code {
1310 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1311 let _ = handle_vertical_nav(
1312 &mut state.selected,
1313 state.items.len().saturating_sub(1),
1314 key.code.clone(),
1315 );
1316 consumed_indices.push(i);
1317 }
1318 KeyCode::Enter | KeyCode::Char(' ') => {
1319 consumed_indices.push(i);
1320 }
1321 _ => {}
1322 }
1323 }
1324 self.consume_indices(consumed_indices);
1325 }
1326
1327 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1328
1329 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1330 let mut consumed = Vec::new();
1331 for (i, mouse) in clicks {
1332 let clicked_idx = (mouse.y - rect.y) as usize;
1333 if clicked_idx < state.items.len() {
1334 state.selected = clicked_idx;
1335 consumed.push(i);
1336 }
1337 }
1338 self.consume_indices(consumed);
1339 }
1340
1341 self.commands
1342 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1343 direction: Direction::Column,
1344 gap: 0,
1345 align: Align::Start,
1346 align_self: None,
1347 justify: Justify::Start,
1348 border: None,
1349 border_sides: BorderSides::all(),
1350 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1351 bg_color: None,
1352 padding: Padding::default(),
1353 margin: Margin::default(),
1354 constraints: Constraints::default(),
1355 title: None,
1356 grow: 0,
1357 group_name: None,
1358 })));
1359
1360 for (idx, item) in state.items.iter().enumerate() {
1361 let is_selected = idx == state.selected;
1362 let marker = if is_selected { "●" } else { "○" };
1363 let style = if is_selected {
1364 if focused {
1365 Style::new()
1366 .bold()
1367 .fg(colors.accent.unwrap_or(self.theme.primary))
1368 } else {
1369 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1370 }
1371 } else {
1372 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1373 };
1374 let prefix = if focused && idx == state.selected {
1375 "▸ "
1376 } else {
1377 " "
1378 };
1379 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1380 row.push_str(prefix);
1381 row.push_str(marker);
1382 row.push(' ');
1383 row.push_str(item);
1384 self.styled(row, style);
1385 }
1386
1387 self.commands.push(Command::EndContainer);
1388 self.rollback.last_text_idx = None;
1389 response.changed = state.selected != old_selected;
1390 response
1391 }
1392
1393 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1397 if state.items.is_empty() {
1398 return Response::none();
1399 }
1400 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1401 let focused = self.register_focusable();
1402 let old_selected = state.selected.clone();
1403
1404 if focused {
1405 let mut consumed_indices = Vec::new();
1406 for (i, key) in self.available_key_presses() {
1407 match key.code {
1408 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1409 let _ = handle_vertical_nav(
1410 &mut state.cursor,
1411 state.items.len().saturating_sub(1),
1412 key.code.clone(),
1413 );
1414 consumed_indices.push(i);
1415 }
1416 KeyCode::Char(' ') | KeyCode::Enter => {
1417 state.toggle(state.cursor);
1418 consumed_indices.push(i);
1419 }
1420 _ => {}
1421 }
1422 }
1423 self.consume_indices(consumed_indices);
1424 }
1425
1426 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1427
1428 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1429 let mut consumed = Vec::new();
1430 for (i, mouse) in clicks {
1431 let clicked_idx = (mouse.y - rect.y) as usize;
1432 if clicked_idx < state.items.len() {
1433 state.toggle(clicked_idx);
1434 state.cursor = clicked_idx;
1435 consumed.push(i);
1436 }
1437 }
1438 self.consume_indices(consumed);
1439 }
1440
1441 self.commands
1442 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1443 direction: Direction::Column,
1444 gap: 0,
1445 align: Align::Start,
1446 align_self: None,
1447 justify: Justify::Start,
1448 border: None,
1449 border_sides: BorderSides::all(),
1450 border_style: Style::new().fg(self.theme.border),
1451 bg_color: None,
1452 padding: Padding::default(),
1453 margin: Margin::default(),
1454 constraints: Constraints::default(),
1455 title: None,
1456 grow: 0,
1457 group_name: None,
1458 })));
1459
1460 for (idx, item) in state.items.iter().enumerate() {
1461 let checked = state.selected.contains(&idx);
1462 let marker = if checked { "[x]" } else { "[ ]" };
1463 let is_cursor = idx == state.cursor;
1464 let style = if is_cursor && focused {
1465 Style::new().bold().fg(self.theme.primary)
1466 } else if checked {
1467 Style::new().fg(self.theme.success)
1468 } else {
1469 Style::new().fg(self.theme.text)
1470 };
1471 let prefix = if is_cursor && focused { "▸ " } else { " " };
1472 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1473 row.push_str(prefix);
1474 row.push_str(marker);
1475 row.push(' ');
1476 row.push_str(item);
1477 self.styled(row, style);
1478 }
1479
1480 self.commands.push(Command::EndContainer);
1481 self.rollback.last_text_idx = None;
1482 response.changed = state.selected != old_selected;
1483 response
1484 }
1485
1486 pub fn color_picker(&mut self, state: &mut ColorPickerState) -> Response {
1518 let colors = self.widget_theme.color_picker;
1519 self.color_picker_colored(state, &colors)
1520 }
1521
1522 pub fn color_picker_colored(
1540 &mut self,
1541 state: &mut ColorPickerState,
1542 colors: &WidgetColors,
1543 ) -> Response {
1544 if state.colors.is_empty() {
1545 return Response::none();
1546 }
1547 let columns = state.columns.max(1);
1548 state.selected = state.selected.min(state.colors.len() - 1);
1549
1550 let focused = self.register_focusable();
1551 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1552 let old_color = state.selected();
1553
1554 self.color_picker_handle_keys(state, focused, columns);
1555 self.color_picker_handle_clicks(state, interaction_id, columns);
1556 self.color_picker_render(state, focused, columns, colors);
1557
1558 response.changed = state.selected() != old_color;
1559 response
1560 }
1561
1562 fn color_picker_handle_keys(
1563 &mut self,
1564 state: &mut ColorPickerState,
1565 focused: bool,
1566 columns: usize,
1567 ) {
1568 if !focused {
1569 return;
1570 }
1571 let len = state.colors.len();
1572 let mut consumed_indices = Vec::new();
1573 for (i, key) in self.available_key_presses() {
1574 match state.mode {
1575 PickerMode::Palette => match key.code {
1576 KeyCode::Left | KeyCode::Char('h') => {
1577 if state.selected % columns > 0 {
1578 state.selected -= 1;
1579 }
1580 consumed_indices.push(i);
1581 }
1582 KeyCode::Right | KeyCode::Char('l') => {
1583 if state.selected % columns < columns - 1 && state.selected + 1 < len {
1584 state.selected += 1;
1585 }
1586 consumed_indices.push(i);
1587 }
1588 KeyCode::Up | KeyCode::Char('k') => {
1589 if state.selected >= columns {
1590 state.selected -= columns;
1591 }
1592 consumed_indices.push(i);
1593 }
1594 KeyCode::Down | KeyCode::Char('j') => {
1595 if state.selected + columns < len {
1596 state.selected += columns;
1597 }
1598 consumed_indices.push(i);
1599 }
1600 KeyCode::Tab => {
1601 state.mode = PickerMode::Hex;
1602 consumed_indices.push(i);
1603 }
1604 KeyCode::Enter | KeyCode::Char(' ') => {
1605 consumed_indices.push(i);
1606 }
1607 _ => {}
1608 },
1609 PickerMode::Hex => match key.code {
1610 KeyCode::Tab => {
1611 state.mode = PickerMode::Palette;
1612 consumed_indices.push(i);
1613 }
1614 KeyCode::Enter => {
1615 consumed_indices.push(i);
1616 }
1617 KeyCode::Char(ch) => {
1618 let index =
1619 byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1620 state.hex_input.value.insert(index, ch);
1621 state.hex_input.cursor += 1;
1622 color_picker_validate_hex(&mut state.hex_input);
1623 consumed_indices.push(i);
1624 }
1625 KeyCode::Backspace => {
1626 if state.hex_input.cursor > 0 {
1627 let start = byte_index_for_char(
1628 &state.hex_input.value,
1629 state.hex_input.cursor - 1,
1630 );
1631 let end =
1632 byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1633 state.hex_input.value.replace_range(start..end, "");
1634 state.hex_input.cursor -= 1;
1635 }
1636 color_picker_validate_hex(&mut state.hex_input);
1637 consumed_indices.push(i);
1638 }
1639 _ => {}
1640 },
1641 }
1642 }
1643 self.consume_indices(consumed_indices);
1644 }
1645
1646 fn color_picker_handle_clicks(
1647 &mut self,
1648 state: &mut ColorPickerState,
1649 interaction_id: usize,
1650 columns: usize,
1651 ) {
1652 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1653 let grid_x0 = rect.x + GRID_X_OFFSET;
1657 let grid_y0 = rect.y + GRID_Y_OFFSET;
1658 let rows = state.colors.len().div_ceil(columns);
1659 let mut consumed = Vec::new();
1660 for (i, mouse) in clicks {
1661 if mouse.x < grid_x0 || mouse.y < grid_y0 {
1662 continue;
1663 }
1664 let row = (mouse.y - grid_y0) as usize;
1665 let col = (mouse.x - grid_x0) as usize / SWATCH_WIDTH;
1666 if row < rows && col < columns {
1667 let idx = row * columns + col;
1668 if idx < state.colors.len() {
1669 state.mode = PickerMode::Palette;
1670 state.selected = idx;
1671 consumed.push(i);
1672 }
1673 }
1674 }
1675 self.consume_indices(consumed);
1676 }
1677 }
1678
1679 fn color_picker_render(
1680 &mut self,
1681 state: &ColorPickerState,
1682 focused: bool,
1683 columns: usize,
1684 colors: &WidgetColors,
1685 ) {
1686 let border_color = if focused {
1687 colors.accent.unwrap_or(self.theme.primary)
1688 } else {
1689 colors.border.unwrap_or(self.theme.border)
1690 };
1691 let text_color = colors.fg.unwrap_or(self.theme.text);
1692
1693 self.commands
1694 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1695 direction: Direction::Column,
1696 gap: 0,
1697 align: Align::Start,
1698 align_self: None,
1699 justify: Justify::Start,
1700 border: Some(Border::Rounded),
1701 border_sides: BorderSides::all(),
1702 border_style: Style::new().fg(border_color),
1703 bg_color: None,
1704 padding: Padding::xy(1, 0),
1705 margin: Margin::default(),
1706 constraints: Constraints::default(),
1707 title: None,
1708 grow: 0,
1709 group_name: None,
1710 })));
1711
1712 let rows = state.colors.len().div_ceil(columns);
1714 for row in 0..rows {
1715 self.commands
1716 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1717 direction: Direction::Row,
1718 gap: 0,
1719 align: Align::Start,
1720 align_self: None,
1721 justify: Justify::Start,
1722 border: None,
1723 border_sides: BorderSides::all(),
1724 border_style: Style::new(),
1725 bg_color: None,
1726 padding: Padding::default(),
1727 margin: Margin::default(),
1728 constraints: Constraints::default(),
1729 title: None,
1730 grow: 0,
1731 group_name: None,
1732 })));
1733 for col in 0..columns {
1734 let idx = row * columns + col;
1735 let Some(&swatch) = state.colors.get(idx) else {
1736 break;
1737 };
1738 let is_cursor = idx == state.selected && state.mode == PickerMode::Palette;
1739 let marker = if is_cursor { '▣' } else { ' ' };
1740 let mut cell = String::with_capacity(SWATCH_WIDTH);
1741 cell.push(' ');
1742 cell.push(marker);
1743 cell.push(' ');
1744 let mut style = Style::new().bg(swatch).fg(Color::contrast_fg(swatch));
1747 if is_cursor {
1748 style = style.bold();
1749 }
1750 self.styled(cell, style);
1751 }
1752 self.commands.push(Command::EndContainer);
1753 self.rollback.last_text_idx = None;
1754 }
1755
1756 let selected = state.selected();
1759 let label = color_hex_label(selected).unwrap_or_else(|| "selected".to_string());
1760 let mut readout = String::with_capacity(label.len() + 3);
1761 readout.push_str("▸ ");
1762 readout.push_str(&label);
1763 self.styled(readout, Style::new().fg(text_color).bold());
1764
1765 let hex_active = state.mode == PickerMode::Hex;
1769 let hex_display = if state.hex_input.value.is_empty() {
1770 state.hex_input.placeholder.clone()
1771 } else {
1772 state.hex_input.value.clone()
1773 };
1774 let mut hex_line = String::with_capacity(hex_display.len() + 6);
1775 hex_line.push_str(if hex_active { "▸ hex " } else { " hex " });
1776 hex_line.push_str(&hex_display);
1777 if state.hex_input.validation_error.is_some() {
1778 hex_line.push_str(" ✗");
1779 }
1780 let hex_style = if hex_active {
1781 Style::new()
1782 .fg(colors.accent.unwrap_or(self.theme.primary))
1783 .bold()
1784 } else {
1785 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1786 };
1787 self.styled(hex_line, hex_style);
1788
1789 self.commands.push(Command::EndContainer);
1790 self.rollback.last_text_idx = None;
1791 }
1792
1793 }
1795
1796const SWATCH_WIDTH: usize = 3;
1798
1799const GRID_X_OFFSET: u32 = 2;
1802
1803const GRID_Y_OFFSET: u32 = 1;
1806
1807fn color_picker_validate_hex(input: &mut TextInputState) {
1813 if input.value.is_empty() {
1814 input.validation_error = None;
1815 } else if parse_hex_color(&input.value).is_none() {
1816 input.validation_error = Some("invalid hex".to_string());
1817 } else {
1818 input.validation_error = None;
1819 }
1820}