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.skip_interaction_slot();
49 self.commands
50 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
51 direction: Direction::Column,
52 gap: 0,
53 align: Align::Start,
54 align_self: None,
55 justify: Justify::Start,
56 border: None,
57 border_sides: BorderSides::all(),
58 border_style: Style::new().fg(self.theme.border),
59 bg_color: None,
60 padding: Padding::default(),
61 margin: Margin::default(),
62 constraints: Constraints::default(),
63 title: None,
64 grow: 0,
65 group_name: None,
66 })));
67
68 for (label, value) in data {
69 let label_width = UnicodeWidthStr::width(*label);
70 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
71 let normalized = (*value / denom).clamp(0.0, 1.0);
72 let bar = Self::horizontal_bar_text(normalized, max_width);
73
74 self.skip_interaction_slot();
75 self.commands
76 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
77 direction: Direction::Row,
78 gap: 1,
79 align: Align::Start,
80 align_self: None,
81 justify: Justify::Start,
82 border: None,
83 border_sides: BorderSides::all(),
84 border_style: Style::new().fg(self.theme.border),
85 bg_color: None,
86 padding: Padding::default(),
87 margin: Margin::default(),
88 constraints: Constraints::default(),
89 title: None,
90 grow: 0,
91 group_name: None,
92 })));
93 let mut label_text = String::with_capacity(label.len() + label_padding.len());
94 label_text.push_str(label);
95 label_text.push_str(&label_padding);
96 self.styled(label_text, Style::new().fg(self.theme.text));
97 self.styled(bar, Style::new().fg(self.theme.primary));
98 self.styled(
99 format_compact_number(*value),
100 Style::new().fg(self.theme.text_dim),
101 );
102 self.commands.push(Command::EndContainer);
103 self.rollback.last_text_idx = None;
104 }
105
106 self.commands.push(Command::EndContainer);
107 self.rollback.last_text_idx = None;
108
109 Response::none()
110 }
111
112 pub fn bar_chart_with(
114 &mut self,
115 bars: &[Bar],
116 configure: impl FnOnce(&mut BarChartConfig),
117 max_size: u32,
118 ) -> Response {
119 if bars.is_empty() {
120 return Response::none();
121 }
122
123 let (config, denom) = self.bar_chart_styled_layout(bars, configure);
124 self.bar_chart_styled_render(bars, max_size, denom, &config);
125
126 Response::none()
127 }
128
129 fn bar_chart_styled_layout(
130 &self,
131 bars: &[Bar],
132 configure: impl FnOnce(&mut BarChartConfig),
133 ) -> (BarChartConfig, f64) {
134 let mut config = BarChartConfig::default();
135 configure(&mut config);
136
137 let auto_max = bars
138 .iter()
139 .map(|bar| bar.value)
140 .fold(f64::NEG_INFINITY, f64::max);
141 let max_value = config.max_value.unwrap_or(auto_max);
142 let denom = if max_value > 0.0 { max_value } else { 1.0 };
143
144 (config, denom)
145 }
146
147 fn bar_chart_styled_render(
148 &mut self,
149 bars: &[Bar],
150 max_size: u32,
151 denom: f64,
152 config: &BarChartConfig,
153 ) {
154 match config.direction {
155 BarDirection::Horizontal => {
156 self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
157 }
158 BarDirection::Vertical => self.render_vertical_styled_bars(
159 bars,
160 max_size,
161 denom,
162 config.bar_width,
163 config.bar_gap,
164 ),
165 }
166 }
167
168 fn render_horizontal_styled_bars(
169 &mut self,
170 bars: &[Bar],
171 max_width: u32,
172 denom: f64,
173 bar_gap: u16,
174 ) {
175 let max_label_width = bars
176 .iter()
177 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
178 .max()
179 .unwrap_or(0);
180
181 self.skip_interaction_slot();
182 self.commands
183 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
184 direction: Direction::Column,
185 gap: bar_gap as u32,
186 align: Align::Start,
187 align_self: None,
188 justify: Justify::Start,
189 border: None,
190 border_sides: BorderSides::all(),
191 border_style: Style::new().fg(self.theme.border),
192 bg_color: None,
193 padding: Padding::default(),
194 margin: Margin::default(),
195 constraints: Constraints::default(),
196 title: None,
197 grow: 0,
198 group_name: None,
199 })));
200
201 for bar in bars {
202 self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
203 }
204
205 self.commands.push(Command::EndContainer);
206 self.rollback.last_text_idx = None;
207 }
208
209 fn render_horizontal_styled_bar_row(
210 &mut self,
211 bar: &Bar,
212 max_label_width: usize,
213 max_width: u32,
214 denom: f64,
215 ) {
216 let label_width = UnicodeWidthStr::width(bar.label.as_str());
217 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
218 let normalized = (bar.value / denom).clamp(0.0, 1.0);
219 let bar_text = Self::horizontal_bar_text(normalized, max_width);
220 let color = bar.color.unwrap_or(self.theme.primary);
221
222 self.skip_interaction_slot();
223 self.commands
224 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
225 direction: Direction::Row,
226 gap: 1,
227 align: Align::Start,
228 align_self: None,
229 justify: Justify::Start,
230 border: None,
231 border_sides: BorderSides::all(),
232 border_style: Style::new().fg(self.theme.border),
233 bg_color: None,
234 padding: Padding::default(),
235 margin: Margin::default(),
236 constraints: Constraints::default(),
237 title: None,
238 grow: 0,
239 group_name: None,
240 })));
241 let mut label_text = String::with_capacity(bar.label.len() + label_padding.len());
242 label_text.push_str(&bar.label);
243 label_text.push_str(&label_padding);
244 self.styled(label_text, Style::new().fg(self.theme.text));
245 self.styled(bar_text, Style::new().fg(color));
246 self.styled(
247 Self::bar_display_value(bar),
248 bar.value_style
249 .unwrap_or(Style::new().fg(self.theme.text_dim)),
250 );
251 self.commands.push(Command::EndContainer);
252 self.rollback.last_text_idx = None;
253 }
254
255 fn render_vertical_styled_bars(
256 &mut self,
257 bars: &[Bar],
258 max_height: u32,
259 denom: f64,
260 bar_width: u16,
261 bar_gap: u16,
262 ) {
263 let layout = self.compute_vertical_bar_layout(bars, max_height, denom, bar_width);
264
265 self.skip_interaction_slot();
266 self.commands
267 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
268 direction: Direction::Column,
269 gap: 0,
270 align: Align::Start,
271 align_self: None,
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
285 self.render_vertical_bar_body(
286 bars,
287 &layout.bar_units,
288 layout.chart_height,
289 layout.col_width,
290 layout.bar_width,
291 bar_gap,
292 &layout.value_labels,
293 );
294 self.render_vertical_bar_labels(bars, layout.col_width, bar_gap);
295
296 self.commands.push(Command::EndContainer);
297 self.rollback.last_text_idx = None;
298 }
299
300 fn compute_vertical_bar_layout(
301 &self,
302 bars: &[Bar],
303 max_height: u32,
304 denom: f64,
305 bar_width: u16,
306 ) -> VerticalBarLayout {
307 let chart_height = max_height.max(1) as usize;
308 let bar_width = bar_width.max(1) as usize;
309 let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
310 let label_width = bars
311 .iter()
312 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
313 .max()
314 .unwrap_or(1);
315 let value_width = value_labels
316 .iter()
317 .map(|value| UnicodeWidthStr::width(value.as_str()))
318 .max()
319 .unwrap_or(1);
320 let col_width = bar_width.max(label_width.max(value_width).max(1));
321 let bar_units: Vec<usize> = bars
322 .iter()
323 .map(|bar| {
324 ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
325 })
326 .collect();
327
328 VerticalBarLayout {
329 chart_height,
330 bar_width,
331 value_labels,
332 col_width,
333 bar_units,
334 }
335 }
336
337 #[allow(clippy::too_many_arguments)]
338 fn render_vertical_bar_body(
339 &mut self,
340 bars: &[Bar],
341 bar_units: &[usize],
342 chart_height: usize,
343 col_width: usize,
344 bar_width: usize,
345 bar_gap: u16,
346 value_labels: &[String],
347 ) {
348 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
349
350 let top_rows: Vec<usize> = bar_units
352 .iter()
353 .map(|units| {
354 if *units == 0 {
355 usize::MAX
356 } else {
357 (*units - 1) / 8
358 }
359 })
360 .collect();
361
362 for row in (0..chart_height).rev() {
363 self.skip_interaction_slot();
364 self.commands
365 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
366 direction: Direction::Row,
367 gap: bar_gap as u32,
368 align: Align::Start,
369 align_self: None,
370 justify: Justify::Start,
371 border: None,
372 border_sides: BorderSides::all(),
373 border_style: Style::new().fg(self.theme.border),
374 bg_color: None,
375 padding: Padding::default(),
376 margin: Margin::default(),
377 constraints: Constraints::default(),
378 title: None,
379 grow: 0,
380 group_name: None,
381 })));
382
383 let row_base = row * 8;
384 for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
385 let color = bar.color.unwrap_or(self.theme.primary);
386
387 if *units <= row_base {
388 if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
390 let label = &value_labels[i];
391 let centered = Self::center_and_truncate_text(label, col_width);
392 self.styled(
393 centered,
394 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
395 );
396 } else {
397 let empty = " ".repeat(col_width);
398 self.styled(empty, Style::new());
399 }
400 continue;
401 }
402
403 if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
404 let label = &value_labels[i];
405 let centered = Self::center_and_truncate_text(label, col_width);
406 self.styled(
407 centered,
408 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
409 );
410 continue;
411 }
412
413 let delta = *units - row_base;
414 let fill = if delta >= 8 {
415 '█'
416 } else {
417 FRACTION_BLOCKS[delta]
418 };
419 let fill_text = fill.to_string().repeat(bar_width);
420 let centered_fill = center_text(&fill_text, col_width);
421 self.styled(centered_fill, Style::new().fg(color));
422 }
423
424 self.commands.push(Command::EndContainer);
425 self.rollback.last_text_idx = None;
426 }
427 }
428
429 fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
430 self.skip_interaction_slot();
431 self.commands
432 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
433 direction: Direction::Row,
434 gap: bar_gap as u32,
435 align: Align::Start,
436 align_self: None,
437 justify: Justify::Start,
438 border: None,
439 border_sides: BorderSides::all(),
440 border_style: Style::new().fg(self.theme.border),
441 bg_color: None,
442 padding: Padding::default(),
443 margin: Margin::default(),
444 constraints: Constraints::default(),
445 title: None,
446 grow: 0,
447 group_name: None,
448 })));
449 for bar in bars {
450 self.styled(
451 Self::center_and_truncate_text(&bar.label, col_width),
452 Style::new().fg(self.theme.text),
453 );
454 }
455 self.commands.push(Command::EndContainer);
456 self.rollback.last_text_idx = None;
457 }
458
459 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
476 self.bar_chart_grouped_with(groups, |_| {}, max_width)
477 }
478
479 pub fn bar_chart_grouped_with(
481 &mut self,
482 groups: &[BarGroup],
483 configure: impl FnOnce(&mut BarChartConfig),
484 max_size: u32,
485 ) -> Response {
486 if groups.is_empty() {
487 return Response::none();
488 }
489
490 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
491 if all_bars.is_empty() {
492 return Response::none();
493 }
494
495 let mut config = BarChartConfig::default();
496 configure(&mut config);
497
498 let auto_max = all_bars
499 .iter()
500 .map(|bar| bar.value)
501 .fold(f64::NEG_INFINITY, f64::max);
502 let max_value = config.max_value.unwrap_or(auto_max);
503 let denom = if max_value > 0.0 { max_value } else { 1.0 };
504
505 match config.direction {
506 BarDirection::Horizontal => {
507 self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
508 }
509 BarDirection::Vertical => {
510 self.render_grouped_vertical_bars(groups, max_size, denom, &config)
511 }
512 }
513
514 Response::none()
515 }
516
517 fn render_grouped_horizontal_bars(
518 &mut self,
519 groups: &[BarGroup],
520 max_width: u32,
521 denom: f64,
522 config: &BarChartConfig,
523 ) {
524 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
525 let max_label_width = all_bars
526 .iter()
527 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
528 .max()
529 .unwrap_or(0);
530
531 self.skip_interaction_slot();
532 self.commands
533 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
534 direction: Direction::Column,
535 gap: config.group_gap as u32,
536 align: Align::Start,
537 align_self: None,
538 justify: Justify::Start,
539 border: None,
540 border_sides: BorderSides::all(),
541 border_style: Style::new().fg(self.theme.border),
542 bg_color: None,
543 padding: Padding::default(),
544 margin: Margin::default(),
545 constraints: Constraints::default(),
546 title: None,
547 grow: 0,
548 group_name: None,
549 })));
550
551 for group in groups {
552 self.skip_interaction_slot();
553 self.commands
554 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
555 direction: Direction::Column,
556 gap: config.bar_gap as u32,
557 align: Align::Start,
558 align_self: None,
559 justify: Justify::Start,
560 border: None,
561 border_sides: BorderSides::all(),
562 border_style: Style::new().fg(self.theme.border),
563 bg_color: None,
564 padding: Padding::default(),
565 margin: Margin::default(),
566 constraints: Constraints::default(),
567 title: None,
568 grow: 0,
569 group_name: None,
570 })));
571
572 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
573
574 for bar in &group.bars {
575 let label_width = UnicodeWidthStr::width(bar.label.as_str());
576 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
577 let normalized = (bar.value / denom).clamp(0.0, 1.0);
578 let bar_text = Self::horizontal_bar_text(normalized, max_width);
579
580 self.skip_interaction_slot();
581 self.commands
582 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
583 direction: Direction::Row,
584 gap: 1,
585 align: Align::Start,
586 align_self: None,
587 justify: Justify::Start,
588 border: None,
589 border_sides: BorderSides::all(),
590 border_style: Style::new().fg(self.theme.border),
591 bg_color: None,
592 padding: Padding::default(),
593 margin: Margin::default(),
594 constraints: Constraints::default(),
595 title: None,
596 grow: 0,
597 group_name: None,
598 })));
599 let mut label_text =
600 String::with_capacity(2 + bar.label.len() + label_padding.len());
601 label_text.push_str(" ");
602 label_text.push_str(&bar.label);
603 label_text.push_str(&label_padding);
604 self.styled(label_text, Style::new().fg(self.theme.text));
605 self.styled(
606 bar_text,
607 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
608 );
609 self.styled(
610 Self::bar_display_value(bar),
611 bar.value_style
612 .unwrap_or(Style::new().fg(self.theme.text_dim)),
613 );
614 self.commands.push(Command::EndContainer);
615 self.rollback.last_text_idx = None;
616 }
617
618 self.commands.push(Command::EndContainer);
619 self.rollback.last_text_idx = None;
620 }
621
622 self.commands.push(Command::EndContainer);
623 self.rollback.last_text_idx = None;
624 }
625
626 fn render_grouped_vertical_bars(
627 &mut self,
628 groups: &[BarGroup],
629 max_height: u32,
630 denom: f64,
631 config: &BarChartConfig,
632 ) {
633 self.skip_interaction_slot();
634 self.commands
635 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
636 direction: Direction::Column,
637 gap: config.group_gap as u32,
638 align: Align::Start,
639 align_self: None,
640 justify: Justify::Start,
641 border: None,
642 border_sides: BorderSides::all(),
643 border_style: Style::new().fg(self.theme.border),
644 bg_color: None,
645 padding: Padding::default(),
646 margin: Margin::default(),
647 constraints: Constraints::default(),
648 title: None,
649 grow: 0,
650 group_name: None,
651 })));
652
653 for group in groups {
654 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
655 if !group.bars.is_empty() {
656 self.render_vertical_styled_bars(
657 &group.bars,
658 max_height,
659 denom,
660 config.bar_width,
661 config.bar_gap,
662 );
663 }
664 }
665
666 self.commands.push(Command::EndContainer);
667 self.rollback.last_text_idx = None;
668 }
669
670 fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
671 let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
672 "█".repeat(filled)
673 }
674
675 fn bar_display_value(bar: &Bar) -> String {
676 bar.text_value
677 .clone()
678 .unwrap_or_else(|| format_compact_number(bar.value))
679 }
680
681 fn center_and_truncate_text(text: &str, width: usize) -> String {
682 if width == 0 {
683 return String::new();
684 }
685
686 let mut out = String::new();
687 let mut used = 0usize;
688 for ch in text.chars() {
689 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
690 if used + cw > width {
691 break;
692 }
693 out.push(ch);
694 used += cw;
695 }
696 center_text(&out, width)
697 }
698
699 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
715 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
716
717 let w = width as usize;
718 if data.is_empty() || w == 0 {
719 return Response::none();
720 }
721
722 let points: Vec<f64> = if data.len() >= w {
723 data[data.len() - w..].to_vec()
724 } else if data.len() == 1 {
725 vec![data[0]; w]
726 } else {
727 (0..w)
728 .map(|i| {
729 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
730 let idx = t.floor() as usize;
731 let frac = t - idx as f64;
732 if idx + 1 < data.len() {
733 data[idx] * (1.0 - frac) + data[idx + 1] * frac
734 } else {
735 data[idx.min(data.len() - 1)]
736 }
737 })
738 .collect()
739 };
740
741 let min = points.iter().copied().fold(f64::INFINITY, f64::min);
742 let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
743 let range = max - min;
744
745 let line: String = points
746 .iter()
747 .map(|&value| {
748 let normalized = if range == 0.0 {
749 0.5
750 } else {
751 (value - min) / range
752 };
753 let idx = (normalized * 7.0).round() as usize;
754 BLOCKS[idx.min(7)]
755 })
756 .collect();
757
758 self.styled(line, Style::new().fg(self.theme.primary));
759 Response::none()
760 }
761
762 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
782 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
783
784 let w = width as usize;
785 if data.is_empty() || w == 0 {
786 return Response::none();
787 }
788
789 let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
790 data[data.len() - w..].to_vec()
791 } else if data.len() == 1 {
792 vec![data[0]; w]
793 } else {
794 (0..w)
795 .map(|i| {
796 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
797 let idx = t.floor() as usize;
798 let frac = t - idx as f64;
799 let nearest = if frac < 0.5 {
800 idx
801 } else {
802 (idx + 1).min(data.len() - 1)
803 };
804 let color = data[nearest].1;
805 let (v1, _) = data[idx];
806 let (v2, _) = data[(idx + 1).min(data.len() - 1)];
807 let value = if v1.is_nan() || v2.is_nan() {
808 if frac < 0.5 {
809 v1
810 } else {
811 v2
812 }
813 } else {
814 v1 * (1.0 - frac) + v2 * frac
815 };
816 (value, color)
817 })
818 .collect()
819 };
820
821 let mut finite_values = window
822 .iter()
823 .map(|(value, _)| *value)
824 .filter(|value| !value.is_nan());
825 let Some(first) = finite_values.next() else {
826 self.styled(
827 " ".repeat(window.len()),
828 Style::new().fg(self.theme.text_dim),
829 );
830 return Response::none();
831 };
832
833 let mut min = first;
834 let mut max = first;
835 for value in finite_values {
836 min = f64::min(min, value);
837 max = f64::max(max, value);
838 }
839 let range = max - min;
840
841 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
842 for (value, color) in &window {
843 if value.is_nan() {
844 cells.push((' ', self.theme.text_dim));
845 continue;
846 }
847
848 let normalized = if range == 0.0 {
849 0.5
850 } else {
851 ((*value - min) / range).clamp(0.0, 1.0)
852 };
853 let idx = (normalized * 7.0).round() as usize;
854 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
855 }
856
857 self.skip_interaction_slot();
858 self.commands
859 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
860 direction: Direction::Row,
861 gap: 0,
862 align: Align::Start,
863 align_self: None,
864 justify: Justify::Start,
865 border: None,
866 border_sides: BorderSides::all(),
867 border_style: Style::new().fg(self.theme.border),
868 bg_color: None,
869 padding: Padding::default(),
870 margin: Margin::default(),
871 constraints: Constraints::default(),
872 title: None,
873 grow: 0,
874 group_name: None,
875 })));
876
877 if cells.is_empty() {
878 self.commands.push(Command::EndContainer);
879 self.rollback.last_text_idx = None;
880 return Response::none();
881 }
882
883 let mut seg = String::new();
884 let mut seg_color = cells[0].1;
885 for (ch, color) in cells {
886 if color != seg_color {
887 self.styled(seg, Style::new().fg(seg_color));
888 seg = String::new();
889 seg_color = color;
890 }
891 seg.push(ch);
892 }
893 if !seg.is_empty() {
894 self.styled(seg, Style::new().fg(seg_color));
895 }
896
897 self.commands.push(Command::EndContainer);
898 self.rollback.last_text_idx = None;
899
900 Response::none()
901 }
902
903 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
917 self.line_chart_colored(data, width, height, self.theme.primary)
918 }
919
920 pub fn line_chart_colored(
922 &mut self,
923 data: &[f64],
924 width: u32,
925 height: u32,
926 color: Color,
927 ) -> Response {
928 self.render_line_chart_internal(data, width, height, color, false)
929 }
930
931 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
933 self.area_chart_colored(data, width, height, self.theme.primary)
934 }
935
936 pub fn area_chart_colored(
938 &mut self,
939 data: &[f64],
940 width: u32,
941 height: u32,
942 color: Color,
943 ) -> Response {
944 self.render_line_chart_internal(data, width, height, color, true)
945 }
946
947 fn render_line_chart_internal(
948 &mut self,
949 data: &[f64],
950 width: u32,
951 height: u32,
952 color: Color,
953 fill: bool,
954 ) -> Response {
955 if data.is_empty() || width == 0 || height == 0 {
956 return Response::none();
957 }
958
959 let cols = width as usize;
960 let rows = height as usize;
961 let px_w = cols * 2;
962 let px_h = rows * 4;
963
964 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
965 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
966 let range = if (max - min).abs() < f64::EPSILON {
967 1.0
968 } else {
969 max - min
970 };
971
972 let points: Vec<usize> = (0..px_w)
973 .map(|px| {
974 let data_idx = if px_w <= 1 {
975 0.0
976 } else {
977 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
978 };
979 let idx = data_idx.floor() as usize;
980 let frac = data_idx - idx as f64;
981 let value = if idx + 1 < data.len() {
982 data[idx] * (1.0 - frac) + data[idx + 1] * frac
983 } else {
984 data[idx.min(data.len() - 1)]
985 };
986
987 let normalized = (value - min) / range;
988 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
989 py.min(px_h - 1)
990 })
991 .collect();
992
993 use crate::chart::{BRAILLE_LEFT_BITS as LEFT_BITS, BRAILLE_RIGHT_BITS as RIGHT_BITS};
995
996 let mut grid = vec![vec![0u32; cols]; rows];
997
998 for i in 0..points.len() {
999 let px = i;
1000 let py = points[i];
1001 let char_col = px / 2;
1002 let char_row = py / 4;
1003 let sub_col = px % 2;
1004 let sub_row = py % 4;
1005
1006 if char_col < cols && char_row < rows {
1007 grid[char_row][char_col] |= if sub_col == 0 {
1008 LEFT_BITS[sub_row]
1009 } else {
1010 RIGHT_BITS[sub_row]
1011 };
1012 }
1013
1014 if i + 1 < points.len() {
1015 let py_next = points[i + 1];
1016 let (y_start, y_end) = if py <= py_next {
1017 (py, py_next)
1018 } else {
1019 (py_next, py)
1020 };
1021 for y in y_start..=y_end {
1022 let cell_row = y / 4;
1023 let sub_y = y % 4;
1024 if char_col < cols && cell_row < rows {
1025 grid[cell_row][char_col] |= if sub_col == 0 {
1026 LEFT_BITS[sub_y]
1027 } else {
1028 RIGHT_BITS[sub_y]
1029 };
1030 }
1031 }
1032 }
1033
1034 if fill {
1035 for y in py..px_h {
1036 let cell_row = y / 4;
1037 let sub_y = y % 4;
1038 if char_col < cols && cell_row < rows {
1039 grid[cell_row][char_col] |= if sub_col == 0 {
1040 LEFT_BITS[sub_y]
1041 } else {
1042 RIGHT_BITS[sub_y]
1043 };
1044 }
1045 }
1046 }
1047 }
1048
1049 let style = Style::new().fg(color);
1050 for row in grid {
1051 let line: String = row
1052 .iter()
1053 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1054 .collect();
1055 self.styled(line, style);
1056 }
1057
1058 Response::none()
1059 }
1060
1061 pub fn candlestick(
1063 &mut self,
1064 candles: &[Candle],
1065 up_color: Color,
1066 down_color: Color,
1067 ) -> Response {
1068 if candles.is_empty() {
1069 return Response::none();
1070 }
1071
1072 let candles = candles.to_vec();
1073 self.container().grow(1).draw(move |buf, rect| {
1074 let w = rect.width as usize;
1075 let h = rect.height as usize;
1076 if w < 2 || h < 2 {
1077 return;
1078 }
1079
1080 let mut lo = f64::INFINITY;
1081 let mut hi = f64::NEG_INFINITY;
1082 for c in &candles {
1083 if c.low.is_finite() {
1084 lo = lo.min(c.low);
1085 }
1086 if c.high.is_finite() {
1087 hi = hi.max(c.high);
1088 }
1089 }
1090
1091 if !lo.is_finite() || !hi.is_finite() {
1092 return;
1093 }
1094
1095 let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1096 let map_y = |v: f64| -> usize {
1097 let t = ((v - lo) / range).clamp(0.0, 1.0);
1098 ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1099 };
1100
1101 for (i, c) in candles.iter().enumerate() {
1102 if !c.open.is_finite()
1103 || !c.high.is_finite()
1104 || !c.low.is_finite()
1105 || !c.close.is_finite()
1106 {
1107 continue;
1108 }
1109
1110 let x0 = i * w / candles.len();
1111 let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1112 if x0 >= w {
1113 continue;
1114 }
1115 let xm = (x0 + x1) / 2;
1116 let color = if c.close >= c.open {
1117 up_color
1118 } else {
1119 down_color
1120 };
1121
1122 let wt = map_y(c.high);
1123 let wb = map_y(c.low);
1124 for row in wt..=wb.min(h - 1) {
1125 buf.set_char(
1126 rect.x + xm as u32,
1127 rect.y + row as u32,
1128 '│',
1129 Style::new().fg(color),
1130 );
1131 }
1132
1133 let bt = map_y(c.open.max(c.close));
1134 let bb = map_y(c.open.min(c.close));
1135 for row in bt..=bb.min(h - 1) {
1136 for col in x0..=x1.min(w - 1) {
1137 buf.set_char(
1138 rect.x + col as u32,
1139 rect.y + row as u32,
1140 '█',
1141 Style::new().fg(color),
1142 );
1143 }
1144 }
1145 }
1146 });
1147
1148 Response::none()
1149 }
1150
1151 pub fn heatmap(
1163 &mut self,
1164 data: &[Vec<f64>],
1165 width: u32,
1166 height: u32,
1167 low_color: Color,
1168 high_color: Color,
1169 ) -> Response {
1170 fn blend_color(a: Color, b: Color, t: f64) -> Color {
1171 let t = t.clamp(0.0, 1.0);
1172 match (a, b) {
1173 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1174 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1175 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1176 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1177 ),
1178 _ => {
1179 if t > 0.5 {
1180 b
1181 } else {
1182 a
1183 }
1184 }
1185 }
1186 }
1187
1188 if data.is_empty() || width == 0 || height == 0 {
1189 return Response::none();
1190 }
1191
1192 let data_rows = data.len();
1193 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1194 if max_data_cols == 0 {
1195 return Response::none();
1196 }
1197
1198 let mut min_value = f64::INFINITY;
1199 let mut max_value = f64::NEG_INFINITY;
1200 for row in data {
1201 for value in row {
1202 if value.is_finite() {
1203 min_value = min_value.min(*value);
1204 max_value = max_value.max(*value);
1205 }
1206 }
1207 }
1208
1209 if !min_value.is_finite() || !max_value.is_finite() {
1210 return Response::none();
1211 }
1212
1213 let range = max_value - min_value;
1214 let zero_range = range.abs() < f64::EPSILON;
1215 let cols = width as usize;
1216 let rows = height as usize;
1217
1218 for row_idx in 0..rows {
1219 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1220 let source_row = &data[data_row_idx];
1221 let source_cols = source_row.len();
1222
1223 self.skip_interaction_slot();
1224 self.commands
1225 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1226 direction: Direction::Row,
1227 gap: 0,
1228 align: Align::Start,
1229 align_self: None,
1230 justify: Justify::Start,
1231 border: None,
1232 border_sides: BorderSides::all(),
1233 border_style: Style::new().fg(self.theme.border),
1234 bg_color: None,
1235 padding: Padding::default(),
1236 margin: Margin::default(),
1237 constraints: Constraints::default(),
1238 title: None,
1239 grow: 0,
1240 group_name: None,
1241 })));
1242
1243 let mut segment = String::new();
1244 let mut segment_color: Option<Color> = None;
1245
1246 for col_idx in 0..cols {
1247 let normalized = if source_cols == 0 {
1248 0.0
1249 } else {
1250 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1251 let value = source_row[data_col_idx];
1252
1253 if !value.is_finite() {
1254 0.0
1255 } else if zero_range {
1256 0.5
1257 } else {
1258 ((value - min_value) / range).clamp(0.0, 1.0)
1259 }
1260 };
1261
1262 let color = blend_color(low_color, high_color, normalized);
1263
1264 match segment_color {
1265 Some(current) if current == color => {
1266 segment.push('█');
1267 }
1268 Some(current) => {
1269 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1270 segment.push('█');
1271 segment_color = Some(color);
1272 }
1273 None => {
1274 segment.push('█');
1275 segment_color = Some(color);
1276 }
1277 }
1278 }
1279
1280 if let Some(color) = segment_color {
1281 self.styled(segment, Style::new().fg(color));
1282 }
1283
1284 self.commands.push(Command::EndContainer);
1285 self.rollback.last_text_idx = None;
1286 }
1287
1288 Response::none()
1289 }
1290
1291 pub fn canvas(
1308 &mut self,
1309 width: u32,
1310 height: u32,
1311 draw: impl FnOnce(&mut CanvasContext),
1312 ) -> Response {
1313 if width == 0 || height == 0 {
1314 return Response::none();
1315 }
1316
1317 let mut canvas = CanvasContext::new(width as usize, height as usize);
1318 draw(&mut canvas);
1319
1320 for segments in canvas.render() {
1321 self.skip_interaction_slot();
1322 self.commands
1323 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1324 direction: Direction::Row,
1325 gap: 0,
1326 align: Align::Start,
1327 align_self: None,
1328 justify: Justify::Start,
1329 border: None,
1330 border_sides: BorderSides::all(),
1331 border_style: Style::new(),
1332 bg_color: None,
1333 padding: Padding::default(),
1334 margin: Margin::default(),
1335 constraints: Constraints::default(),
1336 title: None,
1337 grow: 0,
1338 group_name: None,
1339 })));
1340 for (text, color) in segments {
1341 let c = if color == Color::Reset {
1342 self.theme.primary
1343 } else {
1344 color
1345 };
1346 self.styled(text, Style::new().fg(c));
1347 }
1348 self.commands.push(Command::EndContainer);
1349 self.rollback.last_text_idx = None;
1350 }
1351
1352 Response::none()
1353 }
1354
1355 pub fn chart(
1361 &mut self,
1362 configure: impl FnOnce(&mut ChartBuilder),
1363 width: u32,
1364 height: u32,
1365 ) -> Response {
1366 if width == 0 || height == 0 {
1367 return Response::none();
1368 }
1369
1370 let axis_style = Style::new().fg(self.theme.text_dim);
1371 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1372 configure(&mut builder);
1373
1374 let config = builder.build();
1375 let rows = render_chart(&config);
1376
1377 for row in rows {
1378 self.skip_interaction_slot();
1379 self.commands
1380 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1381 direction: Direction::Row,
1382 gap: 0,
1383 align: Align::Start,
1384 align_self: None,
1385 justify: Justify::Start,
1386 border: None,
1387 border_sides: BorderSides::all(),
1388 border_style: Style::new().fg(self.theme.border),
1389 bg_color: None,
1390 padding: Padding::default(),
1391 margin: Margin::default(),
1392 constraints: Constraints::default(),
1393 title: None,
1394 grow: 0,
1395 group_name: None,
1396 })));
1397 for (text, style) in row.segments {
1398 self.styled(text, style);
1399 }
1400 self.commands.push(Command::EndContainer);
1401 self.rollback.last_text_idx = None;
1402 }
1403
1404 Response::none()
1405 }
1406
1407 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1411 self.chart(
1412 |c| {
1413 c.scatter(data);
1414 c.grid(true);
1415 },
1416 width,
1417 height,
1418 )
1419 }
1420
1421 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1423 self.histogram_with(data, |_| {}, width, height)
1424 }
1425
1426 pub fn histogram_with(
1428 &mut self,
1429 data: &[f64],
1430 configure: impl FnOnce(&mut HistogramBuilder),
1431 width: u32,
1432 height: u32,
1433 ) -> Response {
1434 if width == 0 || height == 0 {
1435 return Response::none();
1436 }
1437
1438 let mut options = HistogramBuilder::default();
1439 configure(&mut options);
1440 let axis_style = Style::new().fg(self.theme.text_dim);
1441 let config = build_histogram_config(data, &options, width, height, axis_style);
1442 let rows = render_chart(&config);
1443
1444 for row in rows {
1445 self.skip_interaction_slot();
1446 self.commands
1447 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1448 direction: Direction::Row,
1449 gap: 0,
1450 align: Align::Start,
1451 align_self: None,
1452 justify: Justify::Start,
1453 border: None,
1454 border_sides: BorderSides::all(),
1455 border_style: Style::new().fg(self.theme.border),
1456 bg_color: None,
1457 padding: Padding::default(),
1458 margin: Margin::default(),
1459 constraints: Constraints::default(),
1460 title: None,
1461 grow: 0,
1462 group_name: None,
1463 })));
1464 for (text, style) in row.segments {
1465 self.styled(text, style);
1466 }
1467 self.commands.push(Command::EndContainer);
1468 self.rollback.last_text_idx = None;
1469 }
1470
1471 Response::none()
1472 }
1473
1474 #[cfg(feature = "qrcode")]
1475 pub fn qr_code(&mut self, data: impl AsRef<str>) -> Response {
1477 let code = match qrcode::QrCode::new(data.as_ref()) {
1478 Ok(code) => code,
1479 Err(_) => {
1480 self.text("[QR Error]");
1481 return Response::none();
1482 }
1483 };
1484
1485 let modules_per_side = code.width();
1486 let modules = code.to_colors();
1487 let qr_side = modules_per_side + 2;
1488 let qr_width = qr_side;
1489 let qr_height = qr_side.div_ceil(2);
1490 let theme_text = self.theme.text;
1491 let theme_bg = self.theme.bg;
1492
1493 self.container()
1494 .w(qr_width as u32)
1495 .h(qr_height as u32)
1496 .draw(move |buf, rect| {
1497 let draw_w = (rect.width as usize).min(qr_width);
1498 let draw_h = (rect.height as usize).min(qr_height);
1499
1500 for row in 0..draw_h {
1501 let upper_y = row * 2;
1502 let lower_y = upper_y + 1;
1503
1504 for x in 0..draw_w {
1505 let resolve_module_color = |mx: usize, my: usize| -> Color {
1506 let dark =
1507 if mx == 0 || my == 0 || mx == qr_side - 1 || my == qr_side - 1 {
1508 false
1509 } else {
1510 let inner_x = mx - 1;
1511 let inner_y = my - 1;
1512 let idx = inner_y * modules_per_side + inner_x;
1513 matches!(modules.get(idx), Some(qrcode::types::Color::Dark))
1514 };
1515
1516 if dark {
1517 theme_text
1518 } else {
1519 theme_bg
1520 }
1521 };
1522
1523 let upper = resolve_module_color(x, upper_y);
1524 let lower = if lower_y < qr_side {
1525 resolve_module_color(x, lower_y)
1526 } else {
1527 theme_bg
1528 };
1529
1530 buf.set_char(
1531 rect.x + x as u32,
1532 rect.y + row as u32,
1533 '▀',
1534 Style::new().fg(upper).bg(lower),
1535 );
1536 }
1537 }
1538 });
1539
1540 Response::none()
1541 }
1542
1543 pub fn heatmap_halfblock(
1561 &mut self,
1562 data: &[Vec<f64>],
1563 width: u32,
1564 height: u32,
1565 low_color: Color,
1566 high_color: Color,
1567 ) -> Response {
1568 if data.is_empty() || width == 0 || height == 0 {
1569 return Response::none();
1570 }
1571
1572 let data_rows = data.len();
1573 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1574 if max_data_cols == 0 {
1575 return Response::none();
1576 }
1577
1578 let mut min_value = f64::INFINITY;
1579 let mut max_value = f64::NEG_INFINITY;
1580 for row in data {
1581 for value in row {
1582 if value.is_finite() {
1583 min_value = min_value.min(*value);
1584 max_value = max_value.max(*value);
1585 }
1586 }
1587 }
1588
1589 if !min_value.is_finite() || !max_value.is_finite() {
1590 return Response::none();
1591 }
1592
1593 let range = max_value - min_value;
1594 let zero_range = range.abs() < f64::EPSILON;
1595
1596 let data = data.to_vec();
1597 let cols = width as usize;
1598 let rows = height as usize;
1599 let virtual_rows = rows * 2;
1601
1602 self.container().w(width).h(height).draw(move |buf, rect| {
1603 let w = rect.width as usize;
1604 let h = rect.height as usize;
1605 if w == 0 || h == 0 {
1606 return;
1607 }
1608
1609 let sample = |data_row_idx: usize, col_idx: usize| -> f64 {
1610 let src_row = &data[data_row_idx.min(data_rows.saturating_sub(1))];
1611 let src_cols = src_row.len();
1612 if src_cols == 0 {
1613 return 0.0;
1614 }
1615 let data_col = (col_idx * src_cols / cols.max(1)).min(src_cols - 1);
1616 let v = src_row[data_col];
1617 if !v.is_finite() {
1618 0.0
1619 } else if zero_range {
1620 0.5
1621 } else {
1622 ((v - min_value) / range).clamp(0.0, 1.0)
1623 }
1624 };
1625
1626 let blend = |t: f64| -> Color {
1627 let t = t.clamp(0.0, 1.0);
1628 match (low_color, high_color) {
1629 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1630 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1631 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1632 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1633 ),
1634 _ => {
1635 if t > 0.5 {
1636 high_color
1637 } else {
1638 low_color
1639 }
1640 }
1641 }
1642 };
1643
1644 for row in 0..h {
1645 let upper_data_row =
1646 (row * 2 * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1647 let lower_data_row =
1648 ((row * 2 + 1) * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1649
1650 for col in 0..w.min(cols) {
1651 let upper_t = sample(upper_data_row, col);
1652 let lower_t = sample(lower_data_row, col);
1653 let upper_color = blend(upper_t);
1654 let lower_color = blend(lower_t);
1655
1656 buf.set_char(
1657 rect.x + col as u32,
1658 rect.y + row as u32,
1659 '▀',
1660 Style::new().fg(upper_color).bg(lower_color),
1661 );
1662 }
1663 }
1664 });
1665
1666 Response::none()
1667 }
1668
1669 pub fn candlestick_hd(
1687 &mut self,
1688 candles: &[Candle],
1689 up_color: Color,
1690 down_color: Color,
1691 ) -> Response {
1692 if candles.is_empty() {
1693 return Response::none();
1694 }
1695
1696 let candles = candles.to_vec();
1697 self.container().grow(1).draw(move |buf, rect| {
1698 let w = rect.width as usize;
1699 let h = rect.height as usize;
1700 if w < 2 || h < 2 {
1701 return;
1702 }
1703
1704 let mut lo = f64::INFINITY;
1705 let mut hi = f64::NEG_INFINITY;
1706 for c in &candles {
1707 if c.low.is_finite() {
1708 lo = lo.min(c.low);
1709 }
1710 if c.high.is_finite() {
1711 hi = hi.max(c.high);
1712 }
1713 }
1714 if !lo.is_finite() || !hi.is_finite() {
1715 return;
1716 }
1717
1718 let price_range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1719 let map_y = |v: f64| -> usize {
1720 let t = ((v - lo) / price_range).clamp(0.0, 1.0);
1721 ((1.0 - t) * h.saturating_sub(1) as f64).round() as usize
1722 };
1723
1724 let n = candles.len();
1725
1726 for (i, c) in candles.iter().enumerate() {
1727 if !c.open.is_finite()
1728 || !c.high.is_finite()
1729 || !c.low.is_finite()
1730 || !c.close.is_finite()
1731 {
1732 continue;
1733 }
1734
1735 let x0 = i * w / n;
1737 let x1 = ((i + 1) * w / n).saturating_sub(1).max(x0);
1738 if x0 >= w {
1739 continue;
1740 }
1741 let xm = x0 + (x1 - x0) / 2;
1743 let color = if c.close >= c.open {
1744 up_color
1745 } else {
1746 down_color
1747 };
1748
1749 let wick_top = map_y(c.high);
1751 let wick_bot = map_y(c.low);
1752 for row in wick_top..=wick_bot.min(h - 1) {
1753 buf.set_char(
1754 rect.x + xm as u32,
1755 rect.y + row as u32,
1756 '┃',
1757 Style::new().fg(color),
1758 );
1759 }
1760
1761 let body_top = map_y(c.open.max(c.close));
1763 let body_bot = map_y(c.open.min(c.close));
1764 for row in body_top..=body_bot.min(h - 1) {
1765 for col in x0..=x1.min(w - 1) {
1766 buf.set_char(
1767 rect.x + col as u32,
1768 rect.y + row as u32,
1769 '█',
1770 Style::new().fg(color),
1771 );
1772 }
1773 }
1774 }
1775 });
1776
1777 Response::none()
1778 }
1779
1780 pub fn treemap(&mut self, items: &[TreemapItem]) -> Response {
1800 if items.is_empty() {
1801 return Response::none();
1802 }
1803
1804 let items = items.to_vec();
1805 self.container().grow(1).draw(move |buf, rect| {
1806 let w = rect.width as usize;
1807 let h = rect.height as usize;
1808 if w < 2 || h < 2 {
1809 return;
1810 }
1811
1812 let total_area = w as f64 * h as f64;
1814 let total_value: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
1815 let min_area_threshold = 1.0; let visible_items: Vec<&TreemapItem> = if total_value > 0.0 {
1817 items
1818 .iter()
1819 .filter(|item| {
1820 item.value.max(0.0) / total_value * total_area >= min_area_threshold
1821 })
1822 .collect()
1823 } else {
1824 return;
1825 };
1826
1827 if visible_items.is_empty() {
1828 return;
1829 }
1830
1831 let filtered: Vec<TreemapItem> = visible_items.into_iter().cloned().collect();
1833 let rects = squarify_layout(&filtered, 0.0, 0.0, w as f64, h as f64);
1834
1835 for (item, r) in filtered.iter().zip(rects.iter()) {
1836 let x0 = r.x.round() as usize;
1838 let y0 = r.y.round() as usize;
1839 let x1 = (r.x + r.w).round() as usize;
1840 let y1 = (r.y + r.h).round() as usize;
1841
1842 let cell_w = x1.min(w).saturating_sub(x0);
1843 let cell_h = y1.min(h).saturating_sub(y0);
1844 if cell_w == 0 || cell_h == 0 {
1845 continue;
1846 }
1847
1848 for row in y0..y1.min(h) {
1850 for col in x0..x1.min(w) {
1851 buf.set_char(
1852 rect.x + col as u32,
1853 rect.y + row as u32,
1854 ' ',
1855 Style::new().bg(item.color),
1856 );
1857 }
1858 }
1859
1860 let text_color = treemap_label_color(item.color);
1861
1862 if cell_w >= 2 {
1864 let max_label_w = cell_w.saturating_sub(1);
1865 let mut used_w = 0usize;
1866 let mut last_byte = 0usize;
1867 for (idx, ch) in item.label.char_indices() {
1868 let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
1869 if used_w + cw > max_label_w {
1870 break;
1871 }
1872 used_w += cw;
1873 last_byte = idx + ch.len_utf8();
1874 }
1875 let label = &item.label[..last_byte];
1876 let label_unicode_w = UnicodeWidthStr::width(label);
1877 let label_y = y0 + cell_h / 2;
1878 let label_x = x0 + (cell_w.saturating_sub(label_unicode_w)) / 2;
1879 if label_y < y1.min(h) {
1880 for (offset, ch) in label.chars().enumerate() {
1881 let cx = label_x + offset;
1882 if cx < x1.min(w) {
1883 buf.set_char(
1884 rect.x + cx as u32,
1885 rect.y + label_y as u32,
1886 ch,
1887 Style::new().fg(text_color).bg(item.color).bold(),
1888 );
1889 }
1890 }
1891 }
1892
1893 if cell_h >= 3 {
1895 let value_str = format_compact_number(item.value);
1896 let value_y = label_y + 1;
1897 if value_y < y1.min(h) && value_str.len() < cell_w {
1898 let vx = x0 + (cell_w.saturating_sub(value_str.len())) / 2;
1899 for (offset, ch) in value_str.chars().enumerate() {
1900 let cx = vx + offset;
1901 if cx < x1.min(w) {
1902 buf.set_char(
1903 rect.x + cx as u32,
1904 rect.y + value_y as u32,
1905 ch,
1906 Style::new().fg(text_color).bg(item.color).dim(),
1907 );
1908 }
1909 }
1910 }
1911 }
1912 }
1913 }
1914 });
1915
1916 Response::none()
1917 }
1918
1919 pub fn bar_chart_stacked(&mut self, groups: &[BarGroup], max_height: u32) -> Response {
1943 self.bar_chart_stacked_with(groups, |_| {}, max_height)
1944 }
1945
1946 pub fn bar_chart_stacked_with(
1950 &mut self,
1951 groups: &[BarGroup],
1952 configure: impl FnOnce(&mut BarChartConfig),
1953 max_height: u32,
1954 ) -> Response {
1955 if groups.is_empty() {
1956 return Response::none();
1957 }
1958
1959 let all_bars: Vec<&Bar> = groups.iter().flat_map(|g| g.bars.iter()).collect();
1960 if all_bars.is_empty() {
1961 return Response::none();
1962 }
1963
1964 let mut config = BarChartConfig::default();
1965 config.bar_width(3).bar_gap(1);
1966 configure(&mut config);
1967
1968 let max_total: f64 = groups
1970 .iter()
1971 .map(|g| g.bars.iter().map(|b| b.value.max(0.0)).sum::<f64>())
1972 .fold(f64::NEG_INFINITY, f64::max);
1973 let denom = config.max_value.unwrap_or(max_total);
1974 let denom = if denom > 0.0 { denom } else { 1.0 };
1975
1976 let chart_height = max_height.max(1) as usize;
1977 let bar_width = config.bar_width.max(1) as usize;
1978 let gap = config.bar_gap as u32;
1979
1980 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
1981
1982 self.skip_interaction_slot();
1983 self.commands
1984 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1985 direction: Direction::Column,
1986 gap: 0,
1987 align: Align::Start,
1988 align_self: None,
1989 justify: Justify::Start,
1990 border: None,
1991 border_sides: BorderSides::all(),
1992 border_style: Style::new().fg(self.theme.border),
1993 bg_color: None,
1994 padding: Padding::default(),
1995 margin: Margin::default(),
1996 constraints: Constraints::default(),
1997 title: None,
1998 grow: 0,
1999 group_name: None,
2000 })));
2001
2002 struct StackedSegment {
2004 units: usize,
2005 color: Color,
2006 }
2007 let stacked_groups: Vec<(String, Vec<StackedSegment>)> = groups
2008 .iter()
2009 .map(|g| {
2010 let segs: Vec<StackedSegment> = g
2011 .bars
2012 .iter()
2013 .map(|b| {
2014 let normalized = (b.value.max(0.0) / denom).clamp(0.0, 1.0);
2015 StackedSegment {
2016 units: (normalized * chart_height as f64 * 8.0).round() as usize,
2017 color: b.color.unwrap_or(self.theme.primary),
2018 }
2019 })
2020 .collect();
2021 (g.label.clone(), segs)
2022 })
2023 .collect();
2024
2025 for row in (0..chart_height).rev() {
2027 self.skip_interaction_slot();
2028 self.commands
2029 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2030 direction: Direction::Row,
2031 gap,
2032 align: Align::Start,
2033 align_self: None,
2034 justify: Justify::Start,
2035 border: None,
2036 border_sides: BorderSides::all(),
2037 border_style: Style::new().fg(self.theme.border),
2038 bg_color: None,
2039 padding: Padding::default(),
2040 margin: Margin::default(),
2041 constraints: Constraints::default(),
2042 title: None,
2043 grow: 0,
2044 group_name: None,
2045 })));
2046
2047 let row_base = row * 8;
2048
2049 for (_label, segs) in &stacked_groups {
2050 let mut accumulated = 0usize;
2052 let mut cell_char = ' ';
2053 let mut cell_color = self.theme.bg;
2054
2055 for seg in segs {
2056 let seg_bottom = accumulated;
2057 let seg_top = accumulated + seg.units;
2058
2059 if seg_top <= row_base {
2060 accumulated = seg_top;
2062 continue;
2063 }
2064
2065 if seg_bottom >= row_base + 8 {
2066 break;
2068 }
2069
2070 let local_bottom = seg_bottom.saturating_sub(row_base);
2072 let local_top = (seg_top - row_base).min(8);
2073 let fill = local_top - local_bottom;
2074
2075 if local_bottom == 0 {
2076 cell_char = if fill >= 8 {
2078 '█'
2079 } else {
2080 FRACTION_BLOCKS[fill]
2081 };
2082 cell_color = seg.color;
2083 } else {
2084 cell_char = '█';
2086 cell_color = seg.color;
2087 }
2088
2089 accumulated = seg_top;
2090 }
2091
2092 let fill_text = cell_char.to_string().repeat(bar_width);
2093 self.styled(fill_text, Style::new().fg(cell_color));
2094 }
2095
2096 self.commands.push(Command::EndContainer);
2097 self.rollback.last_text_idx = None;
2098 }
2099
2100 self.skip_interaction_slot();
2102 self.commands
2103 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2104 direction: Direction::Row,
2105 gap,
2106 align: Align::Start,
2107 align_self: None,
2108 justify: Justify::Start,
2109 border: None,
2110 border_sides: BorderSides::all(),
2111 border_style: Style::new().fg(self.theme.border),
2112 bg_color: None,
2113 padding: Padding::default(),
2114 margin: Margin::default(),
2115 constraints: Constraints::default(),
2116 title: None,
2117 grow: 0,
2118 group_name: None,
2119 })));
2120 for (label, _) in &stacked_groups {
2121 self.styled(
2122 Self::center_and_truncate_text(label, bar_width),
2123 Style::new().fg(self.theme.text),
2124 );
2125 }
2126 self.commands.push(Command::EndContainer);
2127 self.rollback.last_text_idx = None;
2128
2129 self.commands.push(Command::EndContainer);
2130 self.rollback.last_text_idx = None;
2131
2132 Response::none()
2133 }
2134}
2135
2136#[derive(Debug, Clone)]
2138pub struct TreemapItem {
2139 pub label: String,
2141 pub value: f64,
2143 pub color: Color,
2145}
2146
2147impl TreemapItem {
2148 pub fn new(label: impl Into<String>, value: f64, color: Color) -> Self {
2150 Self {
2151 label: label.into(),
2152 value,
2153 color,
2154 }
2155 }
2156}
2157
2158#[derive(Clone)]
2160struct LayoutRect {
2161 x: f64,
2162 y: f64,
2163 w: f64,
2164 h: f64,
2165}
2166
2167fn squarify_layout(items: &[TreemapItem], x: f64, y: f64, w: f64, h: f64) -> Vec<LayoutRect> {
2169 if items.is_empty() || w <= 0.0 || h <= 0.0 {
2170 return Vec::new();
2171 }
2172
2173 let total: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
2174 if total <= 0.0 {
2175 return items
2176 .iter()
2177 .map(|_| LayoutRect {
2178 x,
2179 y,
2180 w: 0.0,
2181 h: 0.0,
2182 })
2183 .collect();
2184 }
2185
2186 let area = w * h;
2188 let mut sorted_indices: Vec<usize> = (0..items.len()).collect();
2189 sorted_indices.sort_by(|a, b| {
2190 items[*b]
2191 .value
2192 .partial_cmp(&items[*a].value)
2193 .unwrap_or(std::cmp::Ordering::Equal)
2194 });
2195
2196 let areas: Vec<f64> = sorted_indices
2197 .iter()
2198 .map(|&i| items[i].value.max(0.0) / total * area)
2199 .collect();
2200
2201 let mut result = vec![
2202 LayoutRect {
2203 x: 0.0,
2204 y: 0.0,
2205 w: 0.0,
2206 h: 0.0,
2207 };
2208 items.len()
2209 ];
2210 squarify_recursive(&areas, &sorted_indices, x, y, w, h, &mut result);
2211 result
2212}
2213
2214fn worst_ratio(row: &[f64], side: f64) -> f64 {
2215 if row.is_empty() || side <= 0.0 {
2216 return f64::INFINITY;
2217 }
2218 let sum: f64 = row.iter().sum();
2219 let mut worst = 0.0f64;
2220 for &a in row {
2221 if a <= 0.0 {
2222 continue;
2223 }
2224 let ratio1 = (side * side * a) / (sum * sum);
2225 let ratio2 = (sum * sum) / (side * side * a);
2226 worst = worst.max(ratio1.max(ratio2));
2227 }
2228 worst
2229}
2230
2231fn squarify_recursive(
2232 areas: &[f64],
2233 indices: &[usize],
2234 x: f64,
2235 y: f64,
2236 w: f64,
2237 h: f64,
2238 result: &mut [LayoutRect],
2239) {
2240 if areas.is_empty() || w <= 0.0 || h <= 0.0 {
2241 return;
2242 }
2243
2244 if areas.len() == 1 {
2245 result[indices[0]] = LayoutRect { x, y, w, h };
2246 return;
2247 }
2248
2249 let short_side = w.min(h);
2250 let mut row: Vec<f64> = Vec::new();
2251 let mut row_indices: Vec<usize> = Vec::new();
2252
2253 for (i, &area) in areas.iter().enumerate() {
2254 let mut candidate = row.clone();
2255 candidate.push(area);
2256 if row.is_empty() || worst_ratio(&candidate, short_side) <= worst_ratio(&row, short_side) {
2257 row.push(area);
2258 row_indices.push(indices[i]);
2259 } else {
2260 let row_sum: f64 = row.iter().sum();
2262 let row_fraction = row_sum / (w * h).max(f64::EPSILON);
2263
2264 if w >= h {
2265 let row_w = w * row_fraction;
2267 let mut cy = y;
2268 for (j, &a) in row.iter().enumerate() {
2269 let cell_h = if row_sum > 0.0 {
2270 h * (a / row_sum)
2271 } else {
2272 0.0
2273 };
2274 result[row_indices[j]] = LayoutRect {
2275 x,
2276 y: cy,
2277 w: row_w,
2278 h: cell_h,
2279 };
2280 cy += cell_h;
2281 }
2282 squarify_recursive(
2283 &areas[i..],
2284 &indices[i..],
2285 x + row_w,
2286 y,
2287 w - row_w,
2288 h,
2289 result,
2290 );
2291 } else {
2292 let row_h = h * row_fraction;
2294 let mut cx = x;
2295 for (j, &a) in row.iter().enumerate() {
2296 let cell_w = if row_sum > 0.0 {
2297 w * (a / row_sum)
2298 } else {
2299 0.0
2300 };
2301 result[row_indices[j]] = LayoutRect {
2302 x: cx,
2303 y,
2304 w: cell_w,
2305 h: row_h,
2306 };
2307 cx += cell_w;
2308 }
2309 squarify_recursive(
2310 &areas[i..],
2311 &indices[i..],
2312 x,
2313 y + row_h,
2314 w,
2315 h - row_h,
2316 result,
2317 );
2318 }
2319 return;
2320 }
2321 }
2322
2323 if !row.is_empty() {
2325 let row_sum: f64 = row.iter().sum();
2326 if w >= h {
2327 let mut cy = y;
2328 for (j, &a) in row.iter().enumerate() {
2329 let cell_h = if row_sum > 0.0 {
2330 h * (a / row_sum)
2331 } else {
2332 0.0
2333 };
2334 result[row_indices[j]] = LayoutRect {
2335 x,
2336 y: cy,
2337 w,
2338 h: cell_h,
2339 };
2340 cy += cell_h;
2341 }
2342 } else {
2343 let mut cx = x;
2344 for (j, &a) in row.iter().enumerate() {
2345 let cell_w = if row_sum > 0.0 {
2346 w * (a / row_sum)
2347 } else {
2348 0.0
2349 };
2350 result[row_indices[j]] = LayoutRect {
2351 x: cx,
2352 y,
2353 w: cell_w,
2354 h,
2355 };
2356 cx += cell_w;
2357 }
2358 }
2359 }
2360}
2361
2362fn treemap_label_color(bg: Color) -> Color {
2364 match bg {
2365 Color::Rgb(r, g, b) => {
2366 let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
2368 if lum > 128.0 {
2369 Color::Rgb(0, 0, 0)
2370 } else {
2371 Color::Rgb(255, 255, 255)
2372 }
2373 }
2374 _ => Color::White,
2375 }
2376}
2377
2378#[cfg(all(test, feature = "qrcode"))]
2379#[test]
2380fn test_qr_code() {
2381 let mut backend = crate::TestBackend::new(60, 30);
2382 backend.render(|ui| {
2383 let _ = ui.qr_code("hello");
2384 });
2385
2386 let output = backend.to_string();
2387 assert!(output.contains('▀') || output.contains('█'));
2388}
2389
2390#[test]
2391fn treemap_cjk_label_no_panic() {
2392 use super::TreemapItem;
2393 use crate::style::Color;
2394 let mut backend = crate::TestBackend::new(20, 10);
2395 backend.render(|ui| {
2396 let _ = ui.treemap(&[
2397 TreemapItem::new("한글파일", 100.0, Color::Cyan),
2398 TreemapItem::new("English", 50.0, Color::Yellow),
2399 TreemapItem::new("🎉파티", 30.0, Color::Green),
2400 ]);
2401 });
2402 }