1use super::*;
2
3struct VerticalBarLayout {
4 chart_height: usize,
5 bar_width: usize,
6 value_labels: Vec<String>,
7 col_width: usize,
8 bar_units: Vec<usize>,
9}
10
11impl Context {
12 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> Response {
33 if data.is_empty() {
34 return Response::none();
35 }
36
37 let max_label_width = data
38 .iter()
39 .map(|(label, _)| UnicodeWidthStr::width(*label))
40 .max()
41 .unwrap_or(0);
42 let max_value = data
43 .iter()
44 .map(|(_, value)| *value)
45 .fold(f64::NEG_INFINITY, f64::max);
46 let denom = if max_value > 0.0 { max_value } else { 1.0 };
47
48 self.interaction_count += 1;
49 self.commands.push(Command::BeginContainer {
50 direction: Direction::Column,
51 gap: 0,
52 align: Align::Start,
53 align_self: None,
54 justify: Justify::Start,
55 border: None,
56 border_sides: BorderSides::all(),
57 border_style: Style::new().fg(self.theme.border),
58 bg_color: None,
59 padding: Padding::default(),
60 margin: Margin::default(),
61 constraints: Constraints::default(),
62 title: None,
63 grow: 0,
64 group_name: None,
65 });
66
67 for (label, value) in data {
68 let label_width = UnicodeWidthStr::width(*label);
69 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
70 let normalized = (*value / denom).clamp(0.0, 1.0);
71 let bar = Self::horizontal_bar_text(normalized, max_width);
72
73 self.interaction_count += 1;
74 self.commands.push(Command::BeginContainer {
75 direction: Direction::Row,
76 gap: 1,
77 align: Align::Start,
78 align_self: None,
79 justify: Justify::Start,
80 border: None,
81 border_sides: BorderSides::all(),
82 border_style: Style::new().fg(self.theme.border),
83 bg_color: None,
84 padding: Padding::default(),
85 margin: Margin::default(),
86 constraints: Constraints::default(),
87 title: None,
88 grow: 0,
89 group_name: None,
90 });
91 let mut label_text = String::with_capacity(label.len() + label_padding.len());
92 label_text.push_str(label);
93 label_text.push_str(&label_padding);
94 self.styled(label_text, Style::new().fg(self.theme.text));
95 self.styled(bar, Style::new().fg(self.theme.primary));
96 self.styled(
97 format_compact_number(*value),
98 Style::new().fg(self.theme.text_dim),
99 );
100 self.commands.push(Command::EndContainer);
101 self.last_text_idx = None;
102 }
103
104 self.commands.push(Command::EndContainer);
105 self.last_text_idx = None;
106
107 Response::none()
108 }
109
110 pub fn bar_chart_styled(
126 &mut self,
127 bars: &[Bar],
128 max_width: u32,
129 direction: BarDirection,
130 ) -> Response {
131 self.bar_chart_with(
132 bars,
133 |config| {
134 config.direction(direction);
135 },
136 max_width,
137 )
138 }
139
140 pub fn bar_chart_with(
142 &mut self,
143 bars: &[Bar],
144 configure: impl FnOnce(&mut BarChartConfig),
145 max_size: u32,
146 ) -> Response {
147 if bars.is_empty() {
148 return Response::none();
149 }
150
151 let (config, denom) = self.bar_chart_styled_layout(bars, configure);
152 self.bar_chart_styled_render(bars, max_size, denom, &config);
153
154 Response::none()
155 }
156
157 fn bar_chart_styled_layout(
158 &self,
159 bars: &[Bar],
160 configure: impl FnOnce(&mut BarChartConfig),
161 ) -> (BarChartConfig, f64) {
162 let mut config = BarChartConfig::default();
163 configure(&mut config);
164
165 let auto_max = bars
166 .iter()
167 .map(|bar| bar.value)
168 .fold(f64::NEG_INFINITY, f64::max);
169 let max_value = config.max_value.unwrap_or(auto_max);
170 let denom = if max_value > 0.0 { max_value } else { 1.0 };
171
172 (config, denom)
173 }
174
175 fn bar_chart_styled_render(
176 &mut self,
177 bars: &[Bar],
178 max_size: u32,
179 denom: f64,
180 config: &BarChartConfig,
181 ) {
182 match config.direction {
183 BarDirection::Horizontal => {
184 self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
185 }
186 BarDirection::Vertical => self.render_vertical_styled_bars(
187 bars,
188 max_size,
189 denom,
190 config.bar_width,
191 config.bar_gap,
192 ),
193 }
194 }
195
196 fn render_horizontal_styled_bars(
197 &mut self,
198 bars: &[Bar],
199 max_width: u32,
200 denom: f64,
201 bar_gap: u16,
202 ) {
203 let max_label_width = bars
204 .iter()
205 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
206 .max()
207 .unwrap_or(0);
208
209 self.interaction_count += 1;
210 self.commands.push(Command::BeginContainer {
211 direction: Direction::Column,
212 gap: bar_gap as u32,
213 align: Align::Start,
214 align_self: None,
215 justify: Justify::Start,
216 border: None,
217 border_sides: BorderSides::all(),
218 border_style: Style::new().fg(self.theme.border),
219 bg_color: None,
220 padding: Padding::default(),
221 margin: Margin::default(),
222 constraints: Constraints::default(),
223 title: None,
224 grow: 0,
225 group_name: None,
226 });
227
228 for bar in bars {
229 self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
230 }
231
232 self.commands.push(Command::EndContainer);
233 self.last_text_idx = None;
234 }
235
236 fn render_horizontal_styled_bar_row(
237 &mut self,
238 bar: &Bar,
239 max_label_width: usize,
240 max_width: u32,
241 denom: f64,
242 ) {
243 let label_width = UnicodeWidthStr::width(bar.label.as_str());
244 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
245 let normalized = (bar.value / denom).clamp(0.0, 1.0);
246 let bar_text = Self::horizontal_bar_text(normalized, max_width);
247 let color = bar.color.unwrap_or(self.theme.primary);
248
249 self.interaction_count += 1;
250 self.commands.push(Command::BeginContainer {
251 direction: Direction::Row,
252 gap: 1,
253 align: Align::Start,
254 align_self: None,
255 justify: Justify::Start,
256 border: None,
257 border_sides: BorderSides::all(),
258 border_style: Style::new().fg(self.theme.border),
259 bg_color: None,
260 padding: Padding::default(),
261 margin: Margin::default(),
262 constraints: Constraints::default(),
263 title: None,
264 grow: 0,
265 group_name: None,
266 });
267 let mut label_text = String::with_capacity(bar.label.len() + label_padding.len());
268 label_text.push_str(&bar.label);
269 label_text.push_str(&label_padding);
270 self.styled(label_text, Style::new().fg(self.theme.text));
271 self.styled(bar_text, Style::new().fg(color));
272 self.styled(
273 Self::bar_display_value(bar),
274 bar.value_style
275 .unwrap_or(Style::new().fg(self.theme.text_dim)),
276 );
277 self.commands.push(Command::EndContainer);
278 self.last_text_idx = None;
279 }
280
281 fn render_vertical_styled_bars(
282 &mut self,
283 bars: &[Bar],
284 max_height: u32,
285 denom: f64,
286 bar_width: u16,
287 bar_gap: u16,
288 ) {
289 let layout = self.compute_vertical_bar_layout(bars, max_height, denom, bar_width);
290
291 self.interaction_count += 1;
292 self.commands.push(Command::BeginContainer {
293 direction: Direction::Column,
294 gap: 0,
295 align: Align::Start,
296 align_self: None,
297 justify: Justify::Start,
298 border: None,
299 border_sides: BorderSides::all(),
300 border_style: Style::new().fg(self.theme.border),
301 bg_color: None,
302 padding: Padding::default(),
303 margin: Margin::default(),
304 constraints: Constraints::default(),
305 title: None,
306 grow: 0,
307 group_name: None,
308 });
309
310 self.render_vertical_bar_body(
311 bars,
312 &layout.bar_units,
313 layout.chart_height,
314 layout.col_width,
315 layout.bar_width,
316 bar_gap,
317 &layout.value_labels,
318 );
319 self.render_vertical_bar_labels(bars, layout.col_width, bar_gap);
320
321 self.commands.push(Command::EndContainer);
322 self.last_text_idx = None;
323 }
324
325 fn compute_vertical_bar_layout(
326 &self,
327 bars: &[Bar],
328 max_height: u32,
329 denom: f64,
330 bar_width: u16,
331 ) -> VerticalBarLayout {
332 let chart_height = max_height.max(1) as usize;
333 let bar_width = bar_width.max(1) as usize;
334 let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
335 let label_width = bars
336 .iter()
337 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
338 .max()
339 .unwrap_or(1);
340 let value_width = value_labels
341 .iter()
342 .map(|value| UnicodeWidthStr::width(value.as_str()))
343 .max()
344 .unwrap_or(1);
345 let col_width = bar_width.max(label_width.max(value_width).max(1));
346 let bar_units: Vec<usize> = bars
347 .iter()
348 .map(|bar| {
349 ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
350 })
351 .collect();
352
353 VerticalBarLayout {
354 chart_height,
355 bar_width,
356 value_labels,
357 col_width,
358 bar_units,
359 }
360 }
361
362 #[allow(clippy::too_many_arguments)]
363 fn render_vertical_bar_body(
364 &mut self,
365 bars: &[Bar],
366 bar_units: &[usize],
367 chart_height: usize,
368 col_width: usize,
369 bar_width: usize,
370 bar_gap: u16,
371 value_labels: &[String],
372 ) {
373 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
374
375 let top_rows: Vec<usize> = bar_units
377 .iter()
378 .map(|units| {
379 if *units == 0 {
380 usize::MAX
381 } else {
382 (*units - 1) / 8
383 }
384 })
385 .collect();
386
387 for row in (0..chart_height).rev() {
388 self.interaction_count += 1;
389 self.commands.push(Command::BeginContainer {
390 direction: Direction::Row,
391 gap: bar_gap as u32,
392 align: Align::Start,
393 align_self: None,
394 justify: Justify::Start,
395 border: None,
396 border_sides: BorderSides::all(),
397 border_style: Style::new().fg(self.theme.border),
398 bg_color: None,
399 padding: Padding::default(),
400 margin: Margin::default(),
401 constraints: Constraints::default(),
402 title: None,
403 grow: 0,
404 group_name: None,
405 });
406
407 let row_base = row * 8;
408 for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
409 let color = bar.color.unwrap_or(self.theme.primary);
410
411 if *units <= row_base {
412 if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
414 let label = &value_labels[i];
415 let centered = Self::center_and_truncate_text(label, col_width);
416 self.styled(
417 centered,
418 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
419 );
420 } else {
421 let empty = " ".repeat(col_width);
422 self.styled(empty, Style::new());
423 }
424 continue;
425 }
426
427 if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
428 let label = &value_labels[i];
429 let centered = Self::center_and_truncate_text(label, col_width);
430 self.styled(
431 centered,
432 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
433 );
434 continue;
435 }
436
437 let delta = *units - row_base;
438 let fill = if delta >= 8 {
439 '█'
440 } else {
441 FRACTION_BLOCKS[delta]
442 };
443 let fill_text = fill.to_string().repeat(bar_width);
444 let centered_fill = center_text(&fill_text, col_width);
445 self.styled(centered_fill, Style::new().fg(color));
446 }
447
448 self.commands.push(Command::EndContainer);
449 self.last_text_idx = None;
450 }
451 }
452
453 fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
454 self.interaction_count += 1;
455 self.commands.push(Command::BeginContainer {
456 direction: Direction::Row,
457 gap: bar_gap as u32,
458 align: Align::Start,
459 align_self: None,
460 justify: Justify::Start,
461 border: None,
462 border_sides: BorderSides::all(),
463 border_style: Style::new().fg(self.theme.border),
464 bg_color: None,
465 padding: Padding::default(),
466 margin: Margin::default(),
467 constraints: Constraints::default(),
468 title: None,
469 grow: 0,
470 group_name: None,
471 });
472 for bar in bars {
473 self.styled(
474 Self::center_and_truncate_text(&bar.label, col_width),
475 Style::new().fg(self.theme.text),
476 );
477 }
478 self.commands.push(Command::EndContainer);
479 self.last_text_idx = None;
480 }
481
482 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
499 self.bar_chart_grouped_with(groups, |_| {}, max_width)
500 }
501
502 pub fn bar_chart_grouped_with(
504 &mut self,
505 groups: &[BarGroup],
506 configure: impl FnOnce(&mut BarChartConfig),
507 max_size: u32,
508 ) -> Response {
509 if groups.is_empty() {
510 return Response::none();
511 }
512
513 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
514 if all_bars.is_empty() {
515 return Response::none();
516 }
517
518 let mut config = BarChartConfig::default();
519 configure(&mut config);
520
521 let auto_max = all_bars
522 .iter()
523 .map(|bar| bar.value)
524 .fold(f64::NEG_INFINITY, f64::max);
525 let max_value = config.max_value.unwrap_or(auto_max);
526 let denom = if max_value > 0.0 { max_value } else { 1.0 };
527
528 match config.direction {
529 BarDirection::Horizontal => {
530 self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
531 }
532 BarDirection::Vertical => {
533 self.render_grouped_vertical_bars(groups, max_size, denom, &config)
534 }
535 }
536
537 Response::none()
538 }
539
540 fn render_grouped_horizontal_bars(
541 &mut self,
542 groups: &[BarGroup],
543 max_width: u32,
544 denom: f64,
545 config: &BarChartConfig,
546 ) {
547 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
548 let max_label_width = all_bars
549 .iter()
550 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
551 .max()
552 .unwrap_or(0);
553
554 self.interaction_count += 1;
555 self.commands.push(Command::BeginContainer {
556 direction: Direction::Column,
557 gap: config.group_gap as u32,
558 align: Align::Start,
559 align_self: None,
560 justify: Justify::Start,
561 border: None,
562 border_sides: BorderSides::all(),
563 border_style: Style::new().fg(self.theme.border),
564 bg_color: None,
565 padding: Padding::default(),
566 margin: Margin::default(),
567 constraints: Constraints::default(),
568 title: None,
569 grow: 0,
570 group_name: None,
571 });
572
573 for group in groups {
574 self.interaction_count += 1;
575 self.commands.push(Command::BeginContainer {
576 direction: Direction::Column,
577 gap: config.bar_gap as u32,
578 align: Align::Start,
579 align_self: None,
580 justify: Justify::Start,
581 border: None,
582 border_sides: BorderSides::all(),
583 border_style: Style::new().fg(self.theme.border),
584 bg_color: None,
585 padding: Padding::default(),
586 margin: Margin::default(),
587 constraints: Constraints::default(),
588 title: None,
589 grow: 0,
590 group_name: None,
591 });
592
593 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
594
595 for bar in &group.bars {
596 let label_width = UnicodeWidthStr::width(bar.label.as_str());
597 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
598 let normalized = (bar.value / denom).clamp(0.0, 1.0);
599 let bar_text = Self::horizontal_bar_text(normalized, max_width);
600
601 self.interaction_count += 1;
602 self.commands.push(Command::BeginContainer {
603 direction: Direction::Row,
604 gap: 1,
605 align: Align::Start,
606 align_self: None,
607 justify: Justify::Start,
608 border: None,
609 border_sides: BorderSides::all(),
610 border_style: Style::new().fg(self.theme.border),
611 bg_color: None,
612 padding: Padding::default(),
613 margin: Margin::default(),
614 constraints: Constraints::default(),
615 title: None,
616 grow: 0,
617 group_name: None,
618 });
619 let mut label_text =
620 String::with_capacity(2 + bar.label.len() + label_padding.len());
621 label_text.push_str(" ");
622 label_text.push_str(&bar.label);
623 label_text.push_str(&label_padding);
624 self.styled(label_text, Style::new().fg(self.theme.text));
625 self.styled(
626 bar_text,
627 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
628 );
629 self.styled(
630 Self::bar_display_value(bar),
631 bar.value_style
632 .unwrap_or(Style::new().fg(self.theme.text_dim)),
633 );
634 self.commands.push(Command::EndContainer);
635 self.last_text_idx = None;
636 }
637
638 self.commands.push(Command::EndContainer);
639 self.last_text_idx = None;
640 }
641
642 self.commands.push(Command::EndContainer);
643 self.last_text_idx = None;
644 }
645
646 fn render_grouped_vertical_bars(
647 &mut self,
648 groups: &[BarGroup],
649 max_height: u32,
650 denom: f64,
651 config: &BarChartConfig,
652 ) {
653 self.interaction_count += 1;
654 self.commands.push(Command::BeginContainer {
655 direction: Direction::Column,
656 gap: config.group_gap as u32,
657 align: Align::Start,
658 align_self: None,
659 justify: Justify::Start,
660 border: None,
661 border_sides: BorderSides::all(),
662 border_style: Style::new().fg(self.theme.border),
663 bg_color: None,
664 padding: Padding::default(),
665 margin: Margin::default(),
666 constraints: Constraints::default(),
667 title: None,
668 grow: 0,
669 group_name: None,
670 });
671
672 for group in groups {
673 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
674 if !group.bars.is_empty() {
675 self.render_vertical_styled_bars(
676 &group.bars,
677 max_height,
678 denom,
679 config.bar_width,
680 config.bar_gap,
681 );
682 }
683 }
684
685 self.commands.push(Command::EndContainer);
686 self.last_text_idx = None;
687 }
688
689 fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
690 let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
691 "█".repeat(filled)
692 }
693
694 fn bar_display_value(bar: &Bar) -> String {
695 bar.text_value
696 .clone()
697 .unwrap_or_else(|| format_compact_number(bar.value))
698 }
699
700 fn center_and_truncate_text(text: &str, width: usize) -> String {
701 if width == 0 {
702 return String::new();
703 }
704
705 let mut out = String::new();
706 let mut used = 0usize;
707 for ch in text.chars() {
708 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
709 if used + cw > width {
710 break;
711 }
712 out.push(ch);
713 used += cw;
714 }
715 center_text(&out, width)
716 }
717
718 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
734 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
735
736 let w = width as usize;
737 if data.is_empty() || w == 0 {
738 return Response::none();
739 }
740
741 let points: Vec<f64> = if data.len() >= w {
742 data[data.len() - w..].to_vec()
743 } else if data.len() == 1 {
744 vec![data[0]; w]
745 } else {
746 (0..w)
747 .map(|i| {
748 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
749 let idx = t.floor() as usize;
750 let frac = t - idx as f64;
751 if idx + 1 < data.len() {
752 data[idx] * (1.0 - frac) + data[idx + 1] * frac
753 } else {
754 data[idx.min(data.len() - 1)]
755 }
756 })
757 .collect()
758 };
759
760 let min = points.iter().copied().fold(f64::INFINITY, f64::min);
761 let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
762 let range = max - min;
763
764 let line: String = points
765 .iter()
766 .map(|&value| {
767 let normalized = if range == 0.0 {
768 0.5
769 } else {
770 (value - min) / range
771 };
772 let idx = (normalized * 7.0).round() as usize;
773 BLOCKS[idx.min(7)]
774 })
775 .collect();
776
777 self.styled(line, Style::new().fg(self.theme.primary));
778 Response::none()
779 }
780
781 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
801 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
802
803 let w = width as usize;
804 if data.is_empty() || w == 0 {
805 return Response::none();
806 }
807
808 let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
809 data[data.len() - w..].to_vec()
810 } else if data.len() == 1 {
811 vec![data[0]; w]
812 } else {
813 (0..w)
814 .map(|i| {
815 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
816 let idx = t.floor() as usize;
817 let frac = t - idx as f64;
818 let nearest = if frac < 0.5 {
819 idx
820 } else {
821 (idx + 1).min(data.len() - 1)
822 };
823 let color = data[nearest].1;
824 let (v1, _) = data[idx];
825 let (v2, _) = data[(idx + 1).min(data.len() - 1)];
826 let value = if v1.is_nan() || v2.is_nan() {
827 if frac < 0.5 {
828 v1
829 } else {
830 v2
831 }
832 } else {
833 v1 * (1.0 - frac) + v2 * frac
834 };
835 (value, color)
836 })
837 .collect()
838 };
839
840 let mut finite_values = window
841 .iter()
842 .map(|(value, _)| *value)
843 .filter(|value| !value.is_nan());
844 let Some(first) = finite_values.next() else {
845 self.styled(
846 " ".repeat(window.len()),
847 Style::new().fg(self.theme.text_dim),
848 );
849 return Response::none();
850 };
851
852 let mut min = first;
853 let mut max = first;
854 for value in finite_values {
855 min = f64::min(min, value);
856 max = f64::max(max, value);
857 }
858 let range = max - min;
859
860 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
861 for (value, color) in &window {
862 if value.is_nan() {
863 cells.push((' ', self.theme.text_dim));
864 continue;
865 }
866
867 let normalized = if range == 0.0 {
868 0.5
869 } else {
870 ((*value - min) / range).clamp(0.0, 1.0)
871 };
872 let idx = (normalized * 7.0).round() as usize;
873 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
874 }
875
876 self.interaction_count += 1;
877 self.commands.push(Command::BeginContainer {
878 direction: Direction::Row,
879 gap: 0,
880 align: Align::Start,
881 align_self: None,
882 justify: Justify::Start,
883 border: None,
884 border_sides: BorderSides::all(),
885 border_style: Style::new().fg(self.theme.border),
886 bg_color: None,
887 padding: Padding::default(),
888 margin: Margin::default(),
889 constraints: Constraints::default(),
890 title: None,
891 grow: 0,
892 group_name: None,
893 });
894
895 if cells.is_empty() {
896 self.commands.push(Command::EndContainer);
897 self.last_text_idx = None;
898 return Response::none();
899 }
900
901 let mut seg = String::new();
902 let mut seg_color = cells[0].1;
903 for (ch, color) in cells {
904 if color != seg_color {
905 self.styled(seg, Style::new().fg(seg_color));
906 seg = String::new();
907 seg_color = color;
908 }
909 seg.push(ch);
910 }
911 if !seg.is_empty() {
912 self.styled(seg, Style::new().fg(seg_color));
913 }
914
915 self.commands.push(Command::EndContainer);
916 self.last_text_idx = None;
917
918 Response::none()
919 }
920
921 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
935 self.line_chart_colored(data, width, height, self.theme.primary)
936 }
937
938 pub fn line_chart_colored(
940 &mut self,
941 data: &[f64],
942 width: u32,
943 height: u32,
944 color: Color,
945 ) -> Response {
946 self.render_line_chart_internal(data, width, height, color, false)
947 }
948
949 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
951 self.area_chart_colored(data, width, height, self.theme.primary)
952 }
953
954 pub fn area_chart_colored(
956 &mut self,
957 data: &[f64],
958 width: u32,
959 height: u32,
960 color: Color,
961 ) -> Response {
962 self.render_line_chart_internal(data, width, height, color, true)
963 }
964
965 fn render_line_chart_internal(
966 &mut self,
967 data: &[f64],
968 width: u32,
969 height: u32,
970 color: Color,
971 fill: bool,
972 ) -> Response {
973 if data.is_empty() || width == 0 || height == 0 {
974 return Response::none();
975 }
976
977 let cols = width as usize;
978 let rows = height as usize;
979 let px_w = cols * 2;
980 let px_h = rows * 4;
981
982 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
983 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
984 let range = if (max - min).abs() < f64::EPSILON {
985 1.0
986 } else {
987 max - min
988 };
989
990 let points: Vec<usize> = (0..px_w)
991 .map(|px| {
992 let data_idx = if px_w <= 1 {
993 0.0
994 } else {
995 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
996 };
997 let idx = data_idx.floor() as usize;
998 let frac = data_idx - idx as f64;
999 let value = if idx + 1 < data.len() {
1000 data[idx] * (1.0 - frac) + data[idx + 1] * frac
1001 } else {
1002 data[idx.min(data.len() - 1)]
1003 };
1004
1005 let normalized = (value - min) / range;
1006 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
1007 py.min(px_h - 1)
1008 })
1009 .collect();
1010
1011 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
1012 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
1013
1014 let mut grid = vec![vec![0u32; cols]; rows];
1015
1016 for i in 0..points.len() {
1017 let px = i;
1018 let py = points[i];
1019 let char_col = px / 2;
1020 let char_row = py / 4;
1021 let sub_col = px % 2;
1022 let sub_row = py % 4;
1023
1024 if char_col < cols && char_row < rows {
1025 grid[char_row][char_col] |= if sub_col == 0 {
1026 LEFT_BITS[sub_row]
1027 } else {
1028 RIGHT_BITS[sub_row]
1029 };
1030 }
1031
1032 if i + 1 < points.len() {
1033 let py_next = points[i + 1];
1034 let (y_start, y_end) = if py <= py_next {
1035 (py, py_next)
1036 } else {
1037 (py_next, py)
1038 };
1039 for y in y_start..=y_end {
1040 let cell_row = y / 4;
1041 let sub_y = y % 4;
1042 if char_col < cols && cell_row < rows {
1043 grid[cell_row][char_col] |= if sub_col == 0 {
1044 LEFT_BITS[sub_y]
1045 } else {
1046 RIGHT_BITS[sub_y]
1047 };
1048 }
1049 }
1050 }
1051
1052 if fill {
1053 for y in py..px_h {
1054 let cell_row = y / 4;
1055 let sub_y = y % 4;
1056 if char_col < cols && cell_row < rows {
1057 grid[cell_row][char_col] |= if sub_col == 0 {
1058 LEFT_BITS[sub_y]
1059 } else {
1060 RIGHT_BITS[sub_y]
1061 };
1062 }
1063 }
1064 }
1065 }
1066
1067 let style = Style::new().fg(color);
1068 for row in grid {
1069 let line: String = row
1070 .iter()
1071 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1072 .collect();
1073 self.styled(line, style);
1074 }
1075
1076 Response::none()
1077 }
1078
1079 pub fn candlestick(
1081 &mut self,
1082 candles: &[Candle],
1083 up_color: Color,
1084 down_color: Color,
1085 ) -> Response {
1086 if candles.is_empty() {
1087 return Response::none();
1088 }
1089
1090 let candles = candles.to_vec();
1091 self.container().grow(1).draw(move |buf, rect| {
1092 let w = rect.width as usize;
1093 let h = rect.height as usize;
1094 if w < 2 || h < 2 {
1095 return;
1096 }
1097
1098 let mut lo = f64::INFINITY;
1099 let mut hi = f64::NEG_INFINITY;
1100 for c in &candles {
1101 if c.low.is_finite() {
1102 lo = lo.min(c.low);
1103 }
1104 if c.high.is_finite() {
1105 hi = hi.max(c.high);
1106 }
1107 }
1108
1109 if !lo.is_finite() || !hi.is_finite() {
1110 return;
1111 }
1112
1113 let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1114 let map_y = |v: f64| -> usize {
1115 let t = ((v - lo) / range).clamp(0.0, 1.0);
1116 ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1117 };
1118
1119 for (i, c) in candles.iter().enumerate() {
1120 if !c.open.is_finite()
1121 || !c.high.is_finite()
1122 || !c.low.is_finite()
1123 || !c.close.is_finite()
1124 {
1125 continue;
1126 }
1127
1128 let x0 = i * w / candles.len();
1129 let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1130 if x0 >= w {
1131 continue;
1132 }
1133 let xm = (x0 + x1) / 2;
1134 let color = if c.close >= c.open {
1135 up_color
1136 } else {
1137 down_color
1138 };
1139
1140 let wt = map_y(c.high);
1141 let wb = map_y(c.low);
1142 for row in wt..=wb.min(h - 1) {
1143 buf.set_char(
1144 rect.x + xm as u32,
1145 rect.y + row as u32,
1146 '│',
1147 Style::new().fg(color),
1148 );
1149 }
1150
1151 let bt = map_y(c.open.max(c.close));
1152 let bb = map_y(c.open.min(c.close));
1153 for row in bt..=bb.min(h - 1) {
1154 for col in x0..=x1.min(w - 1) {
1155 buf.set_char(
1156 rect.x + col as u32,
1157 rect.y + row as u32,
1158 '█',
1159 Style::new().fg(color),
1160 );
1161 }
1162 }
1163 }
1164 });
1165
1166 Response::none()
1167 }
1168
1169 pub fn heatmap(
1181 &mut self,
1182 data: &[Vec<f64>],
1183 width: u32,
1184 height: u32,
1185 low_color: Color,
1186 high_color: Color,
1187 ) -> Response {
1188 fn blend_color(a: Color, b: Color, t: f64) -> Color {
1189 let t = t.clamp(0.0, 1.0);
1190 match (a, b) {
1191 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1192 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1193 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1194 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1195 ),
1196 _ => {
1197 if t > 0.5 {
1198 b
1199 } else {
1200 a
1201 }
1202 }
1203 }
1204 }
1205
1206 if data.is_empty() || width == 0 || height == 0 {
1207 return Response::none();
1208 }
1209
1210 let data_rows = data.len();
1211 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1212 if max_data_cols == 0 {
1213 return Response::none();
1214 }
1215
1216 let mut min_value = f64::INFINITY;
1217 let mut max_value = f64::NEG_INFINITY;
1218 for row in data {
1219 for value in row {
1220 if value.is_finite() {
1221 min_value = min_value.min(*value);
1222 max_value = max_value.max(*value);
1223 }
1224 }
1225 }
1226
1227 if !min_value.is_finite() || !max_value.is_finite() {
1228 return Response::none();
1229 }
1230
1231 let range = max_value - min_value;
1232 let zero_range = range.abs() < f64::EPSILON;
1233 let cols = width as usize;
1234 let rows = height as usize;
1235
1236 for row_idx in 0..rows {
1237 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1238 let source_row = &data[data_row_idx];
1239 let source_cols = source_row.len();
1240
1241 self.interaction_count += 1;
1242 self.commands.push(Command::BeginContainer {
1243 direction: Direction::Row,
1244 gap: 0,
1245 align: Align::Start,
1246 align_self: None,
1247 justify: Justify::Start,
1248 border: None,
1249 border_sides: BorderSides::all(),
1250 border_style: Style::new().fg(self.theme.border),
1251 bg_color: None,
1252 padding: Padding::default(),
1253 margin: Margin::default(),
1254 constraints: Constraints::default(),
1255 title: None,
1256 grow: 0,
1257 group_name: None,
1258 });
1259
1260 let mut segment = String::new();
1261 let mut segment_color: Option<Color> = None;
1262
1263 for col_idx in 0..cols {
1264 let normalized = if source_cols == 0 {
1265 0.0
1266 } else {
1267 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1268 let value = source_row[data_col_idx];
1269
1270 if !value.is_finite() {
1271 0.0
1272 } else if zero_range {
1273 0.5
1274 } else {
1275 ((value - min_value) / range).clamp(0.0, 1.0)
1276 }
1277 };
1278
1279 let color = blend_color(low_color, high_color, normalized);
1280
1281 match segment_color {
1282 Some(current) if current == color => {
1283 segment.push('█');
1284 }
1285 Some(current) => {
1286 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1287 segment.push('█');
1288 segment_color = Some(color);
1289 }
1290 None => {
1291 segment.push('█');
1292 segment_color = Some(color);
1293 }
1294 }
1295 }
1296
1297 if let Some(color) = segment_color {
1298 self.styled(segment, Style::new().fg(color));
1299 }
1300
1301 self.commands.push(Command::EndContainer);
1302 self.last_text_idx = None;
1303 }
1304
1305 Response::none()
1306 }
1307
1308 pub fn canvas(
1325 &mut self,
1326 width: u32,
1327 height: u32,
1328 draw: impl FnOnce(&mut CanvasContext),
1329 ) -> Response {
1330 if width == 0 || height == 0 {
1331 return Response::none();
1332 }
1333
1334 let mut canvas = CanvasContext::new(width as usize, height as usize);
1335 draw(&mut canvas);
1336
1337 for segments in canvas.render() {
1338 self.interaction_count += 1;
1339 self.commands.push(Command::BeginContainer {
1340 direction: Direction::Row,
1341 gap: 0,
1342 align: Align::Start,
1343 align_self: None,
1344 justify: Justify::Start,
1345 border: None,
1346 border_sides: BorderSides::all(),
1347 border_style: Style::new(),
1348 bg_color: None,
1349 padding: Padding::default(),
1350 margin: Margin::default(),
1351 constraints: Constraints::default(),
1352 title: None,
1353 grow: 0,
1354 group_name: None,
1355 });
1356 for (text, color) in segments {
1357 let c = if color == Color::Reset {
1358 self.theme.primary
1359 } else {
1360 color
1361 };
1362 self.styled(text, Style::new().fg(c));
1363 }
1364 self.commands.push(Command::EndContainer);
1365 self.last_text_idx = None;
1366 }
1367
1368 Response::none()
1369 }
1370
1371 pub fn chart(
1377 &mut self,
1378 configure: impl FnOnce(&mut ChartBuilder),
1379 width: u32,
1380 height: u32,
1381 ) -> Response {
1382 if width == 0 || height == 0 {
1383 return Response::none();
1384 }
1385
1386 let axis_style = Style::new().fg(self.theme.text_dim);
1387 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1388 configure(&mut builder);
1389
1390 let config = builder.build();
1391 let rows = render_chart(&config);
1392
1393 for row in rows {
1394 self.interaction_count += 1;
1395 self.commands.push(Command::BeginContainer {
1396 direction: Direction::Row,
1397 gap: 0,
1398 align: Align::Start,
1399 align_self: None,
1400 justify: Justify::Start,
1401 border: None,
1402 border_sides: BorderSides::all(),
1403 border_style: Style::new().fg(self.theme.border),
1404 bg_color: None,
1405 padding: Padding::default(),
1406 margin: Margin::default(),
1407 constraints: Constraints::default(),
1408 title: None,
1409 grow: 0,
1410 group_name: None,
1411 });
1412 for (text, style) in row.segments {
1413 self.styled(text, style);
1414 }
1415 self.commands.push(Command::EndContainer);
1416 self.last_text_idx = None;
1417 }
1418
1419 Response::none()
1420 }
1421
1422 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1426 self.chart(
1427 |c| {
1428 c.scatter(data);
1429 c.grid(true);
1430 },
1431 width,
1432 height,
1433 )
1434 }
1435
1436 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1438 self.histogram_with(data, |_| {}, width, height)
1439 }
1440
1441 pub fn histogram_with(
1443 &mut self,
1444 data: &[f64],
1445 configure: impl FnOnce(&mut HistogramBuilder),
1446 width: u32,
1447 height: u32,
1448 ) -> Response {
1449 if width == 0 || height == 0 {
1450 return Response::none();
1451 }
1452
1453 let mut options = HistogramBuilder::default();
1454 configure(&mut options);
1455 let axis_style = Style::new().fg(self.theme.text_dim);
1456 let config = build_histogram_config(data, &options, width, height, axis_style);
1457 let rows = render_chart(&config);
1458
1459 for row in rows {
1460 self.interaction_count += 1;
1461 self.commands.push(Command::BeginContainer {
1462 direction: Direction::Row,
1463 gap: 0,
1464 align: Align::Start,
1465 align_self: None,
1466 justify: Justify::Start,
1467 border: None,
1468 border_sides: BorderSides::all(),
1469 border_style: Style::new().fg(self.theme.border),
1470 bg_color: None,
1471 padding: Padding::default(),
1472 margin: Margin::default(),
1473 constraints: Constraints::default(),
1474 title: None,
1475 grow: 0,
1476 group_name: None,
1477 });
1478 for (text, style) in row.segments {
1479 self.styled(text, style);
1480 }
1481 self.commands.push(Command::EndContainer);
1482 self.last_text_idx = None;
1483 }
1484
1485 Response::none()
1486 }
1487
1488 #[cfg(feature = "qrcode")]
1489 pub fn qr_code(&mut self, data: impl AsRef<str>) -> Response {
1491 let code = match qrcode::QrCode::new(data.as_ref()) {
1492 Ok(code) => code,
1493 Err(_) => {
1494 self.text("[QR Error]");
1495 return Response::none();
1496 }
1497 };
1498
1499 let modules_per_side = code.width();
1500 let modules = code.to_colors();
1501 let qr_side = modules_per_side + 2;
1502 let qr_width = qr_side;
1503 let qr_height = qr_side.div_ceil(2);
1504 let theme_text = self.theme.text;
1505 let theme_bg = self.theme.bg;
1506
1507 self.container()
1508 .w(qr_width as u32)
1509 .h(qr_height as u32)
1510 .draw(move |buf, rect| {
1511 let draw_w = (rect.width as usize).min(qr_width);
1512 let draw_h = (rect.height as usize).min(qr_height);
1513
1514 for row in 0..draw_h {
1515 let upper_y = row * 2;
1516 let lower_y = upper_y + 1;
1517
1518 for x in 0..draw_w {
1519 let resolve_module_color = |mx: usize, my: usize| -> Color {
1520 let dark =
1521 if mx == 0 || my == 0 || mx == qr_side - 1 || my == qr_side - 1 {
1522 false
1523 } else {
1524 let inner_x = mx - 1;
1525 let inner_y = my - 1;
1526 let idx = inner_y * modules_per_side + inner_x;
1527 matches!(modules.get(idx), Some(qrcode::types::Color::Dark))
1528 };
1529
1530 if dark {
1531 theme_text
1532 } else {
1533 theme_bg
1534 }
1535 };
1536
1537 let upper = resolve_module_color(x, upper_y);
1538 let lower = if lower_y < qr_side {
1539 resolve_module_color(x, lower_y)
1540 } else {
1541 theme_bg
1542 };
1543
1544 buf.set_char(
1545 rect.x + x as u32,
1546 rect.y + row as u32,
1547 '▀',
1548 Style::new().fg(upper).bg(lower),
1549 );
1550 }
1551 }
1552 });
1553
1554 Response::none()
1555 }
1556}
1557
1558#[cfg(all(test, feature = "qrcode"))]
1559#[test]
1560fn test_qr_code() {
1561 let mut backend = crate::TestBackend::new(60, 30);
1562 backend.render(|ui| {
1563 let _ = ui.qr_code("hello");
1564 });
1565
1566 let output = backend.to_string();
1567 assert!(output.contains('▀') || output.contains('█'));
1568}