1use crate::chart::{build_histogram_config, render_chart, ChartBuilder, HistogramBuilder};
2use crate::event::{Event, KeyCode, KeyModifiers, MouseButton, MouseKind};
3use crate::layout::{Command, Direction};
4use crate::rect::Rect;
5use crate::style::{
6 Align, Border, BorderSides, Color, Constraints, Justify, Margin, Modifiers, Padding, Style,
7 Theme,
8};
9use crate::widgets::{
10 ButtonVariant, CommandPaletteState, FormField, FormState, ListState, MultiSelectState,
11 RadioState, ScrollState, SelectState, SpinnerState, TableState, TabsState, TextInputState,
12 TextareaState, ToastLevel, ToastState, TreeState,
13};
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16#[allow(dead_code)]
17fn slt_assert(condition: bool, msg: &str) {
18 if !condition {
19 panic!("[SLT] {}", msg);
20 }
21}
22
23#[cfg(debug_assertions)]
24#[allow(dead_code)]
25fn slt_warn(msg: &str) {
26 eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
27}
28
29#[cfg(not(debug_assertions))]
30#[allow(dead_code)]
31fn slt_warn(_msg: &str) {}
32
33#[derive(Debug, Clone, Copy, Default)]
39pub struct Response {
40 pub clicked: bool,
42 pub hovered: bool,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum BarDirection {
49 Horizontal,
51 Vertical,
53}
54
55#[derive(Debug, Clone)]
57pub struct Bar {
58 pub label: String,
60 pub value: f64,
62 pub color: Option<Color>,
64}
65
66impl Bar {
67 pub fn new(label: impl Into<String>, value: f64) -> Self {
69 Self {
70 label: label.into(),
71 value,
72 color: None,
73 }
74 }
75
76 pub fn color(mut self, color: Color) -> Self {
78 self.color = Some(color);
79 self
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct BarGroup {
86 pub label: String,
88 pub bars: Vec<Bar>,
90}
91
92impl BarGroup {
93 pub fn new(label: impl Into<String>, bars: Vec<Bar>) -> Self {
95 Self {
96 label: label.into(),
97 bars,
98 }
99 }
100}
101
102pub trait Widget {
164 type Response;
167
168 fn ui(&mut self, ctx: &mut Context) -> Self::Response;
174}
175
176pub struct Context {
192 pub(crate) commands: Vec<Command>,
193 pub(crate) events: Vec<Event>,
194 pub(crate) consumed: Vec<bool>,
195 pub(crate) should_quit: bool,
196 pub(crate) area_width: u32,
197 pub(crate) area_height: u32,
198 pub(crate) tick: u64,
199 pub(crate) focus_index: usize,
200 pub(crate) focus_count: usize,
201 prev_focus_count: usize,
202 scroll_count: usize,
203 prev_scroll_infos: Vec<(u32, u32)>,
204 interaction_count: usize,
205 pub(crate) prev_hit_map: Vec<Rect>,
206 _prev_focus_rects: Vec<(usize, Rect)>,
207 mouse_pos: Option<(u32, u32)>,
208 click_pos: Option<(u32, u32)>,
209 last_text_idx: Option<usize>,
210 overlay_depth: usize,
211 pub(crate) modal_active: bool,
212 prev_modal_active: bool,
213 debug: bool,
214 theme: Theme,
215}
216
217#[must_use = "configure and finalize with .col() or .row()"]
238pub struct ContainerBuilder<'a> {
239 ctx: &'a mut Context,
240 gap: u32,
241 align: Align,
242 justify: Justify,
243 border: Option<Border>,
244 border_sides: BorderSides,
245 border_style: Style,
246 bg_color: Option<Color>,
247 padding: Padding,
248 margin: Margin,
249 constraints: Constraints,
250 title: Option<(String, Style)>,
251 grow: u16,
252 scroll_offset: Option<u32>,
253}
254
255#[derive(Debug, Clone, Copy)]
262struct CanvasPixel {
263 bits: u32,
264 color: Color,
265}
266
267#[derive(Debug, Clone)]
269struct CanvasLabel {
270 x: usize,
271 y: usize,
272 text: String,
273 color: Color,
274}
275
276#[derive(Debug, Clone)]
278struct CanvasLayer {
279 grid: Vec<Vec<CanvasPixel>>,
280 labels: Vec<CanvasLabel>,
281}
282
283pub struct CanvasContext {
284 layers: Vec<CanvasLayer>,
285 cols: usize,
286 rows: usize,
287 px_w: usize,
288 px_h: usize,
289 current_color: Color,
290}
291
292impl CanvasContext {
293 fn new(cols: usize, rows: usize) -> Self {
294 Self {
295 layers: vec![Self::new_layer(cols, rows)],
296 cols,
297 rows,
298 px_w: cols * 2,
299 px_h: rows * 4,
300 current_color: Color::Reset,
301 }
302 }
303
304 fn new_layer(cols: usize, rows: usize) -> CanvasLayer {
305 CanvasLayer {
306 grid: vec![
307 vec![
308 CanvasPixel {
309 bits: 0,
310 color: Color::Reset,
311 };
312 cols
313 ];
314 rows
315 ],
316 labels: Vec::new(),
317 }
318 }
319
320 fn current_layer_mut(&mut self) -> Option<&mut CanvasLayer> {
321 self.layers.last_mut()
322 }
323
324 fn dot_with_color(&mut self, x: usize, y: usize, color: Color) {
325 if x >= self.px_w || y >= self.px_h {
326 return;
327 }
328
329 let char_col = x / 2;
330 let char_row = y / 4;
331 let sub_col = x % 2;
332 let sub_row = y % 4;
333 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
334 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
335
336 let bit = if sub_col == 0 {
337 LEFT_BITS[sub_row]
338 } else {
339 RIGHT_BITS[sub_row]
340 };
341
342 if let Some(layer) = self.current_layer_mut() {
343 let cell = &mut layer.grid[char_row][char_col];
344 let new_bits = cell.bits | bit;
345 if new_bits != cell.bits {
346 cell.bits = new_bits;
347 cell.color = color;
348 }
349 }
350 }
351
352 fn dot_isize(&mut self, x: isize, y: isize) {
353 if x >= 0 && y >= 0 {
354 self.dot(x as usize, y as usize);
355 }
356 }
357
358 pub fn width(&self) -> usize {
360 self.px_w
361 }
362
363 pub fn height(&self) -> usize {
365 self.px_h
366 }
367
368 pub fn dot(&mut self, x: usize, y: usize) {
370 self.dot_with_color(x, y, self.current_color);
371 }
372
373 pub fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
375 let (mut x, mut y) = (x0 as isize, y0 as isize);
376 let (x1, y1) = (x1 as isize, y1 as isize);
377 let dx = (x1 - x).abs();
378 let dy = -(y1 - y).abs();
379 let sx = if x < x1 { 1 } else { -1 };
380 let sy = if y < y1 { 1 } else { -1 };
381 let mut err = dx + dy;
382
383 loop {
384 self.dot_isize(x, y);
385 if x == x1 && y == y1 {
386 break;
387 }
388 let e2 = 2 * err;
389 if e2 >= dy {
390 err += dy;
391 x += sx;
392 }
393 if e2 <= dx {
394 err += dx;
395 y += sy;
396 }
397 }
398 }
399
400 pub fn rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
402 if w == 0 || h == 0 {
403 return;
404 }
405
406 self.line(x, y, x + w.saturating_sub(1), y);
407 self.line(
408 x + w.saturating_sub(1),
409 y,
410 x + w.saturating_sub(1),
411 y + h.saturating_sub(1),
412 );
413 self.line(
414 x + w.saturating_sub(1),
415 y + h.saturating_sub(1),
416 x,
417 y + h.saturating_sub(1),
418 );
419 self.line(x, y + h.saturating_sub(1), x, y);
420 }
421
422 pub fn circle(&mut self, cx: usize, cy: usize, r: usize) {
424 let mut x = r as isize;
425 let mut y: isize = 0;
426 let mut err: isize = 1 - x;
427 let (cx, cy) = (cx as isize, cy as isize);
428
429 while x >= y {
430 for &(dx, dy) in &[
431 (x, y),
432 (y, x),
433 (-x, y),
434 (-y, x),
435 (x, -y),
436 (y, -x),
437 (-x, -y),
438 (-y, -x),
439 ] {
440 let px = cx + dx;
441 let py = cy + dy;
442 self.dot_isize(px, py);
443 }
444
445 y += 1;
446 if err < 0 {
447 err += 2 * y + 1;
448 } else {
449 x -= 1;
450 err += 2 * (y - x) + 1;
451 }
452 }
453 }
454
455 pub fn set_color(&mut self, color: Color) {
457 self.current_color = color;
458 }
459
460 pub fn color(&self) -> Color {
462 self.current_color
463 }
464
465 pub fn filled_rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
467 if w == 0 || h == 0 {
468 return;
469 }
470
471 let x_end = x.saturating_add(w).min(self.px_w);
472 let y_end = y.saturating_add(h).min(self.px_h);
473 if x >= x_end || y >= y_end {
474 return;
475 }
476
477 for yy in y..y_end {
478 self.line(x, yy, x_end.saturating_sub(1), yy);
479 }
480 }
481
482 pub fn filled_circle(&mut self, cx: usize, cy: usize, r: usize) {
484 let (cx, cy, r) = (cx as isize, cy as isize, r as isize);
485 for y in (cy - r)..=(cy + r) {
486 let dy = y - cy;
487 let span_sq = (r * r - dy * dy).max(0);
488 let dx = (span_sq as f64).sqrt() as isize;
489 for x in (cx - dx)..=(cx + dx) {
490 self.dot_isize(x, y);
491 }
492 }
493 }
494
495 pub fn triangle(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, x2: usize, y2: usize) {
497 self.line(x0, y0, x1, y1);
498 self.line(x1, y1, x2, y2);
499 self.line(x2, y2, x0, y0);
500 }
501
502 pub fn filled_triangle(
504 &mut self,
505 x0: usize,
506 y0: usize,
507 x1: usize,
508 y1: usize,
509 x2: usize,
510 y2: usize,
511 ) {
512 let vertices = [
513 (x0 as isize, y0 as isize),
514 (x1 as isize, y1 as isize),
515 (x2 as isize, y2 as isize),
516 ];
517 let min_y = vertices.iter().map(|(_, y)| *y).min().unwrap_or(0);
518 let max_y = vertices.iter().map(|(_, y)| *y).max().unwrap_or(-1);
519
520 for y in min_y..=max_y {
521 let mut intersections: Vec<f64> = Vec::new();
522
523 for edge in [(0usize, 1usize), (1usize, 2usize), (2usize, 0usize)] {
524 let (x_a, y_a) = vertices[edge.0];
525 let (x_b, y_b) = vertices[edge.1];
526 if y_a == y_b {
527 continue;
528 }
529
530 let (x_start, y_start, x_end, y_end) = if y_a < y_b {
531 (x_a, y_a, x_b, y_b)
532 } else {
533 (x_b, y_b, x_a, y_a)
534 };
535
536 if y < y_start || y >= y_end {
537 continue;
538 }
539
540 let t = (y - y_start) as f64 / (y_end - y_start) as f64;
541 intersections.push(x_start as f64 + t * (x_end - x_start) as f64);
542 }
543
544 intersections.sort_by(|a, b| a.total_cmp(b));
545 let mut i = 0usize;
546 while i + 1 < intersections.len() {
547 let x_start = intersections[i].ceil() as isize;
548 let x_end = intersections[i + 1].floor() as isize;
549 for x in x_start..=x_end {
550 self.dot_isize(x, y);
551 }
552 i += 2;
553 }
554 }
555
556 self.triangle(x0, y0, x1, y1, x2, y2);
557 }
558
559 pub fn points(&mut self, pts: &[(usize, usize)]) {
561 for &(x, y) in pts {
562 self.dot(x, y);
563 }
564 }
565
566 pub fn polyline(&mut self, pts: &[(usize, usize)]) {
568 for window in pts.windows(2) {
569 if let [(x0, y0), (x1, y1)] = window {
570 self.line(*x0, *y0, *x1, *y1);
571 }
572 }
573 }
574
575 pub fn print(&mut self, x: usize, y: usize, text: &str) {
578 if text.is_empty() {
579 return;
580 }
581
582 let color = self.current_color;
583 if let Some(layer) = self.current_layer_mut() {
584 layer.labels.push(CanvasLabel {
585 x,
586 y,
587 text: text.to_string(),
588 color,
589 });
590 }
591 }
592
593 pub fn layer(&mut self) {
595 self.layers.push(Self::new_layer(self.cols, self.rows));
596 }
597
598 pub(crate) fn render(&self) -> Vec<Vec<(String, Color)>> {
599 let mut final_grid = vec![
600 vec![
601 CanvasPixel {
602 bits: 0,
603 color: Color::Reset,
604 };
605 self.cols
606 ];
607 self.rows
608 ];
609 let mut labels_overlay: Vec<Vec<Option<(char, Color)>>> =
610 vec![vec![None; self.cols]; self.rows];
611
612 for layer in &self.layers {
613 for (row, final_row) in final_grid.iter_mut().enumerate().take(self.rows) {
614 for (col, dst) in final_row.iter_mut().enumerate().take(self.cols) {
615 let src = layer.grid[row][col];
616 if src.bits == 0 {
617 continue;
618 }
619
620 let merged = dst.bits | src.bits;
621 if merged != dst.bits {
622 dst.bits = merged;
623 dst.color = src.color;
624 }
625 }
626 }
627
628 for label in &layer.labels {
629 let row = label.y / 4;
630 if row >= self.rows {
631 continue;
632 }
633 let start_col = label.x / 2;
634 for (offset, ch) in label.text.chars().enumerate() {
635 let col = start_col + offset;
636 if col >= self.cols {
637 break;
638 }
639 labels_overlay[row][col] = Some((ch, label.color));
640 }
641 }
642 }
643
644 let mut lines: Vec<Vec<(String, Color)>> = Vec::with_capacity(self.rows);
645 for row in 0..self.rows {
646 let mut segments: Vec<(String, Color)> = Vec::new();
647 let mut current_color: Option<Color> = None;
648 let mut current_text = String::new();
649
650 for col in 0..self.cols {
651 let (ch, color) = if let Some((label_ch, label_color)) = labels_overlay[row][col] {
652 (label_ch, label_color)
653 } else {
654 let bits = final_grid[row][col].bits;
655 let ch = char::from_u32(0x2800 + bits).unwrap_or(' ');
656 (ch, final_grid[row][col].color)
657 };
658
659 match current_color {
660 Some(c) if c == color => {
661 current_text.push(ch);
662 }
663 Some(c) => {
664 segments.push((std::mem::take(&mut current_text), c));
665 current_text.push(ch);
666 current_color = Some(color);
667 }
668 None => {
669 current_text.push(ch);
670 current_color = Some(color);
671 }
672 }
673 }
674
675 if let Some(color) = current_color {
676 segments.push((current_text, color));
677 }
678 lines.push(segments);
679 }
680
681 lines
682 }
683}
684
685impl<'a> ContainerBuilder<'a> {
686 pub fn border(mut self, border: Border) -> Self {
690 self.border = Some(border);
691 self
692 }
693
694 pub fn border_top(mut self, show: bool) -> Self {
696 self.border_sides.top = show;
697 self
698 }
699
700 pub fn border_right(mut self, show: bool) -> Self {
702 self.border_sides.right = show;
703 self
704 }
705
706 pub fn border_bottom(mut self, show: bool) -> Self {
708 self.border_sides.bottom = show;
709 self
710 }
711
712 pub fn border_left(mut self, show: bool) -> Self {
714 self.border_sides.left = show;
715 self
716 }
717
718 pub fn border_sides(mut self, sides: BorderSides) -> Self {
720 self.border_sides = sides;
721 self
722 }
723
724 pub fn rounded(self) -> Self {
726 self.border(Border::Rounded)
727 }
728
729 pub fn border_style(mut self, style: Style) -> Self {
731 self.border_style = style;
732 self
733 }
734
735 pub fn bg(mut self, color: Color) -> Self {
736 self.bg_color = Some(color);
737 self
738 }
739
740 pub fn p(self, value: u32) -> Self {
744 self.pad(value)
745 }
746
747 pub fn pad(mut self, value: u32) -> Self {
749 self.padding = Padding::all(value);
750 self
751 }
752
753 pub fn px(mut self, value: u32) -> Self {
755 self.padding.left = value;
756 self.padding.right = value;
757 self
758 }
759
760 pub fn py(mut self, value: u32) -> Self {
762 self.padding.top = value;
763 self.padding.bottom = value;
764 self
765 }
766
767 pub fn pt(mut self, value: u32) -> Self {
769 self.padding.top = value;
770 self
771 }
772
773 pub fn pr(mut self, value: u32) -> Self {
775 self.padding.right = value;
776 self
777 }
778
779 pub fn pb(mut self, value: u32) -> Self {
781 self.padding.bottom = value;
782 self
783 }
784
785 pub fn pl(mut self, value: u32) -> Self {
787 self.padding.left = value;
788 self
789 }
790
791 pub fn padding(mut self, padding: Padding) -> Self {
793 self.padding = padding;
794 self
795 }
796
797 pub fn m(mut self, value: u32) -> Self {
801 self.margin = Margin::all(value);
802 self
803 }
804
805 pub fn mx(mut self, value: u32) -> Self {
807 self.margin.left = value;
808 self.margin.right = value;
809 self
810 }
811
812 pub fn my(mut self, value: u32) -> Self {
814 self.margin.top = value;
815 self.margin.bottom = value;
816 self
817 }
818
819 pub fn mt(mut self, value: u32) -> Self {
821 self.margin.top = value;
822 self
823 }
824
825 pub fn mr(mut self, value: u32) -> Self {
827 self.margin.right = value;
828 self
829 }
830
831 pub fn mb(mut self, value: u32) -> Self {
833 self.margin.bottom = value;
834 self
835 }
836
837 pub fn ml(mut self, value: u32) -> Self {
839 self.margin.left = value;
840 self
841 }
842
843 pub fn margin(mut self, margin: Margin) -> Self {
845 self.margin = margin;
846 self
847 }
848
849 pub fn w(mut self, value: u32) -> Self {
853 self.constraints.min_width = Some(value);
854 self.constraints.max_width = Some(value);
855 self
856 }
857
858 pub fn h(mut self, value: u32) -> Self {
860 self.constraints.min_height = Some(value);
861 self.constraints.max_height = Some(value);
862 self
863 }
864
865 pub fn min_w(mut self, value: u32) -> Self {
867 self.constraints.min_width = Some(value);
868 self
869 }
870
871 pub fn max_w(mut self, value: u32) -> Self {
873 self.constraints.max_width = Some(value);
874 self
875 }
876
877 pub fn min_h(mut self, value: u32) -> Self {
879 self.constraints.min_height = Some(value);
880 self
881 }
882
883 pub fn max_h(mut self, value: u32) -> Self {
885 self.constraints.max_height = Some(value);
886 self
887 }
888
889 pub fn min_width(mut self, value: u32) -> Self {
891 self.constraints.min_width = Some(value);
892 self
893 }
894
895 pub fn max_width(mut self, value: u32) -> Self {
897 self.constraints.max_width = Some(value);
898 self
899 }
900
901 pub fn min_height(mut self, value: u32) -> Self {
903 self.constraints.min_height = Some(value);
904 self
905 }
906
907 pub fn max_height(mut self, value: u32) -> Self {
909 self.constraints.max_height = Some(value);
910 self
911 }
912
913 pub fn w_pct(mut self, pct: u8) -> Self {
915 self.constraints.width_pct = Some(pct.min(100));
916 self
917 }
918
919 pub fn h_pct(mut self, pct: u8) -> Self {
921 self.constraints.height_pct = Some(pct.min(100));
922 self
923 }
924
925 pub fn constraints(mut self, constraints: Constraints) -> Self {
927 self.constraints = constraints;
928 self
929 }
930
931 pub fn gap(mut self, gap: u32) -> Self {
935 self.gap = gap;
936 self
937 }
938
939 pub fn grow(mut self, grow: u16) -> Self {
941 self.grow = grow;
942 self
943 }
944
945 pub fn align(mut self, align: Align) -> Self {
949 self.align = align;
950 self
951 }
952
953 pub fn center(self) -> Self {
955 self.align(Align::Center)
956 }
957
958 pub fn justify(mut self, justify: Justify) -> Self {
960 self.justify = justify;
961 self
962 }
963
964 pub fn space_between(self) -> Self {
966 self.justify(Justify::SpaceBetween)
967 }
968
969 pub fn space_around(self) -> Self {
971 self.justify(Justify::SpaceAround)
972 }
973
974 pub fn space_evenly(self) -> Self {
976 self.justify(Justify::SpaceEvenly)
977 }
978
979 pub fn title(self, title: impl Into<String>) -> Self {
983 self.title_styled(title, Style::new())
984 }
985
986 pub fn title_styled(mut self, title: impl Into<String>, style: Style) -> Self {
988 self.title = Some((title.into(), style));
989 self
990 }
991
992 pub fn scroll_offset(mut self, offset: u32) -> Self {
996 self.scroll_offset = Some(offset);
997 self
998 }
999
1000 pub fn col(self, f: impl FnOnce(&mut Context)) -> Response {
1005 self.finish(Direction::Column, f)
1006 }
1007
1008 pub fn row(self, f: impl FnOnce(&mut Context)) -> Response {
1013 self.finish(Direction::Row, f)
1014 }
1015
1016 fn finish(self, direction: Direction, f: impl FnOnce(&mut Context)) -> Response {
1017 let interaction_id = self.ctx.interaction_count;
1018 self.ctx.interaction_count += 1;
1019
1020 if let Some(scroll_offset) = self.scroll_offset {
1021 self.ctx.commands.push(Command::BeginScrollable {
1022 grow: self.grow,
1023 border: self.border,
1024 border_sides: self.border_sides,
1025 border_style: self.border_style,
1026 padding: self.padding,
1027 margin: self.margin,
1028 constraints: self.constraints,
1029 title: self.title,
1030 scroll_offset,
1031 });
1032 } else {
1033 self.ctx.commands.push(Command::BeginContainer {
1034 direction,
1035 gap: self.gap,
1036 align: self.align,
1037 justify: self.justify,
1038 border: self.border,
1039 border_sides: self.border_sides,
1040 border_style: self.border_style,
1041 bg_color: self.bg_color,
1042 padding: self.padding,
1043 margin: self.margin,
1044 constraints: self.constraints,
1045 title: self.title,
1046 grow: self.grow,
1047 });
1048 }
1049 f(self.ctx);
1050 self.ctx.commands.push(Command::EndContainer);
1051 self.ctx.last_text_idx = None;
1052
1053 self.ctx.response_for(interaction_id)
1054 }
1055}
1056
1057impl Context {
1058 #[allow(clippy::too_many_arguments)]
1059 pub(crate) fn new(
1060 events: Vec<Event>,
1061 width: u32,
1062 height: u32,
1063 tick: u64,
1064 mut focus_index: usize,
1065 prev_focus_count: usize,
1066 prev_scroll_infos: Vec<(u32, u32)>,
1067 prev_hit_map: Vec<Rect>,
1068 prev_focus_rects: Vec<(usize, Rect)>,
1069 debug: bool,
1070 theme: Theme,
1071 last_mouse_pos: Option<(u32, u32)>,
1072 prev_modal_active: bool,
1073 ) -> Self {
1074 let consumed = vec![false; events.len()];
1075
1076 let mut mouse_pos = last_mouse_pos;
1077 let mut click_pos = None;
1078 for event in &events {
1079 if let Event::Mouse(mouse) = event {
1080 mouse_pos = Some((mouse.x, mouse.y));
1081 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1082 click_pos = Some((mouse.x, mouse.y));
1083 }
1084 }
1085 }
1086
1087 if let Some((mx, my)) = click_pos {
1088 let mut best: Option<(usize, u64)> = None;
1089 for &(fid, rect) in &prev_focus_rects {
1090 if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
1091 let area = rect.width as u64 * rect.height as u64;
1092 if best.map_or(true, |(_, ba)| area < ba) {
1093 best = Some((fid, area));
1094 }
1095 }
1096 }
1097 if let Some((fid, _)) = best {
1098 focus_index = fid;
1099 }
1100 }
1101
1102 Self {
1103 commands: Vec::new(),
1104 events,
1105 consumed,
1106 should_quit: false,
1107 area_width: width,
1108 area_height: height,
1109 tick,
1110 focus_index,
1111 focus_count: 0,
1112 prev_focus_count,
1113 scroll_count: 0,
1114 prev_scroll_infos,
1115 interaction_count: 0,
1116 prev_hit_map,
1117 _prev_focus_rects: prev_focus_rects,
1118 mouse_pos,
1119 click_pos,
1120 last_text_idx: None,
1121 overlay_depth: 0,
1122 modal_active: false,
1123 prev_modal_active,
1124 debug,
1125 theme,
1126 }
1127 }
1128
1129 pub(crate) fn process_focus_keys(&mut self) {
1130 for (i, event) in self.events.iter().enumerate() {
1131 if let Event::Key(key) = event {
1132 if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
1133 if self.prev_focus_count > 0 {
1134 self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
1135 }
1136 self.consumed[i] = true;
1137 } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
1138 || key.code == KeyCode::BackTab
1139 {
1140 if self.prev_focus_count > 0 {
1141 self.focus_index = if self.focus_index == 0 {
1142 self.prev_focus_count - 1
1143 } else {
1144 self.focus_index - 1
1145 };
1146 }
1147 self.consumed[i] = true;
1148 }
1149 }
1150 }
1151 }
1152
1153 pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
1157 w.ui(self)
1158 }
1159
1160 pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
1175 self.error_boundary_with(f, |ui, msg| {
1176 ui.styled(
1177 format!("⚠ Error: {msg}"),
1178 Style::new().fg(ui.theme.error).bold(),
1179 );
1180 });
1181 }
1182
1183 pub fn error_boundary_with(
1203 &mut self,
1204 f: impl FnOnce(&mut Context),
1205 fallback: impl FnOnce(&mut Context, String),
1206 ) {
1207 let cmd_count = self.commands.len();
1208 let last_text_idx = self.last_text_idx;
1209
1210 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1211 f(self);
1212 }));
1213
1214 match result {
1215 Ok(()) => {}
1216 Err(panic_info) => {
1217 self.commands.truncate(cmd_count);
1218 self.last_text_idx = last_text_idx;
1219
1220 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
1221 (*s).to_string()
1222 } else if let Some(s) = panic_info.downcast_ref::<String>() {
1223 s.clone()
1224 } else {
1225 "widget panicked".to_string()
1226 };
1227
1228 fallback(self, msg);
1229 }
1230 }
1231 }
1232
1233 pub fn interaction(&mut self) -> Response {
1239 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1240 return Response::default();
1241 }
1242 let id = self.interaction_count;
1243 self.interaction_count += 1;
1244 self.response_for(id)
1245 }
1246
1247 pub fn register_focusable(&mut self) -> bool {
1252 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1253 return false;
1254 }
1255 let id = self.focus_count;
1256 self.focus_count += 1;
1257 self.commands.push(Command::FocusMarker(id));
1258 if self.prev_focus_count == 0 {
1259 return true;
1260 }
1261 self.focus_index % self.prev_focus_count == id
1262 }
1263
1264 pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
1277 let content = s.into();
1278 self.commands.push(Command::Text {
1279 content,
1280 style: Style::new(),
1281 grow: 0,
1282 align: Align::Start,
1283 wrap: false,
1284 margin: Margin::default(),
1285 constraints: Constraints::default(),
1286 });
1287 self.last_text_idx = Some(self.commands.len() - 1);
1288 self
1289 }
1290
1291 pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
1297 let url_str = url.into();
1298 let focused = self.register_focusable();
1299 let interaction_id = self.interaction_count;
1300 self.interaction_count += 1;
1301 let response = self.response_for(interaction_id);
1302
1303 let mut activated = response.clicked;
1304 if focused {
1305 for (i, event) in self.events.iter().enumerate() {
1306 if let Event::Key(key) = event {
1307 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1308 activated = true;
1309 self.consumed[i] = true;
1310 }
1311 }
1312 }
1313 }
1314
1315 if activated {
1316 let _ = open_url(&url_str);
1317 }
1318
1319 let style = if focused {
1320 Style::new()
1321 .fg(self.theme.primary)
1322 .bg(self.theme.surface_hover)
1323 .underline()
1324 .bold()
1325 } else if response.hovered {
1326 Style::new()
1327 .fg(self.theme.accent)
1328 .bg(self.theme.surface_hover)
1329 .underline()
1330 } else {
1331 Style::new().fg(self.theme.primary).underline()
1332 };
1333
1334 self.commands.push(Command::Link {
1335 text: text.into(),
1336 url: url_str,
1337 style,
1338 margin: Margin::default(),
1339 constraints: Constraints::default(),
1340 });
1341 self.last_text_idx = Some(self.commands.len() - 1);
1342 self
1343 }
1344
1345 pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
1350 let content = s.into();
1351 self.commands.push(Command::Text {
1352 content,
1353 style: Style::new(),
1354 grow: 0,
1355 align: Align::Start,
1356 wrap: true,
1357 margin: Margin::default(),
1358 constraints: Constraints::default(),
1359 });
1360 self.last_text_idx = Some(self.commands.len() - 1);
1361 self
1362 }
1363
1364 pub fn bold(&mut self) -> &mut Self {
1368 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
1369 self
1370 }
1371
1372 pub fn dim(&mut self) -> &mut Self {
1377 let text_dim = self.theme.text_dim;
1378 self.modify_last_style(|s| {
1379 s.modifiers |= Modifiers::DIM;
1380 if s.fg.is_none() {
1381 s.fg = Some(text_dim);
1382 }
1383 });
1384 self
1385 }
1386
1387 pub fn italic(&mut self) -> &mut Self {
1389 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
1390 self
1391 }
1392
1393 pub fn underline(&mut self) -> &mut Self {
1395 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
1396 self
1397 }
1398
1399 pub fn reversed(&mut self) -> &mut Self {
1401 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
1402 self
1403 }
1404
1405 pub fn strikethrough(&mut self) -> &mut Self {
1407 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
1408 self
1409 }
1410
1411 pub fn fg(&mut self, color: Color) -> &mut Self {
1413 self.modify_last_style(|s| s.fg = Some(color));
1414 self
1415 }
1416
1417 pub fn bg(&mut self, color: Color) -> &mut Self {
1419 self.modify_last_style(|s| s.bg = Some(color));
1420 self
1421 }
1422
1423 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
1428 self.commands.push(Command::Text {
1429 content: s.into(),
1430 style,
1431 grow: 0,
1432 align: Align::Start,
1433 wrap: false,
1434 margin: Margin::default(),
1435 constraints: Constraints::default(),
1436 });
1437 self.last_text_idx = Some(self.commands.len() - 1);
1438 self
1439 }
1440
1441 pub fn wrap(&mut self) -> &mut Self {
1443 if let Some(idx) = self.last_text_idx {
1444 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1445 *wrap = true;
1446 }
1447 }
1448 self
1449 }
1450
1451 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1452 if let Some(idx) = self.last_text_idx {
1453 match &mut self.commands[idx] {
1454 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1455 _ => {}
1456 }
1457 }
1458 }
1459
1460 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1478 self.push_container(Direction::Column, 0, f)
1479 }
1480
1481 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1485 self.push_container(Direction::Column, gap, f)
1486 }
1487
1488 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1505 self.push_container(Direction::Row, 0, f)
1506 }
1507
1508 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1512 self.push_container(Direction::Row, gap, f)
1513 }
1514
1515 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
1516 self.commands.push(Command::BeginOverlay { modal: true });
1517 self.overlay_depth += 1;
1518 self.modal_active = true;
1519 f(self);
1520 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1521 self.commands.push(Command::EndOverlay);
1522 self.last_text_idx = None;
1523 }
1524
1525 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
1526 self.commands.push(Command::BeginOverlay { modal: false });
1527 self.overlay_depth += 1;
1528 f(self);
1529 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1530 self.commands.push(Command::EndOverlay);
1531 self.last_text_idx = None;
1532 }
1533
1534 pub fn container(&mut self) -> ContainerBuilder<'_> {
1555 let border = self.theme.border;
1556 ContainerBuilder {
1557 ctx: self,
1558 gap: 0,
1559 align: Align::Start,
1560 justify: Justify::Start,
1561 border: None,
1562 border_sides: BorderSides::all(),
1563 border_style: Style::new().fg(border),
1564 bg_color: None,
1565 padding: Padding::default(),
1566 margin: Margin::default(),
1567 constraints: Constraints::default(),
1568 title: None,
1569 grow: 0,
1570 scroll_offset: None,
1571 }
1572 }
1573
1574 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1593 let index = self.scroll_count;
1594 self.scroll_count += 1;
1595 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1596 state.set_bounds(ch, vh);
1597 let max = ch.saturating_sub(vh) as usize;
1598 state.offset = state.offset.min(max);
1599 }
1600
1601 let next_id = self.interaction_count;
1602 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1603 self.auto_scroll(&rect, state);
1604 }
1605
1606 self.container().scroll_offset(state.offset as u32)
1607 }
1608
1609 fn auto_scroll(&mut self, rect: &Rect, state: &mut ScrollState) {
1610 let mut to_consume: Vec<usize> = Vec::new();
1611
1612 for (i, event) in self.events.iter().enumerate() {
1613 if self.consumed[i] {
1614 continue;
1615 }
1616 if let Event::Mouse(mouse) = event {
1617 let in_bounds = mouse.x >= rect.x
1618 && mouse.x < rect.right()
1619 && mouse.y >= rect.y
1620 && mouse.y < rect.bottom();
1621 if !in_bounds {
1622 continue;
1623 }
1624 match mouse.kind {
1625 MouseKind::ScrollUp => {
1626 state.scroll_up(1);
1627 to_consume.push(i);
1628 }
1629 MouseKind::ScrollDown => {
1630 state.scroll_down(1);
1631 to_consume.push(i);
1632 }
1633 MouseKind::Drag(MouseButton::Left) => {
1634 }
1637 _ => {}
1638 }
1639 }
1640 }
1641
1642 for i in to_consume {
1643 self.consumed[i] = true;
1644 }
1645 }
1646
1647 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1651 self.container()
1652 .border(border)
1653 .border_sides(BorderSides::all())
1654 }
1655
1656 fn push_container(
1657 &mut self,
1658 direction: Direction,
1659 gap: u32,
1660 f: impl FnOnce(&mut Context),
1661 ) -> Response {
1662 let interaction_id = self.interaction_count;
1663 self.interaction_count += 1;
1664 let border = self.theme.border;
1665
1666 self.commands.push(Command::BeginContainer {
1667 direction,
1668 gap,
1669 align: Align::Start,
1670 justify: Justify::Start,
1671 border: None,
1672 border_sides: BorderSides::all(),
1673 border_style: Style::new().fg(border),
1674 bg_color: None,
1675 padding: Padding::default(),
1676 margin: Margin::default(),
1677 constraints: Constraints::default(),
1678 title: None,
1679 grow: 0,
1680 });
1681 f(self);
1682 self.commands.push(Command::EndContainer);
1683 self.last_text_idx = None;
1684
1685 self.response_for(interaction_id)
1686 }
1687
1688 fn response_for(&self, interaction_id: usize) -> Response {
1689 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1690 return Response::default();
1691 }
1692 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1693 let clicked = self
1694 .click_pos
1695 .map(|(mx, my)| {
1696 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1697 })
1698 .unwrap_or(false);
1699 let hovered = self
1700 .mouse_pos
1701 .map(|(mx, my)| {
1702 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1703 })
1704 .unwrap_or(false);
1705 Response { clicked, hovered }
1706 } else {
1707 Response::default()
1708 }
1709 }
1710
1711 pub fn grow(&mut self, value: u16) -> &mut Self {
1716 if let Some(idx) = self.last_text_idx {
1717 if let Command::Text { grow, .. } = &mut self.commands[idx] {
1718 *grow = value;
1719 }
1720 }
1721 self
1722 }
1723
1724 pub fn align(&mut self, align: Align) -> &mut Self {
1726 if let Some(idx) = self.last_text_idx {
1727 if let Command::Text {
1728 align: text_align, ..
1729 } = &mut self.commands[idx]
1730 {
1731 *text_align = align;
1732 }
1733 }
1734 self
1735 }
1736
1737 pub fn spacer(&mut self) -> &mut Self {
1741 self.commands.push(Command::Spacer { grow: 1 });
1742 self.last_text_idx = None;
1743 self
1744 }
1745
1746 pub fn form(
1750 &mut self,
1751 state: &mut FormState,
1752 f: impl FnOnce(&mut Context, &mut FormState),
1753 ) -> &mut Self {
1754 self.col(|ui| {
1755 f(ui, state);
1756 });
1757 self
1758 }
1759
1760 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1764 self.col(|ui| {
1765 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1766 ui.text_input(&mut field.input);
1767 if let Some(error) = field.error.as_deref() {
1768 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1769 }
1770 });
1771 self
1772 }
1773
1774 pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
1778 self.button(label)
1779 }
1780
1781 pub fn text_input(&mut self, state: &mut TextInputState) -> &mut Self {
1797 slt_assert(
1798 !state.value.contains('\n'),
1799 "text_input got a newline — use textarea instead",
1800 );
1801 let focused = self.register_focusable();
1802 state.cursor = state.cursor.min(state.value.chars().count());
1803
1804 if focused {
1805 let mut consumed_indices = Vec::new();
1806 for (i, event) in self.events.iter().enumerate() {
1807 if let Event::Key(key) = event {
1808 match key.code {
1809 KeyCode::Char(ch) => {
1810 if let Some(max) = state.max_length {
1811 if state.value.chars().count() >= max {
1812 continue;
1813 }
1814 }
1815 let index = byte_index_for_char(&state.value, state.cursor);
1816 state.value.insert(index, ch);
1817 state.cursor += 1;
1818 consumed_indices.push(i);
1819 }
1820 KeyCode::Backspace => {
1821 if state.cursor > 0 {
1822 let start = byte_index_for_char(&state.value, state.cursor - 1);
1823 let end = byte_index_for_char(&state.value, state.cursor);
1824 state.value.replace_range(start..end, "");
1825 state.cursor -= 1;
1826 }
1827 consumed_indices.push(i);
1828 }
1829 KeyCode::Left => {
1830 state.cursor = state.cursor.saturating_sub(1);
1831 consumed_indices.push(i);
1832 }
1833 KeyCode::Right => {
1834 state.cursor = (state.cursor + 1).min(state.value.chars().count());
1835 consumed_indices.push(i);
1836 }
1837 KeyCode::Home => {
1838 state.cursor = 0;
1839 consumed_indices.push(i);
1840 }
1841 KeyCode::Delete => {
1842 let len = state.value.chars().count();
1843 if state.cursor < len {
1844 let start = byte_index_for_char(&state.value, state.cursor);
1845 let end = byte_index_for_char(&state.value, state.cursor + 1);
1846 state.value.replace_range(start..end, "");
1847 }
1848 consumed_indices.push(i);
1849 }
1850 KeyCode::End => {
1851 state.cursor = state.value.chars().count();
1852 consumed_indices.push(i);
1853 }
1854 _ => {}
1855 }
1856 }
1857 if let Event::Paste(ref text) = event {
1858 for ch in text.chars() {
1859 if let Some(max) = state.max_length {
1860 if state.value.chars().count() >= max {
1861 break;
1862 }
1863 }
1864 let index = byte_index_for_char(&state.value, state.cursor);
1865 state.value.insert(index, ch);
1866 state.cursor += 1;
1867 }
1868 consumed_indices.push(i);
1869 }
1870 }
1871
1872 for index in consumed_indices {
1873 self.consumed[index] = true;
1874 }
1875 }
1876
1877 let show_cursor = focused && (self.tick / 30) % 2 == 0;
1878
1879 let input_text = if state.value.is_empty() {
1880 if state.placeholder.len() > 100 {
1881 slt_warn(
1882 "text_input placeholder is very long (>100 chars) — consider shortening it",
1883 );
1884 }
1885 state.placeholder.clone()
1886 } else {
1887 let mut rendered = String::new();
1888 for (idx, ch) in state.value.chars().enumerate() {
1889 if show_cursor && idx == state.cursor {
1890 rendered.push('▎');
1891 }
1892 rendered.push(if state.masked { '•' } else { ch });
1893 }
1894 if show_cursor && state.cursor >= state.value.chars().count() {
1895 rendered.push('▎');
1896 }
1897 rendered
1898 };
1899 let input_style = if state.value.is_empty() {
1900 Style::new().dim().fg(self.theme.text_dim)
1901 } else {
1902 Style::new().fg(self.theme.text)
1903 };
1904
1905 let border_color = if focused {
1906 self.theme.primary
1907 } else if state.validation_error.is_some() {
1908 self.theme.error
1909 } else {
1910 self.theme.border
1911 };
1912
1913 self.bordered(Border::Rounded)
1914 .border_style(Style::new().fg(border_color))
1915 .px(1)
1916 .col(|ui| {
1917 ui.styled(input_text, input_style);
1918 });
1919
1920 if let Some(error) = state.validation_error.clone() {
1921 self.styled(
1922 format!("⚠ {error}"),
1923 Style::new().dim().fg(self.theme.error),
1924 );
1925 }
1926 self
1927 }
1928
1929 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
1935 self.styled(
1936 state.frame(self.tick).to_string(),
1937 Style::new().fg(self.theme.primary),
1938 )
1939 }
1940
1941 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
1946 state.cleanup(self.tick);
1947 if state.messages.is_empty() {
1948 return self;
1949 }
1950
1951 self.interaction_count += 1;
1952 self.commands.push(Command::BeginContainer {
1953 direction: Direction::Column,
1954 gap: 0,
1955 align: Align::Start,
1956 justify: Justify::Start,
1957 border: None,
1958 border_sides: BorderSides::all(),
1959 border_style: Style::new().fg(self.theme.border),
1960 bg_color: None,
1961 padding: Padding::default(),
1962 margin: Margin::default(),
1963 constraints: Constraints::default(),
1964 title: None,
1965 grow: 0,
1966 });
1967 for message in state.messages.iter().rev() {
1968 let color = match message.level {
1969 ToastLevel::Info => self.theme.primary,
1970 ToastLevel::Success => self.theme.success,
1971 ToastLevel::Warning => self.theme.warning,
1972 ToastLevel::Error => self.theme.error,
1973 };
1974 self.styled(format!(" ● {}", message.text), Style::new().fg(color));
1975 }
1976 self.commands.push(Command::EndContainer);
1977 self.last_text_idx = None;
1978
1979 self
1980 }
1981
1982 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> &mut Self {
1990 if state.lines.is_empty() {
1991 state.lines.push(String::new());
1992 }
1993 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
1994 state.cursor_col = state
1995 .cursor_col
1996 .min(state.lines[state.cursor_row].chars().count());
1997
1998 let focused = self.register_focusable();
1999 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
2000 let wrapping = state.wrap_width.is_some();
2001
2002 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2003
2004 if focused {
2005 let mut consumed_indices = Vec::new();
2006 for (i, event) in self.events.iter().enumerate() {
2007 if let Event::Key(key) = event {
2008 match key.code {
2009 KeyCode::Char(ch) => {
2010 if let Some(max) = state.max_length {
2011 let total: usize =
2012 state.lines.iter().map(|line| line.chars().count()).sum();
2013 if total >= max {
2014 continue;
2015 }
2016 }
2017 let index = byte_index_for_char(
2018 &state.lines[state.cursor_row],
2019 state.cursor_col,
2020 );
2021 state.lines[state.cursor_row].insert(index, ch);
2022 state.cursor_col += 1;
2023 consumed_indices.push(i);
2024 }
2025 KeyCode::Enter => {
2026 let split_index = byte_index_for_char(
2027 &state.lines[state.cursor_row],
2028 state.cursor_col,
2029 );
2030 let remainder = state.lines[state.cursor_row].split_off(split_index);
2031 state.cursor_row += 1;
2032 state.lines.insert(state.cursor_row, remainder);
2033 state.cursor_col = 0;
2034 consumed_indices.push(i);
2035 }
2036 KeyCode::Backspace => {
2037 if state.cursor_col > 0 {
2038 let start = byte_index_for_char(
2039 &state.lines[state.cursor_row],
2040 state.cursor_col - 1,
2041 );
2042 let end = byte_index_for_char(
2043 &state.lines[state.cursor_row],
2044 state.cursor_col,
2045 );
2046 state.lines[state.cursor_row].replace_range(start..end, "");
2047 state.cursor_col -= 1;
2048 } else if state.cursor_row > 0 {
2049 let current = state.lines.remove(state.cursor_row);
2050 state.cursor_row -= 1;
2051 state.cursor_col = state.lines[state.cursor_row].chars().count();
2052 state.lines[state.cursor_row].push_str(¤t);
2053 }
2054 consumed_indices.push(i);
2055 }
2056 KeyCode::Left => {
2057 if state.cursor_col > 0 {
2058 state.cursor_col -= 1;
2059 } else if state.cursor_row > 0 {
2060 state.cursor_row -= 1;
2061 state.cursor_col = state.lines[state.cursor_row].chars().count();
2062 }
2063 consumed_indices.push(i);
2064 }
2065 KeyCode::Right => {
2066 let line_len = state.lines[state.cursor_row].chars().count();
2067 if state.cursor_col < line_len {
2068 state.cursor_col += 1;
2069 } else if state.cursor_row + 1 < state.lines.len() {
2070 state.cursor_row += 1;
2071 state.cursor_col = 0;
2072 }
2073 consumed_indices.push(i);
2074 }
2075 KeyCode::Up => {
2076 if wrapping {
2077 let (vrow, vcol) = textarea_logical_to_visual(
2078 &pre_vlines,
2079 state.cursor_row,
2080 state.cursor_col,
2081 );
2082 if vrow > 0 {
2083 let (lr, lc) =
2084 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
2085 state.cursor_row = lr;
2086 state.cursor_col = lc;
2087 }
2088 } else if state.cursor_row > 0 {
2089 state.cursor_row -= 1;
2090 state.cursor_col = state
2091 .cursor_col
2092 .min(state.lines[state.cursor_row].chars().count());
2093 }
2094 consumed_indices.push(i);
2095 }
2096 KeyCode::Down => {
2097 if wrapping {
2098 let (vrow, vcol) = textarea_logical_to_visual(
2099 &pre_vlines,
2100 state.cursor_row,
2101 state.cursor_col,
2102 );
2103 if vrow + 1 < pre_vlines.len() {
2104 let (lr, lc) =
2105 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
2106 state.cursor_row = lr;
2107 state.cursor_col = lc;
2108 }
2109 } else if state.cursor_row + 1 < state.lines.len() {
2110 state.cursor_row += 1;
2111 state.cursor_col = state
2112 .cursor_col
2113 .min(state.lines[state.cursor_row].chars().count());
2114 }
2115 consumed_indices.push(i);
2116 }
2117 KeyCode::Home => {
2118 state.cursor_col = 0;
2119 consumed_indices.push(i);
2120 }
2121 KeyCode::Delete => {
2122 let line_len = state.lines[state.cursor_row].chars().count();
2123 if state.cursor_col < line_len {
2124 let start = byte_index_for_char(
2125 &state.lines[state.cursor_row],
2126 state.cursor_col,
2127 );
2128 let end = byte_index_for_char(
2129 &state.lines[state.cursor_row],
2130 state.cursor_col + 1,
2131 );
2132 state.lines[state.cursor_row].replace_range(start..end, "");
2133 } else if state.cursor_row + 1 < state.lines.len() {
2134 let next = state.lines.remove(state.cursor_row + 1);
2135 state.lines[state.cursor_row].push_str(&next);
2136 }
2137 consumed_indices.push(i);
2138 }
2139 KeyCode::End => {
2140 state.cursor_col = state.lines[state.cursor_row].chars().count();
2141 consumed_indices.push(i);
2142 }
2143 _ => {}
2144 }
2145 }
2146 if let Event::Paste(ref text) = event {
2147 for ch in text.chars() {
2148 if ch == '\n' || ch == '\r' {
2149 let split_index = byte_index_for_char(
2150 &state.lines[state.cursor_row],
2151 state.cursor_col,
2152 );
2153 let remainder = state.lines[state.cursor_row].split_off(split_index);
2154 state.cursor_row += 1;
2155 state.lines.insert(state.cursor_row, remainder);
2156 state.cursor_col = 0;
2157 } else {
2158 if let Some(max) = state.max_length {
2159 let total: usize =
2160 state.lines.iter().map(|l| l.chars().count()).sum();
2161 if total >= max {
2162 break;
2163 }
2164 }
2165 let index = byte_index_for_char(
2166 &state.lines[state.cursor_row],
2167 state.cursor_col,
2168 );
2169 state.lines[state.cursor_row].insert(index, ch);
2170 state.cursor_col += 1;
2171 }
2172 }
2173 consumed_indices.push(i);
2174 }
2175 }
2176
2177 for index in consumed_indices {
2178 self.consumed[index] = true;
2179 }
2180 }
2181
2182 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2183 let (cursor_vrow, cursor_vcol) =
2184 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
2185
2186 if cursor_vrow < state.scroll_offset {
2187 state.scroll_offset = cursor_vrow;
2188 }
2189 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
2190 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
2191 }
2192
2193 self.interaction_count += 1;
2194 self.commands.push(Command::BeginContainer {
2195 direction: Direction::Column,
2196 gap: 0,
2197 align: Align::Start,
2198 justify: Justify::Start,
2199 border: None,
2200 border_sides: BorderSides::all(),
2201 border_style: Style::new().fg(self.theme.border),
2202 bg_color: None,
2203 padding: Padding::default(),
2204 margin: Margin::default(),
2205 constraints: Constraints::default(),
2206 title: None,
2207 grow: 0,
2208 });
2209
2210 let show_cursor = focused && (self.tick / 30) % 2 == 0;
2211 for vi in 0..visible_rows as usize {
2212 let actual_vi = state.scroll_offset + vi;
2213 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
2214 let line = &state.lines[vl.logical_row];
2215 let text: String = line
2216 .chars()
2217 .skip(vl.char_start)
2218 .take(vl.char_count)
2219 .collect();
2220 (text, actual_vi == cursor_vrow)
2221 } else {
2222 (String::new(), false)
2223 };
2224
2225 let mut rendered = seg_text.clone();
2226 let mut style = if seg_text.is_empty() {
2227 Style::new().fg(self.theme.text_dim)
2228 } else {
2229 Style::new().fg(self.theme.text)
2230 };
2231
2232 if is_cursor_line {
2233 rendered.clear();
2234 for (idx, ch) in seg_text.chars().enumerate() {
2235 if show_cursor && idx == cursor_vcol {
2236 rendered.push('▎');
2237 }
2238 rendered.push(ch);
2239 }
2240 if show_cursor && cursor_vcol >= seg_text.chars().count() {
2241 rendered.push('▎');
2242 }
2243 style = Style::new().fg(self.theme.text);
2244 }
2245
2246 self.styled(rendered, style);
2247 }
2248 self.commands.push(Command::EndContainer);
2249 self.last_text_idx = None;
2250
2251 self
2252 }
2253
2254 pub fn progress(&mut self, ratio: f64) -> &mut Self {
2259 self.progress_bar(ratio, 20)
2260 }
2261
2262 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
2267 let clamped = ratio.clamp(0.0, 1.0);
2268 let filled = (clamped * width as f64).round() as u32;
2269 let empty = width.saturating_sub(filled);
2270 let mut bar = String::new();
2271 for _ in 0..filled {
2272 bar.push('█');
2273 }
2274 for _ in 0..empty {
2275 bar.push('░');
2276 }
2277 self.text(bar)
2278 }
2279
2280 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
2301 if data.is_empty() {
2302 return self;
2303 }
2304
2305 let max_label_width = data
2306 .iter()
2307 .map(|(label, _)| UnicodeWidthStr::width(*label))
2308 .max()
2309 .unwrap_or(0);
2310 let max_value = data
2311 .iter()
2312 .map(|(_, value)| *value)
2313 .fold(f64::NEG_INFINITY, f64::max);
2314 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2315
2316 self.interaction_count += 1;
2317 self.commands.push(Command::BeginContainer {
2318 direction: Direction::Column,
2319 gap: 0,
2320 align: Align::Start,
2321 justify: Justify::Start,
2322 border: None,
2323 border_sides: BorderSides::all(),
2324 border_style: Style::new().fg(self.theme.border),
2325 bg_color: None,
2326 padding: Padding::default(),
2327 margin: Margin::default(),
2328 constraints: Constraints::default(),
2329 title: None,
2330 grow: 0,
2331 });
2332
2333 for (label, value) in data {
2334 let label_width = UnicodeWidthStr::width(*label);
2335 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2336 let normalized = (*value / denom).clamp(0.0, 1.0);
2337 let bar_len = (normalized * max_width as f64).round() as usize;
2338 let bar = "█".repeat(bar_len);
2339
2340 self.interaction_count += 1;
2341 self.commands.push(Command::BeginContainer {
2342 direction: Direction::Row,
2343 gap: 1,
2344 align: Align::Start,
2345 justify: Justify::Start,
2346 border: None,
2347 border_sides: BorderSides::all(),
2348 border_style: Style::new().fg(self.theme.border),
2349 bg_color: None,
2350 padding: Padding::default(),
2351 margin: Margin::default(),
2352 constraints: Constraints::default(),
2353 title: None,
2354 grow: 0,
2355 });
2356 self.styled(
2357 format!("{label}{label_padding}"),
2358 Style::new().fg(self.theme.text),
2359 );
2360 self.styled(bar, Style::new().fg(self.theme.primary));
2361 self.styled(
2362 format_compact_number(*value),
2363 Style::new().fg(self.theme.text_dim),
2364 );
2365 self.commands.push(Command::EndContainer);
2366 self.last_text_idx = None;
2367 }
2368
2369 self.commands.push(Command::EndContainer);
2370 self.last_text_idx = None;
2371
2372 self
2373 }
2374
2375 pub fn bar_chart_styled(
2391 &mut self,
2392 bars: &[Bar],
2393 max_width: u32,
2394 direction: BarDirection,
2395 ) -> &mut Self {
2396 if bars.is_empty() {
2397 return self;
2398 }
2399
2400 let max_value = bars
2401 .iter()
2402 .map(|bar| bar.value)
2403 .fold(f64::NEG_INFINITY, f64::max);
2404 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2405
2406 match direction {
2407 BarDirection::Horizontal => {
2408 let max_label_width = bars
2409 .iter()
2410 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2411 .max()
2412 .unwrap_or(0);
2413
2414 self.interaction_count += 1;
2415 self.commands.push(Command::BeginContainer {
2416 direction: Direction::Column,
2417 gap: 0,
2418 align: Align::Start,
2419 justify: Justify::Start,
2420 border: None,
2421 border_sides: BorderSides::all(),
2422 border_style: Style::new().fg(self.theme.border),
2423 bg_color: None,
2424 padding: Padding::default(),
2425 margin: Margin::default(),
2426 constraints: Constraints::default(),
2427 title: None,
2428 grow: 0,
2429 });
2430
2431 for bar in bars {
2432 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2433 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2434 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2435 let bar_len = (normalized * max_width as f64).round() as usize;
2436 let bar_text = "█".repeat(bar_len);
2437 let color = bar.color.unwrap_or(self.theme.primary);
2438
2439 self.interaction_count += 1;
2440 self.commands.push(Command::BeginContainer {
2441 direction: Direction::Row,
2442 gap: 1,
2443 align: Align::Start,
2444 justify: Justify::Start,
2445 border: None,
2446 border_sides: BorderSides::all(),
2447 border_style: Style::new().fg(self.theme.border),
2448 bg_color: None,
2449 padding: Padding::default(),
2450 margin: Margin::default(),
2451 constraints: Constraints::default(),
2452 title: None,
2453 grow: 0,
2454 });
2455 self.styled(
2456 format!("{}{label_padding}", bar.label),
2457 Style::new().fg(self.theme.text),
2458 );
2459 self.styled(bar_text, Style::new().fg(color));
2460 self.styled(
2461 format_compact_number(bar.value),
2462 Style::new().fg(self.theme.text_dim),
2463 );
2464 self.commands.push(Command::EndContainer);
2465 self.last_text_idx = None;
2466 }
2467
2468 self.commands.push(Command::EndContainer);
2469 self.last_text_idx = None;
2470 }
2471 BarDirection::Vertical => {
2472 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
2473
2474 let chart_height = max_width.max(1) as usize;
2475 let value_labels: Vec<String> = bars
2476 .iter()
2477 .map(|bar| format_compact_number(bar.value))
2478 .collect();
2479 let col_width = bars
2480 .iter()
2481 .zip(value_labels.iter())
2482 .map(|(bar, value)| {
2483 UnicodeWidthStr::width(bar.label.as_str())
2484 .max(UnicodeWidthStr::width(value.as_str()))
2485 .max(1)
2486 })
2487 .max()
2488 .unwrap_or(1);
2489
2490 let bar_units: Vec<usize> = bars
2491 .iter()
2492 .map(|bar| {
2493 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2494 (normalized * chart_height as f64 * 8.0).round() as usize
2495 })
2496 .collect();
2497
2498 self.interaction_count += 1;
2499 self.commands.push(Command::BeginContainer {
2500 direction: Direction::Column,
2501 gap: 0,
2502 align: Align::Start,
2503 justify: Justify::Start,
2504 border: None,
2505 border_sides: BorderSides::all(),
2506 border_style: Style::new().fg(self.theme.border),
2507 bg_color: None,
2508 padding: Padding::default(),
2509 margin: Margin::default(),
2510 constraints: Constraints::default(),
2511 title: None,
2512 grow: 0,
2513 });
2514
2515 self.interaction_count += 1;
2516 self.commands.push(Command::BeginContainer {
2517 direction: Direction::Row,
2518 gap: 1,
2519 align: Align::Start,
2520 justify: Justify::Start,
2521 border: None,
2522 border_sides: BorderSides::all(),
2523 border_style: Style::new().fg(self.theme.border),
2524 bg_color: None,
2525 padding: Padding::default(),
2526 margin: Margin::default(),
2527 constraints: Constraints::default(),
2528 title: None,
2529 grow: 0,
2530 });
2531 for value in &value_labels {
2532 self.styled(
2533 center_text(value, col_width),
2534 Style::new().fg(self.theme.text_dim),
2535 );
2536 }
2537 self.commands.push(Command::EndContainer);
2538 self.last_text_idx = None;
2539
2540 for row in (0..chart_height).rev() {
2541 self.interaction_count += 1;
2542 self.commands.push(Command::BeginContainer {
2543 direction: Direction::Row,
2544 gap: 1,
2545 align: Align::Start,
2546 justify: Justify::Start,
2547 border: None,
2548 border_sides: BorderSides::all(),
2549 border_style: Style::new().fg(self.theme.border),
2550 bg_color: None,
2551 padding: Padding::default(),
2552 margin: Margin::default(),
2553 constraints: Constraints::default(),
2554 title: None,
2555 grow: 0,
2556 });
2557
2558 let row_base = row * 8;
2559 for (bar, units) in bars.iter().zip(bar_units.iter()) {
2560 let fill = if *units <= row_base {
2561 ' '
2562 } else {
2563 let delta = *units - row_base;
2564 if delta >= 8 {
2565 '█'
2566 } else {
2567 FRACTION_BLOCKS[delta]
2568 }
2569 };
2570
2571 self.styled(
2572 center_text(&fill.to_string(), col_width),
2573 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2574 );
2575 }
2576
2577 self.commands.push(Command::EndContainer);
2578 self.last_text_idx = None;
2579 }
2580
2581 self.interaction_count += 1;
2582 self.commands.push(Command::BeginContainer {
2583 direction: Direction::Row,
2584 gap: 1,
2585 align: Align::Start,
2586 justify: Justify::Start,
2587 border: None,
2588 border_sides: BorderSides::all(),
2589 border_style: Style::new().fg(self.theme.border),
2590 bg_color: None,
2591 padding: Padding::default(),
2592 margin: Margin::default(),
2593 constraints: Constraints::default(),
2594 title: None,
2595 grow: 0,
2596 });
2597 for bar in bars {
2598 self.styled(
2599 center_text(&bar.label, col_width),
2600 Style::new().fg(self.theme.text),
2601 );
2602 }
2603 self.commands.push(Command::EndContainer);
2604 self.last_text_idx = None;
2605
2606 self.commands.push(Command::EndContainer);
2607 self.last_text_idx = None;
2608 }
2609 }
2610
2611 self
2612 }
2613
2614 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
2631 if groups.is_empty() {
2632 return self;
2633 }
2634
2635 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
2636 if all_bars.is_empty() {
2637 return self;
2638 }
2639
2640 let max_label_width = all_bars
2641 .iter()
2642 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2643 .max()
2644 .unwrap_or(0);
2645 let max_value = all_bars
2646 .iter()
2647 .map(|bar| bar.value)
2648 .fold(f64::NEG_INFINITY, f64::max);
2649 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2650
2651 self.interaction_count += 1;
2652 self.commands.push(Command::BeginContainer {
2653 direction: Direction::Column,
2654 gap: 1,
2655 align: Align::Start,
2656 justify: Justify::Start,
2657 border: None,
2658 border_sides: BorderSides::all(),
2659 border_style: Style::new().fg(self.theme.border),
2660 bg_color: None,
2661 padding: Padding::default(),
2662 margin: Margin::default(),
2663 constraints: Constraints::default(),
2664 title: None,
2665 grow: 0,
2666 });
2667
2668 for group in groups {
2669 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
2670
2671 for bar in &group.bars {
2672 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2673 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2674 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2675 let bar_len = (normalized * max_width as f64).round() as usize;
2676 let bar_text = "█".repeat(bar_len);
2677
2678 self.interaction_count += 1;
2679 self.commands.push(Command::BeginContainer {
2680 direction: Direction::Row,
2681 gap: 1,
2682 align: Align::Start,
2683 justify: Justify::Start,
2684 border: None,
2685 border_sides: BorderSides::all(),
2686 border_style: Style::new().fg(self.theme.border),
2687 bg_color: None,
2688 padding: Padding::default(),
2689 margin: Margin::default(),
2690 constraints: Constraints::default(),
2691 title: None,
2692 grow: 0,
2693 });
2694 self.styled(
2695 format!(" {}{label_padding}", bar.label),
2696 Style::new().fg(self.theme.text),
2697 );
2698 self.styled(
2699 bar_text,
2700 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2701 );
2702 self.styled(
2703 format_compact_number(bar.value),
2704 Style::new().fg(self.theme.text_dim),
2705 );
2706 self.commands.push(Command::EndContainer);
2707 self.last_text_idx = None;
2708 }
2709 }
2710
2711 self.commands.push(Command::EndContainer);
2712 self.last_text_idx = None;
2713
2714 self
2715 }
2716
2717 pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
2733 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2734
2735 let w = width as usize;
2736 let window = if data.len() > w {
2737 &data[data.len() - w..]
2738 } else {
2739 data
2740 };
2741
2742 if window.is_empty() {
2743 return self;
2744 }
2745
2746 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
2747 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2748 let range = max - min;
2749
2750 let line: String = window
2751 .iter()
2752 .map(|&value| {
2753 let normalized = if range == 0.0 {
2754 0.5
2755 } else {
2756 (value - min) / range
2757 };
2758 let idx = (normalized * 7.0).round() as usize;
2759 BLOCKS[idx.min(7)]
2760 })
2761 .collect();
2762
2763 self.styled(line, Style::new().fg(self.theme.primary))
2764 }
2765
2766 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
2786 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2787
2788 let w = width as usize;
2789 let window = if data.len() > w {
2790 &data[data.len() - w..]
2791 } else {
2792 data
2793 };
2794
2795 if window.is_empty() {
2796 return self;
2797 }
2798
2799 let mut finite_values = window
2800 .iter()
2801 .map(|(value, _)| *value)
2802 .filter(|value| !value.is_nan());
2803 let Some(first) = finite_values.next() else {
2804 return self.styled(
2805 " ".repeat(window.len()),
2806 Style::new().fg(self.theme.text_dim),
2807 );
2808 };
2809
2810 let mut min = first;
2811 let mut max = first;
2812 for value in finite_values {
2813 min = f64::min(min, value);
2814 max = f64::max(max, value);
2815 }
2816 let range = max - min;
2817
2818 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
2819 for (value, color) in window {
2820 if value.is_nan() {
2821 cells.push((' ', self.theme.text_dim));
2822 continue;
2823 }
2824
2825 let normalized = if range == 0.0 {
2826 0.5
2827 } else {
2828 ((*value - min) / range).clamp(0.0, 1.0)
2829 };
2830 let idx = (normalized * 7.0).round() as usize;
2831 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
2832 }
2833
2834 self.interaction_count += 1;
2835 self.commands.push(Command::BeginContainer {
2836 direction: Direction::Row,
2837 gap: 0,
2838 align: Align::Start,
2839 justify: Justify::Start,
2840 border: None,
2841 border_sides: BorderSides::all(),
2842 border_style: Style::new().fg(self.theme.border),
2843 bg_color: None,
2844 padding: Padding::default(),
2845 margin: Margin::default(),
2846 constraints: Constraints::default(),
2847 title: None,
2848 grow: 0,
2849 });
2850
2851 let mut seg = String::new();
2852 let mut seg_color = cells[0].1;
2853 for (ch, color) in cells {
2854 if color != seg_color {
2855 self.styled(seg, Style::new().fg(seg_color));
2856 seg = String::new();
2857 seg_color = color;
2858 }
2859 seg.push(ch);
2860 }
2861 if !seg.is_empty() {
2862 self.styled(seg, Style::new().fg(seg_color));
2863 }
2864
2865 self.commands.push(Command::EndContainer);
2866 self.last_text_idx = None;
2867
2868 self
2869 }
2870
2871 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
2885 if data.is_empty() || width == 0 || height == 0 {
2886 return self;
2887 }
2888
2889 let cols = width as usize;
2890 let rows = height as usize;
2891 let px_w = cols * 2;
2892 let px_h = rows * 4;
2893
2894 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
2895 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2896 let range = if (max - min).abs() < f64::EPSILON {
2897 1.0
2898 } else {
2899 max - min
2900 };
2901
2902 let points: Vec<usize> = (0..px_w)
2903 .map(|px| {
2904 let data_idx = if px_w <= 1 {
2905 0.0
2906 } else {
2907 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
2908 };
2909 let idx = data_idx.floor() as usize;
2910 let frac = data_idx - idx as f64;
2911 let value = if idx + 1 < data.len() {
2912 data[idx] * (1.0 - frac) + data[idx + 1] * frac
2913 } else {
2914 data[idx.min(data.len() - 1)]
2915 };
2916
2917 let normalized = (value - min) / range;
2918 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
2919 py.min(px_h - 1)
2920 })
2921 .collect();
2922
2923 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
2924 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
2925
2926 let mut grid = vec![vec![0u32; cols]; rows];
2927
2928 for i in 0..points.len() {
2929 let px = i;
2930 let py = points[i];
2931 let char_col = px / 2;
2932 let char_row = py / 4;
2933 let sub_col = px % 2;
2934 let sub_row = py % 4;
2935
2936 if char_col < cols && char_row < rows {
2937 grid[char_row][char_col] |= if sub_col == 0 {
2938 LEFT_BITS[sub_row]
2939 } else {
2940 RIGHT_BITS[sub_row]
2941 };
2942 }
2943
2944 if i + 1 < points.len() {
2945 let py_next = points[i + 1];
2946 let (y_start, y_end) = if py <= py_next {
2947 (py, py_next)
2948 } else {
2949 (py_next, py)
2950 };
2951 for y in y_start..=y_end {
2952 let cell_row = y / 4;
2953 let sub_y = y % 4;
2954 if char_col < cols && cell_row < rows {
2955 grid[cell_row][char_col] |= if sub_col == 0 {
2956 LEFT_BITS[sub_y]
2957 } else {
2958 RIGHT_BITS[sub_y]
2959 };
2960 }
2961 }
2962 }
2963 }
2964
2965 let style = Style::new().fg(self.theme.primary);
2966 for row in grid {
2967 let line: String = row
2968 .iter()
2969 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
2970 .collect();
2971 self.styled(line, style);
2972 }
2973
2974 self
2975 }
2976
2977 pub fn canvas(
2994 &mut self,
2995 width: u32,
2996 height: u32,
2997 draw: impl FnOnce(&mut CanvasContext),
2998 ) -> &mut Self {
2999 if width == 0 || height == 0 {
3000 return self;
3001 }
3002
3003 let mut canvas = CanvasContext::new(width as usize, height as usize);
3004 draw(&mut canvas);
3005
3006 for segments in canvas.render() {
3007 self.interaction_count += 1;
3008 self.commands.push(Command::BeginContainer {
3009 direction: Direction::Row,
3010 gap: 0,
3011 align: Align::Start,
3012 justify: Justify::Start,
3013 border: None,
3014 border_sides: BorderSides::all(),
3015 border_style: Style::new(),
3016 bg_color: None,
3017 padding: Padding::default(),
3018 margin: Margin::default(),
3019 constraints: Constraints::default(),
3020 title: None,
3021 grow: 0,
3022 });
3023 for (text, color) in segments {
3024 let c = if color == Color::Reset {
3025 self.theme.primary
3026 } else {
3027 color
3028 };
3029 self.styled(text, Style::new().fg(c));
3030 }
3031 self.commands.push(Command::EndContainer);
3032 self.last_text_idx = None;
3033 }
3034
3035 self
3036 }
3037
3038 pub fn chart(
3040 &mut self,
3041 configure: impl FnOnce(&mut ChartBuilder),
3042 width: u32,
3043 height: u32,
3044 ) -> &mut Self {
3045 if width == 0 || height == 0 {
3046 return self;
3047 }
3048
3049 let axis_style = Style::new().fg(self.theme.text_dim);
3050 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
3051 configure(&mut builder);
3052
3053 let config = builder.build();
3054 let rows = render_chart(&config);
3055
3056 for row in rows {
3057 self.interaction_count += 1;
3058 self.commands.push(Command::BeginContainer {
3059 direction: Direction::Row,
3060 gap: 0,
3061 align: Align::Start,
3062 justify: Justify::Start,
3063 border: None,
3064 border_sides: BorderSides::all(),
3065 border_style: Style::new().fg(self.theme.border),
3066 bg_color: None,
3067 padding: Padding::default(),
3068 margin: Margin::default(),
3069 constraints: Constraints::default(),
3070 title: None,
3071 grow: 0,
3072 });
3073 for (text, style) in row.segments {
3074 self.styled(text, style);
3075 }
3076 self.commands.push(Command::EndContainer);
3077 self.last_text_idx = None;
3078 }
3079
3080 self
3081 }
3082
3083 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
3085 self.histogram_with(data, |_| {}, width, height)
3086 }
3087
3088 pub fn histogram_with(
3090 &mut self,
3091 data: &[f64],
3092 configure: impl FnOnce(&mut HistogramBuilder),
3093 width: u32,
3094 height: u32,
3095 ) -> &mut Self {
3096 if width == 0 || height == 0 {
3097 return self;
3098 }
3099
3100 let mut options = HistogramBuilder::default();
3101 configure(&mut options);
3102 let axis_style = Style::new().fg(self.theme.text_dim);
3103 let config = build_histogram_config(data, &options, width, height, axis_style);
3104 let rows = render_chart(&config);
3105
3106 for row in rows {
3107 self.interaction_count += 1;
3108 self.commands.push(Command::BeginContainer {
3109 direction: Direction::Row,
3110 gap: 0,
3111 align: Align::Start,
3112 justify: Justify::Start,
3113 border: None,
3114 border_sides: BorderSides::all(),
3115 border_style: Style::new().fg(self.theme.border),
3116 bg_color: None,
3117 padding: Padding::default(),
3118 margin: Margin::default(),
3119 constraints: Constraints::default(),
3120 title: None,
3121 grow: 0,
3122 });
3123 for (text, style) in row.segments {
3124 self.styled(text, style);
3125 }
3126 self.commands.push(Command::EndContainer);
3127 self.last_text_idx = None;
3128 }
3129
3130 self
3131 }
3132
3133 pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
3150 slt_assert(cols > 0, "grid() requires at least 1 column");
3151 let interaction_id = self.interaction_count;
3152 self.interaction_count += 1;
3153 let border = self.theme.border;
3154
3155 self.commands.push(Command::BeginContainer {
3156 direction: Direction::Column,
3157 gap: 0,
3158 align: Align::Start,
3159 justify: Justify::Start,
3160 border: None,
3161 border_sides: BorderSides::all(),
3162 border_style: Style::new().fg(border),
3163 bg_color: None,
3164 padding: Padding::default(),
3165 margin: Margin::default(),
3166 constraints: Constraints::default(),
3167 title: None,
3168 grow: 0,
3169 });
3170
3171 let children_start = self.commands.len();
3172 f(self);
3173 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
3174
3175 let mut elements: Vec<Vec<Command>> = Vec::new();
3176 let mut iter = child_commands.into_iter().peekable();
3177 while let Some(cmd) = iter.next() {
3178 match cmd {
3179 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3180 let mut depth = 1_u32;
3181 let mut element = vec![cmd];
3182 for next in iter.by_ref() {
3183 match next {
3184 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3185 depth += 1;
3186 }
3187 Command::EndContainer => {
3188 depth = depth.saturating_sub(1);
3189 }
3190 _ => {}
3191 }
3192 let at_end = matches!(next, Command::EndContainer) && depth == 0;
3193 element.push(next);
3194 if at_end {
3195 break;
3196 }
3197 }
3198 elements.push(element);
3199 }
3200 Command::EndContainer => {}
3201 _ => elements.push(vec![cmd]),
3202 }
3203 }
3204
3205 let cols = cols.max(1) as usize;
3206 for row in elements.chunks(cols) {
3207 self.interaction_count += 1;
3208 self.commands.push(Command::BeginContainer {
3209 direction: Direction::Row,
3210 gap: 0,
3211 align: Align::Start,
3212 justify: Justify::Start,
3213 border: None,
3214 border_sides: BorderSides::all(),
3215 border_style: Style::new().fg(border),
3216 bg_color: None,
3217 padding: Padding::default(),
3218 margin: Margin::default(),
3219 constraints: Constraints::default(),
3220 title: None,
3221 grow: 0,
3222 });
3223
3224 for element in row {
3225 self.interaction_count += 1;
3226 self.commands.push(Command::BeginContainer {
3227 direction: Direction::Column,
3228 gap: 0,
3229 align: Align::Start,
3230 justify: Justify::Start,
3231 border: None,
3232 border_sides: BorderSides::all(),
3233 border_style: Style::new().fg(border),
3234 bg_color: None,
3235 padding: Padding::default(),
3236 margin: Margin::default(),
3237 constraints: Constraints::default(),
3238 title: None,
3239 grow: 1,
3240 });
3241 self.commands.extend(element.iter().cloned());
3242 self.commands.push(Command::EndContainer);
3243 }
3244
3245 self.commands.push(Command::EndContainer);
3246 }
3247
3248 self.commands.push(Command::EndContainer);
3249 self.last_text_idx = None;
3250
3251 self.response_for(interaction_id)
3252 }
3253
3254 pub fn list(&mut self, state: &mut ListState) -> &mut Self {
3259 if state.items.is_empty() {
3260 state.selected = 0;
3261 return self;
3262 }
3263
3264 state.selected = state.selected.min(state.items.len().saturating_sub(1));
3265
3266 let focused = self.register_focusable();
3267 let interaction_id = self.interaction_count;
3268 self.interaction_count += 1;
3269
3270 if focused {
3271 let mut consumed_indices = Vec::new();
3272 for (i, event) in self.events.iter().enumerate() {
3273 if let Event::Key(key) = event {
3274 match key.code {
3275 KeyCode::Up | KeyCode::Char('k') => {
3276 state.selected = state.selected.saturating_sub(1);
3277 consumed_indices.push(i);
3278 }
3279 KeyCode::Down | KeyCode::Char('j') => {
3280 state.selected =
3281 (state.selected + 1).min(state.items.len().saturating_sub(1));
3282 consumed_indices.push(i);
3283 }
3284 _ => {}
3285 }
3286 }
3287 }
3288
3289 for index in consumed_indices {
3290 self.consumed[index] = true;
3291 }
3292 }
3293
3294 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3295 for (i, event) in self.events.iter().enumerate() {
3296 if self.consumed[i] {
3297 continue;
3298 }
3299 if let Event::Mouse(mouse) = event {
3300 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3301 continue;
3302 }
3303 let in_bounds = mouse.x >= rect.x
3304 && mouse.x < rect.right()
3305 && mouse.y >= rect.y
3306 && mouse.y < rect.bottom();
3307 if !in_bounds {
3308 continue;
3309 }
3310 let clicked_idx = (mouse.y - rect.y) as usize;
3311 if clicked_idx < state.items.len() {
3312 state.selected = clicked_idx;
3313 self.consumed[i] = true;
3314 }
3315 }
3316 }
3317 }
3318
3319 self.commands.push(Command::BeginContainer {
3320 direction: Direction::Column,
3321 gap: 0,
3322 align: Align::Start,
3323 justify: Justify::Start,
3324 border: None,
3325 border_sides: BorderSides::all(),
3326 border_style: Style::new().fg(self.theme.border),
3327 bg_color: None,
3328 padding: Padding::default(),
3329 margin: Margin::default(),
3330 constraints: Constraints::default(),
3331 title: None,
3332 grow: 0,
3333 });
3334
3335 for (idx, item) in state.items.iter().enumerate() {
3336 if idx == state.selected {
3337 if focused {
3338 self.styled(
3339 format!("▸ {item}"),
3340 Style::new().bold().fg(self.theme.primary),
3341 );
3342 } else {
3343 self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
3344 }
3345 } else {
3346 self.styled(format!(" {item}"), Style::new().fg(self.theme.text));
3347 }
3348 }
3349
3350 self.commands.push(Command::EndContainer);
3351 self.last_text_idx = None;
3352
3353 self
3354 }
3355
3356 pub fn table(&mut self, state: &mut TableState) -> &mut Self {
3361 if state.is_dirty() {
3362 state.recompute_widths();
3363 }
3364
3365 let focused = self.register_focusable();
3366 let interaction_id = self.interaction_count;
3367 self.interaction_count += 1;
3368
3369 if focused && !state.rows.is_empty() {
3370 let mut consumed_indices = Vec::new();
3371 for (i, event) in self.events.iter().enumerate() {
3372 if let Event::Key(key) = event {
3373 match key.code {
3374 KeyCode::Up | KeyCode::Char('k') => {
3375 state.selected = state.selected.saturating_sub(1);
3376 consumed_indices.push(i);
3377 }
3378 KeyCode::Down | KeyCode::Char('j') => {
3379 state.selected =
3380 (state.selected + 1).min(state.rows.len().saturating_sub(1));
3381 consumed_indices.push(i);
3382 }
3383 _ => {}
3384 }
3385 }
3386 }
3387 for index in consumed_indices {
3388 self.consumed[index] = true;
3389 }
3390 }
3391
3392 if !state.rows.is_empty() {
3393 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3394 for (i, event) in self.events.iter().enumerate() {
3395 if self.consumed[i] {
3396 continue;
3397 }
3398 if let Event::Mouse(mouse) = event {
3399 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3400 continue;
3401 }
3402 let in_bounds = mouse.x >= rect.x
3403 && mouse.x < rect.right()
3404 && mouse.y >= rect.y
3405 && mouse.y < rect.bottom();
3406 if !in_bounds {
3407 continue;
3408 }
3409 if mouse.y < rect.y + 2 {
3410 continue;
3411 }
3412 let clicked_idx = (mouse.y - rect.y - 2) as usize;
3413 if clicked_idx < state.rows.len() {
3414 state.selected = clicked_idx;
3415 self.consumed[i] = true;
3416 }
3417 }
3418 }
3419 }
3420 }
3421
3422 state.selected = state.selected.min(state.rows.len().saturating_sub(1));
3423
3424 self.commands.push(Command::BeginContainer {
3425 direction: Direction::Column,
3426 gap: 0,
3427 align: Align::Start,
3428 justify: Justify::Start,
3429 border: None,
3430 border_sides: BorderSides::all(),
3431 border_style: Style::new().fg(self.theme.border),
3432 bg_color: None,
3433 padding: Padding::default(),
3434 margin: Margin::default(),
3435 constraints: Constraints::default(),
3436 title: None,
3437 grow: 0,
3438 });
3439
3440 let header_line = format_table_row(&state.headers, state.column_widths(), " │ ");
3441 self.styled(header_line, Style::new().bold().fg(self.theme.text));
3442
3443 let separator = state
3444 .column_widths()
3445 .iter()
3446 .map(|w| "─".repeat(*w as usize))
3447 .collect::<Vec<_>>()
3448 .join("─┼─");
3449 self.text(separator);
3450
3451 for (idx, row) in state.rows.iter().enumerate() {
3452 let line = format_table_row(row, state.column_widths(), " │ ");
3453 if idx == state.selected {
3454 let mut style = Style::new()
3455 .bg(self.theme.selected_bg)
3456 .fg(self.theme.selected_fg);
3457 if focused {
3458 style = style.bold();
3459 }
3460 self.styled(line, style);
3461 } else {
3462 self.styled(line, Style::new().fg(self.theme.text));
3463 }
3464 }
3465
3466 self.commands.push(Command::EndContainer);
3467 self.last_text_idx = None;
3468
3469 self
3470 }
3471
3472 pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
3477 if state.labels.is_empty() {
3478 state.selected = 0;
3479 return self;
3480 }
3481
3482 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
3483 let focused = self.register_focusable();
3484 let interaction_id = self.interaction_count;
3485
3486 if focused {
3487 let mut consumed_indices = Vec::new();
3488 for (i, event) in self.events.iter().enumerate() {
3489 if let Event::Key(key) = event {
3490 match key.code {
3491 KeyCode::Left => {
3492 state.selected = if state.selected == 0 {
3493 state.labels.len().saturating_sub(1)
3494 } else {
3495 state.selected - 1
3496 };
3497 consumed_indices.push(i);
3498 }
3499 KeyCode::Right => {
3500 state.selected = (state.selected + 1) % state.labels.len();
3501 consumed_indices.push(i);
3502 }
3503 _ => {}
3504 }
3505 }
3506 }
3507
3508 for index in consumed_indices {
3509 self.consumed[index] = true;
3510 }
3511 }
3512
3513 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3514 for (i, event) in self.events.iter().enumerate() {
3515 if self.consumed[i] {
3516 continue;
3517 }
3518 if let Event::Mouse(mouse) = event {
3519 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3520 continue;
3521 }
3522 let in_bounds = mouse.x >= rect.x
3523 && mouse.x < rect.right()
3524 && mouse.y >= rect.y
3525 && mouse.y < rect.bottom();
3526 if !in_bounds {
3527 continue;
3528 }
3529
3530 let mut x_offset = 0u32;
3531 let rel_x = mouse.x - rect.x;
3532 for (idx, label) in state.labels.iter().enumerate() {
3533 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
3534 if rel_x >= x_offset && rel_x < x_offset + tab_width {
3535 state.selected = idx;
3536 self.consumed[i] = true;
3537 break;
3538 }
3539 x_offset += tab_width + 1;
3540 }
3541 }
3542 }
3543 }
3544
3545 self.interaction_count += 1;
3546 self.commands.push(Command::BeginContainer {
3547 direction: Direction::Row,
3548 gap: 1,
3549 align: Align::Start,
3550 justify: Justify::Start,
3551 border: None,
3552 border_sides: BorderSides::all(),
3553 border_style: Style::new().fg(self.theme.border),
3554 bg_color: None,
3555 padding: Padding::default(),
3556 margin: Margin::default(),
3557 constraints: Constraints::default(),
3558 title: None,
3559 grow: 0,
3560 });
3561 for (idx, label) in state.labels.iter().enumerate() {
3562 let style = if idx == state.selected {
3563 let s = Style::new().fg(self.theme.primary).bold();
3564 if focused {
3565 s.underline()
3566 } else {
3567 s
3568 }
3569 } else {
3570 Style::new().fg(self.theme.text_dim)
3571 };
3572 self.styled(format!("[ {label} ]"), style);
3573 }
3574 self.commands.push(Command::EndContainer);
3575 self.last_text_idx = None;
3576
3577 self
3578 }
3579
3580 pub fn button(&mut self, label: impl Into<String>) -> bool {
3585 let focused = self.register_focusable();
3586 let interaction_id = self.interaction_count;
3587 self.interaction_count += 1;
3588 let response = self.response_for(interaction_id);
3589
3590 let mut activated = response.clicked;
3591 if focused {
3592 let mut consumed_indices = Vec::new();
3593 for (i, event) in self.events.iter().enumerate() {
3594 if let Event::Key(key) = event {
3595 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3596 activated = true;
3597 consumed_indices.push(i);
3598 }
3599 }
3600 }
3601
3602 for index in consumed_indices {
3603 self.consumed[index] = true;
3604 }
3605 }
3606
3607 let hovered = response.hovered;
3608 let style = if focused {
3609 Style::new().fg(self.theme.primary).bold()
3610 } else if hovered {
3611 Style::new().fg(self.theme.accent)
3612 } else {
3613 Style::new().fg(self.theme.text)
3614 };
3615 let hover_bg = if hovered || focused {
3616 Some(self.theme.surface_hover)
3617 } else {
3618 None
3619 };
3620
3621 self.commands.push(Command::BeginContainer {
3622 direction: Direction::Row,
3623 gap: 0,
3624 align: Align::Start,
3625 justify: Justify::Start,
3626 border: None,
3627 border_sides: BorderSides::all(),
3628 border_style: Style::new().fg(self.theme.border),
3629 bg_color: hover_bg,
3630 padding: Padding::default(),
3631 margin: Margin::default(),
3632 constraints: Constraints::default(),
3633 title: None,
3634 grow: 0,
3635 });
3636 self.styled(format!("[ {} ]", label.into()), style);
3637 self.commands.push(Command::EndContainer);
3638 self.last_text_idx = None;
3639
3640 activated
3641 }
3642
3643 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
3648 let focused = self.register_focusable();
3649 let interaction_id = self.interaction_count;
3650 self.interaction_count += 1;
3651 let response = self.response_for(interaction_id);
3652
3653 let mut activated = response.clicked;
3654 if focused {
3655 let mut consumed_indices = Vec::new();
3656 for (i, event) in self.events.iter().enumerate() {
3657 if let Event::Key(key) = event {
3658 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3659 activated = true;
3660 consumed_indices.push(i);
3661 }
3662 }
3663 }
3664 for index in consumed_indices {
3665 self.consumed[index] = true;
3666 }
3667 }
3668
3669 let label = label.into();
3670 let hover_bg = if response.hovered || focused {
3671 Some(self.theme.surface_hover)
3672 } else {
3673 None
3674 };
3675 let (text, style, bg_color, border) = match variant {
3676 ButtonVariant::Default => {
3677 let style = if focused {
3678 Style::new().fg(self.theme.primary).bold()
3679 } else if response.hovered {
3680 Style::new().fg(self.theme.accent)
3681 } else {
3682 Style::new().fg(self.theme.text)
3683 };
3684 (format!("[ {label} ]"), style, hover_bg, None)
3685 }
3686 ButtonVariant::Primary => {
3687 let style = if focused {
3688 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
3689 } else if response.hovered {
3690 Style::new().fg(self.theme.bg).bg(self.theme.accent)
3691 } else {
3692 Style::new().fg(self.theme.bg).bg(self.theme.primary)
3693 };
3694 (format!(" {label} "), style, hover_bg, None)
3695 }
3696 ButtonVariant::Danger => {
3697 let style = if focused {
3698 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
3699 } else if response.hovered {
3700 Style::new().fg(self.theme.bg).bg(self.theme.warning)
3701 } else {
3702 Style::new().fg(self.theme.bg).bg(self.theme.error)
3703 };
3704 (format!(" {label} "), style, hover_bg, None)
3705 }
3706 ButtonVariant::Outline => {
3707 let border_color = if focused {
3708 self.theme.primary
3709 } else if response.hovered {
3710 self.theme.accent
3711 } else {
3712 self.theme.border
3713 };
3714 let style = if focused {
3715 Style::new().fg(self.theme.primary).bold()
3716 } else if response.hovered {
3717 Style::new().fg(self.theme.accent)
3718 } else {
3719 Style::new().fg(self.theme.text)
3720 };
3721 (
3722 format!(" {label} "),
3723 style,
3724 hover_bg,
3725 Some((Border::Rounded, Style::new().fg(border_color))),
3726 )
3727 }
3728 };
3729
3730 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
3731 self.commands.push(Command::BeginContainer {
3732 direction: Direction::Row,
3733 gap: 0,
3734 align: Align::Center,
3735 justify: Justify::Center,
3736 border: if border.is_some() {
3737 Some(btn_border)
3738 } else {
3739 None
3740 },
3741 border_sides: BorderSides::all(),
3742 border_style: btn_border_style,
3743 bg_color,
3744 padding: Padding::default(),
3745 margin: Margin::default(),
3746 constraints: Constraints::default(),
3747 title: None,
3748 grow: 0,
3749 });
3750 self.styled(text, style);
3751 self.commands.push(Command::EndContainer);
3752 self.last_text_idx = None;
3753
3754 activated
3755 }
3756
3757 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
3762 let focused = self.register_focusable();
3763 let interaction_id = self.interaction_count;
3764 self.interaction_count += 1;
3765 let response = self.response_for(interaction_id);
3766 let mut should_toggle = response.clicked;
3767
3768 if focused {
3769 let mut consumed_indices = Vec::new();
3770 for (i, event) in self.events.iter().enumerate() {
3771 if let Event::Key(key) = event {
3772 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3773 should_toggle = true;
3774 consumed_indices.push(i);
3775 }
3776 }
3777 }
3778
3779 for index in consumed_indices {
3780 self.consumed[index] = true;
3781 }
3782 }
3783
3784 if should_toggle {
3785 *checked = !*checked;
3786 }
3787
3788 let hover_bg = if response.hovered || focused {
3789 Some(self.theme.surface_hover)
3790 } else {
3791 None
3792 };
3793 self.commands.push(Command::BeginContainer {
3794 direction: Direction::Row,
3795 gap: 1,
3796 align: Align::Start,
3797 justify: Justify::Start,
3798 border: None,
3799 border_sides: BorderSides::all(),
3800 border_style: Style::new().fg(self.theme.border),
3801 bg_color: hover_bg,
3802 padding: Padding::default(),
3803 margin: Margin::default(),
3804 constraints: Constraints::default(),
3805 title: None,
3806 grow: 0,
3807 });
3808 let marker_style = if *checked {
3809 Style::new().fg(self.theme.success)
3810 } else {
3811 Style::new().fg(self.theme.text_dim)
3812 };
3813 let marker = if *checked { "[x]" } else { "[ ]" };
3814 let label_text = label.into();
3815 if focused {
3816 self.styled(format!("▸ {marker}"), marker_style.bold());
3817 self.styled(label_text, Style::new().fg(self.theme.text).bold());
3818 } else {
3819 self.styled(marker, marker_style);
3820 self.styled(label_text, Style::new().fg(self.theme.text));
3821 }
3822 self.commands.push(Command::EndContainer);
3823 self.last_text_idx = None;
3824
3825 self
3826 }
3827
3828 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
3834 let focused = self.register_focusable();
3835 let interaction_id = self.interaction_count;
3836 self.interaction_count += 1;
3837 let response = self.response_for(interaction_id);
3838 let mut should_toggle = response.clicked;
3839
3840 if focused {
3841 let mut consumed_indices = Vec::new();
3842 for (i, event) in self.events.iter().enumerate() {
3843 if let Event::Key(key) = event {
3844 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3845 should_toggle = true;
3846 consumed_indices.push(i);
3847 }
3848 }
3849 }
3850
3851 for index in consumed_indices {
3852 self.consumed[index] = true;
3853 }
3854 }
3855
3856 if should_toggle {
3857 *on = !*on;
3858 }
3859
3860 let hover_bg = if response.hovered || focused {
3861 Some(self.theme.surface_hover)
3862 } else {
3863 None
3864 };
3865 self.commands.push(Command::BeginContainer {
3866 direction: Direction::Row,
3867 gap: 2,
3868 align: Align::Start,
3869 justify: Justify::Start,
3870 border: None,
3871 border_sides: BorderSides::all(),
3872 border_style: Style::new().fg(self.theme.border),
3873 bg_color: hover_bg,
3874 padding: Padding::default(),
3875 margin: Margin::default(),
3876 constraints: Constraints::default(),
3877 title: None,
3878 grow: 0,
3879 });
3880 let label_text = label.into();
3881 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
3882 let switch_style = if *on {
3883 Style::new().fg(self.theme.success)
3884 } else {
3885 Style::new().fg(self.theme.text_dim)
3886 };
3887 if focused {
3888 self.styled(
3889 format!("▸ {label_text}"),
3890 Style::new().fg(self.theme.text).bold(),
3891 );
3892 self.styled(switch, switch_style.bold());
3893 } else {
3894 self.styled(label_text, Style::new().fg(self.theme.text));
3895 self.styled(switch, switch_style);
3896 }
3897 self.commands.push(Command::EndContainer);
3898 self.last_text_idx = None;
3899
3900 self
3901 }
3902
3903 pub fn select(&mut self, state: &mut SelectState) -> bool {
3909 if state.items.is_empty() {
3910 return false;
3911 }
3912 state.selected = state.selected.min(state.items.len().saturating_sub(1));
3913
3914 let focused = self.register_focusable();
3915 let interaction_id = self.interaction_count;
3916 self.interaction_count += 1;
3917 let response = self.response_for(interaction_id);
3918 let old_selected = state.selected;
3919
3920 if response.clicked {
3921 state.open = !state.open;
3922 if state.open {
3923 state.set_cursor(state.selected);
3924 }
3925 }
3926
3927 if focused {
3928 let mut consumed_indices = Vec::new();
3929 for (i, event) in self.events.iter().enumerate() {
3930 if self.consumed[i] {
3931 continue;
3932 }
3933 if let Event::Key(key) = event {
3934 if state.open {
3935 match key.code {
3936 KeyCode::Up | KeyCode::Char('k') => {
3937 let c = state.cursor();
3938 state.set_cursor(c.saturating_sub(1));
3939 consumed_indices.push(i);
3940 }
3941 KeyCode::Down | KeyCode::Char('j') => {
3942 let c = state.cursor();
3943 state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
3944 consumed_indices.push(i);
3945 }
3946 KeyCode::Enter | KeyCode::Char(' ') => {
3947 state.selected = state.cursor();
3948 state.open = false;
3949 consumed_indices.push(i);
3950 }
3951 KeyCode::Esc => {
3952 state.open = false;
3953 consumed_indices.push(i);
3954 }
3955 _ => {}
3956 }
3957 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3958 state.open = true;
3959 state.set_cursor(state.selected);
3960 consumed_indices.push(i);
3961 }
3962 }
3963 }
3964 for idx in consumed_indices {
3965 self.consumed[idx] = true;
3966 }
3967 }
3968
3969 let changed = state.selected != old_selected;
3970
3971 let border_color = if focused {
3972 self.theme.primary
3973 } else {
3974 self.theme.border
3975 };
3976 let display_text = state
3977 .items
3978 .get(state.selected)
3979 .cloned()
3980 .unwrap_or_else(|| state.placeholder.clone());
3981 let arrow = if state.open { "▲" } else { "▼" };
3982
3983 self.commands.push(Command::BeginContainer {
3984 direction: Direction::Column,
3985 gap: 0,
3986 align: Align::Start,
3987 justify: Justify::Start,
3988 border: None,
3989 border_sides: BorderSides::all(),
3990 border_style: Style::new().fg(self.theme.border),
3991 bg_color: None,
3992 padding: Padding::default(),
3993 margin: Margin::default(),
3994 constraints: Constraints::default(),
3995 title: None,
3996 grow: 0,
3997 });
3998
3999 self.commands.push(Command::BeginContainer {
4000 direction: Direction::Row,
4001 gap: 1,
4002 align: Align::Start,
4003 justify: Justify::Start,
4004 border: Some(Border::Rounded),
4005 border_sides: BorderSides::all(),
4006 border_style: Style::new().fg(border_color),
4007 bg_color: None,
4008 padding: Padding {
4009 left: 1,
4010 right: 1,
4011 top: 0,
4012 bottom: 0,
4013 },
4014 margin: Margin::default(),
4015 constraints: Constraints::default(),
4016 title: None,
4017 grow: 0,
4018 });
4019 self.interaction_count += 1;
4020 self.styled(&display_text, Style::new().fg(self.theme.text));
4021 self.styled(arrow, Style::new().fg(self.theme.text_dim));
4022 self.commands.push(Command::EndContainer);
4023 self.last_text_idx = None;
4024
4025 if state.open {
4026 for (idx, item) in state.items.iter().enumerate() {
4027 let is_cursor = idx == state.cursor();
4028 let style = if is_cursor {
4029 Style::new().bold().fg(self.theme.primary)
4030 } else {
4031 Style::new().fg(self.theme.text)
4032 };
4033 let prefix = if is_cursor { "▸ " } else { " " };
4034 self.styled(format!("{prefix}{item}"), style);
4035 }
4036 }
4037
4038 self.commands.push(Command::EndContainer);
4039 self.last_text_idx = None;
4040 changed
4041 }
4042
4043 pub fn radio(&mut self, state: &mut RadioState) -> bool {
4047 if state.items.is_empty() {
4048 return false;
4049 }
4050 state.selected = state.selected.min(state.items.len().saturating_sub(1));
4051 let focused = self.register_focusable();
4052 let old_selected = state.selected;
4053
4054 if focused {
4055 let mut consumed_indices = Vec::new();
4056 for (i, event) in self.events.iter().enumerate() {
4057 if self.consumed[i] {
4058 continue;
4059 }
4060 if let Event::Key(key) = event {
4061 match key.code {
4062 KeyCode::Up | KeyCode::Char('k') => {
4063 state.selected = state.selected.saturating_sub(1);
4064 consumed_indices.push(i);
4065 }
4066 KeyCode::Down | KeyCode::Char('j') => {
4067 state.selected =
4068 (state.selected + 1).min(state.items.len().saturating_sub(1));
4069 consumed_indices.push(i);
4070 }
4071 KeyCode::Enter | KeyCode::Char(' ') => {
4072 consumed_indices.push(i);
4073 }
4074 _ => {}
4075 }
4076 }
4077 }
4078 for idx in consumed_indices {
4079 self.consumed[idx] = true;
4080 }
4081 }
4082
4083 let interaction_id = self.interaction_count;
4084 self.interaction_count += 1;
4085
4086 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4087 for (i, event) in self.events.iter().enumerate() {
4088 if self.consumed[i] {
4089 continue;
4090 }
4091 if let Event::Mouse(mouse) = event {
4092 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4093 continue;
4094 }
4095 let in_bounds = mouse.x >= rect.x
4096 && mouse.x < rect.right()
4097 && mouse.y >= rect.y
4098 && mouse.y < rect.bottom();
4099 if !in_bounds {
4100 continue;
4101 }
4102 let clicked_idx = (mouse.y - rect.y) as usize;
4103 if clicked_idx < state.items.len() {
4104 state.selected = clicked_idx;
4105 self.consumed[i] = true;
4106 }
4107 }
4108 }
4109 }
4110
4111 self.commands.push(Command::BeginContainer {
4112 direction: Direction::Column,
4113 gap: 0,
4114 align: Align::Start,
4115 justify: Justify::Start,
4116 border: None,
4117 border_sides: BorderSides::all(),
4118 border_style: Style::new().fg(self.theme.border),
4119 bg_color: None,
4120 padding: Padding::default(),
4121 margin: Margin::default(),
4122 constraints: Constraints::default(),
4123 title: None,
4124 grow: 0,
4125 });
4126
4127 for (idx, item) in state.items.iter().enumerate() {
4128 let is_selected = idx == state.selected;
4129 let marker = if is_selected { "●" } else { "○" };
4130 let style = if is_selected {
4131 if focused {
4132 Style::new().bold().fg(self.theme.primary)
4133 } else {
4134 Style::new().fg(self.theme.primary)
4135 }
4136 } else {
4137 Style::new().fg(self.theme.text)
4138 };
4139 let prefix = if focused && idx == state.selected {
4140 "▸ "
4141 } else {
4142 " "
4143 };
4144 self.styled(format!("{prefix}{marker} {item}"), style);
4145 }
4146
4147 self.commands.push(Command::EndContainer);
4148 self.last_text_idx = None;
4149 state.selected != old_selected
4150 }
4151
4152 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
4156 if state.items.is_empty() {
4157 return self;
4158 }
4159 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
4160 let focused = self.register_focusable();
4161
4162 if focused {
4163 let mut consumed_indices = Vec::new();
4164 for (i, event) in self.events.iter().enumerate() {
4165 if self.consumed[i] {
4166 continue;
4167 }
4168 if let Event::Key(key) = event {
4169 match key.code {
4170 KeyCode::Up | KeyCode::Char('k') => {
4171 state.cursor = state.cursor.saturating_sub(1);
4172 consumed_indices.push(i);
4173 }
4174 KeyCode::Down | KeyCode::Char('j') => {
4175 state.cursor =
4176 (state.cursor + 1).min(state.items.len().saturating_sub(1));
4177 consumed_indices.push(i);
4178 }
4179 KeyCode::Char(' ') | KeyCode::Enter => {
4180 state.toggle(state.cursor);
4181 consumed_indices.push(i);
4182 }
4183 _ => {}
4184 }
4185 }
4186 }
4187 for idx in consumed_indices {
4188 self.consumed[idx] = true;
4189 }
4190 }
4191
4192 let interaction_id = self.interaction_count;
4193 self.interaction_count += 1;
4194
4195 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4196 for (i, event) in self.events.iter().enumerate() {
4197 if self.consumed[i] {
4198 continue;
4199 }
4200 if let Event::Mouse(mouse) = event {
4201 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4202 continue;
4203 }
4204 let in_bounds = mouse.x >= rect.x
4205 && mouse.x < rect.right()
4206 && mouse.y >= rect.y
4207 && mouse.y < rect.bottom();
4208 if !in_bounds {
4209 continue;
4210 }
4211 let clicked_idx = (mouse.y - rect.y) as usize;
4212 if clicked_idx < state.items.len() {
4213 state.toggle(clicked_idx);
4214 state.cursor = clicked_idx;
4215 self.consumed[i] = true;
4216 }
4217 }
4218 }
4219 }
4220
4221 self.commands.push(Command::BeginContainer {
4222 direction: Direction::Column,
4223 gap: 0,
4224 align: Align::Start,
4225 justify: Justify::Start,
4226 border: None,
4227 border_sides: BorderSides::all(),
4228 border_style: Style::new().fg(self.theme.border),
4229 bg_color: None,
4230 padding: Padding::default(),
4231 margin: Margin::default(),
4232 constraints: Constraints::default(),
4233 title: None,
4234 grow: 0,
4235 });
4236
4237 for (idx, item) in state.items.iter().enumerate() {
4238 let checked = state.selected.contains(&idx);
4239 let marker = if checked { "[x]" } else { "[ ]" };
4240 let is_cursor = idx == state.cursor;
4241 let style = if is_cursor && focused {
4242 Style::new().bold().fg(self.theme.primary)
4243 } else if checked {
4244 Style::new().fg(self.theme.success)
4245 } else {
4246 Style::new().fg(self.theme.text)
4247 };
4248 let prefix = if is_cursor && focused { "▸ " } else { " " };
4249 self.styled(format!("{prefix}{marker} {item}"), style);
4250 }
4251
4252 self.commands.push(Command::EndContainer);
4253 self.last_text_idx = None;
4254 self
4255 }
4256
4257 pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
4261 let entries = state.flatten();
4262 if entries.is_empty() {
4263 return self;
4264 }
4265 state.selected = state.selected.min(entries.len().saturating_sub(1));
4266 let focused = self.register_focusable();
4267
4268 if focused {
4269 let mut consumed_indices = Vec::new();
4270 for (i, event) in self.events.iter().enumerate() {
4271 if self.consumed[i] {
4272 continue;
4273 }
4274 if let Event::Key(key) = event {
4275 match key.code {
4276 KeyCode::Up | KeyCode::Char('k') => {
4277 state.selected = state.selected.saturating_sub(1);
4278 consumed_indices.push(i);
4279 }
4280 KeyCode::Down | KeyCode::Char('j') => {
4281 let max = state.flatten().len().saturating_sub(1);
4282 state.selected = (state.selected + 1).min(max);
4283 consumed_indices.push(i);
4284 }
4285 KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
4286 state.toggle_at(state.selected);
4287 consumed_indices.push(i);
4288 }
4289 KeyCode::Left => {
4290 let entry = &entries[state.selected.min(entries.len() - 1)];
4291 if entry.expanded {
4292 state.toggle_at(state.selected);
4293 }
4294 consumed_indices.push(i);
4295 }
4296 _ => {}
4297 }
4298 }
4299 }
4300 for idx in consumed_indices {
4301 self.consumed[idx] = true;
4302 }
4303 }
4304
4305 self.interaction_count += 1;
4306 self.commands.push(Command::BeginContainer {
4307 direction: Direction::Column,
4308 gap: 0,
4309 align: Align::Start,
4310 justify: Justify::Start,
4311 border: None,
4312 border_sides: BorderSides::all(),
4313 border_style: Style::new().fg(self.theme.border),
4314 bg_color: None,
4315 padding: Padding::default(),
4316 margin: Margin::default(),
4317 constraints: Constraints::default(),
4318 title: None,
4319 grow: 0,
4320 });
4321
4322 let entries = state.flatten();
4323 for (idx, entry) in entries.iter().enumerate() {
4324 let indent = " ".repeat(entry.depth);
4325 let icon = if entry.is_leaf {
4326 " "
4327 } else if entry.expanded {
4328 "▾ "
4329 } else {
4330 "▸ "
4331 };
4332 let is_selected = idx == state.selected;
4333 let style = if is_selected && focused {
4334 Style::new().bold().fg(self.theme.primary)
4335 } else if is_selected {
4336 Style::new().fg(self.theme.primary)
4337 } else {
4338 Style::new().fg(self.theme.text)
4339 };
4340 let cursor = if is_selected && focused { "▸" } else { " " };
4341 self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
4342 }
4343
4344 self.commands.push(Command::EndContainer);
4345 self.last_text_idx = None;
4346 self
4347 }
4348
4349 pub fn virtual_list(
4356 &mut self,
4357 state: &mut ListState,
4358 visible_height: usize,
4359 f: impl Fn(&mut Context, usize),
4360 ) -> &mut Self {
4361 if state.items.is_empty() {
4362 return self;
4363 }
4364 state.selected = state.selected.min(state.items.len().saturating_sub(1));
4365 let focused = self.register_focusable();
4366
4367 if focused {
4368 let mut consumed_indices = Vec::new();
4369 for (i, event) in self.events.iter().enumerate() {
4370 if self.consumed[i] {
4371 continue;
4372 }
4373 if let Event::Key(key) = event {
4374 match key.code {
4375 KeyCode::Up | KeyCode::Char('k') => {
4376 state.selected = state.selected.saturating_sub(1);
4377 consumed_indices.push(i);
4378 }
4379 KeyCode::Down | KeyCode::Char('j') => {
4380 state.selected =
4381 (state.selected + 1).min(state.items.len().saturating_sub(1));
4382 consumed_indices.push(i);
4383 }
4384 KeyCode::PageUp => {
4385 state.selected = state.selected.saturating_sub(visible_height);
4386 consumed_indices.push(i);
4387 }
4388 KeyCode::PageDown => {
4389 state.selected = (state.selected + visible_height)
4390 .min(state.items.len().saturating_sub(1));
4391 consumed_indices.push(i);
4392 }
4393 KeyCode::Home => {
4394 state.selected = 0;
4395 consumed_indices.push(i);
4396 }
4397 KeyCode::End => {
4398 state.selected = state.items.len().saturating_sub(1);
4399 consumed_indices.push(i);
4400 }
4401 _ => {}
4402 }
4403 }
4404 }
4405 for idx in consumed_indices {
4406 self.consumed[idx] = true;
4407 }
4408 }
4409
4410 let start = if state.selected >= visible_height {
4411 state.selected - visible_height + 1
4412 } else {
4413 0
4414 };
4415 let end = (start + visible_height).min(state.items.len());
4416
4417 self.interaction_count += 1;
4418 self.commands.push(Command::BeginContainer {
4419 direction: Direction::Column,
4420 gap: 0,
4421 align: Align::Start,
4422 justify: Justify::Start,
4423 border: None,
4424 border_sides: BorderSides::all(),
4425 border_style: Style::new().fg(self.theme.border),
4426 bg_color: None,
4427 padding: Padding::default(),
4428 margin: Margin::default(),
4429 constraints: Constraints::default(),
4430 title: None,
4431 grow: 0,
4432 });
4433
4434 if start > 0 {
4435 self.styled(
4436 format!(" ↑ {} more", start),
4437 Style::new().fg(self.theme.text_dim).dim(),
4438 );
4439 }
4440
4441 for idx in start..end {
4442 f(self, idx);
4443 }
4444
4445 let remaining = state.items.len().saturating_sub(end);
4446 if remaining > 0 {
4447 self.styled(
4448 format!(" ↓ {} more", remaining),
4449 Style::new().fg(self.theme.text_dim).dim(),
4450 );
4451 }
4452
4453 self.commands.push(Command::EndContainer);
4454 self.last_text_idx = None;
4455 self
4456 }
4457
4458 pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
4462 if !state.open {
4463 return None;
4464 }
4465
4466 let filtered = state.filtered_indices();
4467 let sel = state.selected().min(filtered.len().saturating_sub(1));
4468 state.set_selected(sel);
4469
4470 let mut consumed_indices = Vec::new();
4471 let mut result: Option<usize> = None;
4472
4473 for (i, event) in self.events.iter().enumerate() {
4474 if self.consumed[i] {
4475 continue;
4476 }
4477 if let Event::Key(key) = event {
4478 match key.code {
4479 KeyCode::Esc => {
4480 state.open = false;
4481 consumed_indices.push(i);
4482 }
4483 KeyCode::Up => {
4484 let s = state.selected();
4485 state.set_selected(s.saturating_sub(1));
4486 consumed_indices.push(i);
4487 }
4488 KeyCode::Down => {
4489 let s = state.selected();
4490 state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
4491 consumed_indices.push(i);
4492 }
4493 KeyCode::Enter => {
4494 if let Some(&cmd_idx) = filtered.get(state.selected()) {
4495 result = Some(cmd_idx);
4496 state.open = false;
4497 }
4498 consumed_indices.push(i);
4499 }
4500 KeyCode::Backspace => {
4501 if state.cursor > 0 {
4502 let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
4503 let end_idx = byte_index_for_char(&state.input, state.cursor);
4504 state.input.replace_range(byte_idx..end_idx, "");
4505 state.cursor -= 1;
4506 state.set_selected(0);
4507 }
4508 consumed_indices.push(i);
4509 }
4510 KeyCode::Char(ch) => {
4511 let byte_idx = byte_index_for_char(&state.input, state.cursor);
4512 state.input.insert(byte_idx, ch);
4513 state.cursor += 1;
4514 state.set_selected(0);
4515 consumed_indices.push(i);
4516 }
4517 _ => {}
4518 }
4519 }
4520 }
4521 for idx in consumed_indices {
4522 self.consumed[idx] = true;
4523 }
4524
4525 let filtered = state.filtered_indices();
4526
4527 self.modal(|ui| {
4528 let primary = ui.theme.primary;
4529 ui.container()
4530 .border(Border::Rounded)
4531 .border_style(Style::new().fg(primary))
4532 .pad(1)
4533 .max_w(60)
4534 .col(|ui| {
4535 let border_color = ui.theme.primary;
4536 ui.bordered(Border::Rounded)
4537 .border_style(Style::new().fg(border_color))
4538 .px(1)
4539 .col(|ui| {
4540 let display = if state.input.is_empty() {
4541 "Type to search...".to_string()
4542 } else {
4543 state.input.clone()
4544 };
4545 let style = if state.input.is_empty() {
4546 Style::new().dim().fg(ui.theme.text_dim)
4547 } else {
4548 Style::new().fg(ui.theme.text)
4549 };
4550 ui.styled(display, style);
4551 });
4552
4553 for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
4554 let cmd = &state.commands[cmd_idx];
4555 let is_selected = list_idx == state.selected();
4556 let style = if is_selected {
4557 Style::new().bold().fg(ui.theme.primary)
4558 } else {
4559 Style::new().fg(ui.theme.text)
4560 };
4561 let prefix = if is_selected { "▸ " } else { " " };
4562 let shortcut_text = cmd
4563 .shortcut
4564 .as_deref()
4565 .map(|s| format!(" ({s})"))
4566 .unwrap_or_default();
4567 ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
4568 if is_selected && !cmd.description.is_empty() {
4569 ui.styled(
4570 format!(" {}", cmd.description),
4571 Style::new().dim().fg(ui.theme.text_dim),
4572 );
4573 }
4574 }
4575
4576 if filtered.is_empty() {
4577 ui.styled(
4578 " No matching commands",
4579 Style::new().dim().fg(ui.theme.text_dim),
4580 );
4581 }
4582 });
4583 });
4584
4585 result
4586 }
4587
4588 pub fn markdown(&mut self, text: &str) -> &mut Self {
4595 self.commands.push(Command::BeginContainer {
4596 direction: Direction::Column,
4597 gap: 0,
4598 align: Align::Start,
4599 justify: Justify::Start,
4600 border: None,
4601 border_sides: BorderSides::all(),
4602 border_style: Style::new().fg(self.theme.border),
4603 bg_color: None,
4604 padding: Padding::default(),
4605 margin: Margin::default(),
4606 constraints: Constraints::default(),
4607 title: None,
4608 grow: 0,
4609 });
4610 self.interaction_count += 1;
4611
4612 for line in text.lines() {
4613 let trimmed = line.trim();
4614 if trimmed.is_empty() {
4615 self.text(" ");
4616 continue;
4617 }
4618 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
4619 self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
4620 continue;
4621 }
4622 if let Some(heading) = trimmed.strip_prefix("### ") {
4623 self.styled(heading, Style::new().bold().fg(self.theme.accent));
4624 } else if let Some(heading) = trimmed.strip_prefix("## ") {
4625 self.styled(heading, Style::new().bold().fg(self.theme.secondary));
4626 } else if let Some(heading) = trimmed.strip_prefix("# ") {
4627 self.styled(heading, Style::new().bold().fg(self.theme.primary));
4628 } else if let Some(item) = trimmed
4629 .strip_prefix("- ")
4630 .or_else(|| trimmed.strip_prefix("* "))
4631 {
4632 self.styled(
4633 format!(" • {}", Self::render_inline_md(item)),
4634 Style::new().fg(self.theme.text),
4635 );
4636 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
4637 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
4638 if parts.len() == 2 {
4639 self.styled(
4640 format!(" {}. {}", parts[0], Self::render_inline_md(parts[1])),
4641 Style::new().fg(self.theme.text),
4642 );
4643 } else {
4644 self.text(trimmed);
4645 }
4646 } else if let Some(code) = trimmed.strip_prefix("```") {
4647 let _ = code;
4648 self.styled(" ┌─code─", Style::new().fg(self.theme.border).dim());
4649 } else {
4650 self.styled(
4651 Self::render_inline_md(trimmed).to_string(),
4652 Style::new().fg(self.theme.text),
4653 );
4654 }
4655 }
4656
4657 self.commands.push(Command::EndContainer);
4658 self.last_text_idx = None;
4659 self
4660 }
4661
4662 fn render_inline_md(text: &str) -> String {
4663 let mut result = String::with_capacity(text.len());
4664 let chars: Vec<char> = text.chars().collect();
4665 let mut i = 0;
4666 while i < chars.len() {
4667 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
4668 if let Some(end) = text[i + 2..].find("**") {
4669 let inner = &text[i + 2..i + 2 + end];
4670 result.push_str(inner);
4671 i += 4 + end;
4672 continue;
4673 }
4674 }
4675 if chars[i] == '`' {
4676 if let Some(end) = text[i + 1..].find('`') {
4677 let inner = &text[i + 1..i + 1 + end];
4678 result.push('`');
4679 result.push_str(inner);
4680 result.push('`');
4681 i += 2 + end;
4682 continue;
4683 }
4684 }
4685 result.push(chars[i]);
4686 i += 1;
4687 }
4688 result
4689 }
4690
4691 pub fn key_seq(&self, seq: &str) -> bool {
4698 if seq.is_empty() {
4699 return false;
4700 }
4701 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4702 return false;
4703 }
4704 let target: Vec<char> = seq.chars().collect();
4705 let mut matched = 0;
4706 for (i, event) in self.events.iter().enumerate() {
4707 if self.consumed[i] {
4708 continue;
4709 }
4710 if let Event::Key(key) = event {
4711 if let KeyCode::Char(c) = key.code {
4712 if c == target[matched] {
4713 matched += 1;
4714 if matched == target.len() {
4715 return true;
4716 }
4717 } else {
4718 matched = 0;
4719 if c == target[0] {
4720 matched = 1;
4721 }
4722 }
4723 }
4724 }
4725 }
4726 false
4727 }
4728
4729 pub fn separator(&mut self) -> &mut Self {
4734 self.commands.push(Command::Text {
4735 content: "─".repeat(200),
4736 style: Style::new().fg(self.theme.border).dim(),
4737 grow: 0,
4738 align: Align::Start,
4739 wrap: false,
4740 margin: Margin::default(),
4741 constraints: Constraints::default(),
4742 });
4743 self.last_text_idx = Some(self.commands.len() - 1);
4744 self
4745 }
4746
4747 pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
4753 if bindings.is_empty() {
4754 return self;
4755 }
4756
4757 self.interaction_count += 1;
4758 self.commands.push(Command::BeginContainer {
4759 direction: Direction::Row,
4760 gap: 2,
4761 align: Align::Start,
4762 justify: Justify::Start,
4763 border: None,
4764 border_sides: BorderSides::all(),
4765 border_style: Style::new().fg(self.theme.border),
4766 bg_color: None,
4767 padding: Padding::default(),
4768 margin: Margin::default(),
4769 constraints: Constraints::default(),
4770 title: None,
4771 grow: 0,
4772 });
4773 for (idx, (key, action)) in bindings.iter().enumerate() {
4774 if idx > 0 {
4775 self.styled("·", Style::new().fg(self.theme.text_dim));
4776 }
4777 self.styled(*key, Style::new().bold().fg(self.theme.primary));
4778 self.styled(*action, Style::new().fg(self.theme.text_dim));
4779 }
4780 self.commands.push(Command::EndContainer);
4781 self.last_text_idx = None;
4782
4783 self
4784 }
4785
4786 pub fn key(&self, c: char) -> bool {
4792 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4793 return false;
4794 }
4795 self.events.iter().enumerate().any(|(i, e)| {
4796 !self.consumed[i] && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c))
4797 })
4798 }
4799
4800 pub fn key_code(&self, code: KeyCode) -> bool {
4804 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4805 return false;
4806 }
4807 self.events
4808 .iter()
4809 .enumerate()
4810 .any(|(i, e)| !self.consumed[i] && matches!(e, Event::Key(k) if k.code == code))
4811 }
4812
4813 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
4817 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4818 return false;
4819 }
4820 self.events.iter().enumerate().any(|(i, e)| {
4821 !self.consumed[i]
4822 && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
4823 })
4824 }
4825
4826 pub fn mouse_down(&self) -> Option<(u32, u32)> {
4830 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4831 return None;
4832 }
4833 self.events.iter().enumerate().find_map(|(i, event)| {
4834 if self.consumed[i] {
4835 return None;
4836 }
4837 if let Event::Mouse(mouse) = event {
4838 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4839 return Some((mouse.x, mouse.y));
4840 }
4841 }
4842 None
4843 })
4844 }
4845
4846 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
4851 self.mouse_pos
4852 }
4853
4854 pub fn paste(&self) -> Option<&str> {
4856 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4857 return None;
4858 }
4859 self.events.iter().enumerate().find_map(|(i, event)| {
4860 if self.consumed[i] {
4861 return None;
4862 }
4863 if let Event::Paste(ref text) = event {
4864 return Some(text.as_str());
4865 }
4866 None
4867 })
4868 }
4869
4870 pub fn scroll_up(&self) -> bool {
4872 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4873 return false;
4874 }
4875 self.events.iter().enumerate().any(|(i, event)| {
4876 !self.consumed[i]
4877 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
4878 })
4879 }
4880
4881 pub fn scroll_down(&self) -> bool {
4883 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4884 return false;
4885 }
4886 self.events.iter().enumerate().any(|(i, event)| {
4887 !self.consumed[i]
4888 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
4889 })
4890 }
4891
4892 pub fn quit(&mut self) {
4894 self.should_quit = true;
4895 }
4896
4897 pub fn theme(&self) -> &Theme {
4899 &self.theme
4900 }
4901
4902 pub fn set_theme(&mut self, theme: Theme) {
4906 self.theme = theme;
4907 }
4908
4909 pub fn width(&self) -> u32 {
4913 self.area_width
4914 }
4915
4916 pub fn height(&self) -> u32 {
4918 self.area_height
4919 }
4920
4921 pub fn tick(&self) -> u64 {
4926 self.tick
4927 }
4928
4929 pub fn debug_enabled(&self) -> bool {
4933 self.debug
4934 }
4935}
4936
4937#[inline]
4938fn byte_index_for_char(value: &str, char_index: usize) -> usize {
4939 if char_index == 0 {
4940 return 0;
4941 }
4942 value
4943 .char_indices()
4944 .nth(char_index)
4945 .map_or(value.len(), |(idx, _)| idx)
4946}
4947
4948fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
4949 let mut parts: Vec<String> = Vec::new();
4950 for (i, width) in widths.iter().enumerate() {
4951 let cell = cells.get(i).map(String::as_str).unwrap_or("");
4952 let cell_width = UnicodeWidthStr::width(cell) as u32;
4953 let padding = (*width).saturating_sub(cell_width) as usize;
4954 parts.push(format!("{cell}{}", " ".repeat(padding)));
4955 }
4956 parts.join(separator)
4957}
4958
4959fn format_compact_number(value: f64) -> String {
4960 if value.fract().abs() < f64::EPSILON {
4961 return format!("{value:.0}");
4962 }
4963
4964 let mut s = format!("{value:.2}");
4965 while s.contains('.') && s.ends_with('0') {
4966 s.pop();
4967 }
4968 if s.ends_with('.') {
4969 s.pop();
4970 }
4971 s
4972}
4973
4974fn center_text(text: &str, width: usize) -> String {
4975 let text_width = UnicodeWidthStr::width(text);
4976 if text_width >= width {
4977 return text.to_string();
4978 }
4979
4980 let total = width - text_width;
4981 let left = total / 2;
4982 let right = total - left;
4983 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
4984}
4985
4986struct TextareaVLine {
4987 logical_row: usize,
4988 char_start: usize,
4989 char_count: usize,
4990}
4991
4992fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
4993 let mut out = Vec::new();
4994 for (row, line) in lines.iter().enumerate() {
4995 if line.is_empty() || wrap_width == u32::MAX {
4996 out.push(TextareaVLine {
4997 logical_row: row,
4998 char_start: 0,
4999 char_count: line.chars().count(),
5000 });
5001 continue;
5002 }
5003 let mut seg_start = 0usize;
5004 let mut seg_chars = 0usize;
5005 let mut seg_width = 0u32;
5006 for (idx, ch) in line.chars().enumerate() {
5007 let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
5008 if seg_width + cw > wrap_width && seg_chars > 0 {
5009 out.push(TextareaVLine {
5010 logical_row: row,
5011 char_start: seg_start,
5012 char_count: seg_chars,
5013 });
5014 seg_start = idx;
5015 seg_chars = 0;
5016 seg_width = 0;
5017 }
5018 seg_chars += 1;
5019 seg_width += cw;
5020 }
5021 out.push(TextareaVLine {
5022 logical_row: row,
5023 char_start: seg_start,
5024 char_count: seg_chars,
5025 });
5026 }
5027 out
5028}
5029
5030fn textarea_logical_to_visual(
5031 vlines: &[TextareaVLine],
5032 logical_row: usize,
5033 logical_col: usize,
5034) -> (usize, usize) {
5035 for (i, vl) in vlines.iter().enumerate() {
5036 if vl.logical_row != logical_row {
5037 continue;
5038 }
5039 let seg_end = vl.char_start + vl.char_count;
5040 if logical_col >= vl.char_start && logical_col < seg_end {
5041 return (i, logical_col - vl.char_start);
5042 }
5043 if logical_col == seg_end {
5044 let is_last_seg = vlines
5045 .get(i + 1)
5046 .map_or(true, |next| next.logical_row != logical_row);
5047 if is_last_seg {
5048 return (i, logical_col - vl.char_start);
5049 }
5050 }
5051 }
5052 (vlines.len().saturating_sub(1), 0)
5053}
5054
5055fn textarea_visual_to_logical(
5056 vlines: &[TextareaVLine],
5057 visual_row: usize,
5058 visual_col: usize,
5059) -> (usize, usize) {
5060 if let Some(vl) = vlines.get(visual_row) {
5061 let logical_col = vl.char_start + visual_col.min(vl.char_count);
5062 (vl.logical_row, logical_col)
5063 } else {
5064 (0, 0)
5065 }
5066}
5067
5068fn open_url(url: &str) -> std::io::Result<()> {
5069 #[cfg(target_os = "macos")]
5070 {
5071 std::process::Command::new("open").arg(url).spawn()?;
5072 }
5073 #[cfg(target_os = "linux")]
5074 {
5075 std::process::Command::new("xdg-open").arg(url).spawn()?;
5076 }
5077 #[cfg(target_os = "windows")]
5078 {
5079 std::process::Command::new("cmd")
5080 .args(["/c", "start", "", url])
5081 .spawn()?;
5082 }
5083 Ok(())
5084}