1use crate::chart::{build_histogram_config, render_chart, ChartBuilder, HistogramBuilder};
2use crate::event::{Event, KeyCode, KeyModifiers, MouseButton, MouseKind};
3use crate::layout::{Command, Direction};
4use crate::rect::Rect;
5use crate::style::{
6 Align, Border, Color, Constraints, Justify, Margin, Modifiers, Padding, Style, Theme,
7};
8use crate::widgets::{
9 ButtonVariant, FormField, FormState, ListState, ScrollState, SpinnerState, TableState,
10 TabsState, TextInputState, TextareaState, ToastLevel, ToastState,
11};
12use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
13
14#[allow(dead_code)]
15fn slt_assert(condition: bool, msg: &str) {
16 if !condition {
17 panic!("[SLT] {}", msg);
18 }
19}
20
21#[cfg(debug_assertions)]
22#[allow(dead_code)]
23fn slt_warn(msg: &str) {
24 eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
25}
26
27#[cfg(not(debug_assertions))]
28#[allow(dead_code)]
29fn slt_warn(_msg: &str) {}
30
31#[derive(Debug, Clone, Copy, Default)]
37pub struct Response {
38 pub clicked: bool,
40 pub hovered: bool,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BarDirection {
47 Horizontal,
49 Vertical,
51}
52
53#[derive(Debug, Clone)]
55pub struct Bar {
56 pub label: String,
58 pub value: f64,
60 pub color: Option<Color>,
62}
63
64impl Bar {
65 pub fn new(label: impl Into<String>, value: f64) -> Self {
67 Self {
68 label: label.into(),
69 value,
70 color: None,
71 }
72 }
73
74 pub fn color(mut self, color: Color) -> Self {
76 self.color = Some(color);
77 self
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct BarGroup {
84 pub label: String,
86 pub bars: Vec<Bar>,
88}
89
90impl BarGroup {
91 pub fn new(label: impl Into<String>, bars: Vec<Bar>) -> Self {
93 Self {
94 label: label.into(),
95 bars,
96 }
97 }
98}
99
100pub trait Widget {
162 type Response;
165
166 fn ui(&mut self, ctx: &mut Context) -> Self::Response;
172}
173
174pub struct Context {
190 pub(crate) commands: Vec<Command>,
191 pub(crate) events: Vec<Event>,
192 pub(crate) consumed: Vec<bool>,
193 pub(crate) should_quit: bool,
194 pub(crate) area_width: u32,
195 pub(crate) area_height: u32,
196 pub(crate) tick: u64,
197 pub(crate) focus_index: usize,
198 pub(crate) focus_count: usize,
199 prev_focus_count: usize,
200 scroll_count: usize,
201 prev_scroll_infos: Vec<(u32, u32)>,
202 interaction_count: usize,
203 pub(crate) prev_hit_map: Vec<Rect>,
204 _prev_focus_rects: Vec<(usize, Rect)>,
205 mouse_pos: Option<(u32, u32)>,
206 click_pos: Option<(u32, u32)>,
207 last_text_idx: Option<usize>,
208 overlay_depth: usize,
209 pub(crate) modal_active: bool,
210 prev_modal_active: bool,
211 debug: bool,
212 theme: Theme,
213}
214
215#[must_use = "configure and finalize with .col() or .row()"]
236pub struct ContainerBuilder<'a> {
237 ctx: &'a mut Context,
238 gap: u32,
239 align: Align,
240 justify: Justify,
241 border: Option<Border>,
242 border_style: Style,
243 bg_color: Option<Color>,
244 padding: Padding,
245 margin: Margin,
246 constraints: Constraints,
247 title: Option<(String, Style)>,
248 grow: u16,
249 scroll_offset: Option<u32>,
250}
251
252#[derive(Debug, Clone, Copy)]
259struct CanvasPixel {
260 bits: u32,
261 color: Color,
262}
263
264#[derive(Debug, Clone)]
266struct CanvasLabel {
267 x: usize,
268 y: usize,
269 text: String,
270 color: Color,
271}
272
273#[derive(Debug, Clone)]
275struct CanvasLayer {
276 grid: Vec<Vec<CanvasPixel>>,
277 labels: Vec<CanvasLabel>,
278}
279
280pub struct CanvasContext {
281 layers: Vec<CanvasLayer>,
282 cols: usize,
283 rows: usize,
284 px_w: usize,
285 px_h: usize,
286 current_color: Color,
287}
288
289impl CanvasContext {
290 fn new(cols: usize, rows: usize) -> Self {
291 Self {
292 layers: vec![Self::new_layer(cols, rows)],
293 cols,
294 rows,
295 px_w: cols * 2,
296 px_h: rows * 4,
297 current_color: Color::Reset,
298 }
299 }
300
301 fn new_layer(cols: usize, rows: usize) -> CanvasLayer {
302 CanvasLayer {
303 grid: vec![
304 vec![
305 CanvasPixel {
306 bits: 0,
307 color: Color::Reset,
308 };
309 cols
310 ];
311 rows
312 ],
313 labels: Vec::new(),
314 }
315 }
316
317 fn current_layer_mut(&mut self) -> Option<&mut CanvasLayer> {
318 self.layers.last_mut()
319 }
320
321 fn dot_with_color(&mut self, x: usize, y: usize, color: Color) {
322 if x >= self.px_w || y >= self.px_h {
323 return;
324 }
325
326 let char_col = x / 2;
327 let char_row = y / 4;
328 let sub_col = x % 2;
329 let sub_row = y % 4;
330 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
331 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
332
333 let bit = if sub_col == 0 {
334 LEFT_BITS[sub_row]
335 } else {
336 RIGHT_BITS[sub_row]
337 };
338
339 if let Some(layer) = self.current_layer_mut() {
340 let cell = &mut layer.grid[char_row][char_col];
341 let new_bits = cell.bits | bit;
342 if new_bits != cell.bits {
343 cell.bits = new_bits;
344 cell.color = color;
345 }
346 }
347 }
348
349 fn dot_isize(&mut self, x: isize, y: isize) {
350 if x >= 0 && y >= 0 {
351 self.dot(x as usize, y as usize);
352 }
353 }
354
355 pub fn width(&self) -> usize {
357 self.px_w
358 }
359
360 pub fn height(&self) -> usize {
362 self.px_h
363 }
364
365 pub fn dot(&mut self, x: usize, y: usize) {
367 self.dot_with_color(x, y, self.current_color);
368 }
369
370 pub fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
372 let (mut x, mut y) = (x0 as isize, y0 as isize);
373 let (x1, y1) = (x1 as isize, y1 as isize);
374 let dx = (x1 - x).abs();
375 let dy = -(y1 - y).abs();
376 let sx = if x < x1 { 1 } else { -1 };
377 let sy = if y < y1 { 1 } else { -1 };
378 let mut err = dx + dy;
379
380 loop {
381 self.dot_isize(x, y);
382 if x == x1 && y == y1 {
383 break;
384 }
385 let e2 = 2 * err;
386 if e2 >= dy {
387 err += dy;
388 x += sx;
389 }
390 if e2 <= dx {
391 err += dx;
392 y += sy;
393 }
394 }
395 }
396
397 pub fn rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
399 if w == 0 || h == 0 {
400 return;
401 }
402
403 self.line(x, y, x + w.saturating_sub(1), y);
404 self.line(
405 x + w.saturating_sub(1),
406 y,
407 x + w.saturating_sub(1),
408 y + h.saturating_sub(1),
409 );
410 self.line(
411 x + w.saturating_sub(1),
412 y + h.saturating_sub(1),
413 x,
414 y + h.saturating_sub(1),
415 );
416 self.line(x, y + h.saturating_sub(1), x, y);
417 }
418
419 pub fn circle(&mut self, cx: usize, cy: usize, r: usize) {
421 let mut x = r as isize;
422 let mut y: isize = 0;
423 let mut err: isize = 1 - x;
424 let (cx, cy) = (cx as isize, cy as isize);
425
426 while x >= y {
427 for &(dx, dy) in &[
428 (x, y),
429 (y, x),
430 (-x, y),
431 (-y, x),
432 (x, -y),
433 (y, -x),
434 (-x, -y),
435 (-y, -x),
436 ] {
437 let px = cx + dx;
438 let py = cy + dy;
439 self.dot_isize(px, py);
440 }
441
442 y += 1;
443 if err < 0 {
444 err += 2 * y + 1;
445 } else {
446 x -= 1;
447 err += 2 * (y - x) + 1;
448 }
449 }
450 }
451
452 pub fn set_color(&mut self, color: Color) {
454 self.current_color = color;
455 }
456
457 pub fn color(&self) -> Color {
459 self.current_color
460 }
461
462 pub fn filled_rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
464 if w == 0 || h == 0 {
465 return;
466 }
467
468 let x_end = x.saturating_add(w).min(self.px_w);
469 let y_end = y.saturating_add(h).min(self.px_h);
470 if x >= x_end || y >= y_end {
471 return;
472 }
473
474 for yy in y..y_end {
475 self.line(x, yy, x_end.saturating_sub(1), yy);
476 }
477 }
478
479 pub fn filled_circle(&mut self, cx: usize, cy: usize, r: usize) {
481 let (cx, cy, r) = (cx as isize, cy as isize, r as isize);
482 for y in (cy - r)..=(cy + r) {
483 let dy = y - cy;
484 let span_sq = (r * r - dy * dy).max(0);
485 let dx = (span_sq as f64).sqrt() as isize;
486 for x in (cx - dx)..=(cx + dx) {
487 self.dot_isize(x, y);
488 }
489 }
490 }
491
492 pub fn triangle(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, x2: usize, y2: usize) {
494 self.line(x0, y0, x1, y1);
495 self.line(x1, y1, x2, y2);
496 self.line(x2, y2, x0, y0);
497 }
498
499 pub fn filled_triangle(
501 &mut self,
502 x0: usize,
503 y0: usize,
504 x1: usize,
505 y1: usize,
506 x2: usize,
507 y2: usize,
508 ) {
509 let vertices = [
510 (x0 as isize, y0 as isize),
511 (x1 as isize, y1 as isize),
512 (x2 as isize, y2 as isize),
513 ];
514 let min_y = vertices.iter().map(|(_, y)| *y).min().unwrap_or(0);
515 let max_y = vertices.iter().map(|(_, y)| *y).max().unwrap_or(-1);
516
517 for y in min_y..=max_y {
518 let mut intersections: Vec<f64> = Vec::new();
519
520 for edge in [(0usize, 1usize), (1usize, 2usize), (2usize, 0usize)] {
521 let (x_a, y_a) = vertices[edge.0];
522 let (x_b, y_b) = vertices[edge.1];
523 if y_a == y_b {
524 continue;
525 }
526
527 let (x_start, y_start, x_end, y_end) = if y_a < y_b {
528 (x_a, y_a, x_b, y_b)
529 } else {
530 (x_b, y_b, x_a, y_a)
531 };
532
533 if y < y_start || y >= y_end {
534 continue;
535 }
536
537 let t = (y - y_start) as f64 / (y_end - y_start) as f64;
538 intersections.push(x_start as f64 + t * (x_end - x_start) as f64);
539 }
540
541 intersections.sort_by(|a, b| a.total_cmp(b));
542 let mut i = 0usize;
543 while i + 1 < intersections.len() {
544 let x_start = intersections[i].ceil() as isize;
545 let x_end = intersections[i + 1].floor() as isize;
546 for x in x_start..=x_end {
547 self.dot_isize(x, y);
548 }
549 i += 2;
550 }
551 }
552
553 self.triangle(x0, y0, x1, y1, x2, y2);
554 }
555
556 pub fn points(&mut self, pts: &[(usize, usize)]) {
558 for &(x, y) in pts {
559 self.dot(x, y);
560 }
561 }
562
563 pub fn polyline(&mut self, pts: &[(usize, usize)]) {
565 for window in pts.windows(2) {
566 if let [(x0, y0), (x1, y1)] = window {
567 self.line(*x0, *y0, *x1, *y1);
568 }
569 }
570 }
571
572 pub fn print(&mut self, x: usize, y: usize, text: &str) {
575 if text.is_empty() {
576 return;
577 }
578
579 let color = self.current_color;
580 if let Some(layer) = self.current_layer_mut() {
581 layer.labels.push(CanvasLabel {
582 x,
583 y,
584 text: text.to_string(),
585 color,
586 });
587 }
588 }
589
590 pub fn layer(&mut self) {
592 self.layers.push(Self::new_layer(self.cols, self.rows));
593 }
594
595 pub(crate) fn render(&self) -> Vec<Vec<(String, Color)>> {
596 let mut final_grid = vec![
597 vec![
598 CanvasPixel {
599 bits: 0,
600 color: Color::Reset,
601 };
602 self.cols
603 ];
604 self.rows
605 ];
606 let mut labels_overlay: Vec<Vec<Option<(char, Color)>>> =
607 vec![vec![None; self.cols]; self.rows];
608
609 for layer in &self.layers {
610 for (row, final_row) in final_grid.iter_mut().enumerate().take(self.rows) {
611 for (col, dst) in final_row.iter_mut().enumerate().take(self.cols) {
612 let src = layer.grid[row][col];
613 if src.bits == 0 {
614 continue;
615 }
616
617 let merged = dst.bits | src.bits;
618 if merged != dst.bits {
619 dst.bits = merged;
620 dst.color = src.color;
621 }
622 }
623 }
624
625 for label in &layer.labels {
626 let row = label.y / 4;
627 if row >= self.rows {
628 continue;
629 }
630 let start_col = label.x / 2;
631 for (offset, ch) in label.text.chars().enumerate() {
632 let col = start_col + offset;
633 if col >= self.cols {
634 break;
635 }
636 labels_overlay[row][col] = Some((ch, label.color));
637 }
638 }
639 }
640
641 let mut lines: Vec<Vec<(String, Color)>> = Vec::with_capacity(self.rows);
642 for row in 0..self.rows {
643 let mut segments: Vec<(String, Color)> = Vec::new();
644 let mut current_color: Option<Color> = None;
645 let mut current_text = String::new();
646
647 for col in 0..self.cols {
648 let (ch, color) = if let Some((label_ch, label_color)) = labels_overlay[row][col] {
649 (label_ch, label_color)
650 } else {
651 let bits = final_grid[row][col].bits;
652 let ch = char::from_u32(0x2800 + bits).unwrap_or(' ');
653 (ch, final_grid[row][col].color)
654 };
655
656 match current_color {
657 Some(c) if c == color => {
658 current_text.push(ch);
659 }
660 Some(c) => {
661 segments.push((std::mem::take(&mut current_text), c));
662 current_text.push(ch);
663 current_color = Some(color);
664 }
665 None => {
666 current_text.push(ch);
667 current_color = Some(color);
668 }
669 }
670 }
671
672 if let Some(color) = current_color {
673 segments.push((current_text, color));
674 }
675 lines.push(segments);
676 }
677
678 lines
679 }
680}
681
682impl<'a> ContainerBuilder<'a> {
683 pub fn border(mut self, border: Border) -> Self {
687 self.border = Some(border);
688 self
689 }
690
691 pub fn rounded(self) -> Self {
693 self.border(Border::Rounded)
694 }
695
696 pub fn border_style(mut self, style: Style) -> Self {
698 self.border_style = style;
699 self
700 }
701
702 pub fn bg(mut self, color: Color) -> Self {
703 self.bg_color = Some(color);
704 self
705 }
706
707 pub fn p(self, value: u32) -> Self {
711 self.pad(value)
712 }
713
714 pub fn pad(mut self, value: u32) -> Self {
716 self.padding = Padding::all(value);
717 self
718 }
719
720 pub fn px(mut self, value: u32) -> Self {
722 self.padding.left = value;
723 self.padding.right = value;
724 self
725 }
726
727 pub fn py(mut self, value: u32) -> Self {
729 self.padding.top = value;
730 self.padding.bottom = value;
731 self
732 }
733
734 pub fn pt(mut self, value: u32) -> Self {
736 self.padding.top = value;
737 self
738 }
739
740 pub fn pr(mut self, value: u32) -> Self {
742 self.padding.right = value;
743 self
744 }
745
746 pub fn pb(mut self, value: u32) -> Self {
748 self.padding.bottom = value;
749 self
750 }
751
752 pub fn pl(mut self, value: u32) -> Self {
754 self.padding.left = value;
755 self
756 }
757
758 pub fn padding(mut self, padding: Padding) -> Self {
760 self.padding = padding;
761 self
762 }
763
764 pub fn m(mut self, value: u32) -> Self {
768 self.margin = Margin::all(value);
769 self
770 }
771
772 pub fn mx(mut self, value: u32) -> Self {
774 self.margin.left = value;
775 self.margin.right = value;
776 self
777 }
778
779 pub fn my(mut self, value: u32) -> Self {
781 self.margin.top = value;
782 self.margin.bottom = value;
783 self
784 }
785
786 pub fn mt(mut self, value: u32) -> Self {
788 self.margin.top = value;
789 self
790 }
791
792 pub fn mr(mut self, value: u32) -> Self {
794 self.margin.right = value;
795 self
796 }
797
798 pub fn mb(mut self, value: u32) -> Self {
800 self.margin.bottom = value;
801 self
802 }
803
804 pub fn ml(mut self, value: u32) -> Self {
806 self.margin.left = value;
807 self
808 }
809
810 pub fn margin(mut self, margin: Margin) -> Self {
812 self.margin = margin;
813 self
814 }
815
816 pub fn w(mut self, value: u32) -> Self {
820 self.constraints.min_width = Some(value);
821 self.constraints.max_width = Some(value);
822 self
823 }
824
825 pub fn h(mut self, value: u32) -> Self {
827 self.constraints.min_height = Some(value);
828 self.constraints.max_height = Some(value);
829 self
830 }
831
832 pub fn min_w(mut self, value: u32) -> Self {
834 self.constraints.min_width = Some(value);
835 self
836 }
837
838 pub fn max_w(mut self, value: u32) -> Self {
840 self.constraints.max_width = Some(value);
841 self
842 }
843
844 pub fn min_h(mut self, value: u32) -> Self {
846 self.constraints.min_height = Some(value);
847 self
848 }
849
850 pub fn max_h(mut self, value: u32) -> Self {
852 self.constraints.max_height = Some(value);
853 self
854 }
855
856 pub fn min_width(mut self, value: u32) -> Self {
858 self.constraints.min_width = Some(value);
859 self
860 }
861
862 pub fn max_width(mut self, value: u32) -> Self {
864 self.constraints.max_width = Some(value);
865 self
866 }
867
868 pub fn min_height(mut self, value: u32) -> Self {
870 self.constraints.min_height = Some(value);
871 self
872 }
873
874 pub fn max_height(mut self, value: u32) -> Self {
876 self.constraints.max_height = Some(value);
877 self
878 }
879
880 pub fn constraints(mut self, constraints: Constraints) -> Self {
882 self.constraints = constraints;
883 self
884 }
885
886 pub fn gap(mut self, gap: u32) -> Self {
890 self.gap = gap;
891 self
892 }
893
894 pub fn grow(mut self, grow: u16) -> Self {
896 self.grow = grow;
897 self
898 }
899
900 pub fn align(mut self, align: Align) -> Self {
904 self.align = align;
905 self
906 }
907
908 pub fn center(self) -> Self {
910 self.align(Align::Center)
911 }
912
913 pub fn justify(mut self, justify: Justify) -> Self {
915 self.justify = justify;
916 self
917 }
918
919 pub fn space_between(self) -> Self {
921 self.justify(Justify::SpaceBetween)
922 }
923
924 pub fn space_around(self) -> Self {
926 self.justify(Justify::SpaceAround)
927 }
928
929 pub fn space_evenly(self) -> Self {
931 self.justify(Justify::SpaceEvenly)
932 }
933
934 pub fn title(self, title: impl Into<String>) -> Self {
938 self.title_styled(title, Style::new())
939 }
940
941 pub fn title_styled(mut self, title: impl Into<String>, style: Style) -> Self {
943 self.title = Some((title.into(), style));
944 self
945 }
946
947 pub fn scroll_offset(mut self, offset: u32) -> Self {
951 self.scroll_offset = Some(offset);
952 self
953 }
954
955 pub fn col(self, f: impl FnOnce(&mut Context)) -> Response {
960 self.finish(Direction::Column, f)
961 }
962
963 pub fn row(self, f: impl FnOnce(&mut Context)) -> Response {
968 self.finish(Direction::Row, f)
969 }
970
971 fn finish(self, direction: Direction, f: impl FnOnce(&mut Context)) -> Response {
972 let interaction_id = self.ctx.interaction_count;
973 self.ctx.interaction_count += 1;
974
975 if let Some(scroll_offset) = self.scroll_offset {
976 self.ctx.commands.push(Command::BeginScrollable {
977 grow: self.grow,
978 border: self.border,
979 border_style: self.border_style,
980 padding: self.padding,
981 margin: self.margin,
982 constraints: self.constraints,
983 title: self.title,
984 scroll_offset,
985 });
986 } else {
987 self.ctx.commands.push(Command::BeginContainer {
988 direction,
989 gap: self.gap,
990 align: self.align,
991 justify: self.justify,
992 border: self.border,
993 border_style: self.border_style,
994 bg_color: self.bg_color,
995 padding: self.padding,
996 margin: self.margin,
997 constraints: self.constraints,
998 title: self.title,
999 grow: self.grow,
1000 });
1001 }
1002 f(self.ctx);
1003 self.ctx.commands.push(Command::EndContainer);
1004 self.ctx.last_text_idx = None;
1005
1006 self.ctx.response_for(interaction_id)
1007 }
1008}
1009
1010impl Context {
1011 #[allow(clippy::too_many_arguments)]
1012 pub(crate) fn new(
1013 events: Vec<Event>,
1014 width: u32,
1015 height: u32,
1016 tick: u64,
1017 mut focus_index: usize,
1018 prev_focus_count: usize,
1019 prev_scroll_infos: Vec<(u32, u32)>,
1020 prev_hit_map: Vec<Rect>,
1021 prev_focus_rects: Vec<(usize, Rect)>,
1022 debug: bool,
1023 theme: Theme,
1024 last_mouse_pos: Option<(u32, u32)>,
1025 prev_modal_active: bool,
1026 ) -> Self {
1027 let consumed = vec![false; events.len()];
1028
1029 let mut mouse_pos = last_mouse_pos;
1030 let mut click_pos = None;
1031 for event in &events {
1032 if let Event::Mouse(mouse) = event {
1033 mouse_pos = Some((mouse.x, mouse.y));
1034 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1035 click_pos = Some((mouse.x, mouse.y));
1036 }
1037 }
1038 }
1039
1040 if let Some((mx, my)) = click_pos {
1041 let mut best: Option<(usize, u64)> = None;
1042 for &(fid, rect) in &prev_focus_rects {
1043 if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
1044 let area = rect.width as u64 * rect.height as u64;
1045 if best.map_or(true, |(_, ba)| area < ba) {
1046 best = Some((fid, area));
1047 }
1048 }
1049 }
1050 if let Some((fid, _)) = best {
1051 focus_index = fid;
1052 }
1053 }
1054
1055 Self {
1056 commands: Vec::new(),
1057 events,
1058 consumed,
1059 should_quit: false,
1060 area_width: width,
1061 area_height: height,
1062 tick,
1063 focus_index,
1064 focus_count: 0,
1065 prev_focus_count,
1066 scroll_count: 0,
1067 prev_scroll_infos,
1068 interaction_count: 0,
1069 prev_hit_map,
1070 _prev_focus_rects: prev_focus_rects,
1071 mouse_pos,
1072 click_pos,
1073 last_text_idx: None,
1074 overlay_depth: 0,
1075 modal_active: false,
1076 prev_modal_active,
1077 debug,
1078 theme,
1079 }
1080 }
1081
1082 pub(crate) fn process_focus_keys(&mut self) {
1083 for (i, event) in self.events.iter().enumerate() {
1084 if let Event::Key(key) = event {
1085 if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
1086 if self.prev_focus_count > 0 {
1087 self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
1088 }
1089 self.consumed[i] = true;
1090 } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
1091 || key.code == KeyCode::BackTab
1092 {
1093 if self.prev_focus_count > 0 {
1094 self.focus_index = if self.focus_index == 0 {
1095 self.prev_focus_count - 1
1096 } else {
1097 self.focus_index - 1
1098 };
1099 }
1100 self.consumed[i] = true;
1101 }
1102 }
1103 }
1104 }
1105
1106 pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
1110 w.ui(self)
1111 }
1112
1113 pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
1128 self.error_boundary_with(f, |ui, msg| {
1129 ui.styled(
1130 format!("⚠ Error: {msg}"),
1131 Style::new().fg(ui.theme.error).bold(),
1132 );
1133 });
1134 }
1135
1136 pub fn error_boundary_with(
1156 &mut self,
1157 f: impl FnOnce(&mut Context),
1158 fallback: impl FnOnce(&mut Context, String),
1159 ) {
1160 let cmd_count = self.commands.len();
1161 let last_text_idx = self.last_text_idx;
1162
1163 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1164 f(self);
1165 }));
1166
1167 match result {
1168 Ok(()) => {}
1169 Err(panic_info) => {
1170 self.commands.truncate(cmd_count);
1171 self.last_text_idx = last_text_idx;
1172
1173 let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
1174 (*s).to_string()
1175 } else if let Some(s) = panic_info.downcast_ref::<String>() {
1176 s.clone()
1177 } else {
1178 "widget panicked".to_string()
1179 };
1180
1181 fallback(self, msg);
1182 }
1183 }
1184 }
1185
1186 pub fn interaction(&mut self) -> Response {
1192 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1193 return Response::default();
1194 }
1195 let id = self.interaction_count;
1196 self.interaction_count += 1;
1197 self.response_for(id)
1198 }
1199
1200 pub fn register_focusable(&mut self) -> bool {
1205 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1206 return false;
1207 }
1208 let id = self.focus_count;
1209 self.focus_count += 1;
1210 self.commands.push(Command::FocusMarker(id));
1211 if self.prev_focus_count == 0 {
1212 return true;
1213 }
1214 self.focus_index % self.prev_focus_count == id
1215 }
1216
1217 pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
1230 let content = s.into();
1231 self.commands.push(Command::Text {
1232 content,
1233 style: Style::new(),
1234 grow: 0,
1235 align: Align::Start,
1236 wrap: false,
1237 margin: Margin::default(),
1238 constraints: Constraints::default(),
1239 });
1240 self.last_text_idx = Some(self.commands.len() - 1);
1241 self
1242 }
1243
1244 pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
1250 let url_str = url.into();
1251 let focused = self.register_focusable();
1252 let interaction_id = self.interaction_count;
1253 self.interaction_count += 1;
1254 let response = self.response_for(interaction_id);
1255
1256 let mut activated = response.clicked;
1257 if focused {
1258 for (i, event) in self.events.iter().enumerate() {
1259 if let Event::Key(key) = event {
1260 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1261 activated = true;
1262 self.consumed[i] = true;
1263 }
1264 }
1265 }
1266 }
1267
1268 if activated {
1269 let _ = open_url(&url_str);
1270 }
1271
1272 let style = if focused {
1273 Style::new()
1274 .fg(self.theme.primary)
1275 .bg(self.theme.surface_hover)
1276 .underline()
1277 .bold()
1278 } else if response.hovered {
1279 Style::new()
1280 .fg(self.theme.accent)
1281 .bg(self.theme.surface_hover)
1282 .underline()
1283 } else {
1284 Style::new().fg(self.theme.primary).underline()
1285 };
1286
1287 self.commands.push(Command::Link {
1288 text: text.into(),
1289 url: url_str,
1290 style,
1291 margin: Margin::default(),
1292 constraints: Constraints::default(),
1293 });
1294 self.last_text_idx = Some(self.commands.len() - 1);
1295 self
1296 }
1297
1298 pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
1303 let content = s.into();
1304 self.commands.push(Command::Text {
1305 content,
1306 style: Style::new(),
1307 grow: 0,
1308 align: Align::Start,
1309 wrap: true,
1310 margin: Margin::default(),
1311 constraints: Constraints::default(),
1312 });
1313 self.last_text_idx = Some(self.commands.len() - 1);
1314 self
1315 }
1316
1317 pub fn bold(&mut self) -> &mut Self {
1321 self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
1322 self
1323 }
1324
1325 pub fn dim(&mut self) -> &mut Self {
1330 let text_dim = self.theme.text_dim;
1331 self.modify_last_style(|s| {
1332 s.modifiers |= Modifiers::DIM;
1333 if s.fg.is_none() {
1334 s.fg = Some(text_dim);
1335 }
1336 });
1337 self
1338 }
1339
1340 pub fn italic(&mut self) -> &mut Self {
1342 self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
1343 self
1344 }
1345
1346 pub fn underline(&mut self) -> &mut Self {
1348 self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
1349 self
1350 }
1351
1352 pub fn reversed(&mut self) -> &mut Self {
1354 self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
1355 self
1356 }
1357
1358 pub fn strikethrough(&mut self) -> &mut Self {
1360 self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
1361 self
1362 }
1363
1364 pub fn fg(&mut self, color: Color) -> &mut Self {
1366 self.modify_last_style(|s| s.fg = Some(color));
1367 self
1368 }
1369
1370 pub fn bg(&mut self, color: Color) -> &mut Self {
1372 self.modify_last_style(|s| s.bg = Some(color));
1373 self
1374 }
1375
1376 pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
1381 self.commands.push(Command::Text {
1382 content: s.into(),
1383 style,
1384 grow: 0,
1385 align: Align::Start,
1386 wrap: false,
1387 margin: Margin::default(),
1388 constraints: Constraints::default(),
1389 });
1390 self.last_text_idx = Some(self.commands.len() - 1);
1391 self
1392 }
1393
1394 pub fn wrap(&mut self) -> &mut Self {
1396 if let Some(idx) = self.last_text_idx {
1397 if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1398 *wrap = true;
1399 }
1400 }
1401 self
1402 }
1403
1404 fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1405 if let Some(idx) = self.last_text_idx {
1406 match &mut self.commands[idx] {
1407 Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1408 _ => {}
1409 }
1410 }
1411 }
1412
1413 pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1431 self.push_container(Direction::Column, 0, f)
1432 }
1433
1434 pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1438 self.push_container(Direction::Column, gap, f)
1439 }
1440
1441 pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1458 self.push_container(Direction::Row, 0, f)
1459 }
1460
1461 pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1465 self.push_container(Direction::Row, gap, f)
1466 }
1467
1468 pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
1469 self.commands.push(Command::BeginOverlay { modal: true });
1470 self.overlay_depth += 1;
1471 self.modal_active = true;
1472 f(self);
1473 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1474 self.commands.push(Command::EndOverlay);
1475 self.last_text_idx = None;
1476 }
1477
1478 pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
1479 self.commands.push(Command::BeginOverlay { modal: false });
1480 self.overlay_depth += 1;
1481 f(self);
1482 self.overlay_depth = self.overlay_depth.saturating_sub(1);
1483 self.commands.push(Command::EndOverlay);
1484 self.last_text_idx = None;
1485 }
1486
1487 pub fn container(&mut self) -> ContainerBuilder<'_> {
1508 let border = self.theme.border;
1509 ContainerBuilder {
1510 ctx: self,
1511 gap: 0,
1512 align: Align::Start,
1513 justify: Justify::Start,
1514 border: None,
1515 border_style: Style::new().fg(border),
1516 bg_color: None,
1517 padding: Padding::default(),
1518 margin: Margin::default(),
1519 constraints: Constraints::default(),
1520 title: None,
1521 grow: 0,
1522 scroll_offset: None,
1523 }
1524 }
1525
1526 pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1545 let index = self.scroll_count;
1546 self.scroll_count += 1;
1547 if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1548 state.set_bounds(ch, vh);
1549 let max = ch.saturating_sub(vh) as usize;
1550 state.offset = state.offset.min(max);
1551 }
1552
1553 let next_id = self.interaction_count;
1554 if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1555 self.auto_scroll(&rect, state);
1556 }
1557
1558 self.container().scroll_offset(state.offset as u32)
1559 }
1560
1561 fn auto_scroll(&mut self, rect: &Rect, state: &mut ScrollState) {
1562 let mut to_consume: Vec<usize> = Vec::new();
1563
1564 for (i, event) in self.events.iter().enumerate() {
1565 if self.consumed[i] {
1566 continue;
1567 }
1568 if let Event::Mouse(mouse) = event {
1569 let in_bounds = mouse.x >= rect.x
1570 && mouse.x < rect.right()
1571 && mouse.y >= rect.y
1572 && mouse.y < rect.bottom();
1573 if !in_bounds {
1574 continue;
1575 }
1576 match mouse.kind {
1577 MouseKind::ScrollUp => {
1578 state.scroll_up(1);
1579 to_consume.push(i);
1580 }
1581 MouseKind::ScrollDown => {
1582 state.scroll_down(1);
1583 to_consume.push(i);
1584 }
1585 MouseKind::Drag(MouseButton::Left) => {
1586 }
1589 _ => {}
1590 }
1591 }
1592 }
1593
1594 for i in to_consume {
1595 self.consumed[i] = true;
1596 }
1597 }
1598
1599 pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1603 self.container().border(border)
1604 }
1605
1606 fn push_container(
1607 &mut self,
1608 direction: Direction,
1609 gap: u32,
1610 f: impl FnOnce(&mut Context),
1611 ) -> Response {
1612 let interaction_id = self.interaction_count;
1613 self.interaction_count += 1;
1614 let border = self.theme.border;
1615
1616 self.commands.push(Command::BeginContainer {
1617 direction,
1618 gap,
1619 align: Align::Start,
1620 justify: Justify::Start,
1621 border: None,
1622 border_style: Style::new().fg(border),
1623 bg_color: None,
1624 padding: Padding::default(),
1625 margin: Margin::default(),
1626 constraints: Constraints::default(),
1627 title: None,
1628 grow: 0,
1629 });
1630 f(self);
1631 self.commands.push(Command::EndContainer);
1632 self.last_text_idx = None;
1633
1634 self.response_for(interaction_id)
1635 }
1636
1637 fn response_for(&self, interaction_id: usize) -> Response {
1638 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1639 return Response::default();
1640 }
1641 if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1642 let clicked = self
1643 .click_pos
1644 .map(|(mx, my)| {
1645 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1646 })
1647 .unwrap_or(false);
1648 let hovered = self
1649 .mouse_pos
1650 .map(|(mx, my)| {
1651 mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1652 })
1653 .unwrap_or(false);
1654 Response { clicked, hovered }
1655 } else {
1656 Response::default()
1657 }
1658 }
1659
1660 pub fn grow(&mut self, value: u16) -> &mut Self {
1665 if let Some(idx) = self.last_text_idx {
1666 if let Command::Text { grow, .. } = &mut self.commands[idx] {
1667 *grow = value;
1668 }
1669 }
1670 self
1671 }
1672
1673 pub fn align(&mut self, align: Align) -> &mut Self {
1675 if let Some(idx) = self.last_text_idx {
1676 if let Command::Text {
1677 align: text_align, ..
1678 } = &mut self.commands[idx]
1679 {
1680 *text_align = align;
1681 }
1682 }
1683 self
1684 }
1685
1686 pub fn spacer(&mut self) -> &mut Self {
1690 self.commands.push(Command::Spacer { grow: 1 });
1691 self.last_text_idx = None;
1692 self
1693 }
1694
1695 pub fn form(
1699 &mut self,
1700 state: &mut FormState,
1701 f: impl FnOnce(&mut Context, &mut FormState),
1702 ) -> &mut Self {
1703 self.col(|ui| {
1704 f(ui, state);
1705 });
1706 self
1707 }
1708
1709 pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1713 self.col(|ui| {
1714 ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1715 ui.text_input(&mut field.input);
1716 if let Some(error) = field.error.as_deref() {
1717 ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1718 }
1719 });
1720 self
1721 }
1722
1723 pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
1727 self.button(label)
1728 }
1729
1730 pub fn text_input(&mut self, state: &mut TextInputState) -> &mut Self {
1746 slt_assert(
1747 !state.value.contains('\n'),
1748 "text_input got a newline — use textarea instead",
1749 );
1750 let focused = self.register_focusable();
1751 state.cursor = state.cursor.min(state.value.chars().count());
1752
1753 if focused {
1754 let mut consumed_indices = Vec::new();
1755 for (i, event) in self.events.iter().enumerate() {
1756 if let Event::Key(key) = event {
1757 match key.code {
1758 KeyCode::Char(ch) => {
1759 if let Some(max) = state.max_length {
1760 if state.value.chars().count() >= max {
1761 continue;
1762 }
1763 }
1764 let index = byte_index_for_char(&state.value, state.cursor);
1765 state.value.insert(index, ch);
1766 state.cursor += 1;
1767 consumed_indices.push(i);
1768 }
1769 KeyCode::Backspace => {
1770 if state.cursor > 0 {
1771 let start = byte_index_for_char(&state.value, state.cursor - 1);
1772 let end = byte_index_for_char(&state.value, state.cursor);
1773 state.value.replace_range(start..end, "");
1774 state.cursor -= 1;
1775 }
1776 consumed_indices.push(i);
1777 }
1778 KeyCode::Left => {
1779 state.cursor = state.cursor.saturating_sub(1);
1780 consumed_indices.push(i);
1781 }
1782 KeyCode::Right => {
1783 state.cursor = (state.cursor + 1).min(state.value.chars().count());
1784 consumed_indices.push(i);
1785 }
1786 KeyCode::Home => {
1787 state.cursor = 0;
1788 consumed_indices.push(i);
1789 }
1790 KeyCode::Delete => {
1791 let len = state.value.chars().count();
1792 if state.cursor < len {
1793 let start = byte_index_for_char(&state.value, state.cursor);
1794 let end = byte_index_for_char(&state.value, state.cursor + 1);
1795 state.value.replace_range(start..end, "");
1796 }
1797 consumed_indices.push(i);
1798 }
1799 KeyCode::End => {
1800 state.cursor = state.value.chars().count();
1801 consumed_indices.push(i);
1802 }
1803 _ => {}
1804 }
1805 }
1806 if let Event::Paste(ref text) = event {
1807 for ch in text.chars() {
1808 if let Some(max) = state.max_length {
1809 if state.value.chars().count() >= max {
1810 break;
1811 }
1812 }
1813 let index = byte_index_for_char(&state.value, state.cursor);
1814 state.value.insert(index, ch);
1815 state.cursor += 1;
1816 }
1817 consumed_indices.push(i);
1818 }
1819 }
1820
1821 for index in consumed_indices {
1822 self.consumed[index] = true;
1823 }
1824 }
1825
1826 let show_cursor = focused && (self.tick / 30).is_multiple_of(2);
1827
1828 let input_text = if state.value.is_empty() {
1829 if state.placeholder.len() > 100 {
1830 slt_warn(
1831 "text_input placeholder is very long (>100 chars) — consider shortening it",
1832 );
1833 }
1834 state.placeholder.clone()
1835 } else {
1836 let mut rendered = String::new();
1837 for (idx, ch) in state.value.chars().enumerate() {
1838 if show_cursor && idx == state.cursor {
1839 rendered.push('▎');
1840 }
1841 rendered.push(ch);
1842 }
1843 if show_cursor && state.cursor >= state.value.chars().count() {
1844 rendered.push('▎');
1845 }
1846 rendered
1847 };
1848 let input_style = if state.value.is_empty() {
1849 Style::new().dim().fg(self.theme.text_dim)
1850 } else {
1851 Style::new().fg(self.theme.text)
1852 };
1853
1854 let border_color = if focused {
1855 self.theme.primary
1856 } else if state.validation_error.is_some() {
1857 self.theme.error
1858 } else {
1859 self.theme.border
1860 };
1861
1862 self.bordered(Border::Rounded)
1863 .border_style(Style::new().fg(border_color))
1864 .px(1)
1865 .col(|ui| {
1866 ui.styled(input_text, input_style);
1867 });
1868
1869 if let Some(error) = state.validation_error.clone() {
1870 self.styled(
1871 format!("⚠ {error}"),
1872 Style::new().dim().fg(self.theme.error),
1873 );
1874 }
1875 self
1876 }
1877
1878 pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
1884 self.styled(
1885 state.frame(self.tick).to_string(),
1886 Style::new().fg(self.theme.primary),
1887 )
1888 }
1889
1890 pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
1895 state.cleanup(self.tick);
1896 if state.messages.is_empty() {
1897 return self;
1898 }
1899
1900 self.interaction_count += 1;
1901 self.commands.push(Command::BeginContainer {
1902 direction: Direction::Column,
1903 gap: 0,
1904 align: Align::Start,
1905 justify: Justify::Start,
1906 border: None,
1907 border_style: Style::new().fg(self.theme.border),
1908 bg_color: None,
1909 padding: Padding::default(),
1910 margin: Margin::default(),
1911 constraints: Constraints::default(),
1912 title: None,
1913 grow: 0,
1914 });
1915 for message in state.messages.iter().rev() {
1916 let color = match message.level {
1917 ToastLevel::Info => self.theme.primary,
1918 ToastLevel::Success => self.theme.success,
1919 ToastLevel::Warning => self.theme.warning,
1920 ToastLevel::Error => self.theme.error,
1921 };
1922 self.styled(format!(" ● {}", message.text), Style::new().fg(color));
1923 }
1924 self.commands.push(Command::EndContainer);
1925 self.last_text_idx = None;
1926
1927 self
1928 }
1929
1930 pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> &mut Self {
1938 if state.lines.is_empty() {
1939 state.lines.push(String::new());
1940 }
1941 state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
1942 state.cursor_col = state
1943 .cursor_col
1944 .min(state.lines[state.cursor_row].chars().count());
1945
1946 let focused = self.register_focusable();
1947 let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
1948 let wrapping = state.wrap_width.is_some();
1949
1950 let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
1951
1952 if focused {
1953 let mut consumed_indices = Vec::new();
1954 for (i, event) in self.events.iter().enumerate() {
1955 if let Event::Key(key) = event {
1956 match key.code {
1957 KeyCode::Char(ch) => {
1958 if let Some(max) = state.max_length {
1959 let total: usize =
1960 state.lines.iter().map(|line| line.chars().count()).sum();
1961 if total >= max {
1962 continue;
1963 }
1964 }
1965 let index = byte_index_for_char(
1966 &state.lines[state.cursor_row],
1967 state.cursor_col,
1968 );
1969 state.lines[state.cursor_row].insert(index, ch);
1970 state.cursor_col += 1;
1971 consumed_indices.push(i);
1972 }
1973 KeyCode::Enter => {
1974 let split_index = byte_index_for_char(
1975 &state.lines[state.cursor_row],
1976 state.cursor_col,
1977 );
1978 let remainder = state.lines[state.cursor_row].split_off(split_index);
1979 state.cursor_row += 1;
1980 state.lines.insert(state.cursor_row, remainder);
1981 state.cursor_col = 0;
1982 consumed_indices.push(i);
1983 }
1984 KeyCode::Backspace => {
1985 if state.cursor_col > 0 {
1986 let start = byte_index_for_char(
1987 &state.lines[state.cursor_row],
1988 state.cursor_col - 1,
1989 );
1990 let end = byte_index_for_char(
1991 &state.lines[state.cursor_row],
1992 state.cursor_col,
1993 );
1994 state.lines[state.cursor_row].replace_range(start..end, "");
1995 state.cursor_col -= 1;
1996 } else if state.cursor_row > 0 {
1997 let current = state.lines.remove(state.cursor_row);
1998 state.cursor_row -= 1;
1999 state.cursor_col = state.lines[state.cursor_row].chars().count();
2000 state.lines[state.cursor_row].push_str(¤t);
2001 }
2002 consumed_indices.push(i);
2003 }
2004 KeyCode::Left => {
2005 if state.cursor_col > 0 {
2006 state.cursor_col -= 1;
2007 } else if state.cursor_row > 0 {
2008 state.cursor_row -= 1;
2009 state.cursor_col = state.lines[state.cursor_row].chars().count();
2010 }
2011 consumed_indices.push(i);
2012 }
2013 KeyCode::Right => {
2014 let line_len = state.lines[state.cursor_row].chars().count();
2015 if state.cursor_col < line_len {
2016 state.cursor_col += 1;
2017 } else if state.cursor_row + 1 < state.lines.len() {
2018 state.cursor_row += 1;
2019 state.cursor_col = 0;
2020 }
2021 consumed_indices.push(i);
2022 }
2023 KeyCode::Up => {
2024 if wrapping {
2025 let (vrow, vcol) = textarea_logical_to_visual(
2026 &pre_vlines,
2027 state.cursor_row,
2028 state.cursor_col,
2029 );
2030 if vrow > 0 {
2031 let (lr, lc) =
2032 textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
2033 state.cursor_row = lr;
2034 state.cursor_col = lc;
2035 }
2036 } else if state.cursor_row > 0 {
2037 state.cursor_row -= 1;
2038 state.cursor_col = state
2039 .cursor_col
2040 .min(state.lines[state.cursor_row].chars().count());
2041 }
2042 consumed_indices.push(i);
2043 }
2044 KeyCode::Down => {
2045 if wrapping {
2046 let (vrow, vcol) = textarea_logical_to_visual(
2047 &pre_vlines,
2048 state.cursor_row,
2049 state.cursor_col,
2050 );
2051 if vrow + 1 < pre_vlines.len() {
2052 let (lr, lc) =
2053 textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
2054 state.cursor_row = lr;
2055 state.cursor_col = lc;
2056 }
2057 } else if state.cursor_row + 1 < state.lines.len() {
2058 state.cursor_row += 1;
2059 state.cursor_col = state
2060 .cursor_col
2061 .min(state.lines[state.cursor_row].chars().count());
2062 }
2063 consumed_indices.push(i);
2064 }
2065 KeyCode::Home => {
2066 state.cursor_col = 0;
2067 consumed_indices.push(i);
2068 }
2069 KeyCode::Delete => {
2070 let line_len = state.lines[state.cursor_row].chars().count();
2071 if state.cursor_col < line_len {
2072 let start = byte_index_for_char(
2073 &state.lines[state.cursor_row],
2074 state.cursor_col,
2075 );
2076 let end = byte_index_for_char(
2077 &state.lines[state.cursor_row],
2078 state.cursor_col + 1,
2079 );
2080 state.lines[state.cursor_row].replace_range(start..end, "");
2081 } else if state.cursor_row + 1 < state.lines.len() {
2082 let next = state.lines.remove(state.cursor_row + 1);
2083 state.lines[state.cursor_row].push_str(&next);
2084 }
2085 consumed_indices.push(i);
2086 }
2087 KeyCode::End => {
2088 state.cursor_col = state.lines[state.cursor_row].chars().count();
2089 consumed_indices.push(i);
2090 }
2091 _ => {}
2092 }
2093 }
2094 if let Event::Paste(ref text) = event {
2095 for ch in text.chars() {
2096 if ch == '\n' || ch == '\r' {
2097 let split_index = byte_index_for_char(
2098 &state.lines[state.cursor_row],
2099 state.cursor_col,
2100 );
2101 let remainder = state.lines[state.cursor_row].split_off(split_index);
2102 state.cursor_row += 1;
2103 state.lines.insert(state.cursor_row, remainder);
2104 state.cursor_col = 0;
2105 } else {
2106 if let Some(max) = state.max_length {
2107 let total: usize =
2108 state.lines.iter().map(|l| l.chars().count()).sum();
2109 if total >= max {
2110 break;
2111 }
2112 }
2113 let index = byte_index_for_char(
2114 &state.lines[state.cursor_row],
2115 state.cursor_col,
2116 );
2117 state.lines[state.cursor_row].insert(index, ch);
2118 state.cursor_col += 1;
2119 }
2120 }
2121 consumed_indices.push(i);
2122 }
2123 }
2124
2125 for index in consumed_indices {
2126 self.consumed[index] = true;
2127 }
2128 }
2129
2130 let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2131 let (cursor_vrow, cursor_vcol) =
2132 textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
2133
2134 if cursor_vrow < state.scroll_offset {
2135 state.scroll_offset = cursor_vrow;
2136 }
2137 if cursor_vrow >= state.scroll_offset + visible_rows as usize {
2138 state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
2139 }
2140
2141 self.interaction_count += 1;
2142 self.commands.push(Command::BeginContainer {
2143 direction: Direction::Column,
2144 gap: 0,
2145 align: Align::Start,
2146 justify: Justify::Start,
2147 border: None,
2148 border_style: Style::new().fg(self.theme.border),
2149 bg_color: None,
2150 padding: Padding::default(),
2151 margin: Margin::default(),
2152 constraints: Constraints::default(),
2153 title: None,
2154 grow: 0,
2155 });
2156
2157 let show_cursor = focused && (self.tick / 30).is_multiple_of(2);
2158 for vi in 0..visible_rows as usize {
2159 let actual_vi = state.scroll_offset + vi;
2160 let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
2161 let line = &state.lines[vl.logical_row];
2162 let text: String = line
2163 .chars()
2164 .skip(vl.char_start)
2165 .take(vl.char_count)
2166 .collect();
2167 (text, actual_vi == cursor_vrow)
2168 } else {
2169 (String::new(), false)
2170 };
2171
2172 let mut rendered = seg_text.clone();
2173 let mut style = if seg_text.is_empty() {
2174 Style::new().fg(self.theme.text_dim)
2175 } else {
2176 Style::new().fg(self.theme.text)
2177 };
2178
2179 if is_cursor_line {
2180 rendered.clear();
2181 for (idx, ch) in seg_text.chars().enumerate() {
2182 if show_cursor && idx == cursor_vcol {
2183 rendered.push('▎');
2184 }
2185 rendered.push(ch);
2186 }
2187 if show_cursor && cursor_vcol >= seg_text.chars().count() {
2188 rendered.push('▎');
2189 }
2190 style = Style::new().fg(self.theme.text);
2191 }
2192
2193 self.styled(rendered, style);
2194 }
2195 self.commands.push(Command::EndContainer);
2196 self.last_text_idx = None;
2197
2198 self
2199 }
2200
2201 pub fn progress(&mut self, ratio: f64) -> &mut Self {
2206 self.progress_bar(ratio, 20)
2207 }
2208
2209 pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
2214 let clamped = ratio.clamp(0.0, 1.0);
2215 let filled = (clamped * width as f64).round() as u32;
2216 let empty = width.saturating_sub(filled);
2217 let mut bar = String::new();
2218 for _ in 0..filled {
2219 bar.push('█');
2220 }
2221 for _ in 0..empty {
2222 bar.push('░');
2223 }
2224 self.text(bar)
2225 }
2226
2227 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
2248 if data.is_empty() {
2249 return self;
2250 }
2251
2252 let max_label_width = data
2253 .iter()
2254 .map(|(label, _)| UnicodeWidthStr::width(*label))
2255 .max()
2256 .unwrap_or(0);
2257 let max_value = data
2258 .iter()
2259 .map(|(_, value)| *value)
2260 .fold(f64::NEG_INFINITY, f64::max);
2261 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2262
2263 self.interaction_count += 1;
2264 self.commands.push(Command::BeginContainer {
2265 direction: Direction::Column,
2266 gap: 0,
2267 align: Align::Start,
2268 justify: Justify::Start,
2269 border: None,
2270 border_style: Style::new().fg(self.theme.border),
2271 bg_color: None,
2272 padding: Padding::default(),
2273 margin: Margin::default(),
2274 constraints: Constraints::default(),
2275 title: None,
2276 grow: 0,
2277 });
2278
2279 for (label, value) in data {
2280 let label_width = UnicodeWidthStr::width(*label);
2281 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2282 let normalized = (*value / denom).clamp(0.0, 1.0);
2283 let bar_len = (normalized * max_width as f64).round() as usize;
2284 let bar = "█".repeat(bar_len);
2285
2286 self.interaction_count += 1;
2287 self.commands.push(Command::BeginContainer {
2288 direction: Direction::Row,
2289 gap: 1,
2290 align: Align::Start,
2291 justify: Justify::Start,
2292 border: None,
2293 border_style: Style::new().fg(self.theme.border),
2294 bg_color: None,
2295 padding: Padding::default(),
2296 margin: Margin::default(),
2297 constraints: Constraints::default(),
2298 title: None,
2299 grow: 0,
2300 });
2301 self.styled(
2302 format!("{label}{label_padding}"),
2303 Style::new().fg(self.theme.text),
2304 );
2305 self.styled(bar, Style::new().fg(self.theme.primary));
2306 self.styled(
2307 format_compact_number(*value),
2308 Style::new().fg(self.theme.text_dim),
2309 );
2310 self.commands.push(Command::EndContainer);
2311 self.last_text_idx = None;
2312 }
2313
2314 self.commands.push(Command::EndContainer);
2315 self.last_text_idx = None;
2316
2317 self
2318 }
2319
2320 pub fn bar_chart_styled(
2336 &mut self,
2337 bars: &[Bar],
2338 max_width: u32,
2339 direction: BarDirection,
2340 ) -> &mut Self {
2341 if bars.is_empty() {
2342 return self;
2343 }
2344
2345 let max_value = bars
2346 .iter()
2347 .map(|bar| bar.value)
2348 .fold(f64::NEG_INFINITY, f64::max);
2349 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2350
2351 match direction {
2352 BarDirection::Horizontal => {
2353 let max_label_width = bars
2354 .iter()
2355 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2356 .max()
2357 .unwrap_or(0);
2358
2359 self.interaction_count += 1;
2360 self.commands.push(Command::BeginContainer {
2361 direction: Direction::Column,
2362 gap: 0,
2363 align: Align::Start,
2364 justify: Justify::Start,
2365 border: None,
2366 border_style: Style::new().fg(self.theme.border),
2367 bg_color: None,
2368 padding: Padding::default(),
2369 margin: Margin::default(),
2370 constraints: Constraints::default(),
2371 title: None,
2372 grow: 0,
2373 });
2374
2375 for bar in bars {
2376 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2377 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2378 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2379 let bar_len = (normalized * max_width as f64).round() as usize;
2380 let bar_text = "█".repeat(bar_len);
2381 let color = bar.color.unwrap_or(self.theme.primary);
2382
2383 self.interaction_count += 1;
2384 self.commands.push(Command::BeginContainer {
2385 direction: Direction::Row,
2386 gap: 1,
2387 align: Align::Start,
2388 justify: Justify::Start,
2389 border: None,
2390 border_style: Style::new().fg(self.theme.border),
2391 bg_color: None,
2392 padding: Padding::default(),
2393 margin: Margin::default(),
2394 constraints: Constraints::default(),
2395 title: None,
2396 grow: 0,
2397 });
2398 self.styled(
2399 format!("{}{label_padding}", bar.label),
2400 Style::new().fg(self.theme.text),
2401 );
2402 self.styled(bar_text, Style::new().fg(color));
2403 self.styled(
2404 format_compact_number(bar.value),
2405 Style::new().fg(self.theme.text_dim),
2406 );
2407 self.commands.push(Command::EndContainer);
2408 self.last_text_idx = None;
2409 }
2410
2411 self.commands.push(Command::EndContainer);
2412 self.last_text_idx = None;
2413 }
2414 BarDirection::Vertical => {
2415 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
2416
2417 let chart_height = max_width.max(1) as usize;
2418 let value_labels: Vec<String> = bars
2419 .iter()
2420 .map(|bar| format_compact_number(bar.value))
2421 .collect();
2422 let col_width = bars
2423 .iter()
2424 .zip(value_labels.iter())
2425 .map(|(bar, value)| {
2426 UnicodeWidthStr::width(bar.label.as_str())
2427 .max(UnicodeWidthStr::width(value.as_str()))
2428 .max(1)
2429 })
2430 .max()
2431 .unwrap_or(1);
2432
2433 let bar_units: Vec<usize> = bars
2434 .iter()
2435 .map(|bar| {
2436 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2437 (normalized * chart_height as f64 * 8.0).round() as usize
2438 })
2439 .collect();
2440
2441 self.interaction_count += 1;
2442 self.commands.push(Command::BeginContainer {
2443 direction: Direction::Column,
2444 gap: 0,
2445 align: Align::Start,
2446 justify: Justify::Start,
2447 border: None,
2448 border_style: Style::new().fg(self.theme.border),
2449 bg_color: None,
2450 padding: Padding::default(),
2451 margin: Margin::default(),
2452 constraints: Constraints::default(),
2453 title: None,
2454 grow: 0,
2455 });
2456
2457 self.interaction_count += 1;
2458 self.commands.push(Command::BeginContainer {
2459 direction: Direction::Row,
2460 gap: 1,
2461 align: Align::Start,
2462 justify: Justify::Start,
2463 border: None,
2464 border_style: Style::new().fg(self.theme.border),
2465 bg_color: None,
2466 padding: Padding::default(),
2467 margin: Margin::default(),
2468 constraints: Constraints::default(),
2469 title: None,
2470 grow: 0,
2471 });
2472 for value in &value_labels {
2473 self.styled(
2474 center_text(value, col_width),
2475 Style::new().fg(self.theme.text_dim),
2476 );
2477 }
2478 self.commands.push(Command::EndContainer);
2479 self.last_text_idx = None;
2480
2481 for row in (0..chart_height).rev() {
2482 self.interaction_count += 1;
2483 self.commands.push(Command::BeginContainer {
2484 direction: Direction::Row,
2485 gap: 1,
2486 align: Align::Start,
2487 justify: Justify::Start,
2488 border: None,
2489 border_style: Style::new().fg(self.theme.border),
2490 bg_color: None,
2491 padding: Padding::default(),
2492 margin: Margin::default(),
2493 constraints: Constraints::default(),
2494 title: None,
2495 grow: 0,
2496 });
2497
2498 let row_base = row * 8;
2499 for (bar, units) in bars.iter().zip(bar_units.iter()) {
2500 let fill = if *units <= row_base {
2501 ' '
2502 } else {
2503 let delta = *units - row_base;
2504 if delta >= 8 {
2505 '█'
2506 } else {
2507 FRACTION_BLOCKS[delta]
2508 }
2509 };
2510
2511 self.styled(
2512 center_text(&fill.to_string(), col_width),
2513 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2514 );
2515 }
2516
2517 self.commands.push(Command::EndContainer);
2518 self.last_text_idx = None;
2519 }
2520
2521 self.interaction_count += 1;
2522 self.commands.push(Command::BeginContainer {
2523 direction: Direction::Row,
2524 gap: 1,
2525 align: Align::Start,
2526 justify: Justify::Start,
2527 border: None,
2528 border_style: Style::new().fg(self.theme.border),
2529 bg_color: None,
2530 padding: Padding::default(),
2531 margin: Margin::default(),
2532 constraints: Constraints::default(),
2533 title: None,
2534 grow: 0,
2535 });
2536 for bar in bars {
2537 self.styled(
2538 center_text(&bar.label, col_width),
2539 Style::new().fg(self.theme.text),
2540 );
2541 }
2542 self.commands.push(Command::EndContainer);
2543 self.last_text_idx = None;
2544
2545 self.commands.push(Command::EndContainer);
2546 self.last_text_idx = None;
2547 }
2548 }
2549
2550 self
2551 }
2552
2553 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
2570 if groups.is_empty() {
2571 return self;
2572 }
2573
2574 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
2575 if all_bars.is_empty() {
2576 return self;
2577 }
2578
2579 let max_label_width = all_bars
2580 .iter()
2581 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2582 .max()
2583 .unwrap_or(0);
2584 let max_value = all_bars
2585 .iter()
2586 .map(|bar| bar.value)
2587 .fold(f64::NEG_INFINITY, f64::max);
2588 let denom = if max_value > 0.0 { max_value } else { 1.0 };
2589
2590 self.interaction_count += 1;
2591 self.commands.push(Command::BeginContainer {
2592 direction: Direction::Column,
2593 gap: 1,
2594 align: Align::Start,
2595 justify: Justify::Start,
2596 border: None,
2597 border_style: Style::new().fg(self.theme.border),
2598 bg_color: None,
2599 padding: Padding::default(),
2600 margin: Margin::default(),
2601 constraints: Constraints::default(),
2602 title: None,
2603 grow: 0,
2604 });
2605
2606 for group in groups {
2607 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
2608
2609 for bar in &group.bars {
2610 let label_width = UnicodeWidthStr::width(bar.label.as_str());
2611 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2612 let normalized = (bar.value / denom).clamp(0.0, 1.0);
2613 let bar_len = (normalized * max_width as f64).round() as usize;
2614 let bar_text = "█".repeat(bar_len);
2615
2616 self.interaction_count += 1;
2617 self.commands.push(Command::BeginContainer {
2618 direction: Direction::Row,
2619 gap: 1,
2620 align: Align::Start,
2621 justify: Justify::Start,
2622 border: None,
2623 border_style: Style::new().fg(self.theme.border),
2624 bg_color: None,
2625 padding: Padding::default(),
2626 margin: Margin::default(),
2627 constraints: Constraints::default(),
2628 title: None,
2629 grow: 0,
2630 });
2631 self.styled(
2632 format!(" {}{label_padding}", bar.label),
2633 Style::new().fg(self.theme.text),
2634 );
2635 self.styled(
2636 bar_text,
2637 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2638 );
2639 self.styled(
2640 format_compact_number(bar.value),
2641 Style::new().fg(self.theme.text_dim),
2642 );
2643 self.commands.push(Command::EndContainer);
2644 self.last_text_idx = None;
2645 }
2646 }
2647
2648 self.commands.push(Command::EndContainer);
2649 self.last_text_idx = None;
2650
2651 self
2652 }
2653
2654 pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
2670 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2671
2672 let w = width as usize;
2673 let window = if data.len() > w {
2674 &data[data.len() - w..]
2675 } else {
2676 data
2677 };
2678
2679 if window.is_empty() {
2680 return self;
2681 }
2682
2683 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
2684 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2685 let range = max - min;
2686
2687 let line: String = window
2688 .iter()
2689 .map(|&value| {
2690 let normalized = if range == 0.0 {
2691 0.5
2692 } else {
2693 (value - min) / range
2694 };
2695 let idx = (normalized * 7.0).round() as usize;
2696 BLOCKS[idx.min(7)]
2697 })
2698 .collect();
2699
2700 self.styled(line, Style::new().fg(self.theme.primary))
2701 }
2702
2703 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
2723 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2724
2725 let w = width as usize;
2726 let window = if data.len() > w {
2727 &data[data.len() - w..]
2728 } else {
2729 data
2730 };
2731
2732 if window.is_empty() {
2733 return self;
2734 }
2735
2736 let mut finite_values = window
2737 .iter()
2738 .map(|(value, _)| *value)
2739 .filter(|value| !value.is_nan());
2740 let Some(first) = finite_values.next() else {
2741 return self.styled(
2742 " ".repeat(window.len()),
2743 Style::new().fg(self.theme.text_dim),
2744 );
2745 };
2746
2747 let mut min = first;
2748 let mut max = first;
2749 for value in finite_values {
2750 min = f64::min(min, value);
2751 max = f64::max(max, value);
2752 }
2753 let range = max - min;
2754
2755 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
2756 for (value, color) in window {
2757 if value.is_nan() {
2758 cells.push((' ', self.theme.text_dim));
2759 continue;
2760 }
2761
2762 let normalized = if range == 0.0 {
2763 0.5
2764 } else {
2765 ((*value - min) / range).clamp(0.0, 1.0)
2766 };
2767 let idx = (normalized * 7.0).round() as usize;
2768 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
2769 }
2770
2771 self.interaction_count += 1;
2772 self.commands.push(Command::BeginContainer {
2773 direction: Direction::Row,
2774 gap: 0,
2775 align: Align::Start,
2776 justify: Justify::Start,
2777 border: None,
2778 border_style: Style::new().fg(self.theme.border),
2779 bg_color: None,
2780 padding: Padding::default(),
2781 margin: Margin::default(),
2782 constraints: Constraints::default(),
2783 title: None,
2784 grow: 0,
2785 });
2786
2787 let mut seg = String::new();
2788 let mut seg_color = cells[0].1;
2789 for (ch, color) in cells {
2790 if color != seg_color {
2791 self.styled(seg, Style::new().fg(seg_color));
2792 seg = String::new();
2793 seg_color = color;
2794 }
2795 seg.push(ch);
2796 }
2797 if !seg.is_empty() {
2798 self.styled(seg, Style::new().fg(seg_color));
2799 }
2800
2801 self.commands.push(Command::EndContainer);
2802 self.last_text_idx = None;
2803
2804 self
2805 }
2806
2807 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
2821 if data.is_empty() || width == 0 || height == 0 {
2822 return self;
2823 }
2824
2825 let cols = width as usize;
2826 let rows = height as usize;
2827 let px_w = cols * 2;
2828 let px_h = rows * 4;
2829
2830 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
2831 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2832 let range = if (max - min).abs() < f64::EPSILON {
2833 1.0
2834 } else {
2835 max - min
2836 };
2837
2838 let points: Vec<usize> = (0..px_w)
2839 .map(|px| {
2840 let data_idx = if px_w <= 1 {
2841 0.0
2842 } else {
2843 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
2844 };
2845 let idx = data_idx.floor() as usize;
2846 let frac = data_idx - idx as f64;
2847 let value = if idx + 1 < data.len() {
2848 data[idx] * (1.0 - frac) + data[idx + 1] * frac
2849 } else {
2850 data[idx.min(data.len() - 1)]
2851 };
2852
2853 let normalized = (value - min) / range;
2854 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
2855 py.min(px_h - 1)
2856 })
2857 .collect();
2858
2859 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
2860 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
2861
2862 let mut grid = vec![vec![0u32; cols]; rows];
2863
2864 for i in 0..points.len() {
2865 let px = i;
2866 let py = points[i];
2867 let char_col = px / 2;
2868 let char_row = py / 4;
2869 let sub_col = px % 2;
2870 let sub_row = py % 4;
2871
2872 if char_col < cols && char_row < rows {
2873 grid[char_row][char_col] |= if sub_col == 0 {
2874 LEFT_BITS[sub_row]
2875 } else {
2876 RIGHT_BITS[sub_row]
2877 };
2878 }
2879
2880 if i + 1 < points.len() {
2881 let py_next = points[i + 1];
2882 let (y_start, y_end) = if py <= py_next {
2883 (py, py_next)
2884 } else {
2885 (py_next, py)
2886 };
2887 for y in y_start..=y_end {
2888 let cell_row = y / 4;
2889 let sub_y = y % 4;
2890 if char_col < cols && cell_row < rows {
2891 grid[cell_row][char_col] |= if sub_col == 0 {
2892 LEFT_BITS[sub_y]
2893 } else {
2894 RIGHT_BITS[sub_y]
2895 };
2896 }
2897 }
2898 }
2899 }
2900
2901 let style = Style::new().fg(self.theme.primary);
2902 for row in grid {
2903 let line: String = row
2904 .iter()
2905 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
2906 .collect();
2907 self.styled(line, style);
2908 }
2909
2910 self
2911 }
2912
2913 pub fn canvas(
2930 &mut self,
2931 width: u32,
2932 height: u32,
2933 draw: impl FnOnce(&mut CanvasContext),
2934 ) -> &mut Self {
2935 if width == 0 || height == 0 {
2936 return self;
2937 }
2938
2939 let mut canvas = CanvasContext::new(width as usize, height as usize);
2940 draw(&mut canvas);
2941
2942 for segments in canvas.render() {
2943 self.interaction_count += 1;
2944 self.commands.push(Command::BeginContainer {
2945 direction: Direction::Row,
2946 gap: 0,
2947 align: Align::Start,
2948 justify: Justify::Start,
2949 border: None,
2950 border_style: Style::new(),
2951 bg_color: None,
2952 padding: Padding::default(),
2953 margin: Margin::default(),
2954 constraints: Constraints::default(),
2955 title: None,
2956 grow: 0,
2957 });
2958 for (text, color) in segments {
2959 let c = if color == Color::Reset {
2960 self.theme.primary
2961 } else {
2962 color
2963 };
2964 self.styled(text, Style::new().fg(c));
2965 }
2966 self.commands.push(Command::EndContainer);
2967 self.last_text_idx = None;
2968 }
2969
2970 self
2971 }
2972
2973 pub fn chart(
2975 &mut self,
2976 configure: impl FnOnce(&mut ChartBuilder),
2977 width: u32,
2978 height: u32,
2979 ) -> &mut Self {
2980 if width == 0 || height == 0 {
2981 return self;
2982 }
2983
2984 let axis_style = Style::new().fg(self.theme.text_dim);
2985 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
2986 configure(&mut builder);
2987
2988 let config = builder.build();
2989 let rows = render_chart(&config);
2990
2991 for row in rows {
2992 self.interaction_count += 1;
2993 self.commands.push(Command::BeginContainer {
2994 direction: Direction::Row,
2995 gap: 0,
2996 align: Align::Start,
2997 justify: Justify::Start,
2998 border: None,
2999 border_style: Style::new().fg(self.theme.border),
3000 bg_color: None,
3001 padding: Padding::default(),
3002 margin: Margin::default(),
3003 constraints: Constraints::default(),
3004 title: None,
3005 grow: 0,
3006 });
3007 for (text, style) in row.segments {
3008 self.styled(text, style);
3009 }
3010 self.commands.push(Command::EndContainer);
3011 self.last_text_idx = None;
3012 }
3013
3014 self
3015 }
3016
3017 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
3019 self.histogram_with(data, |_| {}, width, height)
3020 }
3021
3022 pub fn histogram_with(
3024 &mut self,
3025 data: &[f64],
3026 configure: impl FnOnce(&mut HistogramBuilder),
3027 width: u32,
3028 height: u32,
3029 ) -> &mut Self {
3030 if width == 0 || height == 0 {
3031 return self;
3032 }
3033
3034 let mut options = HistogramBuilder::default();
3035 configure(&mut options);
3036 let axis_style = Style::new().fg(self.theme.text_dim);
3037 let config = build_histogram_config(data, &options, width, height, axis_style);
3038 let rows = render_chart(&config);
3039
3040 for row in rows {
3041 self.interaction_count += 1;
3042 self.commands.push(Command::BeginContainer {
3043 direction: Direction::Row,
3044 gap: 0,
3045 align: Align::Start,
3046 justify: Justify::Start,
3047 border: None,
3048 border_style: Style::new().fg(self.theme.border),
3049 bg_color: None,
3050 padding: Padding::default(),
3051 margin: Margin::default(),
3052 constraints: Constraints::default(),
3053 title: None,
3054 grow: 0,
3055 });
3056 for (text, style) in row.segments {
3057 self.styled(text, style);
3058 }
3059 self.commands.push(Command::EndContainer);
3060 self.last_text_idx = None;
3061 }
3062
3063 self
3064 }
3065
3066 pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
3083 slt_assert(cols > 0, "grid() requires at least 1 column");
3084 let interaction_id = self.interaction_count;
3085 self.interaction_count += 1;
3086 let border = self.theme.border;
3087
3088 self.commands.push(Command::BeginContainer {
3089 direction: Direction::Column,
3090 gap: 0,
3091 align: Align::Start,
3092 justify: Justify::Start,
3093 border: None,
3094 border_style: Style::new().fg(border),
3095 bg_color: None,
3096 padding: Padding::default(),
3097 margin: Margin::default(),
3098 constraints: Constraints::default(),
3099 title: None,
3100 grow: 0,
3101 });
3102
3103 let children_start = self.commands.len();
3104 f(self);
3105 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
3106
3107 let mut elements: Vec<Vec<Command>> = Vec::new();
3108 let mut iter = child_commands.into_iter().peekable();
3109 while let Some(cmd) = iter.next() {
3110 match cmd {
3111 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3112 let mut depth = 1_u32;
3113 let mut element = vec![cmd];
3114 for next in iter.by_ref() {
3115 match next {
3116 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3117 depth += 1;
3118 }
3119 Command::EndContainer => {
3120 depth = depth.saturating_sub(1);
3121 }
3122 _ => {}
3123 }
3124 let at_end = matches!(next, Command::EndContainer) && depth == 0;
3125 element.push(next);
3126 if at_end {
3127 break;
3128 }
3129 }
3130 elements.push(element);
3131 }
3132 Command::EndContainer => {}
3133 _ => elements.push(vec![cmd]),
3134 }
3135 }
3136
3137 let cols = cols.max(1) as usize;
3138 for row in elements.chunks(cols) {
3139 self.interaction_count += 1;
3140 self.commands.push(Command::BeginContainer {
3141 direction: Direction::Row,
3142 gap: 0,
3143 align: Align::Start,
3144 justify: Justify::Start,
3145 border: None,
3146 border_style: Style::new().fg(border),
3147 bg_color: None,
3148 padding: Padding::default(),
3149 margin: Margin::default(),
3150 constraints: Constraints::default(),
3151 title: None,
3152 grow: 0,
3153 });
3154
3155 for element in row {
3156 self.interaction_count += 1;
3157 self.commands.push(Command::BeginContainer {
3158 direction: Direction::Column,
3159 gap: 0,
3160 align: Align::Start,
3161 justify: Justify::Start,
3162 border: None,
3163 border_style: Style::new().fg(border),
3164 bg_color: None,
3165 padding: Padding::default(),
3166 margin: Margin::default(),
3167 constraints: Constraints::default(),
3168 title: None,
3169 grow: 1,
3170 });
3171 self.commands.extend(element.iter().cloned());
3172 self.commands.push(Command::EndContainer);
3173 }
3174
3175 self.commands.push(Command::EndContainer);
3176 }
3177
3178 self.commands.push(Command::EndContainer);
3179 self.last_text_idx = None;
3180
3181 self.response_for(interaction_id)
3182 }
3183
3184 pub fn list(&mut self, state: &mut ListState) -> &mut Self {
3189 if state.items.is_empty() {
3190 state.selected = 0;
3191 return self;
3192 }
3193
3194 state.selected = state.selected.min(state.items.len().saturating_sub(1));
3195
3196 let focused = self.register_focusable();
3197 let interaction_id = self.interaction_count;
3198 self.interaction_count += 1;
3199
3200 if focused {
3201 let mut consumed_indices = Vec::new();
3202 for (i, event) in self.events.iter().enumerate() {
3203 if let Event::Key(key) = event {
3204 match key.code {
3205 KeyCode::Up | KeyCode::Char('k') => {
3206 state.selected = state.selected.saturating_sub(1);
3207 consumed_indices.push(i);
3208 }
3209 KeyCode::Down | KeyCode::Char('j') => {
3210 state.selected =
3211 (state.selected + 1).min(state.items.len().saturating_sub(1));
3212 consumed_indices.push(i);
3213 }
3214 _ => {}
3215 }
3216 }
3217 }
3218
3219 for index in consumed_indices {
3220 self.consumed[index] = true;
3221 }
3222 }
3223
3224 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3225 for (i, event) in self.events.iter().enumerate() {
3226 if self.consumed[i] {
3227 continue;
3228 }
3229 if let Event::Mouse(mouse) = event {
3230 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3231 continue;
3232 }
3233 let in_bounds = mouse.x >= rect.x
3234 && mouse.x < rect.right()
3235 && mouse.y >= rect.y
3236 && mouse.y < rect.bottom();
3237 if !in_bounds {
3238 continue;
3239 }
3240 let clicked_idx = (mouse.y - rect.y) as usize;
3241 if clicked_idx < state.items.len() {
3242 state.selected = clicked_idx;
3243 self.consumed[i] = true;
3244 }
3245 }
3246 }
3247 }
3248
3249 self.commands.push(Command::BeginContainer {
3250 direction: Direction::Column,
3251 gap: 0,
3252 align: Align::Start,
3253 justify: Justify::Start,
3254 border: None,
3255 border_style: Style::new().fg(self.theme.border),
3256 bg_color: None,
3257 padding: Padding::default(),
3258 margin: Margin::default(),
3259 constraints: Constraints::default(),
3260 title: None,
3261 grow: 0,
3262 });
3263
3264 for (idx, item) in state.items.iter().enumerate() {
3265 if idx == state.selected {
3266 if focused {
3267 self.styled(
3268 format!("▸ {item}"),
3269 Style::new().bold().fg(self.theme.primary),
3270 );
3271 } else {
3272 self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
3273 }
3274 } else {
3275 self.styled(format!(" {item}"), Style::new().fg(self.theme.text));
3276 }
3277 }
3278
3279 self.commands.push(Command::EndContainer);
3280 self.last_text_idx = None;
3281
3282 self
3283 }
3284
3285 pub fn table(&mut self, state: &mut TableState) -> &mut Self {
3290 if state.is_dirty() {
3291 state.recompute_widths();
3292 }
3293
3294 let focused = self.register_focusable();
3295 let interaction_id = self.interaction_count;
3296 self.interaction_count += 1;
3297
3298 if focused && !state.rows.is_empty() {
3299 let mut consumed_indices = Vec::new();
3300 for (i, event) in self.events.iter().enumerate() {
3301 if let Event::Key(key) = event {
3302 match key.code {
3303 KeyCode::Up | KeyCode::Char('k') => {
3304 state.selected = state.selected.saturating_sub(1);
3305 consumed_indices.push(i);
3306 }
3307 KeyCode::Down | KeyCode::Char('j') => {
3308 state.selected =
3309 (state.selected + 1).min(state.rows.len().saturating_sub(1));
3310 consumed_indices.push(i);
3311 }
3312 _ => {}
3313 }
3314 }
3315 }
3316 for index in consumed_indices {
3317 self.consumed[index] = true;
3318 }
3319 }
3320
3321 if !state.rows.is_empty() {
3322 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3323 for (i, event) in self.events.iter().enumerate() {
3324 if self.consumed[i] {
3325 continue;
3326 }
3327 if let Event::Mouse(mouse) = event {
3328 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3329 continue;
3330 }
3331 let in_bounds = mouse.x >= rect.x
3332 && mouse.x < rect.right()
3333 && mouse.y >= rect.y
3334 && mouse.y < rect.bottom();
3335 if !in_bounds {
3336 continue;
3337 }
3338 if mouse.y < rect.y + 2 {
3339 continue;
3340 }
3341 let clicked_idx = (mouse.y - rect.y - 2) as usize;
3342 if clicked_idx < state.rows.len() {
3343 state.selected = clicked_idx;
3344 self.consumed[i] = true;
3345 }
3346 }
3347 }
3348 }
3349 }
3350
3351 state.selected = state.selected.min(state.rows.len().saturating_sub(1));
3352
3353 self.commands.push(Command::BeginContainer {
3354 direction: Direction::Column,
3355 gap: 0,
3356 align: Align::Start,
3357 justify: Justify::Start,
3358 border: None,
3359 border_style: Style::new().fg(self.theme.border),
3360 bg_color: None,
3361 padding: Padding::default(),
3362 margin: Margin::default(),
3363 constraints: Constraints::default(),
3364 title: None,
3365 grow: 0,
3366 });
3367
3368 let header_line = format_table_row(&state.headers, state.column_widths(), " │ ");
3369 self.styled(header_line, Style::new().bold().fg(self.theme.text));
3370
3371 let separator = state
3372 .column_widths()
3373 .iter()
3374 .map(|w| "─".repeat(*w as usize))
3375 .collect::<Vec<_>>()
3376 .join("─┼─");
3377 self.text(separator);
3378
3379 for (idx, row) in state.rows.iter().enumerate() {
3380 let line = format_table_row(row, state.column_widths(), " │ ");
3381 if idx == state.selected {
3382 let mut style = Style::new()
3383 .bg(self.theme.selected_bg)
3384 .fg(self.theme.selected_fg);
3385 if focused {
3386 style = style.bold();
3387 }
3388 self.styled(line, style);
3389 } else {
3390 self.styled(line, Style::new().fg(self.theme.text));
3391 }
3392 }
3393
3394 self.commands.push(Command::EndContainer);
3395 self.last_text_idx = None;
3396
3397 self
3398 }
3399
3400 pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
3405 if state.labels.is_empty() {
3406 state.selected = 0;
3407 return self;
3408 }
3409
3410 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
3411 let focused = self.register_focusable();
3412 let interaction_id = self.interaction_count;
3413
3414 if focused {
3415 let mut consumed_indices = Vec::new();
3416 for (i, event) in self.events.iter().enumerate() {
3417 if let Event::Key(key) = event {
3418 match key.code {
3419 KeyCode::Left => {
3420 state.selected = if state.selected == 0 {
3421 state.labels.len().saturating_sub(1)
3422 } else {
3423 state.selected - 1
3424 };
3425 consumed_indices.push(i);
3426 }
3427 KeyCode::Right => {
3428 state.selected = (state.selected + 1) % state.labels.len();
3429 consumed_indices.push(i);
3430 }
3431 _ => {}
3432 }
3433 }
3434 }
3435
3436 for index in consumed_indices {
3437 self.consumed[index] = true;
3438 }
3439 }
3440
3441 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3442 for (i, event) in self.events.iter().enumerate() {
3443 if self.consumed[i] {
3444 continue;
3445 }
3446 if let Event::Mouse(mouse) = event {
3447 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3448 continue;
3449 }
3450 let in_bounds = mouse.x >= rect.x
3451 && mouse.x < rect.right()
3452 && mouse.y >= rect.y
3453 && mouse.y < rect.bottom();
3454 if !in_bounds {
3455 continue;
3456 }
3457
3458 let mut x_offset = 0u32;
3459 let rel_x = mouse.x - rect.x;
3460 for (idx, label) in state.labels.iter().enumerate() {
3461 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
3462 if rel_x >= x_offset && rel_x < x_offset + tab_width {
3463 state.selected = idx;
3464 self.consumed[i] = true;
3465 break;
3466 }
3467 x_offset += tab_width + 1;
3468 }
3469 }
3470 }
3471 }
3472
3473 self.interaction_count += 1;
3474 self.commands.push(Command::BeginContainer {
3475 direction: Direction::Row,
3476 gap: 1,
3477 align: Align::Start,
3478 justify: Justify::Start,
3479 border: None,
3480 border_style: Style::new().fg(self.theme.border),
3481 bg_color: None,
3482 padding: Padding::default(),
3483 margin: Margin::default(),
3484 constraints: Constraints::default(),
3485 title: None,
3486 grow: 0,
3487 });
3488 for (idx, label) in state.labels.iter().enumerate() {
3489 let style = if idx == state.selected {
3490 let s = Style::new().fg(self.theme.primary).bold();
3491 if focused {
3492 s.underline()
3493 } else {
3494 s
3495 }
3496 } else {
3497 Style::new().fg(self.theme.text_dim)
3498 };
3499 self.styled(format!("[ {label} ]"), style);
3500 }
3501 self.commands.push(Command::EndContainer);
3502 self.last_text_idx = None;
3503
3504 self
3505 }
3506
3507 pub fn button(&mut self, label: impl Into<String>) -> bool {
3512 let focused = self.register_focusable();
3513 let interaction_id = self.interaction_count;
3514 self.interaction_count += 1;
3515 let response = self.response_for(interaction_id);
3516
3517 let mut activated = response.clicked;
3518 if focused {
3519 let mut consumed_indices = Vec::new();
3520 for (i, event) in self.events.iter().enumerate() {
3521 if let Event::Key(key) = event {
3522 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3523 activated = true;
3524 consumed_indices.push(i);
3525 }
3526 }
3527 }
3528
3529 for index in consumed_indices {
3530 self.consumed[index] = true;
3531 }
3532 }
3533
3534 let hovered = response.hovered;
3535 let style = if focused {
3536 Style::new().fg(self.theme.primary).bold()
3537 } else if hovered {
3538 Style::new().fg(self.theme.accent)
3539 } else {
3540 Style::new().fg(self.theme.text)
3541 };
3542 let hover_bg = if hovered || focused {
3543 Some(self.theme.surface_hover)
3544 } else {
3545 None
3546 };
3547
3548 self.commands.push(Command::BeginContainer {
3549 direction: Direction::Row,
3550 gap: 0,
3551 align: Align::Start,
3552 justify: Justify::Start,
3553 border: None,
3554 border_style: Style::new().fg(self.theme.border),
3555 bg_color: hover_bg,
3556 padding: Padding::default(),
3557 margin: Margin::default(),
3558 constraints: Constraints::default(),
3559 title: None,
3560 grow: 0,
3561 });
3562 self.styled(format!("[ {} ]", label.into()), style);
3563 self.commands.push(Command::EndContainer);
3564 self.last_text_idx = None;
3565
3566 activated
3567 }
3568
3569 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
3574 let focused = self.register_focusable();
3575 let interaction_id = self.interaction_count;
3576 self.interaction_count += 1;
3577 let response = self.response_for(interaction_id);
3578
3579 let mut activated = response.clicked;
3580 if focused {
3581 let mut consumed_indices = Vec::new();
3582 for (i, event) in self.events.iter().enumerate() {
3583 if let Event::Key(key) = event {
3584 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3585 activated = true;
3586 consumed_indices.push(i);
3587 }
3588 }
3589 }
3590 for index in consumed_indices {
3591 self.consumed[index] = true;
3592 }
3593 }
3594
3595 let label = label.into();
3596 let hover_bg = if response.hovered || focused {
3597 Some(self.theme.surface_hover)
3598 } else {
3599 None
3600 };
3601 let (text, style, bg_color, border) = match variant {
3602 ButtonVariant::Default => {
3603 let style = if focused {
3604 Style::new().fg(self.theme.primary).bold()
3605 } else if response.hovered {
3606 Style::new().fg(self.theme.accent)
3607 } else {
3608 Style::new().fg(self.theme.text)
3609 };
3610 (format!("[ {label} ]"), style, hover_bg, None)
3611 }
3612 ButtonVariant::Primary => {
3613 let style = if focused {
3614 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
3615 } else if response.hovered {
3616 Style::new().fg(self.theme.bg).bg(self.theme.accent)
3617 } else {
3618 Style::new().fg(self.theme.bg).bg(self.theme.primary)
3619 };
3620 (format!(" {label} "), style, hover_bg, None)
3621 }
3622 ButtonVariant::Danger => {
3623 let style = if focused {
3624 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
3625 } else if response.hovered {
3626 Style::new().fg(self.theme.bg).bg(self.theme.warning)
3627 } else {
3628 Style::new().fg(self.theme.bg).bg(self.theme.error)
3629 };
3630 (format!(" {label} "), style, hover_bg, None)
3631 }
3632 ButtonVariant::Outline => {
3633 let border_color = if focused {
3634 self.theme.primary
3635 } else if response.hovered {
3636 self.theme.accent
3637 } else {
3638 self.theme.border
3639 };
3640 let style = if focused {
3641 Style::new().fg(self.theme.primary).bold()
3642 } else if response.hovered {
3643 Style::new().fg(self.theme.accent)
3644 } else {
3645 Style::new().fg(self.theme.text)
3646 };
3647 (
3648 format!(" {label} "),
3649 style,
3650 hover_bg,
3651 Some((Border::Rounded, Style::new().fg(border_color))),
3652 )
3653 }
3654 };
3655
3656 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
3657 self.commands.push(Command::BeginContainer {
3658 direction: Direction::Row,
3659 gap: 0,
3660 align: Align::Center,
3661 justify: Justify::Center,
3662 border: if border.is_some() {
3663 Some(btn_border)
3664 } else {
3665 None
3666 },
3667 border_style: btn_border_style,
3668 bg_color,
3669 padding: Padding::default(),
3670 margin: Margin::default(),
3671 constraints: Constraints::default(),
3672 title: None,
3673 grow: 0,
3674 });
3675 self.styled(text, style);
3676 self.commands.push(Command::EndContainer);
3677 self.last_text_idx = None;
3678
3679 activated
3680 }
3681
3682 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
3687 let focused = self.register_focusable();
3688 let interaction_id = self.interaction_count;
3689 self.interaction_count += 1;
3690 let response = self.response_for(interaction_id);
3691 let mut should_toggle = response.clicked;
3692
3693 if focused {
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 matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3698 should_toggle = true;
3699 consumed_indices.push(i);
3700 }
3701 }
3702 }
3703
3704 for index in consumed_indices {
3705 self.consumed[index] = true;
3706 }
3707 }
3708
3709 if should_toggle {
3710 *checked = !*checked;
3711 }
3712
3713 let hover_bg = if response.hovered || focused {
3714 Some(self.theme.surface_hover)
3715 } else {
3716 None
3717 };
3718 self.commands.push(Command::BeginContainer {
3719 direction: Direction::Row,
3720 gap: 1,
3721 align: Align::Start,
3722 justify: Justify::Start,
3723 border: None,
3724 border_style: Style::new().fg(self.theme.border),
3725 bg_color: hover_bg,
3726 padding: Padding::default(),
3727 margin: Margin::default(),
3728 constraints: Constraints::default(),
3729 title: None,
3730 grow: 0,
3731 });
3732 let marker_style = if *checked {
3733 Style::new().fg(self.theme.success)
3734 } else {
3735 Style::new().fg(self.theme.text_dim)
3736 };
3737 let marker = if *checked { "[x]" } else { "[ ]" };
3738 let label_text = label.into();
3739 if focused {
3740 self.styled(format!("▸ {marker}"), marker_style.bold());
3741 self.styled(label_text, Style::new().fg(self.theme.text).bold());
3742 } else {
3743 self.styled(marker, marker_style);
3744 self.styled(label_text, Style::new().fg(self.theme.text));
3745 }
3746 self.commands.push(Command::EndContainer);
3747 self.last_text_idx = None;
3748
3749 self
3750 }
3751
3752 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
3758 let focused = self.register_focusable();
3759 let interaction_id = self.interaction_count;
3760 self.interaction_count += 1;
3761 let response = self.response_for(interaction_id);
3762 let mut should_toggle = response.clicked;
3763
3764 if focused {
3765 let mut consumed_indices = Vec::new();
3766 for (i, event) in self.events.iter().enumerate() {
3767 if let Event::Key(key) = event {
3768 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3769 should_toggle = true;
3770 consumed_indices.push(i);
3771 }
3772 }
3773 }
3774
3775 for index in consumed_indices {
3776 self.consumed[index] = true;
3777 }
3778 }
3779
3780 if should_toggle {
3781 *on = !*on;
3782 }
3783
3784 let hover_bg = if response.hovered || focused {
3785 Some(self.theme.surface_hover)
3786 } else {
3787 None
3788 };
3789 self.commands.push(Command::BeginContainer {
3790 direction: Direction::Row,
3791 gap: 2,
3792 align: Align::Start,
3793 justify: Justify::Start,
3794 border: None,
3795 border_style: Style::new().fg(self.theme.border),
3796 bg_color: hover_bg,
3797 padding: Padding::default(),
3798 margin: Margin::default(),
3799 constraints: Constraints::default(),
3800 title: None,
3801 grow: 0,
3802 });
3803 let label_text = label.into();
3804 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
3805 let switch_style = if *on {
3806 Style::new().fg(self.theme.success)
3807 } else {
3808 Style::new().fg(self.theme.text_dim)
3809 };
3810 if focused {
3811 self.styled(
3812 format!("▸ {label_text}"),
3813 Style::new().fg(self.theme.text).bold(),
3814 );
3815 self.styled(switch, switch_style.bold());
3816 } else {
3817 self.styled(label_text, Style::new().fg(self.theme.text));
3818 self.styled(switch, switch_style);
3819 }
3820 self.commands.push(Command::EndContainer);
3821 self.last_text_idx = None;
3822
3823 self
3824 }
3825
3826 pub fn separator(&mut self) -> &mut Self {
3831 self.commands.push(Command::Text {
3832 content: "─".repeat(200),
3833 style: Style::new().fg(self.theme.border).dim(),
3834 grow: 0,
3835 align: Align::Start,
3836 wrap: false,
3837 margin: Margin::default(),
3838 constraints: Constraints::default(),
3839 });
3840 self.last_text_idx = Some(self.commands.len() - 1);
3841 self
3842 }
3843
3844 pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
3850 if bindings.is_empty() {
3851 return self;
3852 }
3853
3854 self.interaction_count += 1;
3855 self.commands.push(Command::BeginContainer {
3856 direction: Direction::Row,
3857 gap: 2,
3858 align: Align::Start,
3859 justify: Justify::Start,
3860 border: None,
3861 border_style: Style::new().fg(self.theme.border),
3862 bg_color: None,
3863 padding: Padding::default(),
3864 margin: Margin::default(),
3865 constraints: Constraints::default(),
3866 title: None,
3867 grow: 0,
3868 });
3869 for (idx, (key, action)) in bindings.iter().enumerate() {
3870 if idx > 0 {
3871 self.styled("·", Style::new().fg(self.theme.text_dim));
3872 }
3873 self.styled(*key, Style::new().bold().fg(self.theme.primary));
3874 self.styled(*action, Style::new().fg(self.theme.text_dim));
3875 }
3876 self.commands.push(Command::EndContainer);
3877 self.last_text_idx = None;
3878
3879 self
3880 }
3881
3882 pub fn key(&self, c: char) -> bool {
3888 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3889 return false;
3890 }
3891 self.events.iter().enumerate().any(|(i, e)| {
3892 !self.consumed[i] && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c))
3893 })
3894 }
3895
3896 pub fn key_code(&self, code: KeyCode) -> bool {
3900 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3901 return false;
3902 }
3903 self.events
3904 .iter()
3905 .enumerate()
3906 .any(|(i, e)| !self.consumed[i] && matches!(e, Event::Key(k) if k.code == code))
3907 }
3908
3909 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
3913 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3914 return false;
3915 }
3916 self.events.iter().enumerate().any(|(i, e)| {
3917 !self.consumed[i]
3918 && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
3919 })
3920 }
3921
3922 pub fn mouse_down(&self) -> Option<(u32, u32)> {
3926 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3927 return None;
3928 }
3929 self.events.iter().enumerate().find_map(|(i, event)| {
3930 if self.consumed[i] {
3931 return None;
3932 }
3933 if let Event::Mouse(mouse) = event {
3934 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3935 return Some((mouse.x, mouse.y));
3936 }
3937 }
3938 None
3939 })
3940 }
3941
3942 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
3947 self.mouse_pos
3948 }
3949
3950 pub fn paste(&self) -> Option<&str> {
3952 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3953 return None;
3954 }
3955 self.events.iter().enumerate().find_map(|(i, event)| {
3956 if self.consumed[i] {
3957 return None;
3958 }
3959 if let Event::Paste(ref text) = event {
3960 return Some(text.as_str());
3961 }
3962 None
3963 })
3964 }
3965
3966 pub fn scroll_up(&self) -> bool {
3968 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3969 return false;
3970 }
3971 self.events.iter().enumerate().any(|(i, event)| {
3972 !self.consumed[i]
3973 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
3974 })
3975 }
3976
3977 pub fn scroll_down(&self) -> bool {
3979 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3980 return false;
3981 }
3982 self.events.iter().enumerate().any(|(i, event)| {
3983 !self.consumed[i]
3984 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
3985 })
3986 }
3987
3988 pub fn quit(&mut self) {
3990 self.should_quit = true;
3991 }
3992
3993 pub fn theme(&self) -> &Theme {
3995 &self.theme
3996 }
3997
3998 pub fn set_theme(&mut self, theme: Theme) {
4002 self.theme = theme;
4003 }
4004
4005 pub fn width(&self) -> u32 {
4009 self.area_width
4010 }
4011
4012 pub fn height(&self) -> u32 {
4014 self.area_height
4015 }
4016
4017 pub fn tick(&self) -> u64 {
4022 self.tick
4023 }
4024
4025 pub fn debug_enabled(&self) -> bool {
4029 self.debug
4030 }
4031}
4032
4033#[inline]
4034fn byte_index_for_char(value: &str, char_index: usize) -> usize {
4035 if char_index == 0 {
4036 return 0;
4037 }
4038 value
4039 .char_indices()
4040 .nth(char_index)
4041 .map_or(value.len(), |(idx, _)| idx)
4042}
4043
4044fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
4045 let mut parts: Vec<String> = Vec::new();
4046 for (i, width) in widths.iter().enumerate() {
4047 let cell = cells.get(i).map(String::as_str).unwrap_or("");
4048 let cell_width = UnicodeWidthStr::width(cell) as u32;
4049 let padding = (*width).saturating_sub(cell_width) as usize;
4050 parts.push(format!("{cell}{}", " ".repeat(padding)));
4051 }
4052 parts.join(separator)
4053}
4054
4055fn format_compact_number(value: f64) -> String {
4056 if value.fract().abs() < f64::EPSILON {
4057 return format!("{value:.0}");
4058 }
4059
4060 let mut s = format!("{value:.2}");
4061 while s.contains('.') && s.ends_with('0') {
4062 s.pop();
4063 }
4064 if s.ends_with('.') {
4065 s.pop();
4066 }
4067 s
4068}
4069
4070fn center_text(text: &str, width: usize) -> String {
4071 let text_width = UnicodeWidthStr::width(text);
4072 if text_width >= width {
4073 return text.to_string();
4074 }
4075
4076 let total = width - text_width;
4077 let left = total / 2;
4078 let right = total - left;
4079 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
4080}
4081
4082struct TextareaVLine {
4083 logical_row: usize,
4084 char_start: usize,
4085 char_count: usize,
4086}
4087
4088fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
4089 let mut out = Vec::new();
4090 for (row, line) in lines.iter().enumerate() {
4091 if line.is_empty() || wrap_width == u32::MAX {
4092 out.push(TextareaVLine {
4093 logical_row: row,
4094 char_start: 0,
4095 char_count: line.chars().count(),
4096 });
4097 continue;
4098 }
4099 let mut seg_start = 0usize;
4100 let mut seg_chars = 0usize;
4101 let mut seg_width = 0u32;
4102 for (idx, ch) in line.chars().enumerate() {
4103 let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
4104 if seg_width + cw > wrap_width && seg_chars > 0 {
4105 out.push(TextareaVLine {
4106 logical_row: row,
4107 char_start: seg_start,
4108 char_count: seg_chars,
4109 });
4110 seg_start = idx;
4111 seg_chars = 0;
4112 seg_width = 0;
4113 }
4114 seg_chars += 1;
4115 seg_width += cw;
4116 }
4117 out.push(TextareaVLine {
4118 logical_row: row,
4119 char_start: seg_start,
4120 char_count: seg_chars,
4121 });
4122 }
4123 out
4124}
4125
4126fn textarea_logical_to_visual(
4127 vlines: &[TextareaVLine],
4128 logical_row: usize,
4129 logical_col: usize,
4130) -> (usize, usize) {
4131 for (i, vl) in vlines.iter().enumerate() {
4132 if vl.logical_row != logical_row {
4133 continue;
4134 }
4135 let seg_end = vl.char_start + vl.char_count;
4136 if logical_col >= vl.char_start && logical_col < seg_end {
4137 return (i, logical_col - vl.char_start);
4138 }
4139 if logical_col == seg_end {
4140 let is_last_seg = vlines
4141 .get(i + 1)
4142 .map_or(true, |next| next.logical_row != logical_row);
4143 if is_last_seg {
4144 return (i, logical_col - vl.char_start);
4145 }
4146 }
4147 }
4148 (vlines.len().saturating_sub(1), 0)
4149}
4150
4151fn textarea_visual_to_logical(
4152 vlines: &[TextareaVLine],
4153 visual_row: usize,
4154 visual_col: usize,
4155) -> (usize, usize) {
4156 if let Some(vl) = vlines.get(visual_row) {
4157 let logical_col = vl.char_start + visual_col.min(vl.char_count);
4158 (vl.logical_row, logical_col)
4159 } else {
4160 (0, 0)
4161 }
4162}
4163
4164fn open_url(url: &str) -> std::io::Result<()> {
4165 #[cfg(target_os = "macos")]
4166 {
4167 std::process::Command::new("open").arg(url).spawn()?;
4168 }
4169 #[cfg(target_os = "linux")]
4170 {
4171 std::process::Command::new("xdg-open").arg(url).spawn()?;
4172 }
4173 #[cfg(target_os = "windows")]
4174 {
4175 std::process::Command::new("cmd")
4176 .args(["/c", "start", "", url])
4177 .spawn()?;
4178 }
4179 Ok(())
4180}