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