1use crate::chart::{build_histogram_config, render_chart, ChartBuilder, HistogramBuilder};
2use crate::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseKind};
3use crate::halfblock::HalfBlockImage;
4use crate::layout::{Command, Direction};
5use crate::rect::Rect;
6use crate::style::{
7 Align, Border, BorderSides, Breakpoint, Color, Constraints, Justify, Margin, Modifiers,
8 Padding, Style, Theme,
9};
10use crate::widgets::{
11 ApprovalAction, ButtonVariant, CommandPaletteState, ContextItem, FormField, FormState,
12 ListState, MultiSelectState, RadioState, ScrollState, SelectState, SpinnerState,
13 StreamingTextState, TableState, TabsState, TextInputState, TextareaState, ToastLevel,
14 ToastState, ToolApprovalState, TreeState,
15};
16use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
17
18#[allow(dead_code)]
19fn slt_assert(condition: bool, msg: &str) {
20 if !condition {
21 panic!("[SLT] {}", msg);
22 }
23}
24
25#[cfg(debug_assertions)]
26#[allow(dead_code)]
27fn slt_warn(msg: &str) {
28 eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
29}
30
31#[cfg(not(debug_assertions))]
32#[allow(dead_code)]
33fn slt_warn(_msg: &str) {}
34
35#[derive(Debug, Clone, Copy, Default)]
41pub struct Response {
42 pub clicked: bool,
44 pub hovered: bool,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BarDirection {
51 Horizontal,
53 Vertical,
55}
56
57#[derive(Debug, Clone)]
59pub struct Bar {
60 pub label: String,
62 pub value: f64,
64 pub color: Option<Color>,
66}
67
68impl Bar {
69 pub fn new(label: impl Into<String>, value: f64) -> Self {
71 Self {
72 label: label.into(),
73 value,
74 color: None,
75 }
76 }
77
78 pub fn color(mut self, color: Color) -> Self {
80 self.color = Some(color);
81 self
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct BarGroup {
88 pub label: String,
90 pub bars: Vec<Bar>,
92}
93
94impl BarGroup {
95 pub fn new(label: impl Into<String>, bars: Vec<Bar>) -> Self {
97 Self {
98 label: label.into(),
99 bars,
100 }
101 }
102}
103
104pub trait Widget {
166 type Response;
169
170 fn ui(&mut self, ctx: &mut Context) -> Self::Response;
176}
177
178pub struct Context {
194 pub(crate) commands: Vec<Command>,
195 pub(crate) events: Vec<Event>,
196 pub(crate) consumed: Vec<bool>,
197 pub(crate) should_quit: bool,
198 pub(crate) area_width: u32,
199 pub(crate) area_height: u32,
200 pub(crate) tick: u64,
201 pub(crate) focus_index: usize,
202 pub(crate) focus_count: usize,
203 prev_focus_count: usize,
204 scroll_count: usize,
205 prev_scroll_infos: Vec<(u32, u32)>,
206 prev_scroll_rects: Vec<Rect>,
207 interaction_count: usize,
208 pub(crate) prev_hit_map: Vec<Rect>,
209 _prev_focus_rects: Vec<(usize, Rect)>,
210 mouse_pos: Option<(u32, u32)>,
211 click_pos: Option<(u32, u32)>,
212 last_text_idx: Option<usize>,
213 overlay_depth: usize,
214 pub(crate) modal_active: bool,
215 prev_modal_active: bool,
216 pub(crate) clipboard_text: Option<String>,
217 debug: bool,
218 theme: Theme,
219}
220
221#[must_use = "configure and finalize with .col() or .row()"]
242pub struct ContainerBuilder<'a> {
243 ctx: &'a mut Context,
244 gap: u32,
245 align: Align,
246 justify: Justify,
247 border: Option<Border>,
248 border_sides: BorderSides,
249 border_style: Style,
250 bg_color: Option<Color>,
251 padding: Padding,
252 margin: Margin,
253 constraints: Constraints,
254 title: Option<(String, Style)>,
255 grow: u16,
256 scroll_offset: Option<u32>,
257}
258
259#[derive(Debug, Clone, Copy)]
266struct CanvasPixel {
267 bits: u32,
268 color: Color,
269}
270
271#[derive(Debug, Clone)]
273struct CanvasLabel {
274 x: usize,
275 y: usize,
276 text: String,
277 color: Color,
278}
279
280#[derive(Debug, Clone)]
282struct CanvasLayer {
283 grid: Vec<Vec<CanvasPixel>>,
284 labels: Vec<CanvasLabel>,
285}
286
287pub struct CanvasContext {
288 layers: Vec<CanvasLayer>,
289 cols: usize,
290 rows: usize,
291 px_w: usize,
292 px_h: usize,
293 current_color: Color,
294}
295
296impl CanvasContext {
297 fn new(cols: usize, rows: usize) -> Self {
298 Self {
299 layers: vec![Self::new_layer(cols, rows)],
300 cols,
301 rows,
302 px_w: cols * 2,
303 px_h: rows * 4,
304 current_color: Color::Reset,
305 }
306 }
307
308 fn new_layer(cols: usize, rows: usize) -> CanvasLayer {
309 CanvasLayer {
310 grid: vec![
311 vec![
312 CanvasPixel {
313 bits: 0,
314 color: Color::Reset,
315 };
316 cols
317 ];
318 rows
319 ],
320 labels: Vec::new(),
321 }
322 }
323
324 fn current_layer_mut(&mut self) -> Option<&mut CanvasLayer> {
325 self.layers.last_mut()
326 }
327
328 fn dot_with_color(&mut self, x: usize, y: usize, color: Color) {
329 if x >= self.px_w || y >= self.px_h {
330 return;
331 }
332
333 let char_col = x / 2;
334 let char_row = y / 4;
335 let sub_col = x % 2;
336 let sub_row = y % 4;
337 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
338 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
339
340 let bit = if sub_col == 0 {
341 LEFT_BITS[sub_row]
342 } else {
343 RIGHT_BITS[sub_row]
344 };
345
346 if let Some(layer) = self.current_layer_mut() {
347 let cell = &mut layer.grid[char_row][char_col];
348 let new_bits = cell.bits | bit;
349 if new_bits != cell.bits {
350 cell.bits = new_bits;
351 cell.color = color;
352 }
353 }
354 }
355
356 fn dot_isize(&mut self, x: isize, y: isize) {
357 if x >= 0 && y >= 0 {
358 self.dot(x as usize, y as usize);
359 }
360 }
361
362 pub fn width(&self) -> usize {
364 self.px_w
365 }
366
367 pub fn height(&self) -> usize {
369 self.px_h
370 }
371
372 pub fn dot(&mut self, x: usize, y: usize) {
374 self.dot_with_color(x, y, self.current_color);
375 }
376
377 pub fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
379 let (mut x, mut y) = (x0 as isize, y0 as isize);
380 let (x1, y1) = (x1 as isize, y1 as isize);
381 let dx = (x1 - x).abs();
382 let dy = -(y1 - y).abs();
383 let sx = if x < x1 { 1 } else { -1 };
384 let sy = if y < y1 { 1 } else { -1 };
385 let mut err = dx + dy;
386
387 loop {
388 self.dot_isize(x, y);
389 if x == x1 && y == y1 {
390 break;
391 }
392 let e2 = 2 * err;
393 if e2 >= dy {
394 err += dy;
395 x += sx;
396 }
397 if e2 <= dx {
398 err += dx;
399 y += sy;
400 }
401 }
402 }
403
404 pub fn rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
406 if w == 0 || h == 0 {
407 return;
408 }
409
410 self.line(x, y, x + w.saturating_sub(1), y);
411 self.line(
412 x + w.saturating_sub(1),
413 y,
414 x + w.saturating_sub(1),
415 y + h.saturating_sub(1),
416 );
417 self.line(
418 x + w.saturating_sub(1),
419 y + h.saturating_sub(1),
420 x,
421 y + h.saturating_sub(1),
422 );
423 self.line(x, y + h.saturating_sub(1), x, y);
424 }
425
426 pub fn circle(&mut self, cx: usize, cy: usize, r: usize) {
428 let mut x = r as isize;
429 let mut y: isize = 0;
430 let mut err: isize = 1 - x;
431 let (cx, cy) = (cx as isize, cy as isize);
432
433 while x >= y {
434 for &(dx, dy) in &[
435 (x, y),
436 (y, x),
437 (-x, y),
438 (-y, x),
439 (x, -y),
440 (y, -x),
441 (-x, -y),
442 (-y, -x),
443 ] {
444 let px = cx + dx;
445 let py = cy + dy;
446 self.dot_isize(px, py);
447 }
448
449 y += 1;
450 if err < 0 {
451 err += 2 * y + 1;
452 } else {
453 x -= 1;
454 err += 2 * (y - x) + 1;
455 }
456 }
457 }
458
459 pub fn set_color(&mut self, color: Color) {
461 self.current_color = color;
462 }
463
464 pub fn color(&self) -> Color {
466 self.current_color
467 }
468
469 pub fn filled_rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
471 if w == 0 || h == 0 {
472 return;
473 }
474
475 let x_end = x.saturating_add(w).min(self.px_w);
476 let y_end = y.saturating_add(h).min(self.px_h);
477 if x >= x_end || y >= y_end {
478 return;
479 }
480
481 for yy in y..y_end {
482 self.line(x, yy, x_end.saturating_sub(1), yy);
483 }
484 }
485
486 pub fn filled_circle(&mut self, cx: usize, cy: usize, r: usize) {
488 let (cx, cy, r) = (cx as isize, cy as isize, r as isize);
489 for y in (cy - r)..=(cy + r) {
490 let dy = y - cy;
491 let span_sq = (r * r - dy * dy).max(0);
492 let dx = (span_sq as f64).sqrt() as isize;
493 for x in (cx - dx)..=(cx + dx) {
494 self.dot_isize(x, y);
495 }
496 }
497 }
498
499 pub fn triangle(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, x2: usize, y2: usize) {
501 self.line(x0, y0, x1, y1);
502 self.line(x1, y1, x2, y2);
503 self.line(x2, y2, x0, y0);
504 }
505
506 pub fn filled_triangle(
508 &mut self,
509 x0: usize,
510 y0: usize,
511 x1: usize,
512 y1: usize,
513 x2: usize,
514 y2: usize,
515 ) {
516 let vertices = [
517 (x0 as isize, y0 as isize),
518 (x1 as isize, y1 as isize),
519 (x2 as isize, y2 as isize),
520 ];
521 let min_y = vertices.iter().map(|(_, y)| *y).min().unwrap_or(0);
522 let max_y = vertices.iter().map(|(_, y)| *y).max().unwrap_or(-1);
523
524 for y in min_y..=max_y {
525 let mut intersections: Vec<f64> = Vec::new();
526
527 for edge in [(0usize, 1usize), (1usize, 2usize), (2usize, 0usize)] {
528 let (x_a, y_a) = vertices[edge.0];
529 let (x_b, y_b) = vertices[edge.1];
530 if y_a == y_b {
531 continue;
532 }
533
534 let (x_start, y_start, x_end, y_end) = if y_a < y_b {
535 (x_a, y_a, x_b, y_b)
536 } else {
537 (x_b, y_b, x_a, y_a)
538 };
539
540 if y < y_start || y >= y_end {
541 continue;
542 }
543
544 let t = (y - y_start) as f64 / (y_end - y_start) as f64;
545 intersections.push(x_start as f64 + t * (x_end - x_start) as f64);
546 }
547
548 intersections.sort_by(|a, b| a.total_cmp(b));
549 let mut i = 0usize;
550 while i + 1 < intersections.len() {
551 let x_start = intersections[i].ceil() as isize;
552 let x_end = intersections[i + 1].floor() as isize;
553 for x in x_start..=x_end {
554 self.dot_isize(x, y);
555 }
556 i += 2;
557 }
558 }
559
560 self.triangle(x0, y0, x1, y1, x2, y2);
561 }
562
563 pub fn points(&mut self, pts: &[(usize, usize)]) {
565 for &(x, y) in pts {
566 self.dot(x, y);
567 }
568 }
569
570 pub fn polyline(&mut self, pts: &[(usize, usize)]) {
572 for window in pts.windows(2) {
573 if let [(x0, y0), (x1, y1)] = window {
574 self.line(*x0, *y0, *x1, *y1);
575 }
576 }
577 }
578
579 pub fn print(&mut self, x: usize, y: usize, text: &str) {
582 if text.is_empty() {
583 return;
584 }
585
586 let color = self.current_color;
587 if let Some(layer) = self.current_layer_mut() {
588 layer.labels.push(CanvasLabel {
589 x,
590 y,
591 text: text.to_string(),
592 color,
593 });
594 }
595 }
596
597 pub fn layer(&mut self) {
599 self.layers.push(Self::new_layer(self.cols, self.rows));
600 }
601
602 pub(crate) fn render(&self) -> Vec<Vec<(String, Color)>> {
603 let mut final_grid = vec![
604 vec![
605 CanvasPixel {
606 bits: 0,
607 color: Color::Reset,
608 };
609 self.cols
610 ];
611 self.rows
612 ];
613 let mut labels_overlay: Vec<Vec<Option<(char, Color)>>> =
614 vec![vec![None; self.cols]; self.rows];
615
616 for layer in &self.layers {
617 for (row, final_row) in final_grid.iter_mut().enumerate().take(self.rows) {
618 for (col, dst) in final_row.iter_mut().enumerate().take(self.cols) {
619 let src = layer.grid[row][col];
620 if src.bits == 0 {
621 continue;
622 }
623
624 let merged = dst.bits | src.bits;
625 if merged != dst.bits {
626 dst.bits = merged;
627 dst.color = src.color;
628 }
629 }
630 }
631
632 for label in &layer.labels {
633 let row = label.y / 4;
634 if row >= self.rows {
635 continue;
636 }
637 let start_col = label.x / 2;
638 for (offset, ch) in label.text.chars().enumerate() {
639 let col = start_col + offset;
640 if col >= self.cols {
641 break;
642 }
643 labels_overlay[row][col] = Some((ch, label.color));
644 }
645 }
646 }
647
648 let mut lines: Vec<Vec<(String, Color)>> = Vec::with_capacity(self.rows);
649 for row in 0..self.rows {
650 let mut segments: Vec<(String, Color)> = Vec::new();
651 let mut current_color: Option<Color> = None;
652 let mut current_text = String::new();
653
654 for col in 0..self.cols {
655 let (ch, color) = if let Some((label_ch, label_color)) = labels_overlay[row][col] {
656 (label_ch, label_color)
657 } else {
658 let bits = final_grid[row][col].bits;
659 let ch = char::from_u32(0x2800 + bits).unwrap_or(' ');
660 (ch, final_grid[row][col].color)
661 };
662
663 match current_color {
664 Some(c) if c == color => {
665 current_text.push(ch);
666 }
667 Some(c) => {
668 segments.push((std::mem::take(&mut current_text), c));
669 current_text.push(ch);
670 current_color = Some(color);
671 }
672 None => {
673 current_text.push(ch);
674 current_color = Some(color);
675 }
676 }
677 }
678
679 if let Some(color) = current_color {
680 segments.push((current_text, color));
681 }
682 lines.push(segments);
683 }
684
685 lines
686 }
687}
688
689impl<'a> ContainerBuilder<'a> {
690 pub fn border(mut self, border: Border) -> Self {
694 self.border = Some(border);
695 self
696 }
697
698 pub fn border_top(mut self, show: bool) -> Self {
700 self.border_sides.top = show;
701 self
702 }
703
704 pub fn border_right(mut self, show: bool) -> Self {
706 self.border_sides.right = show;
707 self
708 }
709
710 pub fn border_bottom(mut self, show: bool) -> Self {
712 self.border_sides.bottom = show;
713 self
714 }
715
716 pub fn border_left(mut self, show: bool) -> Self {
718 self.border_sides.left = show;
719 self
720 }
721
722 pub fn border_sides(mut self, sides: BorderSides) -> Self {
724 self.border_sides = sides;
725 self
726 }
727
728 pub fn rounded(self) -> Self {
730 self.border(Border::Rounded)
731 }
732
733 pub fn border_style(mut self, style: Style) -> Self {
735 self.border_style = style;
736 self
737 }
738
739 pub fn bg(mut self, color: Color) -> Self {
740 self.bg_color = Some(color);
741 self
742 }
743
744 pub fn p(self, value: u32) -> Self {
748 self.pad(value)
749 }
750
751 pub fn pad(mut self, value: u32) -> Self {
753 self.padding = Padding::all(value);
754 self
755 }
756
757 pub fn px(mut self, value: u32) -> Self {
759 self.padding.left = value;
760 self.padding.right = value;
761 self
762 }
763
764 pub fn py(mut self, value: u32) -> Self {
766 self.padding.top = value;
767 self.padding.bottom = value;
768 self
769 }
770
771 pub fn pt(mut self, value: u32) -> Self {
773 self.padding.top = value;
774 self
775 }
776
777 pub fn pr(mut self, value: u32) -> Self {
779 self.padding.right = value;
780 self
781 }
782
783 pub fn pb(mut self, value: u32) -> Self {
785 self.padding.bottom = value;
786 self
787 }
788
789 pub fn pl(mut self, value: u32) -> Self {
791 self.padding.left = value;
792 self
793 }
794
795 pub fn padding(mut self, padding: Padding) -> Self {
797 self.padding = padding;
798 self
799 }
800
801 pub fn m(mut self, value: u32) -> Self {
805 self.margin = Margin::all(value);
806 self
807 }
808
809 pub fn mx(mut self, value: u32) -> Self {
811 self.margin.left = value;
812 self.margin.right = value;
813 self
814 }
815
816 pub fn my(mut self, value: u32) -> Self {
818 self.margin.top = value;
819 self.margin.bottom = value;
820 self
821 }
822
823 pub fn mt(mut self, value: u32) -> Self {
825 self.margin.top = value;
826 self
827 }
828
829 pub fn mr(mut self, value: u32) -> Self {
831 self.margin.right = value;
832 self
833 }
834
835 pub fn mb(mut self, value: u32) -> Self {
837 self.margin.bottom = value;
838 self
839 }
840
841 pub fn ml(mut self, value: u32) -> Self {
843 self.margin.left = value;
844 self
845 }
846
847 pub fn margin(mut self, margin: Margin) -> Self {
849 self.margin = margin;
850 self
851 }
852
853 pub fn w(mut self, value: u32) -> Self {
857 self.constraints.min_width = Some(value);
858 self.constraints.max_width = Some(value);
859 self
860 }
861
862 pub fn h(mut self, value: u32) -> Self {
864 self.constraints.min_height = Some(value);
865 self.constraints.max_height = Some(value);
866 self
867 }
868
869 pub fn min_w(mut self, value: u32) -> Self {
871 self.constraints.min_width = Some(value);
872 self
873 }
874
875 pub fn max_w(mut self, value: u32) -> Self {
877 self.constraints.max_width = Some(value);
878 self
879 }
880
881 pub fn min_h(mut self, value: u32) -> Self {
883 self.constraints.min_height = Some(value);
884 self
885 }
886
887 pub fn max_h(mut self, value: u32) -> Self {
889 self.constraints.max_height = Some(value);
890 self
891 }
892
893 pub fn min_width(mut self, value: u32) -> Self {
895 self.constraints.min_width = Some(value);
896 self
897 }
898
899 pub fn max_width(mut self, value: u32) -> Self {
901 self.constraints.max_width = Some(value);
902 self
903 }
904
905 pub fn min_height(mut self, value: u32) -> Self {
907 self.constraints.min_height = Some(value);
908 self
909 }
910
911 pub fn max_height(mut self, value: u32) -> Self {
913 self.constraints.max_height = Some(value);
914 self
915 }
916
917 pub fn w_pct(mut self, pct: u8) -> Self {
919 self.constraints.width_pct = Some(pct.min(100));
920 self
921 }
922
923 pub fn h_pct(mut self, pct: u8) -> Self {
925 self.constraints.height_pct = Some(pct.min(100));
926 self
927 }
928
929 pub fn constraints(mut self, constraints: Constraints) -> Self {
931 self.constraints = constraints;
932 self
933 }
934
935 pub fn gap(mut self, gap: u32) -> Self {
939 self.gap = gap;
940 self
941 }
942
943 pub fn grow(mut self, grow: u16) -> Self {
945 self.grow = grow;
946 self
947 }
948
949 pub fn align(mut self, align: Align) -> Self {
953 self.align = align;
954 self
955 }
956
957 pub fn center(self) -> Self {
959 self.align(Align::Center)
960 }
961
962 pub fn justify(mut self, justify: Justify) -> Self {
964 self.justify = justify;
965 self
966 }
967
968 pub fn space_between(self) -> Self {
970 self.justify(Justify::SpaceBetween)
971 }
972
973 pub fn space_around(self) -> Self {
975 self.justify(Justify::SpaceAround)
976 }
977
978 pub fn space_evenly(self) -> Self {
980 self.justify(Justify::SpaceEvenly)
981 }
982
983 pub fn title(self, title: impl Into<String>) -> Self {
987 self.title_styled(title, Style::new())
988 }
989
990 pub fn title_styled(mut self, title: impl Into<String>, style: Style) -> Self {
992 self.title = Some((title.into(), style));
993 self
994 }
995
996 pub fn scroll_offset(mut self, offset: u32) -> Self {
1000 self.scroll_offset = Some(offset);
1001 self
1002 }
1003
1004 pub fn col(self, f: impl FnOnce(&mut Context)) -> Response {
1009 self.finish(Direction::Column, f)
1010 }
1011
1012 pub fn row(self, f: impl FnOnce(&mut Context)) -> Response {
1017 self.finish(Direction::Row, f)
1018 }
1019
1020 pub fn line(mut self, f: impl FnOnce(&mut Context)) -> Response {
1025 self.gap = 0;
1026 self.finish(Direction::Row, f)
1027 }
1028
1029 fn finish(self, direction: Direction, f: impl FnOnce(&mut Context)) -> Response {
1030 let interaction_id = self.ctx.interaction_count;
1031 self.ctx.interaction_count += 1;
1032
1033 if let Some(scroll_offset) = self.scroll_offset {
1034 self.ctx.commands.push(Command::BeginScrollable {
1035 grow: self.grow,
1036 border: self.border,
1037 border_sides: self.border_sides,
1038 border_style: self.border_style,
1039 padding: self.padding,
1040 margin: self.margin,
1041 constraints: self.constraints,
1042 title: self.title,
1043 scroll_offset,
1044 });
1045 } else {
1046 self.ctx.commands.push(Command::BeginContainer {
1047 direction,
1048 gap: self.gap,
1049 align: self.align,
1050 justify: self.justify,
1051 border: self.border,
1052 border_sides: self.border_sides,
1053 border_style: self.border_style,
1054 bg_color: self.bg_color,
1055 padding: self.padding,
1056 margin: self.margin,
1057 constraints: self.constraints,
1058 title: self.title,
1059 grow: self.grow,
1060 });
1061 }
1062 f(self.ctx);
1063 self.ctx.commands.push(Command::EndContainer);
1064 self.ctx.last_text_idx = None;
1065
1066 self.ctx.response_for(interaction_id)
1067 }
1068}
1069
1070impl Context {
1071 #[allow(clippy::too_many_arguments)]
1072 pub(crate) fn new(
1073 events: Vec<Event>,
1074 width: u32,
1075 height: u32,
1076 tick: u64,
1077 mut focus_index: usize,
1078 prev_focus_count: usize,
1079 prev_scroll_infos: Vec<(u32, u32)>,
1080 prev_scroll_rects: Vec<Rect>,
1081 prev_hit_map: Vec<Rect>,
1082 prev_focus_rects: Vec<(usize, Rect)>,
1083 debug: bool,
1084 theme: Theme,
1085 last_mouse_pos: Option<(u32, u32)>,
1086 prev_modal_active: bool,
1087 ) -> Self {
1088 let consumed = vec![false; events.len()];
1089
1090 let mut mouse_pos = last_mouse_pos;
1091 let mut click_pos = None;
1092 for event in &events {
1093 if let Event::Mouse(mouse) = event {
1094 mouse_pos = Some((mouse.x, mouse.y));
1095 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1096 click_pos = Some((mouse.x, mouse.y));
1097 }
1098 }
1099 }
1100
1101 if let Some((mx, my)) = click_pos {
1102 let mut best: Option<(usize, u64)> = None;
1103 for &(fid, rect) in &prev_focus_rects {
1104 if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
1105 let area = rect.width as u64 * rect.height as u64;
1106 if best.map_or(true, |(_, ba)| area < ba) {
1107 best = Some((fid, area));
1108 }
1109 }
1110 }
1111 if let Some((fid, _)) = best {
1112 focus_index = fid;
1113 }
1114 }
1115
1116 Self {
1117 commands: Vec::new(),
1118 events,
1119 consumed,
1120 should_quit: false,
1121 area_width: width,
1122 area_height: height,
1123 tick,
1124 focus_index,
1125 focus_count: 0,
1126 prev_focus_count,
1127 scroll_count: 0,
1128 prev_scroll_infos,
1129 prev_scroll_rects,
1130 interaction_count: 0,
1131 prev_hit_map,
1132 _prev_focus_rects: prev_focus_rects,
1133 mouse_pos,
1134 click_pos,
1135 last_text_idx: None,
1136 overlay_depth: 0,
1137 modal_active: false,
1138 prev_modal_active,
1139 clipboard_text: None,
1140 debug,
1141 theme,
1142 }
1143 }
1144
1145 pub(crate) fn process_focus_keys(&mut self) {
1146 for (i, event) in self.events.iter().enumerate() {
1147 if let Event::Key(key) = event {
1148 if key.kind != KeyEventKind::Press {
1149 continue;
1150 }
1151 if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
1152 if self.prev_focus_count > 0 {
1153 self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
1154 }
1155 self.consumed[i] = true;
1156 } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
1157 || key.code == KeyCode::BackTab
1158 {
1159 if self.prev_focus_count > 0 {
1160 self.focus_index = if self.focus_index == 0 {
1161 self.prev_focus_count - 1
1162 } else {
1163 self.focus_index - 1
1164 };
1165 }
1166 self.consumed[i] = true;
1167 }
1168 }
1169 }
1170 }
1171
1172 pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
1176 w.ui(self)
1177 }
1178
1179 pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
1194 self.error_boundary_with(f, |ui, msg| {
1195 ui.styled(
1196 format!("⚠ Error: {msg}"),
1197 Style::new().fg(ui.theme.error).bold(),
1198 );
1199 });
1200 }
1201
1202 pub fn error_boundary_with(
1222 &mut self,
1223 f: impl FnOnce(&mut Context),
1224 fallback: impl FnOnce(&mut Context, String),
1225 ) {
1226 let cmd_count = self.commands.len();
1227 let last_text_idx = self.last_text_idx;
1228
1229 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1230 f(self);
1231 }));
1232
1233 match result {
1234 Ok(()) => {}
1235 Err(panic_info) => {
1236 self.commands.truncate(cmd_count);
1237 self.last_text_idx = last_text_idx;
1238
1239 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
1240 (*s).to_string()
1241 } else if let Some(s) = panic_info.downcast_ref::<String>() {
1242 s.clone()
1243 } else {
1244 "widget panicked".to_string()
1245 };
1246
1247 fallback(self, msg);
1248 }
1249 }
1250 }
1251
1252 pub fn interaction(&mut self) -> Response {
1258 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1259 return Response::default();
1260 }
1261 let id = self.interaction_count;
1262 self.interaction_count += 1;
1263 self.response_for(id)
1264 }
1265
1266 pub fn register_focusable(&mut self) -> bool {
1271 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1272 return false;
1273 }
1274 let id = self.focus_count;
1275 self.focus_count += 1;
1276 self.commands.push(Command::FocusMarker(id));
1277 if self.prev_focus_count == 0 {
1278 return true;
1279 }
1280 self.focus_index % self.prev_focus_count == id
1281 }
1282
1283 pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
1296 let content = s.into();
1297 self.commands.push(Command::Text {
1298 content,
1299 style: Style::new(),
1300 grow: 0,
1301 align: Align::Start,
1302 wrap: false,
1303 margin: Margin::default(),
1304 constraints: Constraints::default(),
1305 });
1306 self.last_text_idx = Some(self.commands.len() - 1);
1307 self
1308 }
1309
1310 pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
1316 let url_str = url.into();
1317 let focused = self.register_focusable();
1318 let interaction_id = self.interaction_count;
1319 self.interaction_count += 1;
1320 let response = self.response_for(interaction_id);
1321
1322 let mut activated = response.clicked;
1323 if focused {
1324 for (i, event) in self.events.iter().enumerate() {
1325 if let Event::Key(key) = event {
1326 if key.kind != KeyEventKind::Press {
1327 continue;
1328 }
1329 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1330 activated = true;
1331 self.consumed[i] = true;
1332 }
1333 }
1334 }
1335 }
1336
1337 if activated {
1338 let _ = open_url(&url_str);
1339 }
1340
1341 let style = if focused {
1342 Style::new()
1343 .fg(self.theme.primary)
1344 .bg(self.theme.surface_hover)
1345 .underline()
1346 .bold()
1347 } else if response.hovered {
1348 Style::new()
1349 .fg(self.theme.accent)
1350 .bg(self.theme.surface_hover)
1351 .underline()
1352 } else {
1353 Style::new().fg(self.theme.primary).underline()
1354 };
1355
1356 self.commands.push(Command::Link {
1357 text: text.into(),
1358 url: url_str,
1359 style,
1360 margin: Margin::default(),
1361 constraints: Constraints::default(),
1362 });
1363 self.last_text_idx = Some(self.commands.len() - 1);
1364 self
1365 }
1366
1367 pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
1372 let content = s.into();
1373 self.commands.push(Command::Text {
1374 content,
1375 style: Style::new(),
1376 grow: 0,
1377 align: Align::Start,
1378 wrap: true,
1379 margin: Margin::default(),
1380 constraints: Constraints::default(),
1381 });
1382 self.last_text_idx = Some(self.commands.len() - 1);
1383 self
1384 }
1385
1386 pub fn bold(&mut self) -> &mut Self {
1390 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
1391 self
1392 }
1393
1394 pub fn dim(&mut self) -> &mut Self {
1399 let text_dim = self.theme.text_dim;
1400 self.modify_last_style(|s| {
1401 s.modifiers |= Modifiers::DIM;
1402 if s.fg.is_none() {
1403 s.fg = Some(text_dim);
1404 }
1405 });
1406 self
1407 }
1408
1409 pub fn italic(&mut self) -> &mut Self {
1411 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
1412 self
1413 }
1414
1415 pub fn underline(&mut self) -> &mut Self {
1417 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
1418 self
1419 }
1420
1421 pub fn reversed(&mut self) -> &mut Self {
1423 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
1424 self
1425 }
1426
1427 pub fn strikethrough(&mut self) -> &mut Self {
1429 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
1430 self
1431 }
1432
1433 pub fn fg(&mut self, color: Color) -> &mut Self {
1435 self.modify_last_style(|s| s.fg = Some(color));
1436 self
1437 }
1438
1439 pub fn bg(&mut self, color: Color) -> &mut Self {
1441 self.modify_last_style(|s| s.bg = Some(color));
1442 self
1443 }
1444
1445 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
1450 self.commands.push(Command::Text {
1451 content: s.into(),
1452 style,
1453 grow: 0,
1454 align: Align::Start,
1455 wrap: false,
1456 margin: Margin::default(),
1457 constraints: Constraints::default(),
1458 });
1459 self.last_text_idx = Some(self.commands.len() - 1);
1460 self
1461 }
1462
1463 pub fn image(&mut self, img: &HalfBlockImage) {
1485 let width = img.width;
1486 let height = img.height;
1487
1488 self.container().w(width).h(height).gap(0).col(|ui| {
1489 for row in 0..height {
1490 ui.container().gap(0).row(|ui| {
1491 for col in 0..width {
1492 let idx = (row * width + col) as usize;
1493 if let Some(&(upper, lower)) = img.pixels.get(idx) {
1494 ui.styled("▀", Style::new().fg(upper).bg(lower));
1495 }
1496 }
1497 });
1498 }
1499 });
1500 }
1501
1502 pub fn streaming_text(&mut self, state: &mut StreamingTextState) {
1518 if state.streaming {
1519 state.cursor_tick = state.cursor_tick.wrapping_add(1);
1520 state.cursor_visible = (state.cursor_tick / 8) % 2 == 0;
1521 }
1522
1523 if state.content.is_empty() && state.streaming {
1524 let cursor = if state.cursor_visible { "▌" } else { " " };
1525 let primary = self.theme.primary;
1526 self.text(cursor).fg(primary);
1527 return;
1528 }
1529
1530 if !state.content.is_empty() {
1531 if state.streaming && state.cursor_visible {
1532 self.text_wrap(format!("{}▌", state.content));
1533 } else {
1534 self.text_wrap(&state.content);
1535 }
1536 }
1537 }
1538
1539 pub fn tool_approval(&mut self, state: &mut ToolApprovalState) {
1554 let theme = self.theme;
1555 self.bordered(Border::Rounded).col(|ui| {
1556 ui.row(|ui| {
1557 ui.text("⚡").fg(theme.warning);
1558 ui.text(&state.tool_name).bold().fg(theme.primary);
1559 });
1560 ui.text(&state.description).dim();
1561
1562 if state.action == ApprovalAction::Pending {
1563 ui.row(|ui| {
1564 if ui.button("✓ Approve") {
1565 state.action = ApprovalAction::Approved;
1566 }
1567 if ui.button("✗ Reject") {
1568 state.action = ApprovalAction::Rejected;
1569 }
1570 });
1571 } else {
1572 let (label, color) = match state.action {
1573 ApprovalAction::Approved => ("✓ Approved", theme.success),
1574 ApprovalAction::Rejected => ("✗ Rejected", theme.error),
1575 ApprovalAction::Pending => unreachable!(),
1576 };
1577 ui.text(label).fg(color).bold();
1578 }
1579 });
1580 }
1581
1582 pub fn context_bar(&mut self, items: &[ContextItem]) {
1595 if items.is_empty() {
1596 return;
1597 }
1598
1599 let theme = self.theme;
1600 let total: usize = items.iter().map(|item| item.tokens).sum();
1601
1602 self.container().row(|ui| {
1603 ui.text("📎").dim();
1604 for item in items {
1605 ui.text(format!(
1606 "{} ({})",
1607 item.label,
1608 format_token_count(item.tokens)
1609 ))
1610 .fg(theme.secondary);
1611 }
1612 ui.spacer();
1613 ui.text(format!("Σ {}", format_token_count(total))).dim();
1614 });
1615 }
1616
1617 pub fn wrap(&mut self) -> &mut Self {
1619 if let Some(idx) = self.last_text_idx {
1620 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1621 *wrap = true;
1622 }
1623 }
1624 self
1625 }
1626
1627 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1628 if let Some(idx) = self.last_text_idx {
1629 match &mut self.commands[idx] {
1630 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1631 _ => {}
1632 }
1633 }
1634 }
1635
1636 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1654 self.push_container(Direction::Column, 0, f)
1655 }
1656
1657 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1661 self.push_container(Direction::Column, gap, f)
1662 }
1663
1664 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1681 self.push_container(Direction::Row, 0, f)
1682 }
1683
1684 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1688 self.push_container(Direction::Row, gap, f)
1689 }
1690
1691 pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1708 let _ = self.push_container(Direction::Row, 0, f);
1709 self
1710 }
1711
1712 pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1731 let start = self.commands.len();
1732 f(self);
1733 let mut segments: Vec<(String, Style)> = Vec::new();
1734 for cmd in self.commands.drain(start..) {
1735 if let Command::Text { content, style, .. } = cmd {
1736 segments.push((content, style));
1737 }
1738 }
1739 self.commands.push(Command::RichText {
1740 segments,
1741 wrap: true,
1742 align: Align::Start,
1743 margin: Margin::default(),
1744 constraints: Constraints::default(),
1745 });
1746 self.last_text_idx = None;
1747 self
1748 }
1749
1750 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
1751 self.commands.push(Command::BeginOverlay { modal: true });
1752 self.overlay_depth += 1;
1753 self.modal_active = true;
1754 f(self);
1755 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1756 self.commands.push(Command::EndOverlay);
1757 self.last_text_idx = None;
1758 }
1759
1760 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
1761 self.commands.push(Command::BeginOverlay { modal: false });
1762 self.overlay_depth += 1;
1763 f(self);
1764 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1765 self.commands.push(Command::EndOverlay);
1766 self.last_text_idx = None;
1767 }
1768
1769 pub fn container(&mut self) -> ContainerBuilder<'_> {
1790 let border = self.theme.border;
1791 ContainerBuilder {
1792 ctx: self,
1793 gap: 0,
1794 align: Align::Start,
1795 justify: Justify::Start,
1796 border: None,
1797 border_sides: BorderSides::all(),
1798 border_style: Style::new().fg(border),
1799 bg_color: None,
1800 padding: Padding::default(),
1801 margin: Margin::default(),
1802 constraints: Constraints::default(),
1803 title: None,
1804 grow: 0,
1805 scroll_offset: None,
1806 }
1807 }
1808
1809 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1828 let index = self.scroll_count;
1829 self.scroll_count += 1;
1830 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1831 state.set_bounds(ch, vh);
1832 let max = ch.saturating_sub(vh) as usize;
1833 state.offset = state.offset.min(max);
1834 }
1835
1836 let next_id = self.interaction_count;
1837 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1838 let inner_rects: Vec<Rect> = self
1839 .prev_scroll_rects
1840 .iter()
1841 .enumerate()
1842 .filter(|&(j, sr)| {
1843 j != index
1844 && sr.width > 0
1845 && sr.height > 0
1846 && sr.x >= rect.x
1847 && sr.right() <= rect.right()
1848 && sr.y >= rect.y
1849 && sr.bottom() <= rect.bottom()
1850 })
1851 .map(|(_, sr)| *sr)
1852 .collect();
1853 self.auto_scroll_nested(&rect, state, &inner_rects);
1854 }
1855
1856 self.container().scroll_offset(state.offset as u32)
1857 }
1858
1859 pub fn scrollbar(&mut self, state: &ScrollState) {
1879 let vh = state.viewport_height();
1880 let ch = state.content_height();
1881 if vh == 0 || ch <= vh {
1882 return;
1883 }
1884
1885 let track_height = vh;
1886 let thumb_height = ((vh as f64 * vh as f64 / ch as f64).ceil() as u32).max(1);
1887 let max_offset = ch.saturating_sub(vh);
1888 let thumb_pos = if max_offset == 0 {
1889 0
1890 } else {
1891 ((state.offset as f64 / max_offset as f64) * (track_height - thumb_height) as f64)
1892 .round() as u32
1893 };
1894
1895 let theme = self.theme;
1896 let track_char = '│';
1897 let thumb_char = '█';
1898
1899 self.container().w(1).h(track_height).col(|ui| {
1900 for i in 0..track_height {
1901 if i >= thumb_pos && i < thumb_pos + thumb_height {
1902 ui.styled(thumb_char.to_string(), Style::new().fg(theme.primary));
1903 } else {
1904 ui.styled(
1905 track_char.to_string(),
1906 Style::new().fg(theme.text_dim).dim(),
1907 );
1908 }
1909 }
1910 });
1911 }
1912
1913 fn auto_scroll_nested(
1914 &mut self,
1915 rect: &Rect,
1916 state: &mut ScrollState,
1917 inner_scroll_rects: &[Rect],
1918 ) {
1919 let mut to_consume: Vec<usize> = Vec::new();
1920
1921 for (i, event) in self.events.iter().enumerate() {
1922 if self.consumed[i] {
1923 continue;
1924 }
1925 if let Event::Mouse(mouse) = event {
1926 let in_bounds = mouse.x >= rect.x
1927 && mouse.x < rect.right()
1928 && mouse.y >= rect.y
1929 && mouse.y < rect.bottom();
1930 if !in_bounds {
1931 continue;
1932 }
1933 let in_inner = inner_scroll_rects.iter().any(|sr| {
1934 mouse.x >= sr.x
1935 && mouse.x < sr.right()
1936 && mouse.y >= sr.y
1937 && mouse.y < sr.bottom()
1938 });
1939 if in_inner {
1940 continue;
1941 }
1942 match mouse.kind {
1943 MouseKind::ScrollUp => {
1944 state.scroll_up(1);
1945 to_consume.push(i);
1946 }
1947 MouseKind::ScrollDown => {
1948 state.scroll_down(1);
1949 to_consume.push(i);
1950 }
1951 MouseKind::Drag(MouseButton::Left) => {}
1952 _ => {}
1953 }
1954 }
1955 }
1956
1957 for i in to_consume {
1958 self.consumed[i] = true;
1959 }
1960 }
1961
1962 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1966 self.container()
1967 .border(border)
1968 .border_sides(BorderSides::all())
1969 }
1970
1971 fn push_container(
1972 &mut self,
1973 direction: Direction,
1974 gap: u32,
1975 f: impl FnOnce(&mut Context),
1976 ) -> Response {
1977 let interaction_id = self.interaction_count;
1978 self.interaction_count += 1;
1979 let border = self.theme.border;
1980
1981 self.commands.push(Command::BeginContainer {
1982 direction,
1983 gap,
1984 align: Align::Start,
1985 justify: Justify::Start,
1986 border: None,
1987 border_sides: BorderSides::all(),
1988 border_style: Style::new().fg(border),
1989 bg_color: None,
1990 padding: Padding::default(),
1991 margin: Margin::default(),
1992 constraints: Constraints::default(),
1993 title: None,
1994 grow: 0,
1995 });
1996 f(self);
1997 self.commands.push(Command::EndContainer);
1998 self.last_text_idx = None;
1999
2000 self.response_for(interaction_id)
2001 }
2002
2003 fn response_for(&self, interaction_id: usize) -> Response {
2004 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2005 return Response::default();
2006 }
2007 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
2008 let clicked = self
2009 .click_pos
2010 .map(|(mx, my)| {
2011 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
2012 })
2013 .unwrap_or(false);
2014 let hovered = self
2015 .mouse_pos
2016 .map(|(mx, my)| {
2017 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
2018 })
2019 .unwrap_or(false);
2020 Response { clicked, hovered }
2021 } else {
2022 Response::default()
2023 }
2024 }
2025
2026 pub fn grow(&mut self, value: u16) -> &mut Self {
2031 if let Some(idx) = self.last_text_idx {
2032 if let Command::Text { grow, .. } = &mut self.commands[idx] {
2033 *grow = value;
2034 }
2035 }
2036 self
2037 }
2038
2039 pub fn align(&mut self, align: Align) -> &mut Self {
2041 if let Some(idx) = self.last_text_idx {
2042 if let Command::Text {
2043 align: text_align, ..
2044 } = &mut self.commands[idx]
2045 {
2046 *text_align = align;
2047 }
2048 }
2049 self
2050 }
2051
2052 pub fn spacer(&mut self) -> &mut Self {
2056 self.commands.push(Command::Spacer { grow: 1 });
2057 self.last_text_idx = None;
2058 self
2059 }
2060
2061 pub fn form(
2065 &mut self,
2066 state: &mut FormState,
2067 f: impl FnOnce(&mut Context, &mut FormState),
2068 ) -> &mut Self {
2069 self.col(|ui| {
2070 f(ui, state);
2071 });
2072 self
2073 }
2074
2075 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
2079 self.col(|ui| {
2080 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
2081 ui.text_input(&mut field.input);
2082 if let Some(error) = field.error.as_deref() {
2083 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
2084 }
2085 });
2086 self
2087 }
2088
2089 pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
2093 self.button(label)
2094 }
2095
2096 pub fn text_input(&mut self, state: &mut TextInputState) -> &mut Self {
2112 slt_assert(
2113 !state.value.contains('\n'),
2114 "text_input got a newline — use textarea instead",
2115 );
2116 let focused = self.register_focusable();
2117 state.cursor = state.cursor.min(state.value.chars().count());
2118
2119 if focused {
2120 let mut consumed_indices = Vec::new();
2121 for (i, event) in self.events.iter().enumerate() {
2122 if let Event::Key(key) = event {
2123 if key.kind != KeyEventKind::Press {
2124 continue;
2125 }
2126 match key.code {
2127 KeyCode::Char(ch) => {
2128 if let Some(max) = state.max_length {
2129 if state.value.chars().count() >= max {
2130 continue;
2131 }
2132 }
2133 let index = byte_index_for_char(&state.value, state.cursor);
2134 state.value.insert(index, ch);
2135 state.cursor += 1;
2136 consumed_indices.push(i);
2137 }
2138 KeyCode::Backspace => {
2139 if state.cursor > 0 {
2140 let start = byte_index_for_char(&state.value, state.cursor - 1);
2141 let end = byte_index_for_char(&state.value, state.cursor);
2142 state.value.replace_range(start..end, "");
2143 state.cursor -= 1;
2144 }
2145 consumed_indices.push(i);
2146 }
2147 KeyCode::Left => {
2148 state.cursor = state.cursor.saturating_sub(1);
2149 consumed_indices.push(i);
2150 }
2151 KeyCode::Right => {
2152 state.cursor = (state.cursor + 1).min(state.value.chars().count());
2153 consumed_indices.push(i);
2154 }
2155 KeyCode::Home => {
2156 state.cursor = 0;
2157 consumed_indices.push(i);
2158 }
2159 KeyCode::Delete => {
2160 let len = state.value.chars().count();
2161 if state.cursor < len {
2162 let start = byte_index_for_char(&state.value, state.cursor);
2163 let end = byte_index_for_char(&state.value, state.cursor + 1);
2164 state.value.replace_range(start..end, "");
2165 }
2166 consumed_indices.push(i);
2167 }
2168 KeyCode::End => {
2169 state.cursor = state.value.chars().count();
2170 consumed_indices.push(i);
2171 }
2172 _ => {}
2173 }
2174 }
2175 if let Event::Paste(ref text) = event {
2176 for ch in text.chars() {
2177 if let Some(max) = state.max_length {
2178 if state.value.chars().count() >= max {
2179 break;
2180 }
2181 }
2182 let index = byte_index_for_char(&state.value, state.cursor);
2183 state.value.insert(index, ch);
2184 state.cursor += 1;
2185 }
2186 consumed_indices.push(i);
2187 }
2188 }
2189
2190 for index in consumed_indices {
2191 self.consumed[index] = true;
2192 }
2193 }
2194
2195 let show_cursor = focused && (self.tick / 30) % 2 == 0;
2196
2197 let input_text = if state.value.is_empty() {
2198 if state.placeholder.len() > 100 {
2199 slt_warn(
2200 "text_input placeholder is very long (>100 chars) — consider shortening it",
2201 );
2202 }
2203 state.placeholder.clone()
2204 } else {
2205 let mut rendered = String::new();
2206 for (idx, ch) in state.value.chars().enumerate() {
2207 if show_cursor && idx == state.cursor {
2208 rendered.push('▎');
2209 }
2210 rendered.push(if state.masked { '•' } else { ch });
2211 }
2212 if show_cursor && state.cursor >= state.value.chars().count() {
2213 rendered.push('▎');
2214 }
2215 rendered
2216 };
2217 let input_style = if state.value.is_empty() {
2218 Style::new().dim().fg(self.theme.text_dim)
2219 } else {
2220 Style::new().fg(self.theme.text)
2221 };
2222
2223 let border_color = if focused {
2224 self.theme.primary
2225 } else if state.validation_error.is_some() {
2226 self.theme.error
2227 } else {
2228 self.theme.border
2229 };
2230
2231 self.bordered(Border::Rounded)
2232 .border_style(Style::new().fg(border_color))
2233 .px(1)
2234 .col(|ui| {
2235 ui.styled(input_text, input_style);
2236 });
2237
2238 if let Some(error) = state.validation_error.clone() {
2239 self.styled(
2240 format!("⚠ {error}"),
2241 Style::new().dim().fg(self.theme.error),
2242 );
2243 }
2244 self
2245 }
2246
2247 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
2253 self.styled(
2254 state.frame(self.tick).to_string(),
2255 Style::new().fg(self.theme.primary),
2256 )
2257 }
2258
2259 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
2264 state.cleanup(self.tick);
2265 if state.messages.is_empty() {
2266 return self;
2267 }
2268
2269 self.interaction_count += 1;
2270 self.commands.push(Command::BeginContainer {
2271 direction: Direction::Column,
2272 gap: 0,
2273 align: Align::Start,
2274 justify: Justify::Start,
2275 border: None,
2276 border_sides: BorderSides::all(),
2277 border_style: Style::new().fg(self.theme.border),
2278 bg_color: None,
2279 padding: Padding::default(),
2280 margin: Margin::default(),
2281 constraints: Constraints::default(),
2282 title: None,
2283 grow: 0,
2284 });
2285 for message in state.messages.iter().rev() {
2286 let color = match message.level {
2287 ToastLevel::Info => self.theme.primary,
2288 ToastLevel::Success => self.theme.success,
2289 ToastLevel::Warning => self.theme.warning,
2290 ToastLevel::Error => self.theme.error,
2291 };
2292 self.styled(format!(" ● {}", message.text), Style::new().fg(color));
2293 }
2294 self.commands.push(Command::EndContainer);
2295 self.last_text_idx = None;
2296
2297 self
2298 }
2299
2300 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> &mut Self {
2308 if state.lines.is_empty() {
2309 state.lines.push(String::new());
2310 }
2311 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
2312 state.cursor_col = state
2313 .cursor_col
2314 .min(state.lines[state.cursor_row].chars().count());
2315
2316 let focused = self.register_focusable();
2317 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
2318 let wrapping = state.wrap_width.is_some();
2319
2320 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2321
2322 if focused {
2323 let mut consumed_indices = Vec::new();
2324 for (i, event) in self.events.iter().enumerate() {
2325 if let Event::Key(key) = event {
2326 if key.kind != KeyEventKind::Press {
2327 continue;
2328 }
2329 match key.code {
2330 KeyCode::Char(ch) => {
2331 if let Some(max) = state.max_length {
2332 let total: usize =
2333 state.lines.iter().map(|line| line.chars().count()).sum();
2334 if total >= max {
2335 continue;
2336 }
2337 }
2338 let index = byte_index_for_char(
2339 &state.lines[state.cursor_row],
2340 state.cursor_col,
2341 );
2342 state.lines[state.cursor_row].insert(index, ch);
2343 state.cursor_col += 1;
2344 consumed_indices.push(i);
2345 }
2346 KeyCode::Enter => {
2347 let split_index = byte_index_for_char(
2348 &state.lines[state.cursor_row],
2349 state.cursor_col,
2350 );
2351 let remainder = state.lines[state.cursor_row].split_off(split_index);
2352 state.cursor_row += 1;
2353 state.lines.insert(state.cursor_row, remainder);
2354 state.cursor_col = 0;
2355 consumed_indices.push(i);
2356 }
2357 KeyCode::Backspace => {
2358 if state.cursor_col > 0 {
2359 let start = byte_index_for_char(
2360 &state.lines[state.cursor_row],
2361 state.cursor_col - 1,
2362 );
2363 let end = byte_index_for_char(
2364 &state.lines[state.cursor_row],
2365 state.cursor_col,
2366 );
2367 state.lines[state.cursor_row].replace_range(start..end, "");
2368 state.cursor_col -= 1;
2369 } else if state.cursor_row > 0 {
2370 let current = state.lines.remove(state.cursor_row);
2371 state.cursor_row -= 1;
2372 state.cursor_col = state.lines[state.cursor_row].chars().count();
2373 state.lines[state.cursor_row].push_str(¤t);
2374 }
2375 consumed_indices.push(i);
2376 }
2377 KeyCode::Left => {
2378 if state.cursor_col > 0 {
2379 state.cursor_col -= 1;
2380 } else if state.cursor_row > 0 {
2381 state.cursor_row -= 1;
2382 state.cursor_col = state.lines[state.cursor_row].chars().count();
2383 }
2384 consumed_indices.push(i);
2385 }
2386 KeyCode::Right => {
2387 let line_len = state.lines[state.cursor_row].chars().count();
2388 if state.cursor_col < line_len {
2389 state.cursor_col += 1;
2390 } else if state.cursor_row + 1 < state.lines.len() {
2391 state.cursor_row += 1;
2392 state.cursor_col = 0;
2393 }
2394 consumed_indices.push(i);
2395 }
2396 KeyCode::Up => {
2397 if wrapping {
2398 let (vrow, vcol) = textarea_logical_to_visual(
2399 &pre_vlines,
2400 state.cursor_row,
2401 state.cursor_col,
2402 );
2403 if vrow > 0 {
2404 let (lr, lc) =
2405 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
2406 state.cursor_row = lr;
2407 state.cursor_col = lc;
2408 }
2409 } else if state.cursor_row > 0 {
2410 state.cursor_row -= 1;
2411 state.cursor_col = state
2412 .cursor_col
2413 .min(state.lines[state.cursor_row].chars().count());
2414 }
2415 consumed_indices.push(i);
2416 }
2417 KeyCode::Down => {
2418 if wrapping {
2419 let (vrow, vcol) = textarea_logical_to_visual(
2420 &pre_vlines,
2421 state.cursor_row,
2422 state.cursor_col,
2423 );
2424 if vrow + 1 < pre_vlines.len() {
2425 let (lr, lc) =
2426 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
2427 state.cursor_row = lr;
2428 state.cursor_col = lc;
2429 }
2430 } else if state.cursor_row + 1 < state.lines.len() {
2431 state.cursor_row += 1;
2432 state.cursor_col = state
2433 .cursor_col
2434 .min(state.lines[state.cursor_row].chars().count());
2435 }
2436 consumed_indices.push(i);
2437 }
2438 KeyCode::Home => {
2439 state.cursor_col = 0;
2440 consumed_indices.push(i);
2441 }
2442 KeyCode::Delete => {
2443 let line_len = state.lines[state.cursor_row].chars().count();
2444 if state.cursor_col < line_len {
2445 let start = byte_index_for_char(
2446 &state.lines[state.cursor_row],
2447 state.cursor_col,
2448 );
2449 let end = byte_index_for_char(
2450 &state.lines[state.cursor_row],
2451 state.cursor_col + 1,
2452 );
2453 state.lines[state.cursor_row].replace_range(start..end, "");
2454 } else if state.cursor_row + 1 < state.lines.len() {
2455 let next = state.lines.remove(state.cursor_row + 1);
2456 state.lines[state.cursor_row].push_str(&next);
2457 }
2458 consumed_indices.push(i);
2459 }
2460 KeyCode::End => {
2461 state.cursor_col = state.lines[state.cursor_row].chars().count();
2462 consumed_indices.push(i);
2463 }
2464 _ => {}
2465 }
2466 }
2467 if let Event::Paste(ref text) = event {
2468 for ch in text.chars() {
2469 if ch == '\n' || ch == '\r' {
2470 let split_index = byte_index_for_char(
2471 &state.lines[state.cursor_row],
2472 state.cursor_col,
2473 );
2474 let remainder = state.lines[state.cursor_row].split_off(split_index);
2475 state.cursor_row += 1;
2476 state.lines.insert(state.cursor_row, remainder);
2477 state.cursor_col = 0;
2478 } else {
2479 if let Some(max) = state.max_length {
2480 let total: usize =
2481 state.lines.iter().map(|l| l.chars().count()).sum();
2482 if total >= max {
2483 break;
2484 }
2485 }
2486 let index = byte_index_for_char(
2487 &state.lines[state.cursor_row],
2488 state.cursor_col,
2489 );
2490 state.lines[state.cursor_row].insert(index, ch);
2491 state.cursor_col += 1;
2492 }
2493 }
2494 consumed_indices.push(i);
2495 }
2496 }
2497
2498 for index in consumed_indices {
2499 self.consumed[index] = true;
2500 }
2501 }
2502
2503 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2504 let (cursor_vrow, cursor_vcol) =
2505 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
2506
2507 if cursor_vrow < state.scroll_offset {
2508 state.scroll_offset = cursor_vrow;
2509 }
2510 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
2511 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
2512 }
2513
2514 self.interaction_count += 1;
2515 self.commands.push(Command::BeginContainer {
2516 direction: Direction::Column,
2517 gap: 0,
2518 align: Align::Start,
2519 justify: Justify::Start,
2520 border: None,
2521 border_sides: BorderSides::all(),
2522 border_style: Style::new().fg(self.theme.border),
2523 bg_color: None,
2524 padding: Padding::default(),
2525 margin: Margin::default(),
2526 constraints: Constraints::default(),
2527 title: None,
2528 grow: 0,
2529 });
2530
2531 let show_cursor = focused && (self.tick / 30) % 2 == 0;
2532 for vi in 0..visible_rows as usize {
2533 let actual_vi = state.scroll_offset + vi;
2534 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
2535 let line = &state.lines[vl.logical_row];
2536 let text: String = line
2537 .chars()
2538 .skip(vl.char_start)
2539 .take(vl.char_count)
2540 .collect();
2541 (text, actual_vi == cursor_vrow)
2542 } else {
2543 (String::new(), false)
2544 };
2545
2546 let mut rendered = seg_text.clone();
2547 let mut style = if seg_text.is_empty() {
2548 Style::new().fg(self.theme.text_dim)
2549 } else {
2550 Style::new().fg(self.theme.text)
2551 };
2552
2553 if is_cursor_line {
2554 rendered.clear();
2555 for (idx, ch) in seg_text.chars().enumerate() {
2556 if show_cursor && idx == cursor_vcol {
2557 rendered.push('▎');
2558 }
2559 rendered.push(ch);
2560 }
2561 if show_cursor && cursor_vcol >= seg_text.chars().count() {
2562 rendered.push('▎');
2563 }
2564 style = Style::new().fg(self.theme.text);
2565 }
2566
2567 self.styled(rendered, style);
2568 }
2569 self.commands.push(Command::EndContainer);
2570 self.last_text_idx = None;
2571
2572 self
2573 }
2574
2575 pub fn progress(&mut self, ratio: f64) -> &mut Self {
2580 self.progress_bar(ratio, 20)
2581 }
2582
2583 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
2588 let clamped = ratio.clamp(0.0, 1.0);
2589 let filled = (clamped * width as f64).round() as u32;
2590 let empty = width.saturating_sub(filled);
2591 let mut bar = String::new();
2592 for _ in 0..filled {
2593 bar.push('█');
2594 }
2595 for _ in 0..empty {
2596 bar.push('░');
2597 }
2598 self.text(bar)
2599 }
2600
2601 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
2622 if data.is_empty() {
2623 return self;
2624 }
2625
2626 let max_label_width = data
2627 .iter()
2628 .map(|(label, _)| UnicodeWidthStr::width(*label))
2629 .max()
2630 .unwrap_or(0);
2631 let max_value = data
2632 .iter()
2633 .map(|(_, value)| *value)
2634 .fold(f64::NEG_INFINITY, f64::max);
2635 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2636
2637 self.interaction_count += 1;
2638 self.commands.push(Command::BeginContainer {
2639 direction: Direction::Column,
2640 gap: 0,
2641 align: Align::Start,
2642 justify: Justify::Start,
2643 border: None,
2644 border_sides: BorderSides::all(),
2645 border_style: Style::new().fg(self.theme.border),
2646 bg_color: None,
2647 padding: Padding::default(),
2648 margin: Margin::default(),
2649 constraints: Constraints::default(),
2650 title: None,
2651 grow: 0,
2652 });
2653
2654 for (label, value) in data {
2655 let label_width = UnicodeWidthStr::width(*label);
2656 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2657 let normalized = (*value / denom).clamp(0.0, 1.0);
2658 let bar_len = (normalized * max_width as f64).round() as usize;
2659 let bar = "█".repeat(bar_len);
2660
2661 self.interaction_count += 1;
2662 self.commands.push(Command::BeginContainer {
2663 direction: Direction::Row,
2664 gap: 1,
2665 align: Align::Start,
2666 justify: Justify::Start,
2667 border: None,
2668 border_sides: BorderSides::all(),
2669 border_style: Style::new().fg(self.theme.border),
2670 bg_color: None,
2671 padding: Padding::default(),
2672 margin: Margin::default(),
2673 constraints: Constraints::default(),
2674 title: None,
2675 grow: 0,
2676 });
2677 self.styled(
2678 format!("{label}{label_padding}"),
2679 Style::new().fg(self.theme.text),
2680 );
2681 self.styled(bar, Style::new().fg(self.theme.primary));
2682 self.styled(
2683 format_compact_number(*value),
2684 Style::new().fg(self.theme.text_dim),
2685 );
2686 self.commands.push(Command::EndContainer);
2687 self.last_text_idx = None;
2688 }
2689
2690 self.commands.push(Command::EndContainer);
2691 self.last_text_idx = None;
2692
2693 self
2694 }
2695
2696 pub fn bar_chart_styled(
2712 &mut self,
2713 bars: &[Bar],
2714 max_width: u32,
2715 direction: BarDirection,
2716 ) -> &mut Self {
2717 if bars.is_empty() {
2718 return self;
2719 }
2720
2721 let max_value = bars
2722 .iter()
2723 .map(|bar| bar.value)
2724 .fold(f64::NEG_INFINITY, f64::max);
2725 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2726
2727 match direction {
2728 BarDirection::Horizontal => {
2729 let max_label_width = bars
2730 .iter()
2731 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2732 .max()
2733 .unwrap_or(0);
2734
2735 self.interaction_count += 1;
2736 self.commands.push(Command::BeginContainer {
2737 direction: Direction::Column,
2738 gap: 0,
2739 align: Align::Start,
2740 justify: Justify::Start,
2741 border: None,
2742 border_sides: BorderSides::all(),
2743 border_style: Style::new().fg(self.theme.border),
2744 bg_color: None,
2745 padding: Padding::default(),
2746 margin: Margin::default(),
2747 constraints: Constraints::default(),
2748 title: None,
2749 grow: 0,
2750 });
2751
2752 for bar in bars {
2753 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2754 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2755 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2756 let bar_len = (normalized * max_width as f64).round() as usize;
2757 let bar_text = "█".repeat(bar_len);
2758 let color = bar.color.unwrap_or(self.theme.primary);
2759
2760 self.interaction_count += 1;
2761 self.commands.push(Command::BeginContainer {
2762 direction: Direction::Row,
2763 gap: 1,
2764 align: Align::Start,
2765 justify: Justify::Start,
2766 border: None,
2767 border_sides: BorderSides::all(),
2768 border_style: Style::new().fg(self.theme.border),
2769 bg_color: None,
2770 padding: Padding::default(),
2771 margin: Margin::default(),
2772 constraints: Constraints::default(),
2773 title: None,
2774 grow: 0,
2775 });
2776 self.styled(
2777 format!("{}{label_padding}", bar.label),
2778 Style::new().fg(self.theme.text),
2779 );
2780 self.styled(bar_text, Style::new().fg(color));
2781 self.styled(
2782 format_compact_number(bar.value),
2783 Style::new().fg(self.theme.text_dim),
2784 );
2785 self.commands.push(Command::EndContainer);
2786 self.last_text_idx = None;
2787 }
2788
2789 self.commands.push(Command::EndContainer);
2790 self.last_text_idx = None;
2791 }
2792 BarDirection::Vertical => {
2793 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
2794
2795 let chart_height = max_width.max(1) as usize;
2796 let value_labels: Vec<String> = bars
2797 .iter()
2798 .map(|bar| format_compact_number(bar.value))
2799 .collect();
2800 let col_width = bars
2801 .iter()
2802 .zip(value_labels.iter())
2803 .map(|(bar, value)| {
2804 UnicodeWidthStr::width(bar.label.as_str())
2805 .max(UnicodeWidthStr::width(value.as_str()))
2806 .max(1)
2807 })
2808 .max()
2809 .unwrap_or(1);
2810
2811 let bar_units: Vec<usize> = bars
2812 .iter()
2813 .map(|bar| {
2814 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2815 (normalized * chart_height as f64 * 8.0).round() as usize
2816 })
2817 .collect();
2818
2819 self.interaction_count += 1;
2820 self.commands.push(Command::BeginContainer {
2821 direction: Direction::Column,
2822 gap: 0,
2823 align: Align::Start,
2824 justify: Justify::Start,
2825 border: None,
2826 border_sides: BorderSides::all(),
2827 border_style: Style::new().fg(self.theme.border),
2828 bg_color: None,
2829 padding: Padding::default(),
2830 margin: Margin::default(),
2831 constraints: Constraints::default(),
2832 title: None,
2833 grow: 0,
2834 });
2835
2836 self.interaction_count += 1;
2837 self.commands.push(Command::BeginContainer {
2838 direction: Direction::Row,
2839 gap: 1,
2840 align: Align::Start,
2841 justify: Justify::Start,
2842 border: None,
2843 border_sides: BorderSides::all(),
2844 border_style: Style::new().fg(self.theme.border),
2845 bg_color: None,
2846 padding: Padding::default(),
2847 margin: Margin::default(),
2848 constraints: Constraints::default(),
2849 title: None,
2850 grow: 0,
2851 });
2852 for value in &value_labels {
2853 self.styled(
2854 center_text(value, col_width),
2855 Style::new().fg(self.theme.text_dim),
2856 );
2857 }
2858 self.commands.push(Command::EndContainer);
2859 self.last_text_idx = None;
2860
2861 for row in (0..chart_height).rev() {
2862 self.interaction_count += 1;
2863 self.commands.push(Command::BeginContainer {
2864 direction: Direction::Row,
2865 gap: 1,
2866 align: Align::Start,
2867 justify: Justify::Start,
2868 border: None,
2869 border_sides: BorderSides::all(),
2870 border_style: Style::new().fg(self.theme.border),
2871 bg_color: None,
2872 padding: Padding::default(),
2873 margin: Margin::default(),
2874 constraints: Constraints::default(),
2875 title: None,
2876 grow: 0,
2877 });
2878
2879 let row_base = row * 8;
2880 for (bar, units) in bars.iter().zip(bar_units.iter()) {
2881 let fill = if *units <= row_base {
2882 ' '
2883 } else {
2884 let delta = *units - row_base;
2885 if delta >= 8 {
2886 '█'
2887 } else {
2888 FRACTION_BLOCKS[delta]
2889 }
2890 };
2891
2892 self.styled(
2893 center_text(&fill.to_string(), col_width),
2894 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2895 );
2896 }
2897
2898 self.commands.push(Command::EndContainer);
2899 self.last_text_idx = None;
2900 }
2901
2902 self.interaction_count += 1;
2903 self.commands.push(Command::BeginContainer {
2904 direction: Direction::Row,
2905 gap: 1,
2906 align: Align::Start,
2907 justify: Justify::Start,
2908 border: None,
2909 border_sides: BorderSides::all(),
2910 border_style: Style::new().fg(self.theme.border),
2911 bg_color: None,
2912 padding: Padding::default(),
2913 margin: Margin::default(),
2914 constraints: Constraints::default(),
2915 title: None,
2916 grow: 0,
2917 });
2918 for bar in bars {
2919 self.styled(
2920 center_text(&bar.label, col_width),
2921 Style::new().fg(self.theme.text),
2922 );
2923 }
2924 self.commands.push(Command::EndContainer);
2925 self.last_text_idx = None;
2926
2927 self.commands.push(Command::EndContainer);
2928 self.last_text_idx = None;
2929 }
2930 }
2931
2932 self
2933 }
2934
2935 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
2952 if groups.is_empty() {
2953 return self;
2954 }
2955
2956 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
2957 if all_bars.is_empty() {
2958 return self;
2959 }
2960
2961 let max_label_width = all_bars
2962 .iter()
2963 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2964 .max()
2965 .unwrap_or(0);
2966 let max_value = all_bars
2967 .iter()
2968 .map(|bar| bar.value)
2969 .fold(f64::NEG_INFINITY, f64::max);
2970 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2971
2972 self.interaction_count += 1;
2973 self.commands.push(Command::BeginContainer {
2974 direction: Direction::Column,
2975 gap: 1,
2976 align: Align::Start,
2977 justify: Justify::Start,
2978 border: None,
2979 border_sides: BorderSides::all(),
2980 border_style: Style::new().fg(self.theme.border),
2981 bg_color: None,
2982 padding: Padding::default(),
2983 margin: Margin::default(),
2984 constraints: Constraints::default(),
2985 title: None,
2986 grow: 0,
2987 });
2988
2989 for group in groups {
2990 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
2991
2992 for bar in &group.bars {
2993 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2994 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2995 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2996 let bar_len = (normalized * max_width as f64).round() as usize;
2997 let bar_text = "█".repeat(bar_len);
2998
2999 self.interaction_count += 1;
3000 self.commands.push(Command::BeginContainer {
3001 direction: Direction::Row,
3002 gap: 1,
3003 align: Align::Start,
3004 justify: Justify::Start,
3005 border: None,
3006 border_sides: BorderSides::all(),
3007 border_style: Style::new().fg(self.theme.border),
3008 bg_color: None,
3009 padding: Padding::default(),
3010 margin: Margin::default(),
3011 constraints: Constraints::default(),
3012 title: None,
3013 grow: 0,
3014 });
3015 self.styled(
3016 format!(" {}{label_padding}", bar.label),
3017 Style::new().fg(self.theme.text),
3018 );
3019 self.styled(
3020 bar_text,
3021 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
3022 );
3023 self.styled(
3024 format_compact_number(bar.value),
3025 Style::new().fg(self.theme.text_dim),
3026 );
3027 self.commands.push(Command::EndContainer);
3028 self.last_text_idx = None;
3029 }
3030 }
3031
3032 self.commands.push(Command::EndContainer);
3033 self.last_text_idx = None;
3034
3035 self
3036 }
3037
3038 pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
3054 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
3055
3056 let w = width as usize;
3057 let window = if data.len() > w {
3058 &data[data.len() - w..]
3059 } else {
3060 data
3061 };
3062
3063 if window.is_empty() {
3064 return self;
3065 }
3066
3067 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
3068 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3069 let range = max - min;
3070
3071 let line: String = window
3072 .iter()
3073 .map(|&value| {
3074 let normalized = if range == 0.0 {
3075 0.5
3076 } else {
3077 (value - min) / range
3078 };
3079 let idx = (normalized * 7.0).round() as usize;
3080 BLOCKS[idx.min(7)]
3081 })
3082 .collect();
3083
3084 self.styled(line, Style::new().fg(self.theme.primary))
3085 }
3086
3087 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
3107 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
3108
3109 let w = width as usize;
3110 let window = if data.len() > w {
3111 &data[data.len() - w..]
3112 } else {
3113 data
3114 };
3115
3116 if window.is_empty() {
3117 return self;
3118 }
3119
3120 let mut finite_values = window
3121 .iter()
3122 .map(|(value, _)| *value)
3123 .filter(|value| !value.is_nan());
3124 let Some(first) = finite_values.next() else {
3125 return self.styled(
3126 " ".repeat(window.len()),
3127 Style::new().fg(self.theme.text_dim),
3128 );
3129 };
3130
3131 let mut min = first;
3132 let mut max = first;
3133 for value in finite_values {
3134 min = f64::min(min, value);
3135 max = f64::max(max, value);
3136 }
3137 let range = max - min;
3138
3139 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
3140 for (value, color) in window {
3141 if value.is_nan() {
3142 cells.push((' ', self.theme.text_dim));
3143 continue;
3144 }
3145
3146 let normalized = if range == 0.0 {
3147 0.5
3148 } else {
3149 ((*value - min) / range).clamp(0.0, 1.0)
3150 };
3151 let idx = (normalized * 7.0).round() as usize;
3152 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
3153 }
3154
3155 self.interaction_count += 1;
3156 self.commands.push(Command::BeginContainer {
3157 direction: Direction::Row,
3158 gap: 0,
3159 align: Align::Start,
3160 justify: Justify::Start,
3161 border: None,
3162 border_sides: BorderSides::all(),
3163 border_style: Style::new().fg(self.theme.border),
3164 bg_color: None,
3165 padding: Padding::default(),
3166 margin: Margin::default(),
3167 constraints: Constraints::default(),
3168 title: None,
3169 grow: 0,
3170 });
3171
3172 let mut seg = String::new();
3173 let mut seg_color = cells[0].1;
3174 for (ch, color) in cells {
3175 if color != seg_color {
3176 self.styled(seg, Style::new().fg(seg_color));
3177 seg = String::new();
3178 seg_color = color;
3179 }
3180 seg.push(ch);
3181 }
3182 if !seg.is_empty() {
3183 self.styled(seg, Style::new().fg(seg_color));
3184 }
3185
3186 self.commands.push(Command::EndContainer);
3187 self.last_text_idx = None;
3188
3189 self
3190 }
3191
3192 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
3206 if data.is_empty() || width == 0 || height == 0 {
3207 return self;
3208 }
3209
3210 let cols = width as usize;
3211 let rows = height as usize;
3212 let px_w = cols * 2;
3213 let px_h = rows * 4;
3214
3215 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
3216 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3217 let range = if (max - min).abs() < f64::EPSILON {
3218 1.0
3219 } else {
3220 max - min
3221 };
3222
3223 let points: Vec<usize> = (0..px_w)
3224 .map(|px| {
3225 let data_idx = if px_w <= 1 {
3226 0.0
3227 } else {
3228 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
3229 };
3230 let idx = data_idx.floor() as usize;
3231 let frac = data_idx - idx as f64;
3232 let value = if idx + 1 < data.len() {
3233 data[idx] * (1.0 - frac) + data[idx + 1] * frac
3234 } else {
3235 data[idx.min(data.len() - 1)]
3236 };
3237
3238 let normalized = (value - min) / range;
3239 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
3240 py.min(px_h - 1)
3241 })
3242 .collect();
3243
3244 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
3245 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
3246
3247 let mut grid = vec![vec![0u32; cols]; rows];
3248
3249 for i in 0..points.len() {
3250 let px = i;
3251 let py = points[i];
3252 let char_col = px / 2;
3253 let char_row = py / 4;
3254 let sub_col = px % 2;
3255 let sub_row = py % 4;
3256
3257 if char_col < cols && char_row < rows {
3258 grid[char_row][char_col] |= if sub_col == 0 {
3259 LEFT_BITS[sub_row]
3260 } else {
3261 RIGHT_BITS[sub_row]
3262 };
3263 }
3264
3265 if i + 1 < points.len() {
3266 let py_next = points[i + 1];
3267 let (y_start, y_end) = if py <= py_next {
3268 (py, py_next)
3269 } else {
3270 (py_next, py)
3271 };
3272 for y in y_start..=y_end {
3273 let cell_row = y / 4;
3274 let sub_y = y % 4;
3275 if char_col < cols && cell_row < rows {
3276 grid[cell_row][char_col] |= if sub_col == 0 {
3277 LEFT_BITS[sub_y]
3278 } else {
3279 RIGHT_BITS[sub_y]
3280 };
3281 }
3282 }
3283 }
3284 }
3285
3286 let style = Style::new().fg(self.theme.primary);
3287 for row in grid {
3288 let line: String = row
3289 .iter()
3290 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
3291 .collect();
3292 self.styled(line, style);
3293 }
3294
3295 self
3296 }
3297
3298 pub fn canvas(
3315 &mut self,
3316 width: u32,
3317 height: u32,
3318 draw: impl FnOnce(&mut CanvasContext),
3319 ) -> &mut Self {
3320 if width == 0 || height == 0 {
3321 return self;
3322 }
3323
3324 let mut canvas = CanvasContext::new(width as usize, height as usize);
3325 draw(&mut canvas);
3326
3327 for segments in canvas.render() {
3328 self.interaction_count += 1;
3329 self.commands.push(Command::BeginContainer {
3330 direction: Direction::Row,
3331 gap: 0,
3332 align: Align::Start,
3333 justify: Justify::Start,
3334 border: None,
3335 border_sides: BorderSides::all(),
3336 border_style: Style::new(),
3337 bg_color: None,
3338 padding: Padding::default(),
3339 margin: Margin::default(),
3340 constraints: Constraints::default(),
3341 title: None,
3342 grow: 0,
3343 });
3344 for (text, color) in segments {
3345 let c = if color == Color::Reset {
3346 self.theme.primary
3347 } else {
3348 color
3349 };
3350 self.styled(text, Style::new().fg(c));
3351 }
3352 self.commands.push(Command::EndContainer);
3353 self.last_text_idx = None;
3354 }
3355
3356 self
3357 }
3358
3359 pub fn chart(
3361 &mut self,
3362 configure: impl FnOnce(&mut ChartBuilder),
3363 width: u32,
3364 height: u32,
3365 ) -> &mut Self {
3366 if width == 0 || height == 0 {
3367 return self;
3368 }
3369
3370 let axis_style = Style::new().fg(self.theme.text_dim);
3371 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
3372 configure(&mut builder);
3373
3374 let config = builder.build();
3375 let rows = render_chart(&config);
3376
3377 for row in rows {
3378 self.interaction_count += 1;
3379 self.commands.push(Command::BeginContainer {
3380 direction: Direction::Row,
3381 gap: 0,
3382 align: Align::Start,
3383 justify: Justify::Start,
3384 border: None,
3385 border_sides: BorderSides::all(),
3386 border_style: Style::new().fg(self.theme.border),
3387 bg_color: None,
3388 padding: Padding::default(),
3389 margin: Margin::default(),
3390 constraints: Constraints::default(),
3391 title: None,
3392 grow: 0,
3393 });
3394 for (text, style) in row.segments {
3395 self.styled(text, style);
3396 }
3397 self.commands.push(Command::EndContainer);
3398 self.last_text_idx = None;
3399 }
3400
3401 self
3402 }
3403
3404 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
3406 self.histogram_with(data, |_| {}, width, height)
3407 }
3408
3409 pub fn histogram_with(
3411 &mut self,
3412 data: &[f64],
3413 configure: impl FnOnce(&mut HistogramBuilder),
3414 width: u32,
3415 height: u32,
3416 ) -> &mut Self {
3417 if width == 0 || height == 0 {
3418 return self;
3419 }
3420
3421 let mut options = HistogramBuilder::default();
3422 configure(&mut options);
3423 let axis_style = Style::new().fg(self.theme.text_dim);
3424 let config = build_histogram_config(data, &options, width, height, axis_style);
3425 let rows = render_chart(&config);
3426
3427 for row in rows {
3428 self.interaction_count += 1;
3429 self.commands.push(Command::BeginContainer {
3430 direction: Direction::Row,
3431 gap: 0,
3432 align: Align::Start,
3433 justify: Justify::Start,
3434 border: None,
3435 border_sides: BorderSides::all(),
3436 border_style: Style::new().fg(self.theme.border),
3437 bg_color: None,
3438 padding: Padding::default(),
3439 margin: Margin::default(),
3440 constraints: Constraints::default(),
3441 title: None,
3442 grow: 0,
3443 });
3444 for (text, style) in row.segments {
3445 self.styled(text, style);
3446 }
3447 self.commands.push(Command::EndContainer);
3448 self.last_text_idx = None;
3449 }
3450
3451 self
3452 }
3453
3454 pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
3471 slt_assert(cols > 0, "grid() requires at least 1 column");
3472 let interaction_id = self.interaction_count;
3473 self.interaction_count += 1;
3474 let border = self.theme.border;
3475
3476 self.commands.push(Command::BeginContainer {
3477 direction: Direction::Column,
3478 gap: 0,
3479 align: Align::Start,
3480 justify: Justify::Start,
3481 border: None,
3482 border_sides: BorderSides::all(),
3483 border_style: Style::new().fg(border),
3484 bg_color: None,
3485 padding: Padding::default(),
3486 margin: Margin::default(),
3487 constraints: Constraints::default(),
3488 title: None,
3489 grow: 0,
3490 });
3491
3492 let children_start = self.commands.len();
3493 f(self);
3494 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
3495
3496 let mut elements: Vec<Vec<Command>> = Vec::new();
3497 let mut iter = child_commands.into_iter().peekable();
3498 while let Some(cmd) = iter.next() {
3499 match cmd {
3500 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3501 let mut depth = 1_u32;
3502 let mut element = vec![cmd];
3503 for next in iter.by_ref() {
3504 match next {
3505 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3506 depth += 1;
3507 }
3508 Command::EndContainer => {
3509 depth = depth.saturating_sub(1);
3510 }
3511 _ => {}
3512 }
3513 let at_end = matches!(next, Command::EndContainer) && depth == 0;
3514 element.push(next);
3515 if at_end {
3516 break;
3517 }
3518 }
3519 elements.push(element);
3520 }
3521 Command::EndContainer => {}
3522 _ => elements.push(vec![cmd]),
3523 }
3524 }
3525
3526 let cols = cols.max(1) as usize;
3527 for row in elements.chunks(cols) {
3528 self.interaction_count += 1;
3529 self.commands.push(Command::BeginContainer {
3530 direction: Direction::Row,
3531 gap: 0,
3532 align: Align::Start,
3533 justify: Justify::Start,
3534 border: None,
3535 border_sides: BorderSides::all(),
3536 border_style: Style::new().fg(border),
3537 bg_color: None,
3538 padding: Padding::default(),
3539 margin: Margin::default(),
3540 constraints: Constraints::default(),
3541 title: None,
3542 grow: 0,
3543 });
3544
3545 for element in row {
3546 self.interaction_count += 1;
3547 self.commands.push(Command::BeginContainer {
3548 direction: Direction::Column,
3549 gap: 0,
3550 align: Align::Start,
3551 justify: Justify::Start,
3552 border: None,
3553 border_sides: BorderSides::all(),
3554 border_style: Style::new().fg(border),
3555 bg_color: None,
3556 padding: Padding::default(),
3557 margin: Margin::default(),
3558 constraints: Constraints::default(),
3559 title: None,
3560 grow: 1,
3561 });
3562 self.commands.extend(element.iter().cloned());
3563 self.commands.push(Command::EndContainer);
3564 }
3565
3566 self.commands.push(Command::EndContainer);
3567 }
3568
3569 self.commands.push(Command::EndContainer);
3570 self.last_text_idx = None;
3571
3572 self.response_for(interaction_id)
3573 }
3574
3575 pub fn list(&mut self, state: &mut ListState) -> &mut Self {
3580 if state.items.is_empty() {
3581 state.selected = 0;
3582 return self;
3583 }
3584
3585 state.selected = state.selected.min(state.items.len().saturating_sub(1));
3586
3587 let focused = self.register_focusable();
3588 let interaction_id = self.interaction_count;
3589 self.interaction_count += 1;
3590
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 key.kind != KeyEventKind::Press {
3596 continue;
3597 }
3598 match key.code {
3599 KeyCode::Up | KeyCode::Char('k') => {
3600 state.selected = state.selected.saturating_sub(1);
3601 consumed_indices.push(i);
3602 }
3603 KeyCode::Down | KeyCode::Char('j') => {
3604 state.selected =
3605 (state.selected + 1).min(state.items.len().saturating_sub(1));
3606 consumed_indices.push(i);
3607 }
3608 _ => {}
3609 }
3610 }
3611 }
3612
3613 for index in consumed_indices {
3614 self.consumed[index] = true;
3615 }
3616 }
3617
3618 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3619 for (i, event) in self.events.iter().enumerate() {
3620 if self.consumed[i] {
3621 continue;
3622 }
3623 if let Event::Mouse(mouse) = event {
3624 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3625 continue;
3626 }
3627 let in_bounds = mouse.x >= rect.x
3628 && mouse.x < rect.right()
3629 && mouse.y >= rect.y
3630 && mouse.y < rect.bottom();
3631 if !in_bounds {
3632 continue;
3633 }
3634 let clicked_idx = (mouse.y - rect.y) as usize;
3635 if clicked_idx < state.items.len() {
3636 state.selected = clicked_idx;
3637 self.consumed[i] = true;
3638 }
3639 }
3640 }
3641 }
3642
3643 self.commands.push(Command::BeginContainer {
3644 direction: Direction::Column,
3645 gap: 0,
3646 align: Align::Start,
3647 justify: Justify::Start,
3648 border: None,
3649 border_sides: BorderSides::all(),
3650 border_style: Style::new().fg(self.theme.border),
3651 bg_color: None,
3652 padding: Padding::default(),
3653 margin: Margin::default(),
3654 constraints: Constraints::default(),
3655 title: None,
3656 grow: 0,
3657 });
3658
3659 for (idx, item) in state.items.iter().enumerate() {
3660 if idx == state.selected {
3661 if focused {
3662 self.styled(
3663 format!("▸ {item}"),
3664 Style::new().bold().fg(self.theme.primary),
3665 );
3666 } else {
3667 self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
3668 }
3669 } else {
3670 self.styled(format!(" {item}"), Style::new().fg(self.theme.text));
3671 }
3672 }
3673
3674 self.commands.push(Command::EndContainer);
3675 self.last_text_idx = None;
3676
3677 self
3678 }
3679
3680 pub fn table(&mut self, state: &mut TableState) -> &mut Self {
3685 if state.is_dirty() {
3686 state.recompute_widths();
3687 }
3688
3689 let focused = self.register_focusable();
3690 let interaction_id = self.interaction_count;
3691 self.interaction_count += 1;
3692
3693 if focused && !state.visible_indices().is_empty() {
3694 let mut consumed_indices = Vec::new();
3695 for (i, event) in self.events.iter().enumerate() {
3696 if let Event::Key(key) = event {
3697 if key.kind != KeyEventKind::Press {
3698 continue;
3699 }
3700 match key.code {
3701 KeyCode::Up | KeyCode::Char('k') => {
3702 let visible_len = if state.page_size > 0 {
3703 let start = state
3704 .page
3705 .saturating_mul(state.page_size)
3706 .min(state.visible_indices().len());
3707 let end =
3708 (start + state.page_size).min(state.visible_indices().len());
3709 end.saturating_sub(start)
3710 } else {
3711 state.visible_indices().len()
3712 };
3713 state.selected = state.selected.min(visible_len.saturating_sub(1));
3714 state.selected = state.selected.saturating_sub(1);
3715 consumed_indices.push(i);
3716 }
3717 KeyCode::Down | KeyCode::Char('j') => {
3718 let visible_len = if state.page_size > 0 {
3719 let start = state
3720 .page
3721 .saturating_mul(state.page_size)
3722 .min(state.visible_indices().len());
3723 let end =
3724 (start + state.page_size).min(state.visible_indices().len());
3725 end.saturating_sub(start)
3726 } else {
3727 state.visible_indices().len()
3728 };
3729 state.selected =
3730 (state.selected + 1).min(visible_len.saturating_sub(1));
3731 consumed_indices.push(i);
3732 }
3733 KeyCode::PageUp => {
3734 let old_page = state.page;
3735 state.prev_page();
3736 if state.page != old_page {
3737 state.selected = 0;
3738 }
3739 consumed_indices.push(i);
3740 }
3741 KeyCode::PageDown => {
3742 let old_page = state.page;
3743 state.next_page();
3744 if state.page != old_page {
3745 state.selected = 0;
3746 }
3747 consumed_indices.push(i);
3748 }
3749 _ => {}
3750 }
3751 }
3752 }
3753 for index in consumed_indices {
3754 self.consumed[index] = true;
3755 }
3756 }
3757
3758 if !state.visible_indices().is_empty() || !state.headers.is_empty() {
3759 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3760 for (i, event) in self.events.iter().enumerate() {
3761 if self.consumed[i] {
3762 continue;
3763 }
3764 if let Event::Mouse(mouse) = event {
3765 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3766 continue;
3767 }
3768 let in_bounds = mouse.x >= rect.x
3769 && mouse.x < rect.right()
3770 && mouse.y >= rect.y
3771 && mouse.y < rect.bottom();
3772 if !in_bounds {
3773 continue;
3774 }
3775
3776 if mouse.y == rect.y {
3777 let rel_x = mouse.x.saturating_sub(rect.x);
3778 let mut x_offset = 0u32;
3779 for (col_idx, width) in state.column_widths().iter().enumerate() {
3780 if rel_x >= x_offset && rel_x < x_offset + *width {
3781 state.toggle_sort(col_idx);
3782 state.selected = 0;
3783 self.consumed[i] = true;
3784 break;
3785 }
3786 x_offset += *width;
3787 if col_idx + 1 < state.column_widths().len() {
3788 x_offset += 3;
3789 }
3790 }
3791 continue;
3792 }
3793
3794 if mouse.y < rect.y + 2 {
3795 continue;
3796 }
3797
3798 let visible_len = if state.page_size > 0 {
3799 let start = state
3800 .page
3801 .saturating_mul(state.page_size)
3802 .min(state.visible_indices().len());
3803 let end = (start + state.page_size).min(state.visible_indices().len());
3804 end.saturating_sub(start)
3805 } else {
3806 state.visible_indices().len()
3807 };
3808 let clicked_idx = (mouse.y - rect.y - 2) as usize;
3809 if clicked_idx < visible_len {
3810 state.selected = clicked_idx;
3811 self.consumed[i] = true;
3812 }
3813 }
3814 }
3815 }
3816 }
3817
3818 if state.is_dirty() {
3819 state.recompute_widths();
3820 }
3821
3822 let total_visible = state.visible_indices().len();
3823 let page_start = if state.page_size > 0 {
3824 state
3825 .page
3826 .saturating_mul(state.page_size)
3827 .min(total_visible)
3828 } else {
3829 0
3830 };
3831 let page_end = if state.page_size > 0 {
3832 (page_start + state.page_size).min(total_visible)
3833 } else {
3834 total_visible
3835 };
3836 let visible_len = page_end.saturating_sub(page_start);
3837 state.selected = state.selected.min(visible_len.saturating_sub(1));
3838
3839 self.commands.push(Command::BeginContainer {
3840 direction: Direction::Column,
3841 gap: 0,
3842 align: Align::Start,
3843 justify: Justify::Start,
3844 border: None,
3845 border_sides: BorderSides::all(),
3846 border_style: Style::new().fg(self.theme.border),
3847 bg_color: None,
3848 padding: Padding::default(),
3849 margin: Margin::default(),
3850 constraints: Constraints::default(),
3851 title: None,
3852 grow: 0,
3853 });
3854
3855 let header_cells = state
3856 .headers
3857 .iter()
3858 .enumerate()
3859 .map(|(i, header)| {
3860 if state.sort_column == Some(i) {
3861 if state.sort_ascending {
3862 format!("{header} ▲")
3863 } else {
3864 format!("{header} ▼")
3865 }
3866 } else {
3867 header.clone()
3868 }
3869 })
3870 .collect::<Vec<_>>();
3871 let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
3872 self.styled(header_line, Style::new().bold().fg(self.theme.text));
3873
3874 let separator = state
3875 .column_widths()
3876 .iter()
3877 .map(|w| "─".repeat(*w as usize))
3878 .collect::<Vec<_>>()
3879 .join("─┼─");
3880 self.text(separator);
3881
3882 for idx in 0..visible_len {
3883 let data_idx = state.visible_indices()[page_start + idx];
3884 let Some(row) = state.rows.get(data_idx) else {
3885 continue;
3886 };
3887 let line = format_table_row(row, state.column_widths(), " │ ");
3888 if idx == state.selected {
3889 let mut style = Style::new()
3890 .bg(self.theme.selected_bg)
3891 .fg(self.theme.selected_fg);
3892 if focused {
3893 style = style.bold();
3894 }
3895 self.styled(line, style);
3896 } else {
3897 self.styled(line, Style::new().fg(self.theme.text));
3898 }
3899 }
3900
3901 if state.page_size > 0 && state.total_pages() > 1 {
3902 self.styled(
3903 format!("Page {}/{}", state.page + 1, state.total_pages()),
3904 Style::new().dim().fg(self.theme.text_dim),
3905 );
3906 }
3907
3908 self.commands.push(Command::EndContainer);
3909 self.last_text_idx = None;
3910
3911 self
3912 }
3913
3914 pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
3919 if state.labels.is_empty() {
3920 state.selected = 0;
3921 return self;
3922 }
3923
3924 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
3925 let focused = self.register_focusable();
3926 let interaction_id = self.interaction_count;
3927
3928 if focused {
3929 let mut consumed_indices = Vec::new();
3930 for (i, event) in self.events.iter().enumerate() {
3931 if let Event::Key(key) = event {
3932 if key.kind != KeyEventKind::Press {
3933 continue;
3934 }
3935 match key.code {
3936 KeyCode::Left => {
3937 state.selected = if state.selected == 0 {
3938 state.labels.len().saturating_sub(1)
3939 } else {
3940 state.selected - 1
3941 };
3942 consumed_indices.push(i);
3943 }
3944 KeyCode::Right => {
3945 state.selected = (state.selected + 1) % state.labels.len();
3946 consumed_indices.push(i);
3947 }
3948 _ => {}
3949 }
3950 }
3951 }
3952
3953 for index in consumed_indices {
3954 self.consumed[index] = true;
3955 }
3956 }
3957
3958 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3959 for (i, event) in self.events.iter().enumerate() {
3960 if self.consumed[i] {
3961 continue;
3962 }
3963 if let Event::Mouse(mouse) = event {
3964 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3965 continue;
3966 }
3967 let in_bounds = mouse.x >= rect.x
3968 && mouse.x < rect.right()
3969 && mouse.y >= rect.y
3970 && mouse.y < rect.bottom();
3971 if !in_bounds {
3972 continue;
3973 }
3974
3975 let mut x_offset = 0u32;
3976 let rel_x = mouse.x - rect.x;
3977 for (idx, label) in state.labels.iter().enumerate() {
3978 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
3979 if rel_x >= x_offset && rel_x < x_offset + tab_width {
3980 state.selected = idx;
3981 self.consumed[i] = true;
3982 break;
3983 }
3984 x_offset += tab_width + 1;
3985 }
3986 }
3987 }
3988 }
3989
3990 self.interaction_count += 1;
3991 self.commands.push(Command::BeginContainer {
3992 direction: Direction::Row,
3993 gap: 1,
3994 align: Align::Start,
3995 justify: Justify::Start,
3996 border: None,
3997 border_sides: BorderSides::all(),
3998 border_style: Style::new().fg(self.theme.border),
3999 bg_color: None,
4000 padding: Padding::default(),
4001 margin: Margin::default(),
4002 constraints: Constraints::default(),
4003 title: None,
4004 grow: 0,
4005 });
4006 for (idx, label) in state.labels.iter().enumerate() {
4007 let style = if idx == state.selected {
4008 let s = Style::new().fg(self.theme.primary).bold();
4009 if focused {
4010 s.underline()
4011 } else {
4012 s
4013 }
4014 } else {
4015 Style::new().fg(self.theme.text_dim)
4016 };
4017 self.styled(format!("[ {label} ]"), style);
4018 }
4019 self.commands.push(Command::EndContainer);
4020 self.last_text_idx = None;
4021
4022 self
4023 }
4024
4025 pub fn button(&mut self, label: impl Into<String>) -> bool {
4030 let focused = self.register_focusable();
4031 let interaction_id = self.interaction_count;
4032 self.interaction_count += 1;
4033 let response = self.response_for(interaction_id);
4034
4035 let mut activated = response.clicked;
4036 if focused {
4037 let mut consumed_indices = Vec::new();
4038 for (i, event) in self.events.iter().enumerate() {
4039 if let Event::Key(key) = event {
4040 if key.kind != KeyEventKind::Press {
4041 continue;
4042 }
4043 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4044 activated = true;
4045 consumed_indices.push(i);
4046 }
4047 }
4048 }
4049
4050 for index in consumed_indices {
4051 self.consumed[index] = true;
4052 }
4053 }
4054
4055 let hovered = response.hovered;
4056 let style = if focused {
4057 Style::new().fg(self.theme.primary).bold()
4058 } else if hovered {
4059 Style::new().fg(self.theme.accent)
4060 } else {
4061 Style::new().fg(self.theme.text)
4062 };
4063 let hover_bg = if hovered || focused {
4064 Some(self.theme.surface_hover)
4065 } else {
4066 None
4067 };
4068
4069 self.commands.push(Command::BeginContainer {
4070 direction: Direction::Row,
4071 gap: 0,
4072 align: Align::Start,
4073 justify: Justify::Start,
4074 border: None,
4075 border_sides: BorderSides::all(),
4076 border_style: Style::new().fg(self.theme.border),
4077 bg_color: hover_bg,
4078 padding: Padding::default(),
4079 margin: Margin::default(),
4080 constraints: Constraints::default(),
4081 title: None,
4082 grow: 0,
4083 });
4084 self.styled(format!("[ {} ]", label.into()), style);
4085 self.commands.push(Command::EndContainer);
4086 self.last_text_idx = None;
4087
4088 activated
4089 }
4090
4091 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
4096 let focused = self.register_focusable();
4097 let interaction_id = self.interaction_count;
4098 self.interaction_count += 1;
4099 let response = self.response_for(interaction_id);
4100
4101 let mut activated = response.clicked;
4102 if focused {
4103 let mut consumed_indices = Vec::new();
4104 for (i, event) in self.events.iter().enumerate() {
4105 if let Event::Key(key) = event {
4106 if key.kind != KeyEventKind::Press {
4107 continue;
4108 }
4109 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4110 activated = true;
4111 consumed_indices.push(i);
4112 }
4113 }
4114 }
4115 for index in consumed_indices {
4116 self.consumed[index] = true;
4117 }
4118 }
4119
4120 let label = label.into();
4121 let hover_bg = if response.hovered || focused {
4122 Some(self.theme.surface_hover)
4123 } else {
4124 None
4125 };
4126 let (text, style, bg_color, border) = match variant {
4127 ButtonVariant::Default => {
4128 let style = if focused {
4129 Style::new().fg(self.theme.primary).bold()
4130 } else if response.hovered {
4131 Style::new().fg(self.theme.accent)
4132 } else {
4133 Style::new().fg(self.theme.text)
4134 };
4135 (format!("[ {label} ]"), style, hover_bg, None)
4136 }
4137 ButtonVariant::Primary => {
4138 let style = if focused {
4139 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
4140 } else if response.hovered {
4141 Style::new().fg(self.theme.bg).bg(self.theme.accent)
4142 } else {
4143 Style::new().fg(self.theme.bg).bg(self.theme.primary)
4144 };
4145 (format!(" {label} "), style, hover_bg, None)
4146 }
4147 ButtonVariant::Danger => {
4148 let style = if focused {
4149 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
4150 } else if response.hovered {
4151 Style::new().fg(self.theme.bg).bg(self.theme.warning)
4152 } else {
4153 Style::new().fg(self.theme.bg).bg(self.theme.error)
4154 };
4155 (format!(" {label} "), style, hover_bg, None)
4156 }
4157 ButtonVariant::Outline => {
4158 let border_color = if focused {
4159 self.theme.primary
4160 } else if response.hovered {
4161 self.theme.accent
4162 } else {
4163 self.theme.border
4164 };
4165 let style = if focused {
4166 Style::new().fg(self.theme.primary).bold()
4167 } else if response.hovered {
4168 Style::new().fg(self.theme.accent)
4169 } else {
4170 Style::new().fg(self.theme.text)
4171 };
4172 (
4173 format!(" {label} "),
4174 style,
4175 hover_bg,
4176 Some((Border::Rounded, Style::new().fg(border_color))),
4177 )
4178 }
4179 };
4180
4181 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
4182 self.commands.push(Command::BeginContainer {
4183 direction: Direction::Row,
4184 gap: 0,
4185 align: Align::Center,
4186 justify: Justify::Center,
4187 border: if border.is_some() {
4188 Some(btn_border)
4189 } else {
4190 None
4191 },
4192 border_sides: BorderSides::all(),
4193 border_style: btn_border_style,
4194 bg_color,
4195 padding: Padding::default(),
4196 margin: Margin::default(),
4197 constraints: Constraints::default(),
4198 title: None,
4199 grow: 0,
4200 });
4201 self.styled(text, style);
4202 self.commands.push(Command::EndContainer);
4203 self.last_text_idx = None;
4204
4205 activated
4206 }
4207
4208 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
4213 let focused = self.register_focusable();
4214 let interaction_id = self.interaction_count;
4215 self.interaction_count += 1;
4216 let response = self.response_for(interaction_id);
4217 let mut should_toggle = response.clicked;
4218
4219 if focused {
4220 let mut consumed_indices = Vec::new();
4221 for (i, event) in self.events.iter().enumerate() {
4222 if let Event::Key(key) = event {
4223 if key.kind != KeyEventKind::Press {
4224 continue;
4225 }
4226 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4227 should_toggle = true;
4228 consumed_indices.push(i);
4229 }
4230 }
4231 }
4232
4233 for index in consumed_indices {
4234 self.consumed[index] = true;
4235 }
4236 }
4237
4238 if should_toggle {
4239 *checked = !*checked;
4240 }
4241
4242 let hover_bg = if response.hovered || focused {
4243 Some(self.theme.surface_hover)
4244 } else {
4245 None
4246 };
4247 self.commands.push(Command::BeginContainer {
4248 direction: Direction::Row,
4249 gap: 1,
4250 align: Align::Start,
4251 justify: Justify::Start,
4252 border: None,
4253 border_sides: BorderSides::all(),
4254 border_style: Style::new().fg(self.theme.border),
4255 bg_color: hover_bg,
4256 padding: Padding::default(),
4257 margin: Margin::default(),
4258 constraints: Constraints::default(),
4259 title: None,
4260 grow: 0,
4261 });
4262 let marker_style = if *checked {
4263 Style::new().fg(self.theme.success)
4264 } else {
4265 Style::new().fg(self.theme.text_dim)
4266 };
4267 let marker = if *checked { "[x]" } else { "[ ]" };
4268 let label_text = label.into();
4269 if focused {
4270 self.styled(format!("▸ {marker}"), marker_style.bold());
4271 self.styled(label_text, Style::new().fg(self.theme.text).bold());
4272 } else {
4273 self.styled(marker, marker_style);
4274 self.styled(label_text, Style::new().fg(self.theme.text));
4275 }
4276 self.commands.push(Command::EndContainer);
4277 self.last_text_idx = None;
4278
4279 self
4280 }
4281
4282 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
4288 let focused = self.register_focusable();
4289 let interaction_id = self.interaction_count;
4290 self.interaction_count += 1;
4291 let response = self.response_for(interaction_id);
4292 let mut should_toggle = response.clicked;
4293
4294 if focused {
4295 let mut consumed_indices = Vec::new();
4296 for (i, event) in self.events.iter().enumerate() {
4297 if let Event::Key(key) = event {
4298 if key.kind != KeyEventKind::Press {
4299 continue;
4300 }
4301 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4302 should_toggle = true;
4303 consumed_indices.push(i);
4304 }
4305 }
4306 }
4307
4308 for index in consumed_indices {
4309 self.consumed[index] = true;
4310 }
4311 }
4312
4313 if should_toggle {
4314 *on = !*on;
4315 }
4316
4317 let hover_bg = if response.hovered || focused {
4318 Some(self.theme.surface_hover)
4319 } else {
4320 None
4321 };
4322 self.commands.push(Command::BeginContainer {
4323 direction: Direction::Row,
4324 gap: 2,
4325 align: Align::Start,
4326 justify: Justify::Start,
4327 border: None,
4328 border_sides: BorderSides::all(),
4329 border_style: Style::new().fg(self.theme.border),
4330 bg_color: hover_bg,
4331 padding: Padding::default(),
4332 margin: Margin::default(),
4333 constraints: Constraints::default(),
4334 title: None,
4335 grow: 0,
4336 });
4337 let label_text = label.into();
4338 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
4339 let switch_style = if *on {
4340 Style::new().fg(self.theme.success)
4341 } else {
4342 Style::new().fg(self.theme.text_dim)
4343 };
4344 if focused {
4345 self.styled(
4346 format!("▸ {label_text}"),
4347 Style::new().fg(self.theme.text).bold(),
4348 );
4349 self.styled(switch, switch_style.bold());
4350 } else {
4351 self.styled(label_text, Style::new().fg(self.theme.text));
4352 self.styled(switch, switch_style);
4353 }
4354 self.commands.push(Command::EndContainer);
4355 self.last_text_idx = None;
4356
4357 self
4358 }
4359
4360 pub fn select(&mut self, state: &mut SelectState) -> bool {
4366 if state.items.is_empty() {
4367 return false;
4368 }
4369 state.selected = state.selected.min(state.items.len().saturating_sub(1));
4370
4371 let focused = self.register_focusable();
4372 let interaction_id = self.interaction_count;
4373 self.interaction_count += 1;
4374 let response = self.response_for(interaction_id);
4375 let old_selected = state.selected;
4376
4377 if response.clicked {
4378 state.open = !state.open;
4379 if state.open {
4380 state.set_cursor(state.selected);
4381 }
4382 }
4383
4384 if focused {
4385 let mut consumed_indices = Vec::new();
4386 for (i, event) in self.events.iter().enumerate() {
4387 if self.consumed[i] {
4388 continue;
4389 }
4390 if let Event::Key(key) = event {
4391 if key.kind != KeyEventKind::Press {
4392 continue;
4393 }
4394 if state.open {
4395 match key.code {
4396 KeyCode::Up | KeyCode::Char('k') => {
4397 let c = state.cursor();
4398 state.set_cursor(c.saturating_sub(1));
4399 consumed_indices.push(i);
4400 }
4401 KeyCode::Down | KeyCode::Char('j') => {
4402 let c = state.cursor();
4403 state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
4404 consumed_indices.push(i);
4405 }
4406 KeyCode::Enter | KeyCode::Char(' ') => {
4407 state.selected = state.cursor();
4408 state.open = false;
4409 consumed_indices.push(i);
4410 }
4411 KeyCode::Esc => {
4412 state.open = false;
4413 consumed_indices.push(i);
4414 }
4415 _ => {}
4416 }
4417 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4418 state.open = true;
4419 state.set_cursor(state.selected);
4420 consumed_indices.push(i);
4421 }
4422 }
4423 }
4424 for idx in consumed_indices {
4425 self.consumed[idx] = true;
4426 }
4427 }
4428
4429 let changed = state.selected != old_selected;
4430
4431 let border_color = if focused {
4432 self.theme.primary
4433 } else {
4434 self.theme.border
4435 };
4436 let display_text = state
4437 .items
4438 .get(state.selected)
4439 .cloned()
4440 .unwrap_or_else(|| state.placeholder.clone());
4441 let arrow = if state.open { "▲" } else { "▼" };
4442
4443 self.commands.push(Command::BeginContainer {
4444 direction: Direction::Column,
4445 gap: 0,
4446 align: Align::Start,
4447 justify: Justify::Start,
4448 border: None,
4449 border_sides: BorderSides::all(),
4450 border_style: Style::new().fg(self.theme.border),
4451 bg_color: None,
4452 padding: Padding::default(),
4453 margin: Margin::default(),
4454 constraints: Constraints::default(),
4455 title: None,
4456 grow: 0,
4457 });
4458
4459 self.commands.push(Command::BeginContainer {
4460 direction: Direction::Row,
4461 gap: 1,
4462 align: Align::Start,
4463 justify: Justify::Start,
4464 border: Some(Border::Rounded),
4465 border_sides: BorderSides::all(),
4466 border_style: Style::new().fg(border_color),
4467 bg_color: None,
4468 padding: Padding {
4469 left: 1,
4470 right: 1,
4471 top: 0,
4472 bottom: 0,
4473 },
4474 margin: Margin::default(),
4475 constraints: Constraints::default(),
4476 title: None,
4477 grow: 0,
4478 });
4479 self.interaction_count += 1;
4480 self.styled(&display_text, Style::new().fg(self.theme.text));
4481 self.styled(arrow, Style::new().fg(self.theme.text_dim));
4482 self.commands.push(Command::EndContainer);
4483 self.last_text_idx = None;
4484
4485 if state.open {
4486 for (idx, item) in state.items.iter().enumerate() {
4487 let is_cursor = idx == state.cursor();
4488 let style = if is_cursor {
4489 Style::new().bold().fg(self.theme.primary)
4490 } else {
4491 Style::new().fg(self.theme.text)
4492 };
4493 let prefix = if is_cursor { "▸ " } else { " " };
4494 self.styled(format!("{prefix}{item}"), style);
4495 }
4496 }
4497
4498 self.commands.push(Command::EndContainer);
4499 self.last_text_idx = None;
4500 changed
4501 }
4502
4503 pub fn radio(&mut self, state: &mut RadioState) -> bool {
4507 if state.items.is_empty() {
4508 return false;
4509 }
4510 state.selected = state.selected.min(state.items.len().saturating_sub(1));
4511 let focused = self.register_focusable();
4512 let old_selected = state.selected;
4513
4514 if focused {
4515 let mut consumed_indices = Vec::new();
4516 for (i, event) in self.events.iter().enumerate() {
4517 if self.consumed[i] {
4518 continue;
4519 }
4520 if let Event::Key(key) = event {
4521 if key.kind != KeyEventKind::Press {
4522 continue;
4523 }
4524 match key.code {
4525 KeyCode::Up | KeyCode::Char('k') => {
4526 state.selected = state.selected.saturating_sub(1);
4527 consumed_indices.push(i);
4528 }
4529 KeyCode::Down | KeyCode::Char('j') => {
4530 state.selected =
4531 (state.selected + 1).min(state.items.len().saturating_sub(1));
4532 consumed_indices.push(i);
4533 }
4534 KeyCode::Enter | KeyCode::Char(' ') => {
4535 consumed_indices.push(i);
4536 }
4537 _ => {}
4538 }
4539 }
4540 }
4541 for idx in consumed_indices {
4542 self.consumed[idx] = true;
4543 }
4544 }
4545
4546 let interaction_id = self.interaction_count;
4547 self.interaction_count += 1;
4548
4549 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4550 for (i, event) in self.events.iter().enumerate() {
4551 if self.consumed[i] {
4552 continue;
4553 }
4554 if let Event::Mouse(mouse) = event {
4555 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4556 continue;
4557 }
4558 let in_bounds = mouse.x >= rect.x
4559 && mouse.x < rect.right()
4560 && mouse.y >= rect.y
4561 && mouse.y < rect.bottom();
4562 if !in_bounds {
4563 continue;
4564 }
4565 let clicked_idx = (mouse.y - rect.y) as usize;
4566 if clicked_idx < state.items.len() {
4567 state.selected = clicked_idx;
4568 self.consumed[i] = true;
4569 }
4570 }
4571 }
4572 }
4573
4574 self.commands.push(Command::BeginContainer {
4575 direction: Direction::Column,
4576 gap: 0,
4577 align: Align::Start,
4578 justify: Justify::Start,
4579 border: None,
4580 border_sides: BorderSides::all(),
4581 border_style: Style::new().fg(self.theme.border),
4582 bg_color: None,
4583 padding: Padding::default(),
4584 margin: Margin::default(),
4585 constraints: Constraints::default(),
4586 title: None,
4587 grow: 0,
4588 });
4589
4590 for (idx, item) in state.items.iter().enumerate() {
4591 let is_selected = idx == state.selected;
4592 let marker = if is_selected { "●" } else { "○" };
4593 let style = if is_selected {
4594 if focused {
4595 Style::new().bold().fg(self.theme.primary)
4596 } else {
4597 Style::new().fg(self.theme.primary)
4598 }
4599 } else {
4600 Style::new().fg(self.theme.text)
4601 };
4602 let prefix = if focused && idx == state.selected {
4603 "▸ "
4604 } else {
4605 " "
4606 };
4607 self.styled(format!("{prefix}{marker} {item}"), style);
4608 }
4609
4610 self.commands.push(Command::EndContainer);
4611 self.last_text_idx = None;
4612 state.selected != old_selected
4613 }
4614
4615 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
4619 if state.items.is_empty() {
4620 return self;
4621 }
4622 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
4623 let focused = self.register_focusable();
4624
4625 if focused {
4626 let mut consumed_indices = Vec::new();
4627 for (i, event) in self.events.iter().enumerate() {
4628 if self.consumed[i] {
4629 continue;
4630 }
4631 if let Event::Key(key) = event {
4632 if key.kind != KeyEventKind::Press {
4633 continue;
4634 }
4635 match key.code {
4636 KeyCode::Up | KeyCode::Char('k') => {
4637 state.cursor = state.cursor.saturating_sub(1);
4638 consumed_indices.push(i);
4639 }
4640 KeyCode::Down | KeyCode::Char('j') => {
4641 state.cursor =
4642 (state.cursor + 1).min(state.items.len().saturating_sub(1));
4643 consumed_indices.push(i);
4644 }
4645 KeyCode::Char(' ') | KeyCode::Enter => {
4646 state.toggle(state.cursor);
4647 consumed_indices.push(i);
4648 }
4649 _ => {}
4650 }
4651 }
4652 }
4653 for idx in consumed_indices {
4654 self.consumed[idx] = true;
4655 }
4656 }
4657
4658 let interaction_id = self.interaction_count;
4659 self.interaction_count += 1;
4660
4661 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4662 for (i, event) in self.events.iter().enumerate() {
4663 if self.consumed[i] {
4664 continue;
4665 }
4666 if let Event::Mouse(mouse) = event {
4667 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4668 continue;
4669 }
4670 let in_bounds = mouse.x >= rect.x
4671 && mouse.x < rect.right()
4672 && mouse.y >= rect.y
4673 && mouse.y < rect.bottom();
4674 if !in_bounds {
4675 continue;
4676 }
4677 let clicked_idx = (mouse.y - rect.y) as usize;
4678 if clicked_idx < state.items.len() {
4679 state.toggle(clicked_idx);
4680 state.cursor = clicked_idx;
4681 self.consumed[i] = true;
4682 }
4683 }
4684 }
4685 }
4686
4687 self.commands.push(Command::BeginContainer {
4688 direction: Direction::Column,
4689 gap: 0,
4690 align: Align::Start,
4691 justify: Justify::Start,
4692 border: None,
4693 border_sides: BorderSides::all(),
4694 border_style: Style::new().fg(self.theme.border),
4695 bg_color: None,
4696 padding: Padding::default(),
4697 margin: Margin::default(),
4698 constraints: Constraints::default(),
4699 title: None,
4700 grow: 0,
4701 });
4702
4703 for (idx, item) in state.items.iter().enumerate() {
4704 let checked = state.selected.contains(&idx);
4705 let marker = if checked { "[x]" } else { "[ ]" };
4706 let is_cursor = idx == state.cursor;
4707 let style = if is_cursor && focused {
4708 Style::new().bold().fg(self.theme.primary)
4709 } else if checked {
4710 Style::new().fg(self.theme.success)
4711 } else {
4712 Style::new().fg(self.theme.text)
4713 };
4714 let prefix = if is_cursor && focused { "▸ " } else { " " };
4715 self.styled(format!("{prefix}{marker} {item}"), style);
4716 }
4717
4718 self.commands.push(Command::EndContainer);
4719 self.last_text_idx = None;
4720 self
4721 }
4722
4723 pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
4727 let entries = state.flatten();
4728 if entries.is_empty() {
4729 return self;
4730 }
4731 state.selected = state.selected.min(entries.len().saturating_sub(1));
4732 let focused = self.register_focusable();
4733
4734 if focused {
4735 let mut consumed_indices = Vec::new();
4736 for (i, event) in self.events.iter().enumerate() {
4737 if self.consumed[i] {
4738 continue;
4739 }
4740 if let Event::Key(key) = event {
4741 if key.kind != KeyEventKind::Press {
4742 continue;
4743 }
4744 match key.code {
4745 KeyCode::Up | KeyCode::Char('k') => {
4746 state.selected = state.selected.saturating_sub(1);
4747 consumed_indices.push(i);
4748 }
4749 KeyCode::Down | KeyCode::Char('j') => {
4750 let max = state.flatten().len().saturating_sub(1);
4751 state.selected = (state.selected + 1).min(max);
4752 consumed_indices.push(i);
4753 }
4754 KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
4755 state.toggle_at(state.selected);
4756 consumed_indices.push(i);
4757 }
4758 KeyCode::Left => {
4759 let entry = &entries[state.selected.min(entries.len() - 1)];
4760 if entry.expanded {
4761 state.toggle_at(state.selected);
4762 }
4763 consumed_indices.push(i);
4764 }
4765 _ => {}
4766 }
4767 }
4768 }
4769 for idx in consumed_indices {
4770 self.consumed[idx] = true;
4771 }
4772 }
4773
4774 self.interaction_count += 1;
4775 self.commands.push(Command::BeginContainer {
4776 direction: Direction::Column,
4777 gap: 0,
4778 align: Align::Start,
4779 justify: Justify::Start,
4780 border: None,
4781 border_sides: BorderSides::all(),
4782 border_style: Style::new().fg(self.theme.border),
4783 bg_color: None,
4784 padding: Padding::default(),
4785 margin: Margin::default(),
4786 constraints: Constraints::default(),
4787 title: None,
4788 grow: 0,
4789 });
4790
4791 let entries = state.flatten();
4792 for (idx, entry) in entries.iter().enumerate() {
4793 let indent = " ".repeat(entry.depth);
4794 let icon = if entry.is_leaf {
4795 " "
4796 } else if entry.expanded {
4797 "▾ "
4798 } else {
4799 "▸ "
4800 };
4801 let is_selected = idx == state.selected;
4802 let style = if is_selected && focused {
4803 Style::new().bold().fg(self.theme.primary)
4804 } else if is_selected {
4805 Style::new().fg(self.theme.primary)
4806 } else {
4807 Style::new().fg(self.theme.text)
4808 };
4809 let cursor = if is_selected && focused { "▸" } else { " " };
4810 self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
4811 }
4812
4813 self.commands.push(Command::EndContainer);
4814 self.last_text_idx = None;
4815 self
4816 }
4817
4818 pub fn virtual_list(
4825 &mut self,
4826 state: &mut ListState,
4827 visible_height: usize,
4828 f: impl Fn(&mut Context, usize),
4829 ) -> &mut Self {
4830 if state.items.is_empty() {
4831 return self;
4832 }
4833 state.selected = state.selected.min(state.items.len().saturating_sub(1));
4834 let focused = self.register_focusable();
4835
4836 if focused {
4837 let mut consumed_indices = Vec::new();
4838 for (i, event) in self.events.iter().enumerate() {
4839 if self.consumed[i] {
4840 continue;
4841 }
4842 if let Event::Key(key) = event {
4843 if key.kind != KeyEventKind::Press {
4844 continue;
4845 }
4846 match key.code {
4847 KeyCode::Up | KeyCode::Char('k') => {
4848 state.selected = state.selected.saturating_sub(1);
4849 consumed_indices.push(i);
4850 }
4851 KeyCode::Down | KeyCode::Char('j') => {
4852 state.selected =
4853 (state.selected + 1).min(state.items.len().saturating_sub(1));
4854 consumed_indices.push(i);
4855 }
4856 KeyCode::PageUp => {
4857 state.selected = state.selected.saturating_sub(visible_height);
4858 consumed_indices.push(i);
4859 }
4860 KeyCode::PageDown => {
4861 state.selected = (state.selected + visible_height)
4862 .min(state.items.len().saturating_sub(1));
4863 consumed_indices.push(i);
4864 }
4865 KeyCode::Home => {
4866 state.selected = 0;
4867 consumed_indices.push(i);
4868 }
4869 KeyCode::End => {
4870 state.selected = state.items.len().saturating_sub(1);
4871 consumed_indices.push(i);
4872 }
4873 _ => {}
4874 }
4875 }
4876 }
4877 for idx in consumed_indices {
4878 self.consumed[idx] = true;
4879 }
4880 }
4881
4882 let start = if state.selected >= visible_height {
4883 state.selected - visible_height + 1
4884 } else {
4885 0
4886 };
4887 let end = (start + visible_height).min(state.items.len());
4888
4889 self.interaction_count += 1;
4890 self.commands.push(Command::BeginContainer {
4891 direction: Direction::Column,
4892 gap: 0,
4893 align: Align::Start,
4894 justify: Justify::Start,
4895 border: None,
4896 border_sides: BorderSides::all(),
4897 border_style: Style::new().fg(self.theme.border),
4898 bg_color: None,
4899 padding: Padding::default(),
4900 margin: Margin::default(),
4901 constraints: Constraints::default(),
4902 title: None,
4903 grow: 0,
4904 });
4905
4906 if start > 0 {
4907 self.styled(
4908 format!(" ↑ {} more", start),
4909 Style::new().fg(self.theme.text_dim).dim(),
4910 );
4911 }
4912
4913 for idx in start..end {
4914 f(self, idx);
4915 }
4916
4917 let remaining = state.items.len().saturating_sub(end);
4918 if remaining > 0 {
4919 self.styled(
4920 format!(" ↓ {} more", remaining),
4921 Style::new().fg(self.theme.text_dim).dim(),
4922 );
4923 }
4924
4925 self.commands.push(Command::EndContainer);
4926 self.last_text_idx = None;
4927 self
4928 }
4929
4930 pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
4934 if !state.open {
4935 return None;
4936 }
4937
4938 let filtered = state.filtered_indices();
4939 let sel = state.selected().min(filtered.len().saturating_sub(1));
4940 state.set_selected(sel);
4941
4942 let mut consumed_indices = Vec::new();
4943 let mut result: Option<usize> = None;
4944
4945 for (i, event) in self.events.iter().enumerate() {
4946 if self.consumed[i] {
4947 continue;
4948 }
4949 if let Event::Key(key) = event {
4950 if key.kind != KeyEventKind::Press {
4951 continue;
4952 }
4953 match key.code {
4954 KeyCode::Esc => {
4955 state.open = false;
4956 consumed_indices.push(i);
4957 }
4958 KeyCode::Up => {
4959 let s = state.selected();
4960 state.set_selected(s.saturating_sub(1));
4961 consumed_indices.push(i);
4962 }
4963 KeyCode::Down => {
4964 let s = state.selected();
4965 state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
4966 consumed_indices.push(i);
4967 }
4968 KeyCode::Enter => {
4969 if let Some(&cmd_idx) = filtered.get(state.selected()) {
4970 result = Some(cmd_idx);
4971 state.open = false;
4972 }
4973 consumed_indices.push(i);
4974 }
4975 KeyCode::Backspace => {
4976 if state.cursor > 0 {
4977 let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
4978 let end_idx = byte_index_for_char(&state.input, state.cursor);
4979 state.input.replace_range(byte_idx..end_idx, "");
4980 state.cursor -= 1;
4981 state.set_selected(0);
4982 }
4983 consumed_indices.push(i);
4984 }
4985 KeyCode::Char(ch) => {
4986 let byte_idx = byte_index_for_char(&state.input, state.cursor);
4987 state.input.insert(byte_idx, ch);
4988 state.cursor += 1;
4989 state.set_selected(0);
4990 consumed_indices.push(i);
4991 }
4992 _ => {}
4993 }
4994 }
4995 }
4996 for idx in consumed_indices {
4997 self.consumed[idx] = true;
4998 }
4999
5000 let filtered = state.filtered_indices();
5001
5002 self.modal(|ui| {
5003 let primary = ui.theme.primary;
5004 ui.container()
5005 .border(Border::Rounded)
5006 .border_style(Style::new().fg(primary))
5007 .pad(1)
5008 .max_w(60)
5009 .col(|ui| {
5010 let border_color = ui.theme.primary;
5011 ui.bordered(Border::Rounded)
5012 .border_style(Style::new().fg(border_color))
5013 .px(1)
5014 .col(|ui| {
5015 let display = if state.input.is_empty() {
5016 "Type to search...".to_string()
5017 } else {
5018 state.input.clone()
5019 };
5020 let style = if state.input.is_empty() {
5021 Style::new().dim().fg(ui.theme.text_dim)
5022 } else {
5023 Style::new().fg(ui.theme.text)
5024 };
5025 ui.styled(display, style);
5026 });
5027
5028 for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
5029 let cmd = &state.commands[cmd_idx];
5030 let is_selected = list_idx == state.selected();
5031 let style = if is_selected {
5032 Style::new().bold().fg(ui.theme.primary)
5033 } else {
5034 Style::new().fg(ui.theme.text)
5035 };
5036 let prefix = if is_selected { "▸ " } else { " " };
5037 let shortcut_text = cmd
5038 .shortcut
5039 .as_deref()
5040 .map(|s| format!(" ({s})"))
5041 .unwrap_or_default();
5042 ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
5043 if is_selected && !cmd.description.is_empty() {
5044 ui.styled(
5045 format!(" {}", cmd.description),
5046 Style::new().dim().fg(ui.theme.text_dim),
5047 );
5048 }
5049 }
5050
5051 if filtered.is_empty() {
5052 ui.styled(
5053 " No matching commands",
5054 Style::new().dim().fg(ui.theme.text_dim),
5055 );
5056 }
5057 });
5058 });
5059
5060 result
5061 }
5062
5063 pub fn markdown(&mut self, text: &str) -> &mut Self {
5070 self.commands.push(Command::BeginContainer {
5071 direction: Direction::Column,
5072 gap: 0,
5073 align: Align::Start,
5074 justify: Justify::Start,
5075 border: None,
5076 border_sides: BorderSides::all(),
5077 border_style: Style::new().fg(self.theme.border),
5078 bg_color: None,
5079 padding: Padding::default(),
5080 margin: Margin::default(),
5081 constraints: Constraints::default(),
5082 title: None,
5083 grow: 0,
5084 });
5085 self.interaction_count += 1;
5086
5087 let text_style = Style::new().fg(self.theme.text);
5088 let bold_style = Style::new().fg(self.theme.text).bold();
5089 let code_style = Style::new().fg(self.theme.accent);
5090
5091 for line in text.lines() {
5092 let trimmed = line.trim();
5093 if trimmed.is_empty() {
5094 self.text(" ");
5095 continue;
5096 }
5097 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
5098 self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
5099 continue;
5100 }
5101 if let Some(heading) = trimmed.strip_prefix("### ") {
5102 self.styled(heading, Style::new().bold().fg(self.theme.accent));
5103 } else if let Some(heading) = trimmed.strip_prefix("## ") {
5104 self.styled(heading, Style::new().bold().fg(self.theme.secondary));
5105 } else if let Some(heading) = trimmed.strip_prefix("# ") {
5106 self.styled(heading, Style::new().bold().fg(self.theme.primary));
5107 } else if let Some(item) = trimmed
5108 .strip_prefix("- ")
5109 .or_else(|| trimmed.strip_prefix("* "))
5110 {
5111 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
5112 if segs.len() <= 1 {
5113 self.styled(format!(" • {item}"), text_style);
5114 } else {
5115 self.line(|ui| {
5116 ui.styled(" • ", text_style);
5117 for (s, st) in segs {
5118 ui.styled(s, st);
5119 }
5120 });
5121 }
5122 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
5123 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
5124 if parts.len() == 2 {
5125 let segs =
5126 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
5127 if segs.len() <= 1 {
5128 self.styled(format!(" {}. {}", parts[0], parts[1]), text_style);
5129 } else {
5130 self.line(|ui| {
5131 ui.styled(format!(" {}. ", parts[0]), text_style);
5132 for (s, st) in segs {
5133 ui.styled(s, st);
5134 }
5135 });
5136 }
5137 } else {
5138 self.text(trimmed);
5139 }
5140 } else if let Some(code) = trimmed.strip_prefix("```") {
5141 let _ = code;
5142 self.styled(" ┌─code─", Style::new().fg(self.theme.border).dim());
5143 } else {
5144 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
5145 if segs.len() <= 1 {
5146 self.styled(trimmed, text_style);
5147 } else {
5148 self.line(|ui| {
5149 for (s, st) in segs {
5150 ui.styled(s, st);
5151 }
5152 });
5153 }
5154 }
5155 }
5156
5157 self.commands.push(Command::EndContainer);
5158 self.last_text_idx = None;
5159 self
5160 }
5161
5162 fn parse_inline_segments(
5163 text: &str,
5164 base: Style,
5165 bold: Style,
5166 code: Style,
5167 ) -> Vec<(String, Style)> {
5168 let mut segments: Vec<(String, Style)> = Vec::new();
5169 let mut current = String::new();
5170 let chars: Vec<char> = text.chars().collect();
5171 let mut i = 0;
5172 while i < chars.len() {
5173 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
5174 if let Some(end) = text[i + 2..].find("**") {
5175 if !current.is_empty() {
5176 segments.push((std::mem::take(&mut current), base));
5177 }
5178 segments.push((text[i + 2..i + 2 + end].to_string(), bold));
5179 i += 4 + end;
5180 continue;
5181 }
5182 }
5183 if chars[i] == '*'
5184 && (i + 1 >= chars.len() || chars[i + 1] != '*')
5185 && (i == 0 || chars[i - 1] != '*')
5186 {
5187 if let Some(end) = text[i + 1..].find('*') {
5188 if !current.is_empty() {
5189 segments.push((std::mem::take(&mut current), base));
5190 }
5191 segments.push((text[i + 1..i + 1 + end].to_string(), base.italic()));
5192 i += 2 + end;
5193 continue;
5194 }
5195 }
5196 if chars[i] == '`' {
5197 if let Some(end) = text[i + 1..].find('`') {
5198 if !current.is_empty() {
5199 segments.push((std::mem::take(&mut current), base));
5200 }
5201 segments.push((text[i + 1..i + 1 + end].to_string(), code));
5202 i += 2 + end;
5203 continue;
5204 }
5205 }
5206 current.push(chars[i]);
5207 i += 1;
5208 }
5209 if !current.is_empty() {
5210 segments.push((current, base));
5211 }
5212 segments
5213 }
5214
5215 pub fn key_seq(&self, seq: &str) -> bool {
5222 if seq.is_empty() {
5223 return false;
5224 }
5225 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5226 return false;
5227 }
5228 let target: Vec<char> = seq.chars().collect();
5229 let mut matched = 0;
5230 for (i, event) in self.events.iter().enumerate() {
5231 if self.consumed[i] {
5232 continue;
5233 }
5234 if let Event::Key(key) = event {
5235 if key.kind != KeyEventKind::Press {
5236 continue;
5237 }
5238 if let KeyCode::Char(c) = key.code {
5239 if c == target[matched] {
5240 matched += 1;
5241 if matched == target.len() {
5242 return true;
5243 }
5244 } else {
5245 matched = 0;
5246 if c == target[0] {
5247 matched = 1;
5248 }
5249 }
5250 }
5251 }
5252 }
5253 false
5254 }
5255
5256 pub fn separator(&mut self) -> &mut Self {
5261 self.commands.push(Command::Text {
5262 content: "─".repeat(200),
5263 style: Style::new().fg(self.theme.border).dim(),
5264 grow: 0,
5265 align: Align::Start,
5266 wrap: false,
5267 margin: Margin::default(),
5268 constraints: Constraints::default(),
5269 });
5270 self.last_text_idx = Some(self.commands.len() - 1);
5271 self
5272 }
5273
5274 pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
5280 if bindings.is_empty() {
5281 return self;
5282 }
5283
5284 self.interaction_count += 1;
5285 self.commands.push(Command::BeginContainer {
5286 direction: Direction::Row,
5287 gap: 2,
5288 align: Align::Start,
5289 justify: Justify::Start,
5290 border: None,
5291 border_sides: BorderSides::all(),
5292 border_style: Style::new().fg(self.theme.border),
5293 bg_color: None,
5294 padding: Padding::default(),
5295 margin: Margin::default(),
5296 constraints: Constraints::default(),
5297 title: None,
5298 grow: 0,
5299 });
5300 for (idx, (key, action)) in bindings.iter().enumerate() {
5301 if idx > 0 {
5302 self.styled("·", Style::new().fg(self.theme.text_dim));
5303 }
5304 self.styled(*key, Style::new().bold().fg(self.theme.primary));
5305 self.styled(*action, Style::new().fg(self.theme.text_dim));
5306 }
5307 self.commands.push(Command::EndContainer);
5308 self.last_text_idx = None;
5309
5310 self
5311 }
5312
5313 pub fn key(&self, c: char) -> bool {
5319 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5320 return false;
5321 }
5322 self.events.iter().enumerate().any(|(i, e)| {
5323 !self.consumed[i]
5324 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
5325 })
5326 }
5327
5328 pub fn key_code(&self, code: KeyCode) -> bool {
5332 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5333 return false;
5334 }
5335 self.events.iter().enumerate().any(|(i, e)| {
5336 !self.consumed[i]
5337 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
5338 })
5339 }
5340
5341 pub fn key_release(&self, c: char) -> bool {
5345 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5346 return false;
5347 }
5348 self.events.iter().enumerate().any(|(i, e)| {
5349 !self.consumed[i]
5350 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
5351 })
5352 }
5353
5354 pub fn key_code_release(&self, code: KeyCode) -> bool {
5358 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5359 return false;
5360 }
5361 self.events.iter().enumerate().any(|(i, e)| {
5362 !self.consumed[i]
5363 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
5364 })
5365 }
5366
5367 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
5371 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5372 return false;
5373 }
5374 self.events.iter().enumerate().any(|(i, e)| {
5375 !self.consumed[i]
5376 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
5377 })
5378 }
5379
5380 pub fn mouse_down(&self) -> Option<(u32, u32)> {
5384 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5385 return None;
5386 }
5387 self.events.iter().enumerate().find_map(|(i, event)| {
5388 if self.consumed[i] {
5389 return None;
5390 }
5391 if let Event::Mouse(mouse) = event {
5392 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
5393 return Some((mouse.x, mouse.y));
5394 }
5395 }
5396 None
5397 })
5398 }
5399
5400 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
5405 self.mouse_pos
5406 }
5407
5408 pub fn paste(&self) -> Option<&str> {
5410 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5411 return None;
5412 }
5413 self.events.iter().enumerate().find_map(|(i, event)| {
5414 if self.consumed[i] {
5415 return None;
5416 }
5417 if let Event::Paste(ref text) = event {
5418 return Some(text.as_str());
5419 }
5420 None
5421 })
5422 }
5423
5424 pub fn scroll_up(&self) -> bool {
5426 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5427 return false;
5428 }
5429 self.events.iter().enumerate().any(|(i, event)| {
5430 !self.consumed[i]
5431 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
5432 })
5433 }
5434
5435 pub fn scroll_down(&self) -> bool {
5437 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5438 return false;
5439 }
5440 self.events.iter().enumerate().any(|(i, event)| {
5441 !self.consumed[i]
5442 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
5443 })
5444 }
5445
5446 pub fn quit(&mut self) {
5448 self.should_quit = true;
5449 }
5450
5451 pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
5459 self.clipboard_text = Some(text.into());
5460 }
5461
5462 pub fn theme(&self) -> &Theme {
5464 &self.theme
5465 }
5466
5467 pub fn set_theme(&mut self, theme: Theme) {
5471 self.theme = theme;
5472 }
5473
5474 pub fn width(&self) -> u32 {
5478 self.area_width
5479 }
5480
5481 pub fn breakpoint(&self) -> Breakpoint {
5505 let w = self.area_width;
5506 if w < 40 {
5507 Breakpoint::Xs
5508 } else if w < 80 {
5509 Breakpoint::Sm
5510 } else if w < 120 {
5511 Breakpoint::Md
5512 } else if w < 160 {
5513 Breakpoint::Lg
5514 } else {
5515 Breakpoint::Xl
5516 }
5517 }
5518
5519 pub fn height(&self) -> u32 {
5521 self.area_height
5522 }
5523
5524 pub fn tick(&self) -> u64 {
5529 self.tick
5530 }
5531
5532 pub fn debug_enabled(&self) -> bool {
5536 self.debug
5537 }
5538}
5539
5540#[inline]
5541fn byte_index_for_char(value: &str, char_index: usize) -> usize {
5542 if char_index == 0 {
5543 return 0;
5544 }
5545 value
5546 .char_indices()
5547 .nth(char_index)
5548 .map_or(value.len(), |(idx, _)| idx)
5549}
5550
5551fn format_token_count(count: usize) -> String {
5552 if count >= 1_000_000 {
5553 format!("{:.1}M", count as f64 / 1_000_000.0)
5554 } else if count >= 1_000 {
5555 format!("{:.1}k", count as f64 / 1_000.0)
5556 } else {
5557 format!("{count}")
5558 }
5559}
5560
5561fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
5562 let mut parts: Vec<String> = Vec::new();
5563 for (i, width) in widths.iter().enumerate() {
5564 let cell = cells.get(i).map(String::as_str).unwrap_or("");
5565 let cell_width = UnicodeWidthStr::width(cell) as u32;
5566 let padding = (*width).saturating_sub(cell_width) as usize;
5567 parts.push(format!("{cell}{}", " ".repeat(padding)));
5568 }
5569 parts.join(separator)
5570}
5571
5572fn format_compact_number(value: f64) -> String {
5573 if value.fract().abs() < f64::EPSILON {
5574 return format!("{value:.0}");
5575 }
5576
5577 let mut s = format!("{value:.2}");
5578 while s.contains('.') && s.ends_with('0') {
5579 s.pop();
5580 }
5581 if s.ends_with('.') {
5582 s.pop();
5583 }
5584 s
5585}
5586
5587fn center_text(text: &str, width: usize) -> String {
5588 let text_width = UnicodeWidthStr::width(text);
5589 if text_width >= width {
5590 return text.to_string();
5591 }
5592
5593 let total = width - text_width;
5594 let left = total / 2;
5595 let right = total - left;
5596 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
5597}
5598
5599struct TextareaVLine {
5600 logical_row: usize,
5601 char_start: usize,
5602 char_count: usize,
5603}
5604
5605fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
5606 let mut out = Vec::new();
5607 for (row, line) in lines.iter().enumerate() {
5608 if line.is_empty() || wrap_width == u32::MAX {
5609 out.push(TextareaVLine {
5610 logical_row: row,
5611 char_start: 0,
5612 char_count: line.chars().count(),
5613 });
5614 continue;
5615 }
5616 let mut seg_start = 0usize;
5617 let mut seg_chars = 0usize;
5618 let mut seg_width = 0u32;
5619 for (idx, ch) in line.chars().enumerate() {
5620 let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
5621 if seg_width + cw > wrap_width && seg_chars > 0 {
5622 out.push(TextareaVLine {
5623 logical_row: row,
5624 char_start: seg_start,
5625 char_count: seg_chars,
5626 });
5627 seg_start = idx;
5628 seg_chars = 0;
5629 seg_width = 0;
5630 }
5631 seg_chars += 1;
5632 seg_width += cw;
5633 }
5634 out.push(TextareaVLine {
5635 logical_row: row,
5636 char_start: seg_start,
5637 char_count: seg_chars,
5638 });
5639 }
5640 out
5641}
5642
5643fn textarea_logical_to_visual(
5644 vlines: &[TextareaVLine],
5645 logical_row: usize,
5646 logical_col: usize,
5647) -> (usize, usize) {
5648 for (i, vl) in vlines.iter().enumerate() {
5649 if vl.logical_row != logical_row {
5650 continue;
5651 }
5652 let seg_end = vl.char_start + vl.char_count;
5653 if logical_col >= vl.char_start && logical_col < seg_end {
5654 return (i, logical_col - vl.char_start);
5655 }
5656 if logical_col == seg_end {
5657 let is_last_seg = vlines
5658 .get(i + 1)
5659 .map_or(true, |next| next.logical_row != logical_row);
5660 if is_last_seg {
5661 return (i, logical_col - vl.char_start);
5662 }
5663 }
5664 }
5665 (vlines.len().saturating_sub(1), 0)
5666}
5667
5668fn textarea_visual_to_logical(
5669 vlines: &[TextareaVLine],
5670 visual_row: usize,
5671 visual_col: usize,
5672) -> (usize, usize) {
5673 if let Some(vl) = vlines.get(visual_row) {
5674 let logical_col = vl.char_start + visual_col.min(vl.char_count);
5675 (vl.logical_row, logical_col)
5676 } else {
5677 (0, 0)
5678 }
5679}
5680
5681fn open_url(url: &str) -> std::io::Result<()> {
5682 #[cfg(target_os = "macos")]
5683 {
5684 std::process::Command::new("open").arg(url).spawn()?;
5685 }
5686 #[cfg(target_os = "linux")]
5687 {
5688 std::process::Command::new("xdg-open").arg(url).spawn()?;
5689 }
5690 #[cfg(target_os = "windows")]
5691 {
5692 std::process::Command::new("cmd")
5693 .args(["/c", "start", "", url])
5694 .spawn()?;
5695 }
5696 Ok(())
5697}