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 { s.underline() } else { s }
522 } else {
523 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
524 };
525 let mut tab = String::with_capacity(label.len() + 4);
526 tab.push_str("[ ");
527 tab.push_str(label);
528 tab.push_str(" ]");
529 self.styled(tab, style);
530 }
531 self.commands.push(Command::EndContainer);
532 self.rollback.last_text_idx = None;
533
534 response.changed = state.selected != old_selected;
535 response
536 }
537
538 pub fn paginator(&mut self, state: &mut PaginatorState) -> Response {
561 let colors = self.widget_theme.tabs;
563 self.paginator_colored(state, &colors)
564 }
565
566 pub fn paginator_colored(
586 &mut self,
587 state: &mut PaginatorState,
588 colors: &WidgetColors,
589 ) -> Response {
590 state.page = state.page.min(state.total_pages().saturating_sub(1));
591 let old_page = state.page;
592
593 let focused = self.register_focusable();
594 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
595
596 if focused {
597 let mut consumed_indices = Vec::new();
598 for (i, key) in self.available_key_presses() {
599 match key.code {
600 KeyCode::Left | KeyCode::Char('h') | KeyCode::PageUp => {
601 state.prev_page();
602 consumed_indices.push(i);
603 }
604 KeyCode::Right | KeyCode::Char('l') | KeyCode::PageDown => {
605 state.next_page();
606 consumed_indices.push(i);
607 }
608 _ => {}
609 }
610 }
611 self.consume_indices(consumed_indices);
612 }
613
614 let total_pages = state.total_pages();
615 let use_dots =
617 matches!(state.style, PaginatorStyle::Dots) && total_pages <= PAGINATOR_MAX_DOTS;
618
619 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
620 let mut consumed = Vec::new();
621 for (i, mouse) in clicks {
622 if mouse.y != rect.y {
623 continue;
624 }
625 let rel_x = mouse.x.saturating_sub(rect.x);
626 if use_dots {
627 let target = rel_x as usize;
629 if target < total_pages {
630 state.set_page(target);
631 consumed.push(i);
632 }
633 } else {
634 let label = format!("{}/{}", state.page + 1, total_pages);
636 let width = UnicodeWidthStr::width(label.as_str()) as u32;
637 if rel_x < width {
638 if rel_x < width / 2 {
639 state.prev_page();
640 } else {
641 state.next_page();
642 }
643 consumed.push(i);
644 }
645 }
646 }
647 self.consume_indices(consumed);
648 }
649
650 self.commands
651 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
652 direction: Direction::Row,
653 gap: 0,
654 align: Align::Start,
655 align_self: None,
656 justify: Justify::Start,
657 border: None,
658 border_sides: BorderSides::all(),
659 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
660 bg_color: None,
661 padding: Padding::default(),
662 margin: Margin::default(),
663 constraints: Constraints::default(),
664 title: None,
665 grow: 0,
666 group_name: None,
667 })));
668
669 if use_dots {
670 let active_color = colors.accent.unwrap_or(self.theme.primary);
671 let inactive_color = colors.fg.unwrap_or(self.theme.text_dim);
672 for page in 0..total_pages {
673 let (glyph, color) = if page == state.page {
674 ("●", active_color)
675 } else {
676 ("○", inactive_color)
677 };
678 let style = if page == state.page && focused {
679 Style::new().fg(color).bold()
680 } else {
681 Style::new().fg(color)
682 };
683 self.styled(glyph, style);
684 }
685 } else {
686 let label = format!("{}/{}", state.page + 1, total_pages);
687 let style = Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim));
688 self.styled(label, style);
689 }
690
691 self.commands.push(Command::EndContainer);
692 self.rollback.last_text_idx = None;
693
694 response.changed = state.page != old_page;
695 response
696 }
697
698 pub fn button(&mut self, label: impl Into<String>) -> Response {
704 let colors = self.widget_theme.button;
705 self.button_colored(label, &colors)
706 }
707
708 pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
710 let focused = self.register_focusable();
711 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
712
713 let activated = response.clicked || self.consume_activation_keys(focused);
714
715 let hovered = response.hovered;
716 let base_fg = colors.fg.unwrap_or(self.theme.text);
717 let accent = colors.accent.unwrap_or(self.theme.accent);
718 let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
719 let style = if focused {
720 Style::new().fg(accent).bold()
721 } else if hovered {
722 Style::new().fg(accent)
723 } else {
724 Style::new().fg(base_fg)
725 };
726 let has_custom_bg = colors.bg.is_some();
727 let bg_color = if has_custom_bg || hovered || focused {
728 Some(base_bg)
729 } else {
730 None
731 };
732
733 self.commands
734 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
735 direction: Direction::Row,
736 gap: 0,
737 align: Align::Start,
738 align_self: None,
739 justify: Justify::Start,
740 border: None,
741 border_sides: BorderSides::all(),
742 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
743 bg_color,
744 padding: Padding::default(),
745 margin: Margin::default(),
746 constraints: Constraints::default(),
747 title: None,
748 grow: 0,
749 group_name: None,
750 })));
751 let raw_label = label.into();
752 let mut label_text = String::with_capacity(raw_label.len() + 4);
753 label_text.push_str("[ ");
754 label_text.push_str(&raw_label);
755 label_text.push_str(" ]");
756 self.styled(label_text, style);
757 self.commands.push(Command::EndContainer);
758 self.rollback.last_text_idx = None;
759
760 response.clicked = activated;
761 response
762 }
763
764 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
769 let focused = self.register_focusable();
770 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
771
772 let activated = response.clicked || self.consume_activation_keys(focused);
773
774 let label = label.into();
775 let hover_bg = if response.hovered || focused {
776 Some(self.theme.surface_hover)
777 } else {
778 None
779 };
780 let (text, style, bg_color, border) = match variant {
781 ButtonVariant::Default => {
782 let style = if focused {
783 Style::new().fg(self.theme.primary).bold()
784 } else if response.hovered {
785 Style::new().fg(self.theme.accent)
786 } else {
787 Style::new().fg(self.theme.text)
788 };
789 let mut text = String::with_capacity(label.len() + 4);
790 text.push_str("[ ");
791 text.push_str(&label);
792 text.push_str(" ]");
793 (text, style, hover_bg, None)
794 }
795 ButtonVariant::Primary => {
796 let style = if focused {
797 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
798 } else if response.hovered {
799 Style::new().fg(self.theme.bg).bg(self.theme.accent)
800 } else {
801 Style::new().fg(self.theme.bg).bg(self.theme.primary)
802 };
803 let mut text = String::with_capacity(label.len() + 2);
804 text.push(' ');
805 text.push_str(&label);
806 text.push(' ');
807 (text, style, hover_bg, None)
808 }
809 ButtonVariant::Danger => {
810 let style = if focused {
811 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
812 } else if response.hovered {
813 Style::new().fg(self.theme.bg).bg(self.theme.warning)
814 } else {
815 Style::new().fg(self.theme.bg).bg(self.theme.error)
816 };
817 let mut text = String::with_capacity(label.len() + 2);
818 text.push(' ');
819 text.push_str(&label);
820 text.push(' ');
821 (text, style, hover_bg, None)
822 }
823 ButtonVariant::Outline => {
824 let border_color = if focused {
825 self.theme.primary
826 } else if response.hovered {
827 self.theme.accent
828 } else {
829 self.theme.border
830 };
831 let style = if focused {
832 Style::new().fg(self.theme.primary).bold()
833 } else if response.hovered {
834 Style::new().fg(self.theme.accent)
835 } else {
836 Style::new().fg(self.theme.text)
837 };
838 (
839 {
840 let mut text = String::with_capacity(label.len() + 2);
841 text.push(' ');
842 text.push_str(&label);
843 text.push(' ');
844 text
845 },
846 style,
847 hover_bg,
848 Some((Border::Rounded, Style::new().fg(border_color))),
849 )
850 }
851 };
852
853 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
854 self.commands
855 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
856 direction: Direction::Row,
857 gap: 0,
858 align: Align::Center,
859 align_self: None,
860 justify: Justify::Center,
861 border: if border.is_some() {
862 Some(btn_border)
863 } else {
864 None
865 },
866 border_sides: BorderSides::all(),
867 border_style: btn_border_style,
868 bg_color,
869 padding: Padding::default(),
870 margin: Margin::default(),
871 constraints: Constraints::default(),
872 title: None,
873 grow: 0,
874 group_name: None,
875 })));
876 self.styled(text, style);
877 self.commands.push(Command::EndContainer);
878 self.rollback.last_text_idx = None;
879
880 response.clicked = activated;
881 response
882 }
883
884 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
890 let colors = self.widget_theme.checkbox;
891 self.checkbox_colored(label, checked, &colors)
892 }
893
894 pub fn checkbox_colored(
896 &mut self,
897 label: impl Into<String>,
898 checked: &mut bool,
899 colors: &WidgetColors,
900 ) -> Response {
901 let focused = self.register_focusable();
902 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
903 let mut should_toggle = response.clicked;
904 let old_checked = *checked;
905
906 should_toggle |= self.consume_activation_keys(focused);
907
908 if should_toggle {
909 *checked = !*checked;
910 }
911
912 let hover_bg = if response.hovered || focused {
913 Some(self.theme.surface_hover)
914 } else {
915 None
916 };
917 let cb_gap = self.theme.spacing.xs();
918 self.commands
919 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
920 direction: Direction::Row,
921 gap: cb_gap as i32,
922 align: Align::Start,
923 align_self: None,
924 justify: Justify::Start,
925 border: None,
926 border_sides: BorderSides::all(),
927 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
928 bg_color: hover_bg,
929 padding: Padding::default(),
930 margin: Margin::default(),
931 constraints: Constraints::default(),
932 title: None,
933 grow: 0,
934 group_name: None,
935 })));
936 let marker_style = if *checked {
937 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
938 } else {
939 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
940 };
941 let marker = if *checked { "[x]" } else { "[ ]" };
942 let label_text = label.into();
943 if focused {
944 let mut marker_text = String::with_capacity(2 + marker.len());
945 marker_text.push_str("▸ ");
946 marker_text.push_str(marker);
947 self.styled(marker_text, marker_style.bold());
948 self.styled(
949 label_text,
950 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
951 );
952 } else {
953 self.styled(marker, marker_style);
954 self.styled(
955 label_text,
956 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
957 );
958 }
959 self.commands.push(Command::EndContainer);
960 self.rollback.last_text_idx = None;
961
962 response.changed = *checked != old_checked;
963 response
964 }
965
966 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
973 let colors = self.widget_theme.toggle;
974 self.toggle_colored(label, on, &colors)
975 }
976
977 pub fn toggle_colored(
979 &mut self,
980 label: impl Into<String>,
981 on: &mut bool,
982 colors: &WidgetColors,
983 ) -> Response {
984 let focused = self.register_focusable();
985 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
986 let mut should_toggle = response.clicked;
987 let old_on = *on;
988
989 should_toggle |= self.consume_activation_keys(focused);
990
991 if should_toggle {
992 *on = !*on;
993 }
994
995 let hover_bg = if response.hovered || focused {
996 Some(self.theme.surface_hover)
997 } else {
998 None
999 };
1000 let toggle_gap = self.theme.spacing.sm();
1001 self.commands
1002 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1003 direction: Direction::Row,
1004 gap: toggle_gap as i32,
1005 align: Align::Start,
1006 align_self: None,
1007 justify: Justify::Start,
1008 border: None,
1009 border_sides: BorderSides::all(),
1010 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1011 bg_color: hover_bg,
1012 padding: Padding::default(),
1013 margin: Margin::default(),
1014 constraints: Constraints::default(),
1015 title: None,
1016 grow: 0,
1017 group_name: None,
1018 })));
1019 let label_text = label.into();
1020 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1021 let switch_style = if *on {
1022 Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1023 } else {
1024 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1025 };
1026 if focused {
1027 let mut focused_label = String::with_capacity(2 + label_text.len());
1028 focused_label.push_str("▸ ");
1029 focused_label.push_str(&label_text);
1030 self.styled(
1031 focused_label,
1032 Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1033 );
1034 self.styled(switch, switch_style.bold());
1035 } else {
1036 self.styled(
1037 label_text,
1038 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1039 );
1040 self.styled(switch, switch_style);
1041 }
1042 self.commands.push(Command::EndContainer);
1043 self.rollback.last_text_idx = None;
1044
1045 response.changed = *on != old_on;
1046 response
1047 }
1048
1049 pub fn select(&mut self, state: &mut SelectState) -> Response {
1056 let colors = self.widget_theme.select;
1057 self.select_colored(state, &colors)
1058 }
1059
1060 pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1062 if state.items.is_empty() {
1063 return Response::none();
1064 }
1065 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1066
1067 let focused = self.register_focusable();
1068 let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
1069 let old_selected = state.selected;
1070
1071 self.select_handle_events(state, focused, response.clicked);
1072 if state.open {
1074 let flen = state.filtered_indices().len();
1075 let cur = state.cursor();
1076 if flen == 0 {
1077 state.set_cursor(0);
1078 } else if cur >= flen {
1079 state.set_cursor(flen - 1);
1080 }
1081 }
1082 self.select_render(state, focused, colors);
1083 response.changed = state.selected != old_selected;
1084 response
1085 }
1086
1087 fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
1088 if clicked {
1089 state.open = !state.open;
1090 if state.open {
1091 state.filter.clear();
1092 state.set_cursor(state.selected);
1093 }
1094 }
1095
1096 if !focused {
1097 return;
1098 }
1099
1100 let mut consumed_indices = Vec::new();
1101 for (i, key) in self.available_key_presses() {
1102 if state.open {
1103 let filtered_len = state.filtered_indices().len();
1106 match key.code {
1107 KeyCode::Up => {
1108 state.set_cursor(state.cursor().saturating_sub(1));
1109 consumed_indices.push(i);
1110 }
1111 KeyCode::Down => {
1112 if filtered_len > 0 {
1113 let next = (state.cursor() + 1).min(filtered_len - 1);
1114 state.set_cursor(next);
1115 }
1116 consumed_indices.push(i);
1117 }
1118 KeyCode::Enter => {
1119 if let Some(&real) = state.filtered_indices().get(state.cursor()) {
1120 state.selected = real;
1121 }
1122 state.open = false;
1123 state.filter.clear();
1124 consumed_indices.push(i);
1125 }
1126 KeyCode::Esc => {
1127 if state.filter.is_empty() {
1129 state.open = false;
1130 } else {
1131 state.filter.clear();
1132 state.set_cursor(0);
1133 }
1134 consumed_indices.push(i);
1135 }
1136 KeyCode::Backspace => {
1137 state.filter.pop();
1138 state.set_cursor(0);
1139 consumed_indices.push(i);
1140 }
1141 KeyCode::Char(c) => {
1142 state.filter.push(c);
1145 state.set_cursor(0);
1146 consumed_indices.push(i);
1147 }
1148 _ => {}
1149 }
1150 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1151 state.open = true;
1152 state.filter.clear();
1153 state.set_cursor(state.selected);
1154 consumed_indices.push(i);
1155 }
1156 }
1157 self.consume_indices(consumed_indices);
1158 }
1159
1160 fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
1161 let border_color = if focused {
1162 colors.accent.unwrap_or(self.theme.primary)
1163 } else {
1164 colors.border.unwrap_or(self.theme.border)
1165 };
1166 let display_text = state
1167 .items
1168 .get(state.selected)
1169 .cloned()
1170 .unwrap_or_else(|| state.placeholder.clone());
1171 let arrow = if state.open { "▲" } else { "▼" };
1172
1173 self.commands
1174 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1175 direction: Direction::Column,
1176 gap: 0,
1177 align: Align::Start,
1178 align_self: None,
1179 justify: Justify::Start,
1180 border: None,
1181 border_sides: BorderSides::all(),
1182 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1183 bg_color: None,
1184 padding: Padding::default(),
1185 margin: Margin::default(),
1186 constraints: Constraints::default(),
1187 title: None,
1188 grow: 0,
1189 group_name: None,
1190 })));
1191
1192 self.render_select_trigger(&display_text, arrow, border_color, colors);
1193
1194 if state.open {
1195 self.render_select_dropdown(state, colors);
1196 }
1197
1198 self.commands.push(Command::EndContainer);
1199 self.rollback.last_text_idx = None;
1200 }
1201
1202 fn render_select_trigger(
1203 &mut self,
1204 display_text: &str,
1205 arrow: &str,
1206 border_color: Color,
1207 colors: &WidgetColors,
1208 ) {
1209 let trig_gap = self.theme.spacing.xs();
1210 let trig_h = self.theme.spacing.xs();
1211 self.commands
1212 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1213 direction: Direction::Row,
1214 gap: trig_gap as i32,
1215 align: Align::Start,
1216 align_self: None,
1217 justify: Justify::Start,
1218 border: Some(Border::Rounded),
1219 border_sides: BorderSides::all(),
1220 border_style: Style::new().fg(border_color),
1221 bg_color: None,
1222 padding: Padding {
1223 left: trig_h,
1224 right: trig_h,
1225 top: 0,
1226 bottom: 0,
1227 },
1228 margin: Margin::default(),
1229 constraints: Constraints::default(),
1230 title: None,
1231 grow: 0,
1232 group_name: None,
1233 })));
1234 self.skip_interaction_slot();
1235 self.styled(
1236 display_text,
1237 Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1238 );
1239 self.styled(
1240 arrow,
1241 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1242 );
1243 self.commands.push(Command::EndContainer);
1244 self.rollback.last_text_idx = None;
1245 }
1246
1247 fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1248 let filtered = state.filtered_indices();
1249
1250 if !state.filter.is_empty() {
1252 let dim = self.theme.text_dim;
1253 let mut q = String::with_capacity(state.filter.len() + 1);
1254 q.push('/');
1255 q.push_str(&state.filter);
1256 self.styled(q, Style::new().fg(dim).italic());
1257 }
1258
1259 if filtered.is_empty() {
1260 let dim = self.theme.text_dim;
1261 self.styled(" (no matches)".to_string(), Style::new().fg(dim).dim());
1262 return;
1263 }
1264
1265 let cursor = state.cursor();
1266 for (pos, &idx) in filtered.iter().enumerate() {
1267 let item = &state.items[idx];
1268 let is_cursor = pos == cursor;
1269 let style = if is_cursor {
1270 Style::new()
1271 .bold()
1272 .fg(colors.accent.unwrap_or(self.theme.primary))
1273 } else {
1274 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1275 };
1276 let prefix = if is_cursor { "▸ " } else { " " };
1277 let mut row = String::with_capacity(prefix.len() + item.len());
1278 row.push_str(prefix);
1279 row.push_str(item);
1280 self.styled(row, style);
1281 }
1282 }
1283
1284 pub fn radio(&mut self, state: &mut RadioState) -> Response {
1289 let colors = self.widget_theme.radio;
1290 self.radio_colored(state, &colors)
1291 }
1292
1293 pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1295 if state.items.is_empty() {
1296 return Response::none();
1297 }
1298 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1299 let focused = self.register_focusable();
1300 let old_selected = state.selected;
1301
1302 if focused {
1303 let mut consumed_indices = Vec::new();
1304 for (i, key) in self.available_key_presses() {
1305 match key.code {
1306 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1307 let _ = handle_vertical_nav(
1308 &mut state.selected,
1309 state.items.len().saturating_sub(1),
1310 key.code.clone(),
1311 );
1312 consumed_indices.push(i);
1313 }
1314 KeyCode::Enter | KeyCode::Char(' ') => {
1315 consumed_indices.push(i);
1316 }
1317 _ => {}
1318 }
1319 }
1320 self.consume_indices(consumed_indices);
1321 }
1322
1323 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1324
1325 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1326 let mut consumed = Vec::new();
1327 for (i, mouse) in clicks {
1328 let clicked_idx = (mouse.y - rect.y) as usize;
1329 if clicked_idx < state.items.len() {
1330 state.selected = clicked_idx;
1331 consumed.push(i);
1332 }
1333 }
1334 self.consume_indices(consumed);
1335 }
1336
1337 self.commands
1338 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1339 direction: Direction::Column,
1340 gap: 0,
1341 align: Align::Start,
1342 align_self: None,
1343 justify: Justify::Start,
1344 border: None,
1345 border_sides: BorderSides::all(),
1346 border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1347 bg_color: None,
1348 padding: Padding::default(),
1349 margin: Margin::default(),
1350 constraints: Constraints::default(),
1351 title: None,
1352 grow: 0,
1353 group_name: None,
1354 })));
1355
1356 for (idx, item) in state.items.iter().enumerate() {
1357 let is_selected = idx == state.selected;
1358 let marker = if is_selected { "●" } else { "○" };
1359 let style = if is_selected {
1360 if focused {
1361 Style::new()
1362 .bold()
1363 .fg(colors.accent.unwrap_or(self.theme.primary))
1364 } else {
1365 Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1366 }
1367 } else {
1368 Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1369 };
1370 let prefix = if focused && idx == state.selected {
1371 "▸ "
1372 } else {
1373 " "
1374 };
1375 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1376 row.push_str(prefix);
1377 row.push_str(marker);
1378 row.push(' ');
1379 row.push_str(item);
1380 self.styled(row, style);
1381 }
1382
1383 self.commands.push(Command::EndContainer);
1384 self.rollback.last_text_idx = None;
1385 response.changed = state.selected != old_selected;
1386 response
1387 }
1388
1389 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1393 if state.items.is_empty() {
1394 return Response::none();
1395 }
1396 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1397 let focused = self.register_focusable();
1398 let old_selected = state.selected.clone();
1399
1400 if focused {
1401 let mut consumed_indices = Vec::new();
1402 for (i, key) in self.available_key_presses() {
1403 match key.code {
1404 KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1405 let _ = handle_vertical_nav(
1406 &mut state.cursor,
1407 state.items.len().saturating_sub(1),
1408 key.code.clone(),
1409 );
1410 consumed_indices.push(i);
1411 }
1412 KeyCode::Char(' ') | KeyCode::Enter => {
1413 state.toggle(state.cursor);
1414 consumed_indices.push(i);
1415 }
1416 _ => {}
1417 }
1418 }
1419 self.consume_indices(consumed_indices);
1420 }
1421
1422 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1423
1424 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1425 let mut consumed = Vec::new();
1426 for (i, mouse) in clicks {
1427 let clicked_idx = (mouse.y - rect.y) as usize;
1428 if clicked_idx < state.items.len() {
1429 state.toggle(clicked_idx);
1430 state.cursor = clicked_idx;
1431 consumed.push(i);
1432 }
1433 }
1434 self.consume_indices(consumed);
1435 }
1436
1437 self.commands
1438 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1439 direction: Direction::Column,
1440 gap: 0,
1441 align: Align::Start,
1442 align_self: None,
1443 justify: Justify::Start,
1444 border: None,
1445 border_sides: BorderSides::all(),
1446 border_style: Style::new().fg(self.theme.border),
1447 bg_color: None,
1448 padding: Padding::default(),
1449 margin: Margin::default(),
1450 constraints: Constraints::default(),
1451 title: None,
1452 grow: 0,
1453 group_name: None,
1454 })));
1455
1456 for (idx, item) in state.items.iter().enumerate() {
1457 let checked = state.selected.contains(&idx);
1458 let marker = if checked { "[x]" } else { "[ ]" };
1459 let is_cursor = idx == state.cursor;
1460 let style = if is_cursor && focused {
1461 Style::new().bold().fg(self.theme.primary)
1462 } else if checked {
1463 Style::new().fg(self.theme.success)
1464 } else {
1465 Style::new().fg(self.theme.text)
1466 };
1467 let prefix = if is_cursor && focused { "▸ " } else { " " };
1468 let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1469 row.push_str(prefix);
1470 row.push_str(marker);
1471 row.push(' ');
1472 row.push_str(item);
1473 self.styled(row, style);
1474 }
1475
1476 self.commands.push(Command::EndContainer);
1477 self.rollback.last_text_idx = None;
1478 response.changed = state.selected != old_selected;
1479 response
1480 }
1481
1482 pub fn color_picker(&mut self, state: &mut ColorPickerState) -> Response {
1514 let colors = self.widget_theme.color_picker;
1515 self.color_picker_colored(state, &colors)
1516 }
1517
1518 pub fn color_picker_colored(
1536 &mut self,
1537 state: &mut ColorPickerState,
1538 colors: &WidgetColors,
1539 ) -> Response {
1540 if state.colors.is_empty() {
1541 return Response::none();
1542 }
1543 let columns = state.columns.max(1);
1544 state.selected = state.selected.min(state.colors.len() - 1);
1545
1546 let focused = self.register_focusable();
1547 let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1548 let old_color = state.selected();
1549
1550 self.color_picker_handle_keys(state, focused, columns);
1551 self.color_picker_handle_clicks(state, interaction_id, columns);
1552 self.color_picker_render(state, focused, columns, colors);
1553
1554 response.changed = state.selected() != old_color;
1555 response
1556 }
1557
1558 fn color_picker_handle_keys(
1559 &mut self,
1560 state: &mut ColorPickerState,
1561 focused: bool,
1562 columns: usize,
1563 ) {
1564 if !focused {
1565 return;
1566 }
1567 let len = state.colors.len();
1568 let mut consumed_indices = Vec::new();
1569 for (i, key) in self.available_key_presses() {
1570 match state.mode {
1571 PickerMode::Palette => match key.code {
1572 KeyCode::Left | KeyCode::Char('h') => {
1573 if !state.selected.is_multiple_of(columns) {
1574 state.selected -= 1;
1575 }
1576 consumed_indices.push(i);
1577 }
1578 KeyCode::Right | KeyCode::Char('l') => {
1579 if state.selected % columns < columns - 1 && state.selected + 1 < len {
1580 state.selected += 1;
1581 }
1582 consumed_indices.push(i);
1583 }
1584 KeyCode::Up | KeyCode::Char('k') => {
1585 if state.selected >= columns {
1586 state.selected -= columns;
1587 }
1588 consumed_indices.push(i);
1589 }
1590 KeyCode::Down | KeyCode::Char('j') => {
1591 if state.selected + columns < len {
1592 state.selected += columns;
1593 }
1594 consumed_indices.push(i);
1595 }
1596 KeyCode::Tab => {
1597 state.mode = PickerMode::Hex;
1598 consumed_indices.push(i);
1599 }
1600 KeyCode::Enter | KeyCode::Char(' ') => {
1601 consumed_indices.push(i);
1602 }
1603 _ => {}
1604 },
1605 PickerMode::Hex => match key.code {
1606 KeyCode::Tab => {
1607 state.mode = PickerMode::Palette;
1608 consumed_indices.push(i);
1609 }
1610 KeyCode::Enter => {
1611 consumed_indices.push(i);
1612 }
1613 KeyCode::Char(ch) => {
1614 let index =
1615 byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1616 state.hex_input.value.insert(index, ch);
1617 state.hex_input.cursor += 1;
1618 color_picker_validate_hex(&mut state.hex_input);
1619 consumed_indices.push(i);
1620 }
1621 KeyCode::Backspace => {
1622 if state.hex_input.cursor > 0 {
1623 let start = byte_index_for_char(
1624 &state.hex_input.value,
1625 state.hex_input.cursor - 1,
1626 );
1627 let end =
1628 byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1629 state.hex_input.value.replace_range(start..end, "");
1630 state.hex_input.cursor -= 1;
1631 }
1632 color_picker_validate_hex(&mut state.hex_input);
1633 consumed_indices.push(i);
1634 }
1635 _ => {}
1636 },
1637 }
1638 }
1639 self.consume_indices(consumed_indices);
1640 }
1641
1642 fn color_picker_handle_clicks(
1643 &mut self,
1644 state: &mut ColorPickerState,
1645 interaction_id: usize,
1646 columns: usize,
1647 ) {
1648 if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1649 let grid_x0 = rect.x + GRID_X_OFFSET;
1653 let grid_y0 = rect.y + GRID_Y_OFFSET;
1654 let rows = state.colors.len().div_ceil(columns);
1655 let mut consumed = Vec::new();
1656 for (i, mouse) in clicks {
1657 if mouse.x < grid_x0 || mouse.y < grid_y0 {
1658 continue;
1659 }
1660 let row = (mouse.y - grid_y0) as usize;
1661 let col = (mouse.x - grid_x0) as usize / SWATCH_WIDTH;
1662 if row < rows && col < columns {
1663 let idx = row * columns + col;
1664 if idx < state.colors.len() {
1665 state.mode = PickerMode::Palette;
1666 state.selected = idx;
1667 consumed.push(i);
1668 }
1669 }
1670 }
1671 self.consume_indices(consumed);
1672 }
1673 }
1674
1675 fn color_picker_render(
1676 &mut self,
1677 state: &ColorPickerState,
1678 focused: bool,
1679 columns: usize,
1680 colors: &WidgetColors,
1681 ) {
1682 let border_color = if focused {
1683 colors.accent.unwrap_or(self.theme.primary)
1684 } else {
1685 colors.border.unwrap_or(self.theme.border)
1686 };
1687 let text_color = colors.fg.unwrap_or(self.theme.text);
1688
1689 self.commands
1690 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1691 direction: Direction::Column,
1692 gap: 0,
1693 align: Align::Start,
1694 align_self: None,
1695 justify: Justify::Start,
1696 border: Some(Border::Rounded),
1697 border_sides: BorderSides::all(),
1698 border_style: Style::new().fg(border_color),
1699 bg_color: None,
1700 padding: Padding::xy(1, 0),
1701 margin: Margin::default(),
1702 constraints: Constraints::default(),
1703 title: None,
1704 grow: 0,
1705 group_name: None,
1706 })));
1707
1708 let rows = state.colors.len().div_ceil(columns);
1710 for row in 0..rows {
1711 self.commands
1712 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1713 direction: Direction::Row,
1714 gap: 0,
1715 align: Align::Start,
1716 align_self: None,
1717 justify: Justify::Start,
1718 border: None,
1719 border_sides: BorderSides::all(),
1720 border_style: Style::new(),
1721 bg_color: None,
1722 padding: Padding::default(),
1723 margin: Margin::default(),
1724 constraints: Constraints::default(),
1725 title: None,
1726 grow: 0,
1727 group_name: None,
1728 })));
1729 for col in 0..columns {
1730 let idx = row * columns + col;
1731 let Some(&swatch) = state.colors.get(idx) else {
1732 break;
1733 };
1734 let is_cursor = idx == state.selected && state.mode == PickerMode::Palette;
1735 let marker = if is_cursor { '▣' } else { ' ' };
1736 let mut cell = String::with_capacity(SWATCH_WIDTH);
1737 cell.push(' ');
1738 cell.push(marker);
1739 cell.push(' ');
1740 let mut style = Style::new().bg(swatch).fg(Color::contrast_fg(swatch));
1743 if is_cursor {
1744 style = style.bold();
1745 }
1746 self.styled(cell, style);
1747 }
1748 self.commands.push(Command::EndContainer);
1749 self.rollback.last_text_idx = None;
1750 }
1751
1752 let selected = state.selected();
1755 let label = color_hex_label(selected).unwrap_or_else(|| "selected".to_string());
1756 let mut readout = String::with_capacity(label.len() + 3);
1757 readout.push_str("▸ ");
1758 readout.push_str(&label);
1759 self.styled(readout, Style::new().fg(text_color).bold());
1760
1761 let hex_active = state.mode == PickerMode::Hex;
1765 let hex_display = if state.hex_input.value.is_empty() {
1766 state.hex_input.placeholder.clone()
1767 } else {
1768 state.hex_input.value.clone()
1769 };
1770 let mut hex_line = String::with_capacity(hex_display.len() + 6);
1771 hex_line.push_str(if hex_active { "▸ hex " } else { " hex " });
1772 hex_line.push_str(&hex_display);
1773 if state.hex_input.validation_error.is_some() {
1774 hex_line.push_str(" ✗");
1775 }
1776 let hex_style = if hex_active {
1777 Style::new()
1778 .fg(colors.accent.unwrap_or(self.theme.primary))
1779 .bold()
1780 } else {
1781 Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1782 };
1783 self.styled(hex_line, hex_style);
1784
1785 self.commands.push(Command::EndContainer);
1786 self.rollback.last_text_idx = None;
1787 }
1788
1789 }
1791
1792const SWATCH_WIDTH: usize = 3;
1794
1795const GRID_X_OFFSET: u32 = 2;
1798
1799const GRID_Y_OFFSET: u32 = 1;
1802
1803fn color_picker_validate_hex(input: &mut TextInputState) {
1809 if input.value.is_empty() {
1810 input.validation_error = None;
1811 } else if parse_hex_color(&input.value).is_none() {
1812 input.validation_error = Some("invalid hex".to_string());
1813 } else {
1814 input.validation_error = None;
1815 }
1816}