1use super::*;
2
3impl Context {
4 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
25 if data.is_empty() {
26 return self;
27 }
28
29 let max_label_width = data
30 .iter()
31 .map(|(label, _)| UnicodeWidthStr::width(*label))
32 .max()
33 .unwrap_or(0);
34 let max_value = data
35 .iter()
36 .map(|(_, value)| *value)
37 .fold(f64::NEG_INFINITY, f64::max);
38 let denom = if max_value > 0.0 { max_value } else { 1.0 };
39
40 self.interaction_count += 1;
41 self.commands.push(Command::BeginContainer {
42 direction: Direction::Column,
43 gap: 0,
44 align: Align::Start,
45 justify: Justify::Start,
46 border: None,
47 border_sides: BorderSides::all(),
48 border_style: Style::new().fg(self.theme.border),
49 bg_color: None,
50 padding: Padding::default(),
51 margin: Margin::default(),
52 constraints: Constraints::default(),
53 title: None,
54 grow: 0,
55 group_name: None,
56 });
57
58 for (label, value) in data {
59 let label_width = UnicodeWidthStr::width(*label);
60 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
61 let normalized = (*value / denom).clamp(0.0, 1.0);
62 let bar_len = (normalized * max_width as f64).round() as usize;
63 let bar = "█".repeat(bar_len);
64
65 self.interaction_count += 1;
66 self.commands.push(Command::BeginContainer {
67 direction: Direction::Row,
68 gap: 1,
69 align: Align::Start,
70 justify: Justify::Start,
71 border: None,
72 border_sides: BorderSides::all(),
73 border_style: Style::new().fg(self.theme.border),
74 bg_color: None,
75 padding: Padding::default(),
76 margin: Margin::default(),
77 constraints: Constraints::default(),
78 title: None,
79 grow: 0,
80 group_name: None,
81 });
82 self.styled(
83 format!("{label}{label_padding}"),
84 Style::new().fg(self.theme.text),
85 );
86 self.styled(bar, Style::new().fg(self.theme.primary));
87 self.styled(
88 format_compact_number(*value),
89 Style::new().fg(self.theme.text_dim),
90 );
91 self.commands.push(Command::EndContainer);
92 self.last_text_idx = None;
93 }
94
95 self.commands.push(Command::EndContainer);
96 self.last_text_idx = None;
97
98 self
99 }
100
101 pub fn bar_chart_styled(
117 &mut self,
118 bars: &[Bar],
119 max_width: u32,
120 direction: BarDirection,
121 ) -> &mut Self {
122 if bars.is_empty() {
123 return self;
124 }
125
126 let max_value = bars
127 .iter()
128 .map(|bar| bar.value)
129 .fold(f64::NEG_INFINITY, f64::max);
130 let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132 match direction {
133 BarDirection::Horizontal => {
134 let max_label_width = bars
135 .iter()
136 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
137 .max()
138 .unwrap_or(0);
139
140 self.interaction_count += 1;
141 self.commands.push(Command::BeginContainer {
142 direction: Direction::Column,
143 gap: 0,
144 align: Align::Start,
145 justify: Justify::Start,
146 border: None,
147 border_sides: BorderSides::all(),
148 border_style: Style::new().fg(self.theme.border),
149 bg_color: None,
150 padding: Padding::default(),
151 margin: Margin::default(),
152 constraints: Constraints::default(),
153 title: None,
154 grow: 0,
155 group_name: None,
156 });
157
158 for bar in bars {
159 let label_width = UnicodeWidthStr::width(bar.label.as_str());
160 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
161 let normalized = (bar.value / denom).clamp(0.0, 1.0);
162 let bar_len = (normalized * max_width as f64).round() as usize;
163 let bar_text = "█".repeat(bar_len);
164 let color = bar.color.unwrap_or(self.theme.primary);
165
166 self.interaction_count += 1;
167 self.commands.push(Command::BeginContainer {
168 direction: Direction::Row,
169 gap: 1,
170 align: Align::Start,
171 justify: Justify::Start,
172 border: None,
173 border_sides: BorderSides::all(),
174 border_style: Style::new().fg(self.theme.border),
175 bg_color: None,
176 padding: Padding::default(),
177 margin: Margin::default(),
178 constraints: Constraints::default(),
179 title: None,
180 grow: 0,
181 group_name: None,
182 });
183 self.styled(
184 format!("{}{label_padding}", bar.label),
185 Style::new().fg(self.theme.text),
186 );
187 self.styled(bar_text, Style::new().fg(color));
188 self.styled(
189 format_compact_number(bar.value),
190 Style::new().fg(self.theme.text_dim),
191 );
192 self.commands.push(Command::EndContainer);
193 self.last_text_idx = None;
194 }
195
196 self.commands.push(Command::EndContainer);
197 self.last_text_idx = None;
198 }
199 BarDirection::Vertical => {
200 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
201
202 let chart_height = max_width.max(1) as usize;
203 let value_labels: Vec<String> = bars
204 .iter()
205 .map(|bar| format_compact_number(bar.value))
206 .collect();
207 let col_width = bars
208 .iter()
209 .zip(value_labels.iter())
210 .map(|(bar, value)| {
211 UnicodeWidthStr::width(bar.label.as_str())
212 .max(UnicodeWidthStr::width(value.as_str()))
213 .max(1)
214 })
215 .max()
216 .unwrap_or(1);
217
218 let bar_units: Vec<usize> = bars
219 .iter()
220 .map(|bar| {
221 let normalized = (bar.value / denom).clamp(0.0, 1.0);
222 (normalized * chart_height as f64 * 8.0).round() as usize
223 })
224 .collect();
225
226 self.interaction_count += 1;
227 self.commands.push(Command::BeginContainer {
228 direction: Direction::Column,
229 gap: 0,
230 align: Align::Start,
231 justify: Justify::Start,
232 border: None,
233 border_sides: BorderSides::all(),
234 border_style: Style::new().fg(self.theme.border),
235 bg_color: None,
236 padding: Padding::default(),
237 margin: Margin::default(),
238 constraints: Constraints::default(),
239 title: None,
240 grow: 0,
241 group_name: None,
242 });
243
244 self.interaction_count += 1;
245 self.commands.push(Command::BeginContainer {
246 direction: Direction::Row,
247 gap: 1,
248 align: Align::Start,
249 justify: Justify::Start,
250 border: None,
251 border_sides: BorderSides::all(),
252 border_style: Style::new().fg(self.theme.border),
253 bg_color: None,
254 padding: Padding::default(),
255 margin: Margin::default(),
256 constraints: Constraints::default(),
257 title: None,
258 grow: 0,
259 group_name: None,
260 });
261 for value in &value_labels {
262 self.styled(
263 center_text(value, col_width),
264 Style::new().fg(self.theme.text_dim),
265 );
266 }
267 self.commands.push(Command::EndContainer);
268 self.last_text_idx = None;
269
270 for row in (0..chart_height).rev() {
271 self.interaction_count += 1;
272 self.commands.push(Command::BeginContainer {
273 direction: Direction::Row,
274 gap: 1,
275 align: Align::Start,
276 justify: Justify::Start,
277 border: None,
278 border_sides: BorderSides::all(),
279 border_style: Style::new().fg(self.theme.border),
280 bg_color: None,
281 padding: Padding::default(),
282 margin: Margin::default(),
283 constraints: Constraints::default(),
284 title: None,
285 grow: 0,
286 group_name: None,
287 });
288
289 let row_base = row * 8;
290 for (bar, units) in bars.iter().zip(bar_units.iter()) {
291 let fill = if *units <= row_base {
292 ' '
293 } else {
294 let delta = *units - row_base;
295 if delta >= 8 {
296 '█'
297 } else {
298 FRACTION_BLOCKS[delta]
299 }
300 };
301
302 self.styled(
303 center_text(&fill.to_string(), col_width),
304 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
305 );
306 }
307
308 self.commands.push(Command::EndContainer);
309 self.last_text_idx = None;
310 }
311
312 self.interaction_count += 1;
313 self.commands.push(Command::BeginContainer {
314 direction: Direction::Row,
315 gap: 1,
316 align: Align::Start,
317 justify: Justify::Start,
318 border: None,
319 border_sides: BorderSides::all(),
320 border_style: Style::new().fg(self.theme.border),
321 bg_color: None,
322 padding: Padding::default(),
323 margin: Margin::default(),
324 constraints: Constraints::default(),
325 title: None,
326 grow: 0,
327 group_name: None,
328 });
329 for bar in bars {
330 self.styled(
331 center_text(&bar.label, col_width),
332 Style::new().fg(self.theme.text),
333 );
334 }
335 self.commands.push(Command::EndContainer);
336 self.last_text_idx = None;
337
338 self.commands.push(Command::EndContainer);
339 self.last_text_idx = None;
340 }
341 }
342
343 self
344 }
345
346 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
363 if groups.is_empty() {
364 return self;
365 }
366
367 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
368 if all_bars.is_empty() {
369 return self;
370 }
371
372 let max_label_width = all_bars
373 .iter()
374 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
375 .max()
376 .unwrap_or(0);
377 let max_value = all_bars
378 .iter()
379 .map(|bar| bar.value)
380 .fold(f64::NEG_INFINITY, f64::max);
381 let denom = if max_value > 0.0 { max_value } else { 1.0 };
382
383 self.interaction_count += 1;
384 self.commands.push(Command::BeginContainer {
385 direction: Direction::Column,
386 gap: 1,
387 align: Align::Start,
388 justify: Justify::Start,
389 border: None,
390 border_sides: BorderSides::all(),
391 border_style: Style::new().fg(self.theme.border),
392 bg_color: None,
393 padding: Padding::default(),
394 margin: Margin::default(),
395 constraints: Constraints::default(),
396 title: None,
397 grow: 0,
398 group_name: None,
399 });
400
401 for group in groups {
402 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
403
404 for bar in &group.bars {
405 let label_width = UnicodeWidthStr::width(bar.label.as_str());
406 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
407 let normalized = (bar.value / denom).clamp(0.0, 1.0);
408 let bar_len = (normalized * max_width as f64).round() as usize;
409 let bar_text = "█".repeat(bar_len);
410
411 self.interaction_count += 1;
412 self.commands.push(Command::BeginContainer {
413 direction: Direction::Row,
414 gap: 1,
415 align: Align::Start,
416 justify: Justify::Start,
417 border: None,
418 border_sides: BorderSides::all(),
419 border_style: Style::new().fg(self.theme.border),
420 bg_color: None,
421 padding: Padding::default(),
422 margin: Margin::default(),
423 constraints: Constraints::default(),
424 title: None,
425 grow: 0,
426 group_name: None,
427 });
428 self.styled(
429 format!(" {}{label_padding}", bar.label),
430 Style::new().fg(self.theme.text),
431 );
432 self.styled(
433 bar_text,
434 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
435 );
436 self.styled(
437 format_compact_number(bar.value),
438 Style::new().fg(self.theme.text_dim),
439 );
440 self.commands.push(Command::EndContainer);
441 self.last_text_idx = None;
442 }
443 }
444
445 self.commands.push(Command::EndContainer);
446 self.last_text_idx = None;
447
448 self
449 }
450
451 pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
467 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
468
469 let w = width as usize;
470 let window = if data.len() > w {
471 &data[data.len() - w..]
472 } else {
473 data
474 };
475
476 if window.is_empty() {
477 return self;
478 }
479
480 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
481 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
482 let range = max - min;
483
484 let line: String = window
485 .iter()
486 .map(|&value| {
487 let normalized = if range == 0.0 {
488 0.5
489 } else {
490 (value - min) / range
491 };
492 let idx = (normalized * 7.0).round() as usize;
493 BLOCKS[idx.min(7)]
494 })
495 .collect();
496
497 self.styled(line, Style::new().fg(self.theme.primary))
498 }
499
500 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
520 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
521
522 let w = width as usize;
523 let window = if data.len() > w {
524 &data[data.len() - w..]
525 } else {
526 data
527 };
528
529 if window.is_empty() {
530 return self;
531 }
532
533 let mut finite_values = window
534 .iter()
535 .map(|(value, _)| *value)
536 .filter(|value| !value.is_nan());
537 let Some(first) = finite_values.next() else {
538 return self.styled(
539 " ".repeat(window.len()),
540 Style::new().fg(self.theme.text_dim),
541 );
542 };
543
544 let mut min = first;
545 let mut max = first;
546 for value in finite_values {
547 min = f64::min(min, value);
548 max = f64::max(max, value);
549 }
550 let range = max - min;
551
552 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
553 for (value, color) in window {
554 if value.is_nan() {
555 cells.push((' ', self.theme.text_dim));
556 continue;
557 }
558
559 let normalized = if range == 0.0 {
560 0.5
561 } else {
562 ((*value - min) / range).clamp(0.0, 1.0)
563 };
564 let idx = (normalized * 7.0).round() as usize;
565 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
566 }
567
568 self.interaction_count += 1;
569 self.commands.push(Command::BeginContainer {
570 direction: Direction::Row,
571 gap: 0,
572 align: Align::Start,
573 justify: Justify::Start,
574 border: None,
575 border_sides: BorderSides::all(),
576 border_style: Style::new().fg(self.theme.border),
577 bg_color: None,
578 padding: Padding::default(),
579 margin: Margin::default(),
580 constraints: Constraints::default(),
581 title: None,
582 grow: 0,
583 group_name: None,
584 });
585
586 let mut seg = String::new();
587 let mut seg_color = cells[0].1;
588 for (ch, color) in cells {
589 if color != seg_color {
590 self.styled(seg, Style::new().fg(seg_color));
591 seg = String::new();
592 seg_color = color;
593 }
594 seg.push(ch);
595 }
596 if !seg.is_empty() {
597 self.styled(seg, Style::new().fg(seg_color));
598 }
599
600 self.commands.push(Command::EndContainer);
601 self.last_text_idx = None;
602
603 self
604 }
605
606 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
620 if data.is_empty() || width == 0 || height == 0 {
621 return self;
622 }
623
624 let cols = width as usize;
625 let rows = height as usize;
626 let px_w = cols * 2;
627 let px_h = rows * 4;
628
629 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
630 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
631 let range = if (max - min).abs() < f64::EPSILON {
632 1.0
633 } else {
634 max - min
635 };
636
637 let points: Vec<usize> = (0..px_w)
638 .map(|px| {
639 let data_idx = if px_w <= 1 {
640 0.0
641 } else {
642 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
643 };
644 let idx = data_idx.floor() as usize;
645 let frac = data_idx - idx as f64;
646 let value = if idx + 1 < data.len() {
647 data[idx] * (1.0 - frac) + data[idx + 1] * frac
648 } else {
649 data[idx.min(data.len() - 1)]
650 };
651
652 let normalized = (value - min) / range;
653 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
654 py.min(px_h - 1)
655 })
656 .collect();
657
658 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
659 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
660
661 let mut grid = vec![vec![0u32; cols]; rows];
662
663 for i in 0..points.len() {
664 let px = i;
665 let py = points[i];
666 let char_col = px / 2;
667 let char_row = py / 4;
668 let sub_col = px % 2;
669 let sub_row = py % 4;
670
671 if char_col < cols && char_row < rows {
672 grid[char_row][char_col] |= if sub_col == 0 {
673 LEFT_BITS[sub_row]
674 } else {
675 RIGHT_BITS[sub_row]
676 };
677 }
678
679 if i + 1 < points.len() {
680 let py_next = points[i + 1];
681 let (y_start, y_end) = if py <= py_next {
682 (py, py_next)
683 } else {
684 (py_next, py)
685 };
686 for y in y_start..=y_end {
687 let cell_row = y / 4;
688 let sub_y = y % 4;
689 if char_col < cols && cell_row < rows {
690 grid[cell_row][char_col] |= if sub_col == 0 {
691 LEFT_BITS[sub_y]
692 } else {
693 RIGHT_BITS[sub_y]
694 };
695 }
696 }
697 }
698 }
699
700 let style = Style::new().fg(self.theme.primary);
701 for row in grid {
702 let line: String = row
703 .iter()
704 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
705 .collect();
706 self.styled(line, style);
707 }
708
709 self
710 }
711
712 pub fn canvas(
729 &mut self,
730 width: u32,
731 height: u32,
732 draw: impl FnOnce(&mut CanvasContext),
733 ) -> &mut Self {
734 if width == 0 || height == 0 {
735 return self;
736 }
737
738 let mut canvas = CanvasContext::new(width as usize, height as usize);
739 draw(&mut canvas);
740
741 for segments in canvas.render() {
742 self.interaction_count += 1;
743 self.commands.push(Command::BeginContainer {
744 direction: Direction::Row,
745 gap: 0,
746 align: Align::Start,
747 justify: Justify::Start,
748 border: None,
749 border_sides: BorderSides::all(),
750 border_style: Style::new(),
751 bg_color: None,
752 padding: Padding::default(),
753 margin: Margin::default(),
754 constraints: Constraints::default(),
755 title: None,
756 grow: 0,
757 group_name: None,
758 });
759 for (text, color) in segments {
760 let c = if color == Color::Reset {
761 self.theme.primary
762 } else {
763 color
764 };
765 self.styled(text, Style::new().fg(c));
766 }
767 self.commands.push(Command::EndContainer);
768 self.last_text_idx = None;
769 }
770
771 self
772 }
773
774 pub fn chart(
776 &mut self,
777 configure: impl FnOnce(&mut ChartBuilder),
778 width: u32,
779 height: u32,
780 ) -> &mut Self {
781 if width == 0 || height == 0 {
782 return self;
783 }
784
785 let axis_style = Style::new().fg(self.theme.text_dim);
786 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
787 configure(&mut builder);
788
789 let config = builder.build();
790 let rows = render_chart(&config);
791
792 for row in rows {
793 self.interaction_count += 1;
794 self.commands.push(Command::BeginContainer {
795 direction: Direction::Row,
796 gap: 0,
797 align: Align::Start,
798 justify: Justify::Start,
799 border: None,
800 border_sides: BorderSides::all(),
801 border_style: Style::new().fg(self.theme.border),
802 bg_color: None,
803 padding: Padding::default(),
804 margin: Margin::default(),
805 constraints: Constraints::default(),
806 title: None,
807 grow: 0,
808 group_name: None,
809 });
810 for (text, style) in row.segments {
811 self.styled(text, style);
812 }
813 self.commands.push(Command::EndContainer);
814 self.last_text_idx = None;
815 }
816
817 self
818 }
819
820 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> &mut Self {
824 self.chart(
825 |c| {
826 c.scatter(data);
827 c.grid(true);
828 },
829 width,
830 height,
831 )
832 }
833
834 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
836 self.histogram_with(data, |_| {}, width, height)
837 }
838
839 pub fn histogram_with(
841 &mut self,
842 data: &[f64],
843 configure: impl FnOnce(&mut HistogramBuilder),
844 width: u32,
845 height: u32,
846 ) -> &mut Self {
847 if width == 0 || height == 0 {
848 return self;
849 }
850
851 let mut options = HistogramBuilder::default();
852 configure(&mut options);
853 let axis_style = Style::new().fg(self.theme.text_dim);
854 let config = build_histogram_config(data, &options, width, height, axis_style);
855 let rows = render_chart(&config);
856
857 for row in rows {
858 self.interaction_count += 1;
859 self.commands.push(Command::BeginContainer {
860 direction: Direction::Row,
861 gap: 0,
862 align: Align::Start,
863 justify: Justify::Start,
864 border: None,
865 border_sides: BorderSides::all(),
866 border_style: Style::new().fg(self.theme.border),
867 bg_color: None,
868 padding: Padding::default(),
869 margin: Margin::default(),
870 constraints: Constraints::default(),
871 title: None,
872 grow: 0,
873 group_name: None,
874 });
875 for (text, style) in row.segments {
876 self.styled(text, style);
877 }
878 self.commands.push(Command::EndContainer);
879 self.last_text_idx = None;
880 }
881
882 self
883 }
884
885 pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
902 slt_assert(cols > 0, "grid() requires at least 1 column");
903 let interaction_id = self.interaction_count;
904 self.interaction_count += 1;
905 let border = self.theme.border;
906
907 self.commands.push(Command::BeginContainer {
908 direction: Direction::Column,
909 gap: 0,
910 align: Align::Start,
911 justify: Justify::Start,
912 border: None,
913 border_sides: BorderSides::all(),
914 border_style: Style::new().fg(border),
915 bg_color: None,
916 padding: Padding::default(),
917 margin: Margin::default(),
918 constraints: Constraints::default(),
919 title: None,
920 grow: 0,
921 group_name: None,
922 });
923
924 let children_start = self.commands.len();
925 f(self);
926 let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
927
928 let mut elements: Vec<Vec<Command>> = Vec::new();
929 let mut iter = child_commands.into_iter().peekable();
930 while let Some(cmd) = iter.next() {
931 match cmd {
932 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
933 let mut depth = 1_u32;
934 let mut element = vec![cmd];
935 for next in iter.by_ref() {
936 match next {
937 Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
938 depth += 1;
939 }
940 Command::EndContainer => {
941 depth = depth.saturating_sub(1);
942 }
943 _ => {}
944 }
945 let at_end = matches!(next, Command::EndContainer) && depth == 0;
946 element.push(next);
947 if at_end {
948 break;
949 }
950 }
951 elements.push(element);
952 }
953 Command::EndContainer => {}
954 _ => elements.push(vec![cmd]),
955 }
956 }
957
958 let cols = cols.max(1) as usize;
959 for row in elements.chunks(cols) {
960 self.interaction_count += 1;
961 self.commands.push(Command::BeginContainer {
962 direction: Direction::Row,
963 gap: 0,
964 align: Align::Start,
965 justify: Justify::Start,
966 border: None,
967 border_sides: BorderSides::all(),
968 border_style: Style::new().fg(border),
969 bg_color: None,
970 padding: Padding::default(),
971 margin: Margin::default(),
972 constraints: Constraints::default(),
973 title: None,
974 grow: 0,
975 group_name: None,
976 });
977
978 for element in row {
979 self.interaction_count += 1;
980 self.commands.push(Command::BeginContainer {
981 direction: Direction::Column,
982 gap: 0,
983 align: Align::Start,
984 justify: Justify::Start,
985 border: None,
986 border_sides: BorderSides::all(),
987 border_style: Style::new().fg(border),
988 bg_color: None,
989 padding: Padding::default(),
990 margin: Margin::default(),
991 constraints: Constraints::default(),
992 title: None,
993 grow: 1,
994 group_name: None,
995 });
996 self.commands.extend(element.iter().cloned());
997 self.commands.push(Command::EndContainer);
998 }
999
1000 self.commands.push(Command::EndContainer);
1001 }
1002
1003 self.commands.push(Command::EndContainer);
1004 self.last_text_idx = None;
1005
1006 self.response_for(interaction_id)
1007 }
1008
1009 pub fn list(&mut self, state: &mut ListState) -> &mut Self {
1014 let visible = state.visible_indices().to_vec();
1015 if visible.is_empty() && state.items.is_empty() {
1016 state.selected = 0;
1017 return self;
1018 }
1019
1020 if !visible.is_empty() {
1021 state.selected = state.selected.min(visible.len().saturating_sub(1));
1022 }
1023
1024 let focused = self.register_focusable();
1025 let interaction_id = self.interaction_count;
1026 self.interaction_count += 1;
1027
1028 if focused {
1029 let mut consumed_indices = Vec::new();
1030 for (i, event) in self.events.iter().enumerate() {
1031 if let Event::Key(key) = event {
1032 if key.kind != KeyEventKind::Press {
1033 continue;
1034 }
1035 match key.code {
1036 KeyCode::Up | KeyCode::Char('k') => {
1037 state.selected = state.selected.saturating_sub(1);
1038 consumed_indices.push(i);
1039 }
1040 KeyCode::Down | KeyCode::Char('j') => {
1041 state.selected =
1042 (state.selected + 1).min(visible.len().saturating_sub(1));
1043 consumed_indices.push(i);
1044 }
1045 _ => {}
1046 }
1047 }
1048 }
1049
1050 for index in consumed_indices {
1051 self.consumed[index] = true;
1052 }
1053 }
1054
1055 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1056 for (i, event) in self.events.iter().enumerate() {
1057 if self.consumed[i] {
1058 continue;
1059 }
1060 if let Event::Mouse(mouse) = event {
1061 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1062 continue;
1063 }
1064 let in_bounds = mouse.x >= rect.x
1065 && mouse.x < rect.right()
1066 && mouse.y >= rect.y
1067 && mouse.y < rect.bottom();
1068 if !in_bounds {
1069 continue;
1070 }
1071 let clicked_idx = (mouse.y - rect.y) as usize;
1072 if clicked_idx < visible.len() {
1073 state.selected = clicked_idx;
1074 self.consumed[i] = true;
1075 }
1076 }
1077 }
1078 }
1079
1080 self.commands.push(Command::BeginContainer {
1081 direction: Direction::Column,
1082 gap: 0,
1083 align: Align::Start,
1084 justify: Justify::Start,
1085 border: None,
1086 border_sides: BorderSides::all(),
1087 border_style: Style::new().fg(self.theme.border),
1088 bg_color: None,
1089 padding: Padding::default(),
1090 margin: Margin::default(),
1091 constraints: Constraints::default(),
1092 title: None,
1093 grow: 0,
1094 group_name: None,
1095 });
1096
1097 for (view_idx, &item_idx) in visible.iter().enumerate() {
1098 let item = &state.items[item_idx];
1099 if view_idx == state.selected {
1100 if focused {
1101 self.styled(
1102 format!("▸ {item}"),
1103 Style::new().bold().fg(self.theme.primary),
1104 );
1105 } else {
1106 self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
1107 }
1108 } else {
1109 self.styled(format!(" {item}"), Style::new().fg(self.theme.text));
1110 }
1111 }
1112
1113 self.commands.push(Command::EndContainer);
1114 self.last_text_idx = None;
1115
1116 self
1117 }
1118
1119 pub fn table(&mut self, state: &mut TableState) -> &mut Self {
1124 if state.is_dirty() {
1125 state.recompute_widths();
1126 }
1127
1128 let focused = self.register_focusable();
1129 let interaction_id = self.interaction_count;
1130 self.interaction_count += 1;
1131
1132 if focused && !state.visible_indices().is_empty() {
1133 let mut consumed_indices = Vec::new();
1134 for (i, event) in self.events.iter().enumerate() {
1135 if let Event::Key(key) = event {
1136 if key.kind != KeyEventKind::Press {
1137 continue;
1138 }
1139 match key.code {
1140 KeyCode::Up | KeyCode::Char('k') => {
1141 let visible_len = if state.page_size > 0 {
1142 let start = state
1143 .page
1144 .saturating_mul(state.page_size)
1145 .min(state.visible_indices().len());
1146 let end =
1147 (start + state.page_size).min(state.visible_indices().len());
1148 end.saturating_sub(start)
1149 } else {
1150 state.visible_indices().len()
1151 };
1152 state.selected = state.selected.min(visible_len.saturating_sub(1));
1153 state.selected = state.selected.saturating_sub(1);
1154 consumed_indices.push(i);
1155 }
1156 KeyCode::Down | KeyCode::Char('j') => {
1157 let visible_len = if state.page_size > 0 {
1158 let start = state
1159 .page
1160 .saturating_mul(state.page_size)
1161 .min(state.visible_indices().len());
1162 let end =
1163 (start + state.page_size).min(state.visible_indices().len());
1164 end.saturating_sub(start)
1165 } else {
1166 state.visible_indices().len()
1167 };
1168 state.selected =
1169 (state.selected + 1).min(visible_len.saturating_sub(1));
1170 consumed_indices.push(i);
1171 }
1172 KeyCode::PageUp => {
1173 let old_page = state.page;
1174 state.prev_page();
1175 if state.page != old_page {
1176 state.selected = 0;
1177 }
1178 consumed_indices.push(i);
1179 }
1180 KeyCode::PageDown => {
1181 let old_page = state.page;
1182 state.next_page();
1183 if state.page != old_page {
1184 state.selected = 0;
1185 }
1186 consumed_indices.push(i);
1187 }
1188 _ => {}
1189 }
1190 }
1191 }
1192 for index in consumed_indices {
1193 self.consumed[index] = true;
1194 }
1195 }
1196
1197 if !state.visible_indices().is_empty() || !state.headers.is_empty() {
1198 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1199 for (i, event) in self.events.iter().enumerate() {
1200 if self.consumed[i] {
1201 continue;
1202 }
1203 if let Event::Mouse(mouse) = event {
1204 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1205 continue;
1206 }
1207 let in_bounds = mouse.x >= rect.x
1208 && mouse.x < rect.right()
1209 && mouse.y >= rect.y
1210 && mouse.y < rect.bottom();
1211 if !in_bounds {
1212 continue;
1213 }
1214
1215 if mouse.y == rect.y {
1216 let rel_x = mouse.x.saturating_sub(rect.x);
1217 let mut x_offset = 0u32;
1218 for (col_idx, width) in state.column_widths().iter().enumerate() {
1219 if rel_x >= x_offset && rel_x < x_offset + *width {
1220 state.toggle_sort(col_idx);
1221 state.selected = 0;
1222 self.consumed[i] = true;
1223 break;
1224 }
1225 x_offset += *width;
1226 if col_idx + 1 < state.column_widths().len() {
1227 x_offset += 3;
1228 }
1229 }
1230 continue;
1231 }
1232
1233 if mouse.y < rect.y + 2 {
1234 continue;
1235 }
1236
1237 let visible_len = if state.page_size > 0 {
1238 let start = state
1239 .page
1240 .saturating_mul(state.page_size)
1241 .min(state.visible_indices().len());
1242 let end = (start + state.page_size).min(state.visible_indices().len());
1243 end.saturating_sub(start)
1244 } else {
1245 state.visible_indices().len()
1246 };
1247 let clicked_idx = (mouse.y - rect.y - 2) as usize;
1248 if clicked_idx < visible_len {
1249 state.selected = clicked_idx;
1250 self.consumed[i] = true;
1251 }
1252 }
1253 }
1254 }
1255 }
1256
1257 if state.is_dirty() {
1258 state.recompute_widths();
1259 }
1260
1261 let total_visible = state.visible_indices().len();
1262 let page_start = if state.page_size > 0 {
1263 state
1264 .page
1265 .saturating_mul(state.page_size)
1266 .min(total_visible)
1267 } else {
1268 0
1269 };
1270 let page_end = if state.page_size > 0 {
1271 (page_start + state.page_size).min(total_visible)
1272 } else {
1273 total_visible
1274 };
1275 let visible_len = page_end.saturating_sub(page_start);
1276 state.selected = state.selected.min(visible_len.saturating_sub(1));
1277
1278 self.commands.push(Command::BeginContainer {
1279 direction: Direction::Column,
1280 gap: 0,
1281 align: Align::Start,
1282 justify: Justify::Start,
1283 border: None,
1284 border_sides: BorderSides::all(),
1285 border_style: Style::new().fg(self.theme.border),
1286 bg_color: None,
1287 padding: Padding::default(),
1288 margin: Margin::default(),
1289 constraints: Constraints::default(),
1290 title: None,
1291 grow: 0,
1292 group_name: None,
1293 });
1294
1295 let header_cells = state
1296 .headers
1297 .iter()
1298 .enumerate()
1299 .map(|(i, header)| {
1300 if state.sort_column == Some(i) {
1301 if state.sort_ascending {
1302 format!("{header} ▲")
1303 } else {
1304 format!("{header} ▼")
1305 }
1306 } else {
1307 header.clone()
1308 }
1309 })
1310 .collect::<Vec<_>>();
1311 let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
1312 self.styled(header_line, Style::new().bold().fg(self.theme.text));
1313
1314 let separator = state
1315 .column_widths()
1316 .iter()
1317 .map(|w| "─".repeat(*w as usize))
1318 .collect::<Vec<_>>()
1319 .join("─┼─");
1320 self.text(separator);
1321
1322 for idx in 0..visible_len {
1323 let data_idx = state.visible_indices()[page_start + idx];
1324 let Some(row) = state.rows.get(data_idx) else {
1325 continue;
1326 };
1327 let line = format_table_row(row, state.column_widths(), " │ ");
1328 if idx == state.selected {
1329 let mut style = Style::new()
1330 .bg(self.theme.selected_bg)
1331 .fg(self.theme.selected_fg);
1332 if focused {
1333 style = style.bold();
1334 }
1335 self.styled(line, style);
1336 } else {
1337 self.styled(line, Style::new().fg(self.theme.text));
1338 }
1339 }
1340
1341 if state.page_size > 0 && state.total_pages() > 1 {
1342 self.styled(
1343 format!("Page {}/{}", state.page + 1, state.total_pages()),
1344 Style::new().dim().fg(self.theme.text_dim),
1345 );
1346 }
1347
1348 self.commands.push(Command::EndContainer);
1349 self.last_text_idx = None;
1350
1351 self
1352 }
1353
1354 pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
1359 if state.labels.is_empty() {
1360 state.selected = 0;
1361 return self;
1362 }
1363
1364 state.selected = state.selected.min(state.labels.len().saturating_sub(1));
1365 let focused = self.register_focusable();
1366 let interaction_id = self.interaction_count;
1367
1368 if focused {
1369 let mut consumed_indices = Vec::new();
1370 for (i, event) in self.events.iter().enumerate() {
1371 if let Event::Key(key) = event {
1372 if key.kind != KeyEventKind::Press {
1373 continue;
1374 }
1375 match key.code {
1376 KeyCode::Left => {
1377 state.selected = if state.selected == 0 {
1378 state.labels.len().saturating_sub(1)
1379 } else {
1380 state.selected - 1
1381 };
1382 consumed_indices.push(i);
1383 }
1384 KeyCode::Right => {
1385 if !state.labels.is_empty() {
1386 state.selected = (state.selected + 1) % state.labels.len();
1387 }
1388 consumed_indices.push(i);
1389 }
1390 _ => {}
1391 }
1392 }
1393 }
1394
1395 for index in consumed_indices {
1396 self.consumed[index] = true;
1397 }
1398 }
1399
1400 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1401 for (i, event) in self.events.iter().enumerate() {
1402 if self.consumed[i] {
1403 continue;
1404 }
1405 if let Event::Mouse(mouse) = event {
1406 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1407 continue;
1408 }
1409 let in_bounds = mouse.x >= rect.x
1410 && mouse.x < rect.right()
1411 && mouse.y >= rect.y
1412 && mouse.y < rect.bottom();
1413 if !in_bounds {
1414 continue;
1415 }
1416
1417 let mut x_offset = 0u32;
1418 let rel_x = mouse.x - rect.x;
1419 for (idx, label) in state.labels.iter().enumerate() {
1420 let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
1421 if rel_x >= x_offset && rel_x < x_offset + tab_width {
1422 state.selected = idx;
1423 self.consumed[i] = true;
1424 break;
1425 }
1426 x_offset += tab_width + 1;
1427 }
1428 }
1429 }
1430 }
1431
1432 self.interaction_count += 1;
1433 self.commands.push(Command::BeginContainer {
1434 direction: Direction::Row,
1435 gap: 1,
1436 align: Align::Start,
1437 justify: Justify::Start,
1438 border: None,
1439 border_sides: BorderSides::all(),
1440 border_style: Style::new().fg(self.theme.border),
1441 bg_color: None,
1442 padding: Padding::default(),
1443 margin: Margin::default(),
1444 constraints: Constraints::default(),
1445 title: None,
1446 grow: 0,
1447 group_name: None,
1448 });
1449 for (idx, label) in state.labels.iter().enumerate() {
1450 let style = if idx == state.selected {
1451 let s = Style::new().fg(self.theme.primary).bold();
1452 if focused {
1453 s.underline()
1454 } else {
1455 s
1456 }
1457 } else {
1458 Style::new().fg(self.theme.text_dim)
1459 };
1460 self.styled(format!("[ {label} ]"), style);
1461 }
1462 self.commands.push(Command::EndContainer);
1463 self.last_text_idx = None;
1464
1465 self
1466 }
1467
1468 pub fn button(&mut self, label: impl Into<String>) -> bool {
1473 let focused = self.register_focusable();
1474 let interaction_id = self.interaction_count;
1475 self.interaction_count += 1;
1476 let response = self.response_for(interaction_id);
1477
1478 let mut activated = response.clicked;
1479 if focused {
1480 let mut consumed_indices = Vec::new();
1481 for (i, event) in self.events.iter().enumerate() {
1482 if let Event::Key(key) = event {
1483 if key.kind != KeyEventKind::Press {
1484 continue;
1485 }
1486 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1487 activated = true;
1488 consumed_indices.push(i);
1489 }
1490 }
1491 }
1492
1493 for index in consumed_indices {
1494 self.consumed[index] = true;
1495 }
1496 }
1497
1498 let hovered = response.hovered;
1499 let style = if focused {
1500 Style::new().fg(self.theme.primary).bold()
1501 } else if hovered {
1502 Style::new().fg(self.theme.accent)
1503 } else {
1504 Style::new().fg(self.theme.text)
1505 };
1506 let hover_bg = if hovered || focused {
1507 Some(self.theme.surface_hover)
1508 } else {
1509 None
1510 };
1511
1512 self.commands.push(Command::BeginContainer {
1513 direction: Direction::Row,
1514 gap: 0,
1515 align: Align::Start,
1516 justify: Justify::Start,
1517 border: None,
1518 border_sides: BorderSides::all(),
1519 border_style: Style::new().fg(self.theme.border),
1520 bg_color: hover_bg,
1521 padding: Padding::default(),
1522 margin: Margin::default(),
1523 constraints: Constraints::default(),
1524 title: None,
1525 grow: 0,
1526 group_name: None,
1527 });
1528 self.styled(format!("[ {} ]", label.into()), style);
1529 self.commands.push(Command::EndContainer);
1530 self.last_text_idx = None;
1531
1532 activated
1533 }
1534
1535 pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
1540 let focused = self.register_focusable();
1541 let interaction_id = self.interaction_count;
1542 self.interaction_count += 1;
1543 let response = self.response_for(interaction_id);
1544
1545 let mut activated = response.clicked;
1546 if focused {
1547 let mut consumed_indices = Vec::new();
1548 for (i, event) in self.events.iter().enumerate() {
1549 if let Event::Key(key) = event {
1550 if key.kind != KeyEventKind::Press {
1551 continue;
1552 }
1553 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1554 activated = true;
1555 consumed_indices.push(i);
1556 }
1557 }
1558 }
1559 for index in consumed_indices {
1560 self.consumed[index] = true;
1561 }
1562 }
1563
1564 let label = label.into();
1565 let hover_bg = if response.hovered || focused {
1566 Some(self.theme.surface_hover)
1567 } else {
1568 None
1569 };
1570 let (text, style, bg_color, border) = match variant {
1571 ButtonVariant::Default => {
1572 let style = if focused {
1573 Style::new().fg(self.theme.primary).bold()
1574 } else if response.hovered {
1575 Style::new().fg(self.theme.accent)
1576 } else {
1577 Style::new().fg(self.theme.text)
1578 };
1579 (format!("[ {label} ]"), style, hover_bg, None)
1580 }
1581 ButtonVariant::Primary => {
1582 let style = if focused {
1583 Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
1584 } else if response.hovered {
1585 Style::new().fg(self.theme.bg).bg(self.theme.accent)
1586 } else {
1587 Style::new().fg(self.theme.bg).bg(self.theme.primary)
1588 };
1589 (format!(" {label} "), style, hover_bg, None)
1590 }
1591 ButtonVariant::Danger => {
1592 let style = if focused {
1593 Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1594 } else if response.hovered {
1595 Style::new().fg(self.theme.bg).bg(self.theme.warning)
1596 } else {
1597 Style::new().fg(self.theme.bg).bg(self.theme.error)
1598 };
1599 (format!(" {label} "), style, hover_bg, None)
1600 }
1601 ButtonVariant::Outline => {
1602 let border_color = if focused {
1603 self.theme.primary
1604 } else if response.hovered {
1605 self.theme.accent
1606 } else {
1607 self.theme.border
1608 };
1609 let style = if focused {
1610 Style::new().fg(self.theme.primary).bold()
1611 } else if response.hovered {
1612 Style::new().fg(self.theme.accent)
1613 } else {
1614 Style::new().fg(self.theme.text)
1615 };
1616 (
1617 format!(" {label} "),
1618 style,
1619 hover_bg,
1620 Some((Border::Rounded, Style::new().fg(border_color))),
1621 )
1622 }
1623 };
1624
1625 let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
1626 self.commands.push(Command::BeginContainer {
1627 direction: Direction::Row,
1628 gap: 0,
1629 align: Align::Center,
1630 justify: Justify::Center,
1631 border: if border.is_some() {
1632 Some(btn_border)
1633 } else {
1634 None
1635 },
1636 border_sides: BorderSides::all(),
1637 border_style: btn_border_style,
1638 bg_color,
1639 padding: Padding::default(),
1640 margin: Margin::default(),
1641 constraints: Constraints::default(),
1642 title: None,
1643 grow: 0,
1644 group_name: None,
1645 });
1646 self.styled(text, style);
1647 self.commands.push(Command::EndContainer);
1648 self.last_text_idx = None;
1649
1650 activated
1651 }
1652
1653 pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
1658 let focused = self.register_focusable();
1659 let interaction_id = self.interaction_count;
1660 self.interaction_count += 1;
1661 let response = self.response_for(interaction_id);
1662 let mut should_toggle = response.clicked;
1663
1664 if focused {
1665 let mut consumed_indices = Vec::new();
1666 for (i, event) in self.events.iter().enumerate() {
1667 if let Event::Key(key) = event {
1668 if key.kind != KeyEventKind::Press {
1669 continue;
1670 }
1671 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1672 should_toggle = true;
1673 consumed_indices.push(i);
1674 }
1675 }
1676 }
1677
1678 for index in consumed_indices {
1679 self.consumed[index] = true;
1680 }
1681 }
1682
1683 if should_toggle {
1684 *checked = !*checked;
1685 }
1686
1687 let hover_bg = if response.hovered || focused {
1688 Some(self.theme.surface_hover)
1689 } else {
1690 None
1691 };
1692 self.commands.push(Command::BeginContainer {
1693 direction: Direction::Row,
1694 gap: 1,
1695 align: Align::Start,
1696 justify: Justify::Start,
1697 border: None,
1698 border_sides: BorderSides::all(),
1699 border_style: Style::new().fg(self.theme.border),
1700 bg_color: hover_bg,
1701 padding: Padding::default(),
1702 margin: Margin::default(),
1703 constraints: Constraints::default(),
1704 title: None,
1705 grow: 0,
1706 group_name: None,
1707 });
1708 let marker_style = if *checked {
1709 Style::new().fg(self.theme.success)
1710 } else {
1711 Style::new().fg(self.theme.text_dim)
1712 };
1713 let marker = if *checked { "[x]" } else { "[ ]" };
1714 let label_text = label.into();
1715 if focused {
1716 self.styled(format!("▸ {marker}"), marker_style.bold());
1717 self.styled(label_text, Style::new().fg(self.theme.text).bold());
1718 } else {
1719 self.styled(marker, marker_style);
1720 self.styled(label_text, Style::new().fg(self.theme.text));
1721 }
1722 self.commands.push(Command::EndContainer);
1723 self.last_text_idx = None;
1724
1725 self
1726 }
1727
1728 pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
1734 let focused = self.register_focusable();
1735 let interaction_id = self.interaction_count;
1736 self.interaction_count += 1;
1737 let response = self.response_for(interaction_id);
1738 let mut should_toggle = response.clicked;
1739
1740 if focused {
1741 let mut consumed_indices = Vec::new();
1742 for (i, event) in self.events.iter().enumerate() {
1743 if let Event::Key(key) = event {
1744 if key.kind != KeyEventKind::Press {
1745 continue;
1746 }
1747 if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1748 should_toggle = true;
1749 consumed_indices.push(i);
1750 }
1751 }
1752 }
1753
1754 for index in consumed_indices {
1755 self.consumed[index] = true;
1756 }
1757 }
1758
1759 if should_toggle {
1760 *on = !*on;
1761 }
1762
1763 let hover_bg = if response.hovered || focused {
1764 Some(self.theme.surface_hover)
1765 } else {
1766 None
1767 };
1768 self.commands.push(Command::BeginContainer {
1769 direction: Direction::Row,
1770 gap: 2,
1771 align: Align::Start,
1772 justify: Justify::Start,
1773 border: None,
1774 border_sides: BorderSides::all(),
1775 border_style: Style::new().fg(self.theme.border),
1776 bg_color: hover_bg,
1777 padding: Padding::default(),
1778 margin: Margin::default(),
1779 constraints: Constraints::default(),
1780 title: None,
1781 grow: 0,
1782 group_name: None,
1783 });
1784 let label_text = label.into();
1785 let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1786 let switch_style = if *on {
1787 Style::new().fg(self.theme.success)
1788 } else {
1789 Style::new().fg(self.theme.text_dim)
1790 };
1791 if focused {
1792 self.styled(
1793 format!("▸ {label_text}"),
1794 Style::new().fg(self.theme.text).bold(),
1795 );
1796 self.styled(switch, switch_style.bold());
1797 } else {
1798 self.styled(label_text, Style::new().fg(self.theme.text));
1799 self.styled(switch, switch_style);
1800 }
1801 self.commands.push(Command::EndContainer);
1802 self.last_text_idx = None;
1803
1804 self
1805 }
1806
1807 pub fn select(&mut self, state: &mut SelectState) -> bool {
1813 if state.items.is_empty() {
1814 return false;
1815 }
1816 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1817
1818 let focused = self.register_focusable();
1819 let interaction_id = self.interaction_count;
1820 self.interaction_count += 1;
1821 let response = self.response_for(interaction_id);
1822 let old_selected = state.selected;
1823
1824 if response.clicked {
1825 state.open = !state.open;
1826 if state.open {
1827 state.set_cursor(state.selected);
1828 }
1829 }
1830
1831 if focused {
1832 let mut consumed_indices = Vec::new();
1833 for (i, event) in self.events.iter().enumerate() {
1834 if self.consumed[i] {
1835 continue;
1836 }
1837 if let Event::Key(key) = event {
1838 if key.kind != KeyEventKind::Press {
1839 continue;
1840 }
1841 if state.open {
1842 match key.code {
1843 KeyCode::Up | KeyCode::Char('k') => {
1844 let c = state.cursor();
1845 state.set_cursor(c.saturating_sub(1));
1846 consumed_indices.push(i);
1847 }
1848 KeyCode::Down | KeyCode::Char('j') => {
1849 let c = state.cursor();
1850 state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
1851 consumed_indices.push(i);
1852 }
1853 KeyCode::Enter | KeyCode::Char(' ') => {
1854 state.selected = state.cursor();
1855 state.open = false;
1856 consumed_indices.push(i);
1857 }
1858 KeyCode::Esc => {
1859 state.open = false;
1860 consumed_indices.push(i);
1861 }
1862 _ => {}
1863 }
1864 } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1865 state.open = true;
1866 state.set_cursor(state.selected);
1867 consumed_indices.push(i);
1868 }
1869 }
1870 }
1871 for idx in consumed_indices {
1872 self.consumed[idx] = true;
1873 }
1874 }
1875
1876 let changed = state.selected != old_selected;
1877
1878 let border_color = if focused {
1879 self.theme.primary
1880 } else {
1881 self.theme.border
1882 };
1883 let display_text = state
1884 .items
1885 .get(state.selected)
1886 .cloned()
1887 .unwrap_or_else(|| state.placeholder.clone());
1888 let arrow = if state.open { "▲" } else { "▼" };
1889
1890 self.commands.push(Command::BeginContainer {
1891 direction: Direction::Column,
1892 gap: 0,
1893 align: Align::Start,
1894 justify: Justify::Start,
1895 border: None,
1896 border_sides: BorderSides::all(),
1897 border_style: Style::new().fg(self.theme.border),
1898 bg_color: None,
1899 padding: Padding::default(),
1900 margin: Margin::default(),
1901 constraints: Constraints::default(),
1902 title: None,
1903 grow: 0,
1904 group_name: None,
1905 });
1906
1907 self.commands.push(Command::BeginContainer {
1908 direction: Direction::Row,
1909 gap: 1,
1910 align: Align::Start,
1911 justify: Justify::Start,
1912 border: Some(Border::Rounded),
1913 border_sides: BorderSides::all(),
1914 border_style: Style::new().fg(border_color),
1915 bg_color: None,
1916 padding: Padding {
1917 left: 1,
1918 right: 1,
1919 top: 0,
1920 bottom: 0,
1921 },
1922 margin: Margin::default(),
1923 constraints: Constraints::default(),
1924 title: None,
1925 grow: 0,
1926 group_name: None,
1927 });
1928 self.interaction_count += 1;
1929 self.styled(&display_text, Style::new().fg(self.theme.text));
1930 self.styled(arrow, Style::new().fg(self.theme.text_dim));
1931 self.commands.push(Command::EndContainer);
1932 self.last_text_idx = None;
1933
1934 if state.open {
1935 for (idx, item) in state.items.iter().enumerate() {
1936 let is_cursor = idx == state.cursor();
1937 let style = if is_cursor {
1938 Style::new().bold().fg(self.theme.primary)
1939 } else {
1940 Style::new().fg(self.theme.text)
1941 };
1942 let prefix = if is_cursor { "▸ " } else { " " };
1943 self.styled(format!("{prefix}{item}"), style);
1944 }
1945 }
1946
1947 self.commands.push(Command::EndContainer);
1948 self.last_text_idx = None;
1949 changed
1950 }
1951
1952 pub fn radio(&mut self, state: &mut RadioState) -> bool {
1956 if state.items.is_empty() {
1957 return false;
1958 }
1959 state.selected = state.selected.min(state.items.len().saturating_sub(1));
1960 let focused = self.register_focusable();
1961 let old_selected = state.selected;
1962
1963 if focused {
1964 let mut consumed_indices = Vec::new();
1965 for (i, event) in self.events.iter().enumerate() {
1966 if self.consumed[i] {
1967 continue;
1968 }
1969 if let Event::Key(key) = event {
1970 if key.kind != KeyEventKind::Press {
1971 continue;
1972 }
1973 match key.code {
1974 KeyCode::Up | KeyCode::Char('k') => {
1975 state.selected = state.selected.saturating_sub(1);
1976 consumed_indices.push(i);
1977 }
1978 KeyCode::Down | KeyCode::Char('j') => {
1979 state.selected =
1980 (state.selected + 1).min(state.items.len().saturating_sub(1));
1981 consumed_indices.push(i);
1982 }
1983 KeyCode::Enter | KeyCode::Char(' ') => {
1984 consumed_indices.push(i);
1985 }
1986 _ => {}
1987 }
1988 }
1989 }
1990 for idx in consumed_indices {
1991 self.consumed[idx] = true;
1992 }
1993 }
1994
1995 let interaction_id = self.interaction_count;
1996 self.interaction_count += 1;
1997
1998 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1999 for (i, event) in self.events.iter().enumerate() {
2000 if self.consumed[i] {
2001 continue;
2002 }
2003 if let Event::Mouse(mouse) = event {
2004 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2005 continue;
2006 }
2007 let in_bounds = mouse.x >= rect.x
2008 && mouse.x < rect.right()
2009 && mouse.y >= rect.y
2010 && mouse.y < rect.bottom();
2011 if !in_bounds {
2012 continue;
2013 }
2014 let clicked_idx = (mouse.y - rect.y) as usize;
2015 if clicked_idx < state.items.len() {
2016 state.selected = clicked_idx;
2017 self.consumed[i] = true;
2018 }
2019 }
2020 }
2021 }
2022
2023 self.commands.push(Command::BeginContainer {
2024 direction: Direction::Column,
2025 gap: 0,
2026 align: Align::Start,
2027 justify: Justify::Start,
2028 border: None,
2029 border_sides: BorderSides::all(),
2030 border_style: Style::new().fg(self.theme.border),
2031 bg_color: None,
2032 padding: Padding::default(),
2033 margin: Margin::default(),
2034 constraints: Constraints::default(),
2035 title: None,
2036 grow: 0,
2037 group_name: None,
2038 });
2039
2040 for (idx, item) in state.items.iter().enumerate() {
2041 let is_selected = idx == state.selected;
2042 let marker = if is_selected { "●" } else { "○" };
2043 let style = if is_selected {
2044 if focused {
2045 Style::new().bold().fg(self.theme.primary)
2046 } else {
2047 Style::new().fg(self.theme.primary)
2048 }
2049 } else {
2050 Style::new().fg(self.theme.text)
2051 };
2052 let prefix = if focused && idx == state.selected {
2053 "▸ "
2054 } else {
2055 " "
2056 };
2057 self.styled(format!("{prefix}{marker} {item}"), style);
2058 }
2059
2060 self.commands.push(Command::EndContainer);
2061 self.last_text_idx = None;
2062 state.selected != old_selected
2063 }
2064
2065 pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
2069 if state.items.is_empty() {
2070 return self;
2071 }
2072 state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
2073 let focused = self.register_focusable();
2074
2075 if focused {
2076 let mut consumed_indices = Vec::new();
2077 for (i, event) in self.events.iter().enumerate() {
2078 if self.consumed[i] {
2079 continue;
2080 }
2081 if let Event::Key(key) = event {
2082 if key.kind != KeyEventKind::Press {
2083 continue;
2084 }
2085 match key.code {
2086 KeyCode::Up | KeyCode::Char('k') => {
2087 state.cursor = state.cursor.saturating_sub(1);
2088 consumed_indices.push(i);
2089 }
2090 KeyCode::Down | KeyCode::Char('j') => {
2091 state.cursor =
2092 (state.cursor + 1).min(state.items.len().saturating_sub(1));
2093 consumed_indices.push(i);
2094 }
2095 KeyCode::Char(' ') | KeyCode::Enter => {
2096 state.toggle(state.cursor);
2097 consumed_indices.push(i);
2098 }
2099 _ => {}
2100 }
2101 }
2102 }
2103 for idx in consumed_indices {
2104 self.consumed[idx] = true;
2105 }
2106 }
2107
2108 let interaction_id = self.interaction_count;
2109 self.interaction_count += 1;
2110
2111 if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
2112 for (i, event) in self.events.iter().enumerate() {
2113 if self.consumed[i] {
2114 continue;
2115 }
2116 if let Event::Mouse(mouse) = event {
2117 if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2118 continue;
2119 }
2120 let in_bounds = mouse.x >= rect.x
2121 && mouse.x < rect.right()
2122 && mouse.y >= rect.y
2123 && mouse.y < rect.bottom();
2124 if !in_bounds {
2125 continue;
2126 }
2127 let clicked_idx = (mouse.y - rect.y) as usize;
2128 if clicked_idx < state.items.len() {
2129 state.toggle(clicked_idx);
2130 state.cursor = clicked_idx;
2131 self.consumed[i] = true;
2132 }
2133 }
2134 }
2135 }
2136
2137 self.commands.push(Command::BeginContainer {
2138 direction: Direction::Column,
2139 gap: 0,
2140 align: Align::Start,
2141 justify: Justify::Start,
2142 border: None,
2143 border_sides: BorderSides::all(),
2144 border_style: Style::new().fg(self.theme.border),
2145 bg_color: None,
2146 padding: Padding::default(),
2147 margin: Margin::default(),
2148 constraints: Constraints::default(),
2149 title: None,
2150 grow: 0,
2151 group_name: None,
2152 });
2153
2154 for (idx, item) in state.items.iter().enumerate() {
2155 let checked = state.selected.contains(&idx);
2156 let marker = if checked { "[x]" } else { "[ ]" };
2157 let is_cursor = idx == state.cursor;
2158 let style = if is_cursor && focused {
2159 Style::new().bold().fg(self.theme.primary)
2160 } else if checked {
2161 Style::new().fg(self.theme.success)
2162 } else {
2163 Style::new().fg(self.theme.text)
2164 };
2165 let prefix = if is_cursor && focused { "▸ " } else { " " };
2166 self.styled(format!("{prefix}{marker} {item}"), style);
2167 }
2168
2169 self.commands.push(Command::EndContainer);
2170 self.last_text_idx = None;
2171 self
2172 }
2173
2174 pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
2178 let entries = state.flatten();
2179 if entries.is_empty() {
2180 return self;
2181 }
2182 state.selected = state.selected.min(entries.len().saturating_sub(1));
2183 let focused = self.register_focusable();
2184
2185 if focused {
2186 let mut consumed_indices = Vec::new();
2187 for (i, event) in self.events.iter().enumerate() {
2188 if self.consumed[i] {
2189 continue;
2190 }
2191 if let Event::Key(key) = event {
2192 if key.kind != KeyEventKind::Press {
2193 continue;
2194 }
2195 match key.code {
2196 KeyCode::Up | KeyCode::Char('k') => {
2197 state.selected = state.selected.saturating_sub(1);
2198 consumed_indices.push(i);
2199 }
2200 KeyCode::Down | KeyCode::Char('j') => {
2201 let max = state.flatten().len().saturating_sub(1);
2202 state.selected = (state.selected + 1).min(max);
2203 consumed_indices.push(i);
2204 }
2205 KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
2206 state.toggle_at(state.selected);
2207 consumed_indices.push(i);
2208 }
2209 KeyCode::Left => {
2210 let entry = &entries[state.selected.min(entries.len() - 1)];
2211 if entry.expanded {
2212 state.toggle_at(state.selected);
2213 }
2214 consumed_indices.push(i);
2215 }
2216 _ => {}
2217 }
2218 }
2219 }
2220 for idx in consumed_indices {
2221 self.consumed[idx] = true;
2222 }
2223 }
2224
2225 self.interaction_count += 1;
2226 self.commands.push(Command::BeginContainer {
2227 direction: Direction::Column,
2228 gap: 0,
2229 align: Align::Start,
2230 justify: Justify::Start,
2231 border: None,
2232 border_sides: BorderSides::all(),
2233 border_style: Style::new().fg(self.theme.border),
2234 bg_color: None,
2235 padding: Padding::default(),
2236 margin: Margin::default(),
2237 constraints: Constraints::default(),
2238 title: None,
2239 grow: 0,
2240 group_name: None,
2241 });
2242
2243 let entries = state.flatten();
2244 for (idx, entry) in entries.iter().enumerate() {
2245 let indent = " ".repeat(entry.depth);
2246 let icon = if entry.is_leaf {
2247 " "
2248 } else if entry.expanded {
2249 "▾ "
2250 } else {
2251 "▸ "
2252 };
2253 let is_selected = idx == state.selected;
2254 let style = if is_selected && focused {
2255 Style::new().bold().fg(self.theme.primary)
2256 } else if is_selected {
2257 Style::new().fg(self.theme.primary)
2258 } else {
2259 Style::new().fg(self.theme.text)
2260 };
2261 let cursor = if is_selected && focused { "▸" } else { " " };
2262 self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
2263 }
2264
2265 self.commands.push(Command::EndContainer);
2266 self.last_text_idx = None;
2267 self
2268 }
2269
2270 pub fn virtual_list(
2277 &mut self,
2278 state: &mut ListState,
2279 visible_height: usize,
2280 f: impl Fn(&mut Context, usize),
2281 ) -> &mut Self {
2282 if state.items.is_empty() {
2283 return self;
2284 }
2285 state.selected = state.selected.min(state.items.len().saturating_sub(1));
2286 let focused = self.register_focusable();
2287
2288 if focused {
2289 let mut consumed_indices = Vec::new();
2290 for (i, event) in self.events.iter().enumerate() {
2291 if self.consumed[i] {
2292 continue;
2293 }
2294 if let Event::Key(key) = event {
2295 if key.kind != KeyEventKind::Press {
2296 continue;
2297 }
2298 match key.code {
2299 KeyCode::Up | KeyCode::Char('k') => {
2300 state.selected = state.selected.saturating_sub(1);
2301 consumed_indices.push(i);
2302 }
2303 KeyCode::Down | KeyCode::Char('j') => {
2304 state.selected =
2305 (state.selected + 1).min(state.items.len().saturating_sub(1));
2306 consumed_indices.push(i);
2307 }
2308 KeyCode::PageUp => {
2309 state.selected = state.selected.saturating_sub(visible_height);
2310 consumed_indices.push(i);
2311 }
2312 KeyCode::PageDown => {
2313 state.selected = (state.selected + visible_height)
2314 .min(state.items.len().saturating_sub(1));
2315 consumed_indices.push(i);
2316 }
2317 KeyCode::Home => {
2318 state.selected = 0;
2319 consumed_indices.push(i);
2320 }
2321 KeyCode::End => {
2322 state.selected = state.items.len().saturating_sub(1);
2323 consumed_indices.push(i);
2324 }
2325 _ => {}
2326 }
2327 }
2328 }
2329 for idx in consumed_indices {
2330 self.consumed[idx] = true;
2331 }
2332 }
2333
2334 let start = if state.selected >= visible_height {
2335 state.selected - visible_height + 1
2336 } else {
2337 0
2338 };
2339 let end = (start + visible_height).min(state.items.len());
2340
2341 self.interaction_count += 1;
2342 self.commands.push(Command::BeginContainer {
2343 direction: Direction::Column,
2344 gap: 0,
2345 align: Align::Start,
2346 justify: Justify::Start,
2347 border: None,
2348 border_sides: BorderSides::all(),
2349 border_style: Style::new().fg(self.theme.border),
2350 bg_color: None,
2351 padding: Padding::default(),
2352 margin: Margin::default(),
2353 constraints: Constraints::default(),
2354 title: None,
2355 grow: 0,
2356 group_name: None,
2357 });
2358
2359 if start > 0 {
2360 self.styled(
2361 format!(" ↑ {} more", start),
2362 Style::new().fg(self.theme.text_dim).dim(),
2363 );
2364 }
2365
2366 for idx in start..end {
2367 f(self, idx);
2368 }
2369
2370 let remaining = state.items.len().saturating_sub(end);
2371 if remaining > 0 {
2372 self.styled(
2373 format!(" ↓ {} more", remaining),
2374 Style::new().fg(self.theme.text_dim).dim(),
2375 );
2376 }
2377
2378 self.commands.push(Command::EndContainer);
2379 self.last_text_idx = None;
2380 self
2381 }
2382
2383 pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
2387 if !state.open {
2388 return None;
2389 }
2390
2391 let filtered = state.filtered_indices();
2392 let sel = state.selected().min(filtered.len().saturating_sub(1));
2393 state.set_selected(sel);
2394
2395 let mut consumed_indices = Vec::new();
2396 let mut result: Option<usize> = None;
2397
2398 for (i, event) in self.events.iter().enumerate() {
2399 if self.consumed[i] {
2400 continue;
2401 }
2402 if let Event::Key(key) = event {
2403 if key.kind != KeyEventKind::Press {
2404 continue;
2405 }
2406 match key.code {
2407 KeyCode::Esc => {
2408 state.open = false;
2409 consumed_indices.push(i);
2410 }
2411 KeyCode::Up => {
2412 let s = state.selected();
2413 state.set_selected(s.saturating_sub(1));
2414 consumed_indices.push(i);
2415 }
2416 KeyCode::Down => {
2417 let s = state.selected();
2418 state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
2419 consumed_indices.push(i);
2420 }
2421 KeyCode::Enter => {
2422 if let Some(&cmd_idx) = filtered.get(state.selected()) {
2423 result = Some(cmd_idx);
2424 state.open = false;
2425 }
2426 consumed_indices.push(i);
2427 }
2428 KeyCode::Backspace => {
2429 if state.cursor > 0 {
2430 let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
2431 let end_idx = byte_index_for_char(&state.input, state.cursor);
2432 state.input.replace_range(byte_idx..end_idx, "");
2433 state.cursor -= 1;
2434 state.set_selected(0);
2435 }
2436 consumed_indices.push(i);
2437 }
2438 KeyCode::Char(ch) => {
2439 let byte_idx = byte_index_for_char(&state.input, state.cursor);
2440 state.input.insert(byte_idx, ch);
2441 state.cursor += 1;
2442 state.set_selected(0);
2443 consumed_indices.push(i);
2444 }
2445 _ => {}
2446 }
2447 }
2448 }
2449 for idx in consumed_indices {
2450 self.consumed[idx] = true;
2451 }
2452
2453 let filtered = state.filtered_indices();
2454
2455 self.modal(|ui| {
2456 let primary = ui.theme.primary;
2457 ui.container()
2458 .border(Border::Rounded)
2459 .border_style(Style::new().fg(primary))
2460 .pad(1)
2461 .max_w(60)
2462 .col(|ui| {
2463 let border_color = ui.theme.primary;
2464 ui.bordered(Border::Rounded)
2465 .border_style(Style::new().fg(border_color))
2466 .px(1)
2467 .col(|ui| {
2468 let display = if state.input.is_empty() {
2469 "Type to search...".to_string()
2470 } else {
2471 state.input.clone()
2472 };
2473 let style = if state.input.is_empty() {
2474 Style::new().dim().fg(ui.theme.text_dim)
2475 } else {
2476 Style::new().fg(ui.theme.text)
2477 };
2478 ui.styled(display, style);
2479 });
2480
2481 for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
2482 let cmd = &state.commands[cmd_idx];
2483 let is_selected = list_idx == state.selected();
2484 let style = if is_selected {
2485 Style::new().bold().fg(ui.theme.primary)
2486 } else {
2487 Style::new().fg(ui.theme.text)
2488 };
2489 let prefix = if is_selected { "▸ " } else { " " };
2490 let shortcut_text = cmd
2491 .shortcut
2492 .as_deref()
2493 .map(|s| format!(" ({s})"))
2494 .unwrap_or_default();
2495 ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
2496 if is_selected && !cmd.description.is_empty() {
2497 ui.styled(
2498 format!(" {}", cmd.description),
2499 Style::new().dim().fg(ui.theme.text_dim),
2500 );
2501 }
2502 }
2503
2504 if filtered.is_empty() {
2505 ui.styled(
2506 " No matching commands",
2507 Style::new().dim().fg(ui.theme.text_dim),
2508 );
2509 }
2510 });
2511 });
2512
2513 result
2514 }
2515
2516 pub fn markdown(&mut self, text: &str) -> &mut Self {
2523 self.commands.push(Command::BeginContainer {
2524 direction: Direction::Column,
2525 gap: 0,
2526 align: Align::Start,
2527 justify: Justify::Start,
2528 border: None,
2529 border_sides: BorderSides::all(),
2530 border_style: Style::new().fg(self.theme.border),
2531 bg_color: None,
2532 padding: Padding::default(),
2533 margin: Margin::default(),
2534 constraints: Constraints::default(),
2535 title: None,
2536 grow: 0,
2537 group_name: None,
2538 });
2539 self.interaction_count += 1;
2540
2541 let text_style = Style::new().fg(self.theme.text);
2542 let bold_style = Style::new().fg(self.theme.text).bold();
2543 let code_style = Style::new().fg(self.theme.accent);
2544
2545 for line in text.lines() {
2546 let trimmed = line.trim();
2547 if trimmed.is_empty() {
2548 self.text(" ");
2549 continue;
2550 }
2551 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
2552 self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
2553 continue;
2554 }
2555 if let Some(heading) = trimmed.strip_prefix("### ") {
2556 self.styled(heading, Style::new().bold().fg(self.theme.accent));
2557 } else if let Some(heading) = trimmed.strip_prefix("## ") {
2558 self.styled(heading, Style::new().bold().fg(self.theme.secondary));
2559 } else if let Some(heading) = trimmed.strip_prefix("# ") {
2560 self.styled(heading, Style::new().bold().fg(self.theme.primary));
2561 } else if let Some(item) = trimmed
2562 .strip_prefix("- ")
2563 .or_else(|| trimmed.strip_prefix("* "))
2564 {
2565 let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
2566 if segs.len() <= 1 {
2567 self.styled(format!(" • {item}"), text_style);
2568 } else {
2569 self.line(|ui| {
2570 ui.styled(" • ", text_style);
2571 for (s, st) in segs {
2572 ui.styled(s, st);
2573 }
2574 });
2575 }
2576 } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
2577 let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
2578 if parts.len() == 2 {
2579 let segs =
2580 Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
2581 if segs.len() <= 1 {
2582 self.styled(format!(" {}. {}", parts[0], parts[1]), text_style);
2583 } else {
2584 self.line(|ui| {
2585 ui.styled(format!(" {}. ", parts[0]), text_style);
2586 for (s, st) in segs {
2587 ui.styled(s, st);
2588 }
2589 });
2590 }
2591 } else {
2592 self.text(trimmed);
2593 }
2594 } else if let Some(code) = trimmed.strip_prefix("```") {
2595 let _ = code;
2596 self.styled(" ┌─code─", Style::new().fg(self.theme.border).dim());
2597 } else {
2598 let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
2599 if segs.len() <= 1 {
2600 self.styled(trimmed, text_style);
2601 } else {
2602 self.line(|ui| {
2603 for (s, st) in segs {
2604 ui.styled(s, st);
2605 }
2606 });
2607 }
2608 }
2609 }
2610
2611 self.commands.push(Command::EndContainer);
2612 self.last_text_idx = None;
2613 self
2614 }
2615
2616 fn parse_inline_segments(
2617 text: &str,
2618 base: Style,
2619 bold: Style,
2620 code: Style,
2621 ) -> Vec<(String, Style)> {
2622 let mut segments: Vec<(String, Style)> = Vec::new();
2623 let mut current = String::new();
2624 let chars: Vec<char> = text.chars().collect();
2625 let mut i = 0;
2626 while i < chars.len() {
2627 if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2628 let rest: String = chars[i + 2..].iter().collect();
2629 if let Some(end) = rest.find("**") {
2630 if !current.is_empty() {
2631 segments.push((std::mem::take(&mut current), base));
2632 }
2633 let inner: String = rest[..end].to_string();
2634 let char_count = inner.chars().count();
2635 segments.push((inner, bold));
2636 i += 2 + char_count + 2;
2637 continue;
2638 }
2639 }
2640 if chars[i] == '*'
2641 && (i + 1 >= chars.len() || chars[i + 1] != '*')
2642 && (i == 0 || chars[i - 1] != '*')
2643 {
2644 let rest: String = chars[i + 1..].iter().collect();
2645 if let Some(end) = rest.find('*') {
2646 if !current.is_empty() {
2647 segments.push((std::mem::take(&mut current), base));
2648 }
2649 let inner: String = rest[..end].to_string();
2650 let char_count = inner.chars().count();
2651 segments.push((inner, base.italic()));
2652 i += 1 + char_count + 1;
2653 continue;
2654 }
2655 }
2656 if chars[i] == '`' {
2657 let rest: String = chars[i + 1..].iter().collect();
2658 if let Some(end) = rest.find('`') {
2659 if !current.is_empty() {
2660 segments.push((std::mem::take(&mut current), base));
2661 }
2662 let inner: String = rest[..end].to_string();
2663 let char_count = inner.chars().count();
2664 segments.push((inner, code));
2665 i += 1 + char_count + 1;
2666 continue;
2667 }
2668 }
2669 current.push(chars[i]);
2670 i += 1;
2671 }
2672 if !current.is_empty() {
2673 segments.push((current, base));
2674 }
2675 segments
2676 }
2677
2678 pub fn key_seq(&self, seq: &str) -> bool {
2685 if seq.is_empty() {
2686 return false;
2687 }
2688 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2689 return false;
2690 }
2691 let target: Vec<char> = seq.chars().collect();
2692 let mut matched = 0;
2693 for (i, event) in self.events.iter().enumerate() {
2694 if self.consumed[i] {
2695 continue;
2696 }
2697 if let Event::Key(key) = event {
2698 if key.kind != KeyEventKind::Press {
2699 continue;
2700 }
2701 if let KeyCode::Char(c) = key.code {
2702 if c == target[matched] {
2703 matched += 1;
2704 if matched == target.len() {
2705 return true;
2706 }
2707 } else {
2708 matched = 0;
2709 if c == target[0] {
2710 matched = 1;
2711 }
2712 }
2713 }
2714 }
2715 }
2716 false
2717 }
2718
2719 pub fn separator(&mut self) -> &mut Self {
2724 self.commands.push(Command::Text {
2725 content: "─".repeat(200),
2726 style: Style::new().fg(self.theme.border).dim(),
2727 grow: 0,
2728 align: Align::Start,
2729 wrap: false,
2730 margin: Margin::default(),
2731 constraints: Constraints::default(),
2732 });
2733 self.last_text_idx = Some(self.commands.len() - 1);
2734 self
2735 }
2736
2737 pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
2743 if bindings.is_empty() {
2744 return self;
2745 }
2746
2747 self.interaction_count += 1;
2748 self.commands.push(Command::BeginContainer {
2749 direction: Direction::Row,
2750 gap: 2,
2751 align: Align::Start,
2752 justify: Justify::Start,
2753 border: None,
2754 border_sides: BorderSides::all(),
2755 border_style: Style::new().fg(self.theme.border),
2756 bg_color: None,
2757 padding: Padding::default(),
2758 margin: Margin::default(),
2759 constraints: Constraints::default(),
2760 title: None,
2761 grow: 0,
2762 group_name: None,
2763 });
2764 for (idx, (key, action)) in bindings.iter().enumerate() {
2765 if idx > 0 {
2766 self.styled("·", Style::new().fg(self.theme.text_dim));
2767 }
2768 self.styled(*key, Style::new().bold().fg(self.theme.primary));
2769 self.styled(*action, Style::new().fg(self.theme.text_dim));
2770 }
2771 self.commands.push(Command::EndContainer);
2772 self.last_text_idx = None;
2773
2774 self
2775 }
2776
2777 pub fn key(&self, c: char) -> bool {
2783 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2784 return false;
2785 }
2786 self.events.iter().enumerate().any(|(i, e)| {
2787 !self.consumed[i]
2788 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2789 })
2790 }
2791
2792 pub fn key_code(&self, code: KeyCode) -> bool {
2796 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2797 return false;
2798 }
2799 self.events.iter().enumerate().any(|(i, e)| {
2800 !self.consumed[i]
2801 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
2802 })
2803 }
2804
2805 pub fn key_release(&self, c: char) -> bool {
2809 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2810 return false;
2811 }
2812 self.events.iter().enumerate().any(|(i, e)| {
2813 !self.consumed[i]
2814 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
2815 })
2816 }
2817
2818 pub fn key_code_release(&self, code: KeyCode) -> bool {
2822 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2823 return false;
2824 }
2825 self.events.iter().enumerate().any(|(i, e)| {
2826 !self.consumed[i]
2827 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
2828 })
2829 }
2830
2831 pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2835 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2836 return false;
2837 }
2838 self.events.iter().enumerate().any(|(i, e)| {
2839 !self.consumed[i]
2840 && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2841 })
2842 }
2843
2844 pub fn mouse_down(&self) -> Option<(u32, u32)> {
2848 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2849 return None;
2850 }
2851 self.events.iter().enumerate().find_map(|(i, event)| {
2852 if self.consumed[i] {
2853 return None;
2854 }
2855 if let Event::Mouse(mouse) = event {
2856 if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2857 return Some((mouse.x, mouse.y));
2858 }
2859 }
2860 None
2861 })
2862 }
2863
2864 pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2869 self.mouse_pos
2870 }
2871
2872 pub fn paste(&self) -> Option<&str> {
2874 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2875 return None;
2876 }
2877 self.events.iter().enumerate().find_map(|(i, event)| {
2878 if self.consumed[i] {
2879 return None;
2880 }
2881 if let Event::Paste(ref text) = event {
2882 return Some(text.as_str());
2883 }
2884 None
2885 })
2886 }
2887
2888 pub fn scroll_up(&self) -> bool {
2890 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2891 return false;
2892 }
2893 self.events.iter().enumerate().any(|(i, event)| {
2894 !self.consumed[i]
2895 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2896 })
2897 }
2898
2899 pub fn scroll_down(&self) -> bool {
2901 if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2902 return false;
2903 }
2904 self.events.iter().enumerate().any(|(i, event)| {
2905 !self.consumed[i]
2906 && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2907 })
2908 }
2909
2910 pub fn quit(&mut self) {
2912 self.should_quit = true;
2913 }
2914
2915 pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2923 self.clipboard_text = Some(text.into());
2924 }
2925
2926 pub fn theme(&self) -> &Theme {
2928 &self.theme
2929 }
2930
2931 pub fn set_theme(&mut self, theme: Theme) {
2935 self.theme = theme;
2936 }
2937
2938 pub fn is_dark_mode(&self) -> bool {
2940 self.dark_mode
2941 }
2942
2943 pub fn set_dark_mode(&mut self, dark: bool) {
2945 self.dark_mode = dark;
2946 }
2947
2948 pub fn width(&self) -> u32 {
2952 self.area_width
2953 }
2954
2955 pub fn breakpoint(&self) -> Breakpoint {
2979 let w = self.area_width;
2980 if w < 40 {
2981 Breakpoint::Xs
2982 } else if w < 80 {
2983 Breakpoint::Sm
2984 } else if w < 120 {
2985 Breakpoint::Md
2986 } else if w < 160 {
2987 Breakpoint::Lg
2988 } else {
2989 Breakpoint::Xl
2990 }
2991 }
2992
2993 pub fn height(&self) -> u32 {
2995 self.area_height
2996 }
2997
2998 pub fn tick(&self) -> u64 {
3003 self.tick
3004 }
3005
3006 pub fn debug_enabled(&self) -> bool {
3010 self.debug
3011 }
3012}