1use super::*;
2
3impl Context {
4 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> Response {
25 if data.is_empty() {
26 return Response::none();
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 Response::none()
99 }
100
101 pub fn bar_chart_styled(
117 &mut self,
118 bars: &[Bar],
119 max_width: u32,
120 direction: BarDirection,
121 ) -> Response {
122 if bars.is_empty() {
123 return Response::none();
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 => self.render_horizontal_styled_bars(bars, max_width, denom),
134 BarDirection::Vertical => self.render_vertical_styled_bars(bars, max_width, denom),
135 }
136
137 Response::none()
138 }
139
140 fn render_horizontal_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
141 let max_label_width = bars
142 .iter()
143 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
144 .max()
145 .unwrap_or(0);
146
147 self.interaction_count += 1;
148 self.commands.push(Command::BeginContainer {
149 direction: Direction::Column,
150 gap: 0,
151 align: Align::Start,
152 justify: Justify::Start,
153 border: None,
154 border_sides: BorderSides::all(),
155 border_style: Style::new().fg(self.theme.border),
156 bg_color: None,
157 padding: Padding::default(),
158 margin: Margin::default(),
159 constraints: Constraints::default(),
160 title: None,
161 grow: 0,
162 group_name: None,
163 });
164
165 for bar in bars {
166 self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
167 }
168
169 self.commands.push(Command::EndContainer);
170 self.last_text_idx = None;
171 }
172
173 fn render_horizontal_styled_bar_row(
174 &mut self,
175 bar: &Bar,
176 max_label_width: usize,
177 max_width: u32,
178 denom: f64,
179 ) {
180 let label_width = UnicodeWidthStr::width(bar.label.as_str());
181 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
182 let normalized = (bar.value / denom).clamp(0.0, 1.0);
183 let bar_len = (normalized * max_width as f64).round() as usize;
184 let bar_text = "█".repeat(bar_len);
185 let color = bar.color.unwrap_or(self.theme.primary);
186
187 self.interaction_count += 1;
188 self.commands.push(Command::BeginContainer {
189 direction: Direction::Row,
190 gap: 1,
191 align: Align::Start,
192 justify: Justify::Start,
193 border: None,
194 border_sides: BorderSides::all(),
195 border_style: Style::new().fg(self.theme.border),
196 bg_color: None,
197 padding: Padding::default(),
198 margin: Margin::default(),
199 constraints: Constraints::default(),
200 title: None,
201 grow: 0,
202 group_name: None,
203 });
204 self.styled(
205 format!("{}{label_padding}", bar.label),
206 Style::new().fg(self.theme.text),
207 );
208 self.styled(bar_text, Style::new().fg(color));
209 self.styled(
210 format_compact_number(bar.value),
211 Style::new().fg(self.theme.text_dim),
212 );
213 self.commands.push(Command::EndContainer);
214 self.last_text_idx = None;
215 }
216
217 fn render_vertical_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
218 let chart_height = max_width.max(1) as usize;
219 let value_labels: Vec<String> = bars
220 .iter()
221 .map(|bar| format_compact_number(bar.value))
222 .collect();
223 let col_width = bars
224 .iter()
225 .zip(value_labels.iter())
226 .map(|(bar, value)| {
227 UnicodeWidthStr::width(bar.label.as_str())
228 .max(UnicodeWidthStr::width(value.as_str()))
229 .max(1)
230 })
231 .max()
232 .unwrap_or(1);
233 let bar_units: Vec<usize> = bars
234 .iter()
235 .map(|bar| {
236 ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
237 })
238 .collect();
239
240 self.interaction_count += 1;
241 self.commands.push(Command::BeginContainer {
242 direction: Direction::Column,
243 gap: 0,
244 align: Align::Start,
245 justify: Justify::Start,
246 border: None,
247 border_sides: BorderSides::all(),
248 border_style: Style::new().fg(self.theme.border),
249 bg_color: None,
250 padding: Padding::default(),
251 margin: Margin::default(),
252 constraints: Constraints::default(),
253 title: None,
254 grow: 0,
255 group_name: None,
256 });
257
258 self.render_vertical_bar_values(&value_labels, col_width);
259 self.render_vertical_bar_body(bars, &bar_units, chart_height, col_width);
260 self.render_vertical_bar_labels(bars, col_width);
261
262 self.commands.push(Command::EndContainer);
263 self.last_text_idx = None;
264 }
265
266 fn render_vertical_bar_values(&mut self, value_labels: &[String], col_width: usize) {
267 self.interaction_count += 1;
268 self.commands.push(Command::BeginContainer {
269 direction: Direction::Row,
270 gap: 1,
271 align: Align::Start,
272 justify: Justify::Start,
273 border: None,
274 border_sides: BorderSides::all(),
275 border_style: Style::new().fg(self.theme.border),
276 bg_color: None,
277 padding: Padding::default(),
278 margin: Margin::default(),
279 constraints: Constraints::default(),
280 title: None,
281 grow: 0,
282 group_name: None,
283 });
284 for value in value_labels {
285 self.styled(
286 center_text(value, col_width),
287 Style::new().fg(self.theme.text_dim),
288 );
289 }
290 self.commands.push(Command::EndContainer);
291 self.last_text_idx = None;
292 }
293
294 fn render_vertical_bar_body(
295 &mut self,
296 bars: &[Bar],
297 bar_units: &[usize],
298 chart_height: usize,
299 col_width: usize,
300 ) {
301 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
302
303 for row in (0..chart_height).rev() {
304 self.interaction_count += 1;
305 self.commands.push(Command::BeginContainer {
306 direction: Direction::Row,
307 gap: 1,
308 align: Align::Start,
309 justify: Justify::Start,
310 border: None,
311 border_sides: BorderSides::all(),
312 border_style: Style::new().fg(self.theme.border),
313 bg_color: None,
314 padding: Padding::default(),
315 margin: Margin::default(),
316 constraints: Constraints::default(),
317 title: None,
318 grow: 0,
319 group_name: None,
320 });
321
322 let row_base = row * 8;
323 for (bar, units) in bars.iter().zip(bar_units.iter()) {
324 let fill = if *units <= row_base {
325 ' '
326 } else {
327 let delta = *units - row_base;
328 if delta >= 8 {
329 '█'
330 } else {
331 FRACTION_BLOCKS[delta]
332 }
333 };
334 self.styled(
335 center_text(&fill.to_string(), col_width),
336 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
337 );
338 }
339
340 self.commands.push(Command::EndContainer);
341 self.last_text_idx = None;
342 }
343 }
344
345 fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize) {
346 self.interaction_count += 1;
347 self.commands.push(Command::BeginContainer {
348 direction: Direction::Row,
349 gap: 1,
350 align: Align::Start,
351 justify: Justify::Start,
352 border: None,
353 border_sides: BorderSides::all(),
354 border_style: Style::new().fg(self.theme.border),
355 bg_color: None,
356 padding: Padding::default(),
357 margin: Margin::default(),
358 constraints: Constraints::default(),
359 title: None,
360 grow: 0,
361 group_name: None,
362 });
363 for bar in bars {
364 self.styled(
365 center_text(&bar.label, col_width),
366 Style::new().fg(self.theme.text),
367 );
368 }
369 self.commands.push(Command::EndContainer);
370 self.last_text_idx = None;
371 }
372
373 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
390 if groups.is_empty() {
391 return Response::none();
392 }
393
394 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
395 if all_bars.is_empty() {
396 return Response::none();
397 }
398
399 let max_label_width = all_bars
400 .iter()
401 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
402 .max()
403 .unwrap_or(0);
404 let max_value = all_bars
405 .iter()
406 .map(|bar| bar.value)
407 .fold(f64::NEG_INFINITY, f64::max);
408 let denom = if max_value > 0.0 { max_value } else { 1.0 };
409
410 self.interaction_count += 1;
411 self.commands.push(Command::BeginContainer {
412 direction: Direction::Column,
413 gap: 1,
414 align: Align::Start,
415 justify: Justify::Start,
416 border: None,
417 border_sides: BorderSides::all(),
418 border_style: Style::new().fg(self.theme.border),
419 bg_color: None,
420 padding: Padding::default(),
421 margin: Margin::default(),
422 constraints: Constraints::default(),
423 title: None,
424 grow: 0,
425 group_name: None,
426 });
427
428 for group in groups {
429 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
430
431 for bar in &group.bars {
432 let label_width = UnicodeWidthStr::width(bar.label.as_str());
433 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
434 let normalized = (bar.value / denom).clamp(0.0, 1.0);
435 let bar_len = (normalized * max_width as f64).round() as usize;
436 let bar_text = "█".repeat(bar_len);
437
438 self.interaction_count += 1;
439 self.commands.push(Command::BeginContainer {
440 direction: Direction::Row,
441 gap: 1,
442 align: Align::Start,
443 justify: Justify::Start,
444 border: None,
445 border_sides: BorderSides::all(),
446 border_style: Style::new().fg(self.theme.border),
447 bg_color: None,
448 padding: Padding::default(),
449 margin: Margin::default(),
450 constraints: Constraints::default(),
451 title: None,
452 grow: 0,
453 group_name: None,
454 });
455 self.styled(
456 format!(" {}{label_padding}", bar.label),
457 Style::new().fg(self.theme.text),
458 );
459 self.styled(
460 bar_text,
461 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
462 );
463 self.styled(
464 format_compact_number(bar.value),
465 Style::new().fg(self.theme.text_dim),
466 );
467 self.commands.push(Command::EndContainer);
468 self.last_text_idx = None;
469 }
470 }
471
472 self.commands.push(Command::EndContainer);
473 self.last_text_idx = None;
474
475 Response::none()
476 }
477
478 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
494 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
495
496 let w = width as usize;
497 let window = if data.len() > w {
498 &data[data.len() - w..]
499 } else {
500 data
501 };
502
503 if window.is_empty() {
504 return Response::none();
505 }
506
507 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
508 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
509 let range = max - min;
510
511 let line: String = window
512 .iter()
513 .map(|&value| {
514 let normalized = if range == 0.0 {
515 0.5
516 } else {
517 (value - min) / range
518 };
519 let idx = (normalized * 7.0).round() as usize;
520 BLOCKS[idx.min(7)]
521 })
522 .collect();
523
524 self.styled(line, Style::new().fg(self.theme.primary));
525 Response::none()
526 }
527
528 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
548 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
549
550 let w = width as usize;
551 let window = if data.len() > w {
552 &data[data.len() - w..]
553 } else {
554 data
555 };
556
557 if window.is_empty() {
558 return Response::none();
559 }
560
561 let mut finite_values = window
562 .iter()
563 .map(|(value, _)| *value)
564 .filter(|value| !value.is_nan());
565 let Some(first) = finite_values.next() else {
566 self.styled(
567 " ".repeat(window.len()),
568 Style::new().fg(self.theme.text_dim),
569 );
570 return Response::none();
571 };
572
573 let mut min = first;
574 let mut max = first;
575 for value in finite_values {
576 min = f64::min(min, value);
577 max = f64::max(max, value);
578 }
579 let range = max - min;
580
581 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
582 for (value, color) in window {
583 if value.is_nan() {
584 cells.push((' ', self.theme.text_dim));
585 continue;
586 }
587
588 let normalized = if range == 0.0 {
589 0.5
590 } else {
591 ((*value - min) / range).clamp(0.0, 1.0)
592 };
593 let idx = (normalized * 7.0).round() as usize;
594 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
595 }
596
597 self.interaction_count += 1;
598 self.commands.push(Command::BeginContainer {
599 direction: Direction::Row,
600 gap: 0,
601 align: Align::Start,
602 justify: Justify::Start,
603 border: None,
604 border_sides: BorderSides::all(),
605 border_style: Style::new().fg(self.theme.border),
606 bg_color: None,
607 padding: Padding::default(),
608 margin: Margin::default(),
609 constraints: Constraints::default(),
610 title: None,
611 grow: 0,
612 group_name: None,
613 });
614
615 let mut seg = String::new();
616 let mut seg_color = cells[0].1;
617 for (ch, color) in cells {
618 if color != seg_color {
619 self.styled(seg, Style::new().fg(seg_color));
620 seg = String::new();
621 seg_color = color;
622 }
623 seg.push(ch);
624 }
625 if !seg.is_empty() {
626 self.styled(seg, Style::new().fg(seg_color));
627 }
628
629 self.commands.push(Command::EndContainer);
630 self.last_text_idx = None;
631
632 Response::none()
633 }
634
635 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
649 if data.is_empty() || width == 0 || height == 0 {
650 return Response::none();
651 }
652
653 let cols = width as usize;
654 let rows = height as usize;
655 let px_w = cols * 2;
656 let px_h = rows * 4;
657
658 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
659 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
660 let range = if (max - min).abs() < f64::EPSILON {
661 1.0
662 } else {
663 max - min
664 };
665
666 let points: Vec<usize> = (0..px_w)
667 .map(|px| {
668 let data_idx = if px_w <= 1 {
669 0.0
670 } else {
671 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
672 };
673 let idx = data_idx.floor() as usize;
674 let frac = data_idx - idx as f64;
675 let value = if idx + 1 < data.len() {
676 data[idx] * (1.0 - frac) + data[idx + 1] * frac
677 } else {
678 data[idx.min(data.len() - 1)]
679 };
680
681 let normalized = (value - min) / range;
682 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
683 py.min(px_h - 1)
684 })
685 .collect();
686
687 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
688 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
689
690 let mut grid = vec![vec![0u32; cols]; rows];
691
692 for i in 0..points.len() {
693 let px = i;
694 let py = points[i];
695 let char_col = px / 2;
696 let char_row = py / 4;
697 let sub_col = px % 2;
698 let sub_row = py % 4;
699
700 if char_col < cols && char_row < rows {
701 grid[char_row][char_col] |= if sub_col == 0 {
702 LEFT_BITS[sub_row]
703 } else {
704 RIGHT_BITS[sub_row]
705 };
706 }
707
708 if i + 1 < points.len() {
709 let py_next = points[i + 1];
710 let (y_start, y_end) = if py <= py_next {
711 (py, py_next)
712 } else {
713 (py_next, py)
714 };
715 for y in y_start..=y_end {
716 let cell_row = y / 4;
717 let sub_y = y % 4;
718 if char_col < cols && cell_row < rows {
719 grid[cell_row][char_col] |= if sub_col == 0 {
720 LEFT_BITS[sub_y]
721 } else {
722 RIGHT_BITS[sub_y]
723 };
724 }
725 }
726 }
727 }
728
729 let style = Style::new().fg(self.theme.primary);
730 for row in grid {
731 let line: String = row
732 .iter()
733 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
734 .collect();
735 self.styled(line, style);
736 }
737
738 Response::none()
739 }
740
741 pub fn canvas(
758 &mut self,
759 width: u32,
760 height: u32,
761 draw: impl FnOnce(&mut CanvasContext),
762 ) -> Response {
763 if width == 0 || height == 0 {
764 return Response::none();
765 }
766
767 let mut canvas = CanvasContext::new(width as usize, height as usize);
768 draw(&mut canvas);
769
770 for segments in canvas.render() {
771 self.interaction_count += 1;
772 self.commands.push(Command::BeginContainer {
773 direction: Direction::Row,
774 gap: 0,
775 align: Align::Start,
776 justify: Justify::Start,
777 border: None,
778 border_sides: BorderSides::all(),
779 border_style: Style::new(),
780 bg_color: None,
781 padding: Padding::default(),
782 margin: Margin::default(),
783 constraints: Constraints::default(),
784 title: None,
785 grow: 0,
786 group_name: None,
787 });
788 for (text, color) in segments {
789 let c = if color == Color::Reset {
790 self.theme.primary
791 } else {
792 color
793 };
794 self.styled(text, Style::new().fg(c));
795 }
796 self.commands.push(Command::EndContainer);
797 self.last_text_idx = None;
798 }
799
800 Response::none()
801 }
802
803 pub fn chart(
805 &mut self,
806 configure: impl FnOnce(&mut ChartBuilder),
807 width: u32,
808 height: u32,
809 ) -> Response {
810 if width == 0 || height == 0 {
811 return Response::none();
812 }
813
814 let axis_style = Style::new().fg(self.theme.text_dim);
815 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
816 configure(&mut builder);
817
818 let config = builder.build();
819 let rows = render_chart(&config);
820
821 for row in rows {
822 self.interaction_count += 1;
823 self.commands.push(Command::BeginContainer {
824 direction: Direction::Row,
825 gap: 0,
826 align: Align::Start,
827 justify: Justify::Start,
828 border: None,
829 border_sides: BorderSides::all(),
830 border_style: Style::new().fg(self.theme.border),
831 bg_color: None,
832 padding: Padding::default(),
833 margin: Margin::default(),
834 constraints: Constraints::default(),
835 title: None,
836 grow: 0,
837 group_name: None,
838 });
839 for (text, style) in row.segments {
840 self.styled(text, style);
841 }
842 self.commands.push(Command::EndContainer);
843 self.last_text_idx = None;
844 }
845
846 Response::none()
847 }
848
849 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
853 self.chart(
854 |c| {
855 c.scatter(data);
856 c.grid(true);
857 },
858 width,
859 height,
860 )
861 }
862
863 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
865 self.histogram_with(data, |_| {}, width, height)
866 }
867
868 pub fn histogram_with(
870 &mut self,
871 data: &[f64],
872 configure: impl FnOnce(&mut HistogramBuilder),
873 width: u32,
874 height: u32,
875 ) -> Response {
876 if width == 0 || height == 0 {
877 return Response::none();
878 }
879
880 let mut options = HistogramBuilder::default();
881 configure(&mut options);
882 let axis_style = Style::new().fg(self.theme.text_dim);
883 let config = build_histogram_config(data, &options, width, height, axis_style);
884 let rows = render_chart(&config);
885
886 for row in rows {
887 self.interaction_count += 1;
888 self.commands.push(Command::BeginContainer {
889 direction: Direction::Row,
890 gap: 0,
891 align: Align::Start,
892 justify: Justify::Start,
893 border: None,
894 border_sides: BorderSides::all(),
895 border_style: Style::new().fg(self.theme.border),
896 bg_color: None,
897 padding: Padding::default(),
898 margin: Margin::default(),
899 constraints: Constraints::default(),
900 title: None,
901 grow: 0,
902 group_name: None,
903 });
904 for (text, style) in row.segments {
905 self.styled(text, style);
906 }
907 self.commands.push(Command::EndContainer);
908 self.last_text_idx = None;
909 }
910
911 Response::none()
912 }
913}