1#![warn(missing_docs)]
87#![warn(clippy::pedantic)]
88#![allow(clippy::module_name_repetitions)]
89
90use std::f64::consts::PI;
91
92use ratatui::buffer::Buffer;
93use ratatui::layout::Rect;
94use ratatui::style::{Color, Style, Styled};
95use ratatui::text::{Line, Span};
96use ratatui::widgets::{Block, Widget};
97
98pub mod border_style;
99pub mod legend;
100#[macro_use]
101pub mod macros;
102pub mod symbols;
103pub mod title;
104
105pub use legend::{LegendAlignment, LegendLayout, LegendPosition};
107pub use title::{BlockExt, TitleAlignment, TitlePosition, TitleStyle};
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum Resolution {
132 #[default]
136 Standard,
137
138 Braille,
143}
144
145#[derive(Debug, Clone, PartialEq)]
158pub struct PieSlice<'a> {
159 label: &'a str,
161 value: f64,
163 color: Color,
165}
166
167impl<'a> PieSlice<'a> {
168 #[must_use]
179 pub const fn new(label: &'a str, value: f64, color: Color) -> Self {
180 Self {
181 label,
182 value,
183 color,
184 }
185 }
186
187 #[must_use]
189 pub const fn label(&self) -> &'a str {
190 self.label
191 }
192
193 #[must_use]
195 pub const fn value(&self) -> f64 {
196 self.value
197 }
198
199 #[must_use]
201 pub const fn color(&self) -> Color {
202 self.color
203 }
204}
205
206#[derive(Debug, Clone, PartialEq)]
225pub struct PieChart<'a> {
226 slices: Vec<PieSlice<'a>>,
228 block: Option<Block<'a>>,
230 style: Style,
232 show_legend: bool,
234 show_percentages: bool,
236 pie_char: char,
238 legend_marker: &'a str,
240 resolution: Resolution,
242 legend_position: LegendPosition,
244 legend_layout: LegendLayout,
246 legend_alignment: LegendAlignment,
248}
249
250impl Default for PieChart<'_> {
251 fn default() -> Self {
262 Self {
263 slices: Vec::new(),
264 block: None,
265 style: Style::default(),
266 show_legend: true,
267 show_percentages: true,
268 pie_char: symbols::PIE_CHAR,
269 legend_marker: symbols::LEGEND_MARKER,
270 resolution: Resolution::default(),
271 legend_position: LegendPosition::default(),
272 legend_layout: LegendLayout::default(),
273 legend_alignment: LegendAlignment::default(),
274 }
275 }
276}
277
278impl<'a> PieChart<'a> {
279 #[must_use]
294 pub fn new(slices: Vec<PieSlice<'a>>) -> Self {
295 Self {
296 slices,
297 ..Default::default()
298 }
299 }
300
301 #[must_use]
315 pub fn slices(mut self, slices: Vec<PieSlice<'a>>) -> Self {
316 self.slices = slices;
317 self
318 }
319
320 #[must_use]
334 pub fn block(mut self, block: Block<'a>) -> Self {
335 self.block = Some(block);
336 self
337 }
338
339 #[must_use]
351 pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
352 self.style = style.into();
353 self
354 }
355
356 #[must_use]
366 pub const fn show_legend(mut self, show: bool) -> Self {
367 self.show_legend = show;
368 self
369 }
370
371 #[must_use]
381 pub const fn show_percentages(mut self, show: bool) -> Self {
382 self.show_percentages = show;
383 self
384 }
385
386 #[must_use]
409 pub const fn pie_char(mut self, c: char) -> Self {
410 self.pie_char = c;
411 self
412 }
413
414 #[must_use]
444 pub const fn legend_marker(mut self, marker: &'a str) -> Self {
445 self.legend_marker = marker;
446 self
447 }
448
449 #[must_use]
464 pub const fn resolution(mut self, resolution: Resolution) -> Self {
465 self.resolution = resolution;
466 self
467 }
468
469 #[must_use]
482 pub const fn high_resolution(mut self, enabled: bool) -> Self {
483 self.resolution = if enabled {
484 Resolution::Braille
485 } else {
486 Resolution::Standard
487 };
488 self
489 }
490
491 #[must_use]
502 pub const fn legend_position(mut self, position: LegendPosition) -> Self {
503 self.legend_position = position;
504 self
505 }
506
507 #[must_use]
523 pub const fn legend_layout(mut self, layout: LegendLayout) -> Self {
524 self.legend_layout = layout;
525 self
526 }
527
528 #[must_use]
544 pub const fn legend_alignment(mut self, alignment: LegendAlignment) -> Self {
545 self.legend_alignment = alignment;
546 self
547 }
548
549 fn total_value(&self) -> f64 {
550 self.slices.iter().map(|s| s.value).sum()
551 }
552
553 fn percentage(&self, slice: &PieSlice) -> f64 {
555 let total = self.total_value();
556 if total > 0.0 {
557 (slice.value / total) * 100.0
558 } else {
559 0.0
560 }
561 }
562}
563
564impl Styled for PieChart<'_> {
565 type Item = Self;
566
567 fn style(&self) -> Style {
568 self.style
569 }
570
571 fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
572 self.style = style.into();
573 self
574 }
575}
576
577impl Widget for PieChart<'_> {
578 fn render(self, area: Rect, buf: &mut Buffer) {
579 Widget::render(&self, area, buf);
580 }
581}
582
583impl Widget for &PieChart<'_> {
584 fn render(self, area: Rect, buf: &mut Buffer) {
585 buf.set_style(area, self.style);
586 let inner = if let Some(ref block) = self.block {
587 let inner_area = block.inner(area);
588 block.render(area, buf);
589 inner_area
590 } else {
591 area
592 };
593 self.render_piechart(inner, buf);
594 }
595}
596
597impl PieChart<'_> {
598 const LEGEND_VERTICAL_MAX_RATIO: u16 = 3;
600
601 const LEGEND_VERTICAL_MIN_WIDTH: u16 = 20;
603
604 const LEGEND_HORIZONTAL_MAX_RATIO: u16 = 5;
607
608 const LEGEND_HORIZONTAL_MAX_WIDTH: u16 = 60;
610
611 const LEGEND_VERTICAL_MAX_HEIGHT: u16 = 9;
614
615 const LEGEND_HORIZONTAL_HEIGHT: u16 = 3;
617
618 const LEGEND_SPACING: u16 = 1;
620
621 const LEGEND_PADDING: u16 = 1;
623
624 fn render_piechart(&self, area: Rect, buf: &mut Buffer) {
625 if area.is_empty() || self.slices.is_empty() {
626 return;
627 }
628
629 let total = self.total_value();
630 if total <= 0.0 {
631 return;
632 }
633
634 match self.resolution {
635 Resolution::Standard => {
636 }
638 Resolution::Braille => {
639 self.render_piechart_braille(area, buf);
640 return;
641 }
642 }
643
644 let (pie_area, legend_area_opt) = self.calculate_layout(area);
646
647 let center_x = pie_area.width / 2;
650 let center_y = pie_area.height / 2;
651
652 let radius = center_x.min(center_y * 2).saturating_sub(1);
654
655 let mut cumulative_percent = 0.0;
657 for slice in &self.slices {
658 let percent = self.percentage(slice);
659 self.render_slice(
660 pie_area,
661 buf,
662 center_x,
663 center_y,
664 radius,
665 cumulative_percent,
666 percent,
667 slice.color,
668 );
669 cumulative_percent += percent;
670 }
671
672 if let Some(legend_area) = legend_area_opt {
674 self.render_legend(buf, legend_area);
675 }
676 }
677
678 #[allow(clippy::too_many_arguments, clippy::similar_names)]
679 fn render_slice(
680 &self,
681 area: Rect,
682 buf: &mut Buffer,
683 center_x: u16,
684 center_y: u16,
685 radius: u16,
686 start_percent: f64,
687 percent: f64,
688 color: Color,
689 ) {
690 if radius == 0 || percent <= 0.0 {
691 return;
692 }
693
694 let start_angle = (start_percent / 100.0) * 2.0 * PI - PI / 2.0;
696 let end_angle = ((start_percent + percent) / 100.0) * 2.0 * PI - PI / 2.0;
697
698 let scan_width = i32::from(radius + 1);
700 let scan_height = i32::from((radius / 2) + 1); for dy in -scan_height..=scan_height {
703 for dx in -scan_width..=scan_width {
704 let x = i32::from(area.x) + i32::from(center_x) + dx;
706 let y = i32::from(area.y) + i32::from(center_y) + dy;
707
708 if x < i32::from(area.x)
710 || x >= i32::from(area.x + area.width)
711 || y < i32::from(area.y)
712 || y >= i32::from(area.y + area.height)
713 {
714 continue;
715 }
716
717 #[allow(clippy::cast_precision_loss)]
719 let adjusted_dx = f64::from(dx);
720 #[allow(clippy::cast_precision_loss)]
721 let adjusted_dy = f64::from(dy * 2);
722
723 let distance = (adjusted_dx * adjusted_dx + adjusted_dy * adjusted_dy).sqrt();
725
726 #[allow(clippy::cast_precision_loss)]
728 if distance <= f64::from(radius) {
729 let angle = adjusted_dy.atan2(adjusted_dx);
731
732 if Self::is_angle_in_slice(angle, start_angle, end_angle) {
734 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
735 {
736 let cell = &mut buf[(x as u16, y as u16)];
737 cell.set_char(self.pie_char).set_fg(color);
738 }
739 }
740 }
741 }
742 }
743 }
744
745 fn is_angle_in_slice(angle: f64, start: f64, end: f64) -> bool {
746 let normalize = |a: f64| {
748 let mut normalized = a % (2.0 * PI);
749 if normalized < 0.0 {
750 normalized += 2.0 * PI;
751 }
752 normalized
753 };
754
755 let norm_angle = normalize(angle);
756 let norm_start = normalize(start);
757 let norm_end = normalize(end);
758
759 if norm_start <= norm_end {
760 norm_angle >= norm_start && norm_angle <= norm_end
761 } else {
762 norm_angle >= norm_start || norm_angle <= norm_end
764 }
765 }
766
767 fn format_legend_text(&self, slice: &PieSlice, total: f64, spacing: &str) -> String {
768 if self.show_percentages {
769 let percent = if total > 0.0 {
770 (slice.value / total) * 100.0
771 } else {
772 0.0
773 };
774 format!(
775 "{} {} {:.1}%{}",
776 self.legend_marker, slice.label, percent, spacing
777 )
778 } else {
779 format!("{} {}{}", self.legend_marker, slice.label, spacing)
780 }
781 }
782
783 fn calculate_aligned_x(&self, legend_area: Rect, content_width: u16) -> u16 {
784 match self.legend_alignment {
785 LegendAlignment::Left => legend_area.x,
786 LegendAlignment::Center => {
787 legend_area.x + (legend_area.width.saturating_sub(content_width)) / 2
788 }
789 LegendAlignment::Right => {
790 legend_area.x + legend_area.width.saturating_sub(content_width)
791 }
792 }
793 }
794
795 fn render_legend(&self, buf: &mut Buffer, legend_area: Rect) {
796 let total = self.total_value();
797
798 match self.legend_layout {
799 LegendLayout::Vertical => {
800 self.render_vertical_legend(buf, legend_area, total);
801 }
802 LegendLayout::Horizontal => {
803 self.render_horizontal_legend(buf, legend_area, total);
804 }
805 }
806 }
807
808 fn render_vertical_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
809 for (idx, slice) in self.slices.iter().enumerate() {
810 #[allow(clippy::cast_possible_truncation)]
811 let y_offset = (idx as u16) * 2;
812
813 if y_offset >= legend_area.height {
814 break;
815 }
816
817 let legend_text = self.format_legend_text(slice, total, "");
818 #[allow(clippy::cast_possible_truncation)]
819 let text_width = legend_text.len() as u16;
820 let x_pos = self.calculate_aligned_x(legend_area, text_width);
821
822 let line = Line::from(vec![Span::styled(
823 legend_text,
824 Style::default().fg(slice.color),
825 )]);
826 let item_area = Rect {
827 x: x_pos,
828 y: legend_area.y + y_offset,
829 width: text_width.min(legend_area.width),
830 height: 1,
831 };
832
833 line.render(item_area, buf);
834 }
835 }
836
837 fn render_horizontal_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
838 let mut total_width = 0u16;
839 let mut item_widths = Vec::new();
840
841 for slice in &self.slices {
842 let legend_text = self.format_legend_text(slice, total, " ");
843 #[allow(clippy::cast_possible_truncation)]
844 let text_width = legend_text.len() as u16;
845 item_widths.push(text_width);
846 total_width = total_width.saturating_add(text_width);
847 }
848
849 let start_x = self.calculate_aligned_x(legend_area, total_width.min(legend_area.width));
850 let mut x_offset = 0u16;
851
852 for (idx, slice) in self.slices.iter().enumerate() {
853 if x_offset >= legend_area.width {
854 break;
855 }
856
857 let legend_text = self.format_legend_text(slice, total, " ");
858 let text_width = item_widths[idx];
859
860 let line = Line::from(vec![Span::styled(
861 legend_text,
862 Style::default().fg(slice.color),
863 )]);
864 let item_area = Rect {
865 x: start_x + x_offset,
866 y: legend_area.y,
867 width: text_width.min(legend_area.width.saturating_sub(x_offset)),
868 height: 1,
869 };
870
871 line.render(item_area, buf);
872 x_offset = x_offset.saturating_add(text_width);
873 }
874 }
875
876 #[allow(clippy::too_many_lines)]
877 fn calculate_layout(&self, area: Rect) -> (Rect, Option<Rect>) {
878 if !self.show_legend || area.width < 20 || area.height < 10 {
879 return (area, None);
880 }
881
882 match (self.legend_position, self.legend_layout) {
884 (LegendPosition::Left | LegendPosition::Right, LegendLayout::Vertical) => {
886 let legend_width = self
887 .calculate_legend_width()
888 .min(area.width / Self::LEGEND_VERTICAL_MAX_RATIO)
889 .max(Self::LEGEND_VERTICAL_MIN_WIDTH);
890 let is_left = matches!(self.legend_position, LegendPosition::Left);
891 Self::layout_horizontal_split(area, legend_width, is_left)
892 }
893 (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Horizontal) => {
895 let is_top = matches!(self.legend_position, LegendPosition::Top);
896 Self::layout_vertical_split(area, Self::LEGEND_HORIZONTAL_HEIGHT, is_top)
897 }
898 (LegendPosition::Left | LegendPosition::Right, LegendLayout::Horizontal) => {
900 let legend_width = self
902 .calculate_legend_horizontal_width()
903 .min(
904 (area.width * (Self::LEGEND_HORIZONTAL_MAX_RATIO - 1))
905 / Self::LEGEND_HORIZONTAL_MAX_RATIO,
906 )
907 .min(Self::LEGEND_HORIZONTAL_MAX_WIDTH);
908 let is_left = matches!(self.legend_position, LegendPosition::Left);
909 Self::layout_horizontal_split(area, legend_width, is_left)
910 }
911 (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Vertical) => {
912 let legend_height = self.calculate_vertical_grid_height(area.width);
914 let is_top = matches!(self.legend_position, LegendPosition::Top);
915 Self::layout_vertical_split(area, legend_height, is_top)
916 }
917 }
918 }
919
920 fn calculate_vertical_grid_height(&self, available_width: u16) -> u16 {
921 let max_item_width = self.calculate_legend_width();
923 let columns = (available_width.saturating_sub(Self::LEGEND_PADDING * 2)
924 / max_item_width.max(1))
925 .clamp(1, 2);
926
927 #[allow(clippy::cast_possible_truncation)]
928 let num_items = self.slices.len() as u16;
929
930 let rows = num_items.div_ceil(columns);
932 (rows * 2 + Self::LEGEND_PADDING).clamp(4, Self::LEGEND_VERTICAL_MAX_HEIGHT)
934 }
935
936 fn layout_horizontal_split(
937 area: Rect,
938 legend_width: u16,
939 legend_on_left: bool,
940 ) -> (Rect, Option<Rect>) {
941 if area.width <= legend_width {
942 return (area, None);
943 }
944
945 let pie_width = area
946 .width
947 .saturating_sub(legend_width + Self::LEGEND_SPACING);
948
949 if legend_on_left {
950 (
951 Rect {
952 x: area.x + legend_width + Self::LEGEND_SPACING,
953 y: area.y,
954 width: pie_width,
955 height: area.height,
956 },
957 Some(Rect {
958 x: area.x,
959 y: area.y + Self::LEGEND_PADDING,
960 width: legend_width,
961 height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
962 }),
963 )
964 } else {
965 (
966 Rect {
967 x: area.x,
968 y: area.y,
969 width: pie_width,
970 height: area.height,
971 },
972 Some(Rect {
973 x: area.x + pie_width + Self::LEGEND_SPACING,
974 y: area.y + Self::LEGEND_PADDING,
975 width: legend_width,
976 height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
977 }),
978 )
979 }
980 }
981
982 fn layout_vertical_split(
983 area: Rect,
984 legend_height: u16,
985 legend_on_top: bool,
986 ) -> (Rect, Option<Rect>) {
987 if area.height <= legend_height {
988 return (area, None);
989 }
990
991 let pie_height = area
992 .height
993 .saturating_sub(legend_height + Self::LEGEND_SPACING);
994
995 if legend_on_top {
996 (
997 Rect {
998 x: area.x,
999 y: area.y + legend_height + Self::LEGEND_SPACING,
1000 width: area.width,
1001 height: pie_height,
1002 },
1003 Some(Rect {
1004 x: area.x + Self::LEGEND_PADDING,
1005 y: area.y + Self::LEGEND_PADDING,
1006 width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1007 height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1008 }),
1009 )
1010 } else {
1011 (
1012 Rect {
1013 x: area.x,
1014 y: area.y,
1015 width: area.width,
1016 height: pie_height,
1017 },
1018 Some(Rect {
1019 x: area.x + Self::LEGEND_PADDING,
1020 y: area.y + pie_height + Self::LEGEND_SPACING,
1021 width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1022 height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1023 }),
1024 )
1025 }
1026 }
1027
1028 fn calculate_legend_width(&self) -> u16 {
1029 let total = self.total_value();
1030
1031 match self.legend_layout {
1032 LegendLayout::Vertical => {
1033 let mut max_width = 0u16;
1035
1036 for slice in &self.slices {
1037 let text = if self.show_percentages {
1038 let percent = if total > 0.0 {
1039 (slice.value / total) * 100.0
1040 } else {
1041 0.0
1042 };
1043 format!("{} {} {:.1}% ", self.legend_marker, slice.label, percent)
1044 } else {
1045 format!("{} {} ", self.legend_marker, slice.label)
1046 };
1047
1048 #[allow(clippy::cast_possible_truncation)]
1049 let text_width = text.len() as u16;
1050 max_width = max_width.max(text_width);
1051 }
1052
1053 max_width.saturating_add(2)
1054 }
1055 LegendLayout::Horizontal => {
1056 let mut total_width = 0u16;
1058
1059 for slice in &self.slices {
1060 let text = if self.show_percentages {
1061 let percent = if total > 0.0 {
1062 (slice.value / total) * 100.0
1063 } else {
1064 0.0
1065 };
1066 format!("{} {} {:.1}% ", self.legend_marker, slice.label, percent)
1067 } else {
1068 format!("{} {} ", self.legend_marker, slice.label)
1069 };
1070
1071 #[allow(clippy::cast_possible_truncation)]
1072 let text_width = text.len() as u16;
1073 total_width = total_width.saturating_add(text_width);
1074 }
1075
1076 total_width.saturating_add(2)
1077 }
1078 }
1079 }
1080
1081 fn calculate_legend_horizontal_width(&self) -> u16 {
1082 let total = self.total_value();
1083 let mut total_width = 0u16;
1084
1085 for slice in &self.slices {
1086 let text = if self.show_percentages {
1087 let percent = if total > 0.0 {
1088 (slice.value / total) * 100.0
1089 } else {
1090 0.0
1091 };
1092 format!("{} {} {:.1}% ", self.legend_marker, slice.label, percent)
1093 } else {
1094 format!("{} {} ", self.legend_marker, slice.label)
1095 };
1096
1097 #[allow(clippy::cast_possible_truncation)]
1098 let text_width = text.len() as u16;
1099 total_width = total_width.saturating_add(text_width);
1100 }
1101
1102 total_width.saturating_add(2)
1103 }
1104
1105 #[allow(clippy::similar_names)]
1106 fn render_piechart_braille(&self, area: Rect, buf: &mut Buffer) {
1107 let (pie_area, legend_area_opt) = self.calculate_layout(area);
1109
1110 let center_x_chars = pie_area.width / 2;
1112 let center_y_chars = pie_area.height / 2;
1113
1114 let center_x_dots = center_x_chars * 2;
1116 let center_y_dots = center_y_chars * 4;
1117
1118 let radius = (center_x_dots).min(center_y_dots).saturating_sub(2);
1124
1125 let width_dots = pie_area.width * 2;
1127 let height_dots = pie_area.height * 4;
1128
1129 let mut dot_slices: Vec<Vec<Option<usize>>> =
1130 vec![vec![None; width_dots as usize]; height_dots as usize];
1131
1132 let mut cumulative_percent = 0.0;
1134 for (slice_idx, slice) in self.slices.iter().enumerate() {
1135 let percent = self.percentage(slice);
1136 let start_angle = (cumulative_percent / 100.0) * 2.0 * PI - PI / 2.0;
1137 let end_angle = ((cumulative_percent + percent) / 100.0) * 2.0 * PI - PI / 2.0;
1138
1139 for dy in 0..height_dots {
1140 for dx in 0..width_dots {
1141 let rel_x = f64::from(dx) - f64::from(center_x_dots);
1142 let rel_y = f64::from(dy) - f64::from(center_y_dots);
1143
1144 let distance = (rel_x * rel_x + rel_y * rel_y).sqrt();
1147
1148 if distance <= f64::from(radius) {
1149 let angle = rel_y.atan2(rel_x);
1150 if Self::is_angle_in_slice(angle, start_angle, end_angle) {
1151 dot_slices[dy as usize][dx as usize] = Some(slice_idx);
1152 }
1153 }
1154 }
1155 }
1156
1157 cumulative_percent += percent;
1158 }
1159
1160 for char_y in 0..pie_area.height {
1162 for char_x in 0..pie_area.width {
1163 let base_dot_x = char_x * 2;
1164 let base_dot_y = char_y * 4;
1165
1166 let dot_positions = [
1173 (0, 0, 0x01), (0, 1, 0x02), (0, 2, 0x04), (1, 0, 0x08), (1, 1, 0x10), (1, 2, 0x20), (0, 3, 0x40), (1, 3, 0x80), ];
1182
1183 let mut pattern = 0u32;
1184 let mut slice_colors: Vec<(usize, u32)> = Vec::new();
1185
1186 for (dx, dy, bit) in dot_positions {
1187 let dot_x = base_dot_x + dx;
1188 let dot_y = base_dot_y + dy;
1189
1190 if dot_y < height_dots && dot_x < width_dots {
1191 if let Some(slice_idx) = dot_slices[dot_y as usize][dot_x as usize] {
1192 pattern |= bit;
1193 if let Some(entry) =
1195 slice_colors.iter_mut().find(|(idx, _)| *idx == slice_idx)
1196 {
1197 entry.1 += 1;
1198 } else {
1199 slice_colors.push((slice_idx, 1));
1200 }
1201 }
1202 }
1203 }
1204
1205 if pattern > 0 {
1206 if let Some((slice_idx, _)) = slice_colors.iter().max_by_key(|(_, count)| count)
1208 {
1209 let braille_char = char::from_u32(0x2800 + pattern).unwrap_or('⠀');
1210 let color = self.slices[*slice_idx].color;
1211
1212 let cell = &mut buf[(pie_area.x + char_x, pie_area.y + char_y)];
1213 cell.set_char(braille_char).set_fg(color);
1214 }
1215 }
1216 }
1217 }
1218
1219 if let Some(legend_area) = legend_area_opt {
1221 self.render_legend(buf, legend_area);
1222 }
1223 }
1224}
1225
1226#[cfg(test)]
1227#[allow(clippy::float_cmp)]
1228mod tests {
1229 use super::*;
1230
1231 #[test]
1232 fn pie_slice_new() {
1233 let slice = PieSlice::new("Test", 50.0, Color::Red);
1234 assert_eq!(slice.label(), "Test");
1235 assert_eq!(slice.value(), 50.0);
1236 assert_eq!(slice.color(), Color::Red);
1237 }
1238
1239 #[test]
1240 fn piechart_new() {
1241 let slices = vec![
1242 PieSlice::new("A", 30.0, Color::Red),
1243 PieSlice::new("B", 70.0, Color::Blue),
1244 ];
1245 let piechart = PieChart::new(slices.clone());
1246 assert_eq!(piechart.slices, slices);
1247 }
1248
1249 #[test]
1250 fn piechart_default() {
1251 let piechart = PieChart::default();
1252 assert!(piechart.slices.is_empty());
1253 assert!(piechart.show_legend);
1254 assert!(piechart.show_percentages);
1255 }
1256
1257 #[test]
1258 fn piechart_slices() {
1259 let slices = vec![PieSlice::new("Test", 100.0, Color::Green)];
1260 let piechart = PieChart::default().slices(slices.clone());
1261 assert_eq!(piechart.slices, slices);
1262 }
1263
1264 #[test]
1265 fn piechart_style() {
1266 let style = Style::default().fg(Color::Red);
1267 let piechart = PieChart::default().style(style);
1268 assert_eq!(piechart.style, style);
1269 }
1270
1271 #[test]
1272 fn piechart_show_legend() {
1273 let piechart = PieChart::default().show_legend(false);
1274 assert!(!piechart.show_legend);
1275 }
1276
1277 #[test]
1278 fn piechart_show_percentages() {
1279 let piechart = PieChart::default().show_percentages(false);
1280 assert!(!piechart.show_percentages);
1281 }
1282
1283 #[test]
1284 fn piechart_pie_char() {
1285 let piechart = PieChart::default().pie_char('█');
1286 assert_eq!(piechart.pie_char, '█');
1287 }
1288
1289 #[test]
1290 fn piechart_total_value() {
1291 let slices = vec![
1292 PieSlice::new("A", 30.0, Color::Red),
1293 PieSlice::new("B", 70.0, Color::Blue),
1294 ];
1295 let piechart = PieChart::new(slices);
1296 assert_eq!(piechart.total_value(), 100.0);
1297 }
1298
1299 #[test]
1300 fn piechart_percentage() {
1301 let slices = vec![
1302 PieSlice::new("A", 30.0, Color::Red),
1303 PieSlice::new("B", 70.0, Color::Blue),
1304 ];
1305 let piechart = PieChart::new(slices);
1306 assert_eq!(
1307 piechart.percentage(&PieSlice::new("A", 30.0, Color::Red)),
1308 30.0
1309 );
1310 }
1311
1312 render_empty_test!(piechart_render_empty_area, PieChart::default());
1314
1315 render_with_size_test!(
1316 piechart_render_with_block,
1317 {
1318 let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1319 PieChart::new(slices).block(Block::bordered())
1320 },
1321 width: 20,
1322 height: 10
1323 );
1324
1325 render_test!(
1326 piechart_render_basic,
1327 {
1328 let slices = vec![
1329 PieSlice::new("Rust", 45.0, Color::Red),
1330 PieSlice::new("Go", 30.0, Color::Blue),
1331 PieSlice::new("Python", 25.0, Color::Green),
1332 ];
1333 PieChart::new(slices)
1334 },
1335 Rect::new(0, 0, 40, 20)
1336 );
1337
1338 #[test]
1339 fn piechart_styled_trait() {
1340 use ratatui::style::Stylize;
1341 let piechart = PieChart::default().red();
1342 assert_eq!(piechart.style.fg, Some(Color::Red));
1343 }
1344
1345 #[test]
1346 fn piechart_with_multiple_slices() {
1347 let slices = vec![
1348 PieSlice::new("A", 25.0, Color::Red),
1349 PieSlice::new("B", 25.0, Color::Blue),
1350 PieSlice::new("C", 25.0, Color::Green),
1351 PieSlice::new("D", 25.0, Color::Yellow),
1352 ];
1353 let piechart = PieChart::new(slices);
1354 assert_eq!(piechart.total_value(), 100.0);
1355 }
1356
1357 render_with_size_test!(
1359 piechart_multi_slice_render,
1360 {
1361 let slices = vec![
1362 PieSlice::new("A", 25.0, Color::Red),
1363 PieSlice::new("B", 25.0, Color::Blue),
1364 PieSlice::new("C", 25.0, Color::Green),
1365 PieSlice::new("D", 25.0, Color::Yellow),
1366 ];
1367 PieChart::new(slices)
1368 },
1369 width: 50,
1370 height: 30
1371 );
1372
1373 #[test]
1374 fn piechart_zero_values() {
1375 let slices = vec![
1376 PieSlice::new("A", 0.0, Color::Red),
1377 PieSlice::new("B", 0.0, Color::Blue),
1378 ];
1379 let piechart = PieChart::new(slices);
1380 assert_eq!(piechart.total_value(), 0.0);
1381 }
1382
1383 #[test]
1384 fn piechart_method_chaining() {
1385 use ratatui::widgets::Block;
1386
1387 let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1388 let piechart = PieChart::new(slices)
1389 .show_legend(true)
1390 .show_percentages(true)
1391 .pie_char('█')
1392 .block(Block::bordered().title("Test"))
1393 .style(Style::default().fg(Color::White));
1394
1395 assert!(piechart.show_legend);
1396 assert!(piechart.show_percentages);
1397 assert_eq!(piechart.pie_char, '█');
1398 assert!(piechart.block.is_some());
1399 assert_eq!(piechart.style.fg, Some(Color::White));
1400 }
1401
1402 #[test]
1403 fn piechart_custom_symbols() {
1404 use crate::symbols;
1405
1406 let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_BLOCK);
1407 assert_eq!(piechart.pie_char, '█');
1408
1409 let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_CIRCLE);
1410 assert_eq!(piechart.pie_char, '◉');
1411
1412 let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_SQUARE);
1413 assert_eq!(piechart.pie_char, '■');
1414 }
1415
1416 #[test]
1417 fn piechart_is_angle_in_slice() {
1418 use std::f64::consts::PI;
1419
1420 assert!(PieChart::is_angle_in_slice(PI / 4.0, 0.0, PI / 2.0));
1422
1423 assert!(!PieChart::is_angle_in_slice(PI, 0.0, PI / 2.0));
1425
1426 assert!(PieChart::is_angle_in_slice(0.1, 1.5 * PI, 0.5));
1428 }
1429}