Skip to main content

tui_piechart/
lib.rs

1//! # tui-piechart
2//!
3//! A customizable pie chart widget for [Ratatui](https://github.com/ratatui/ratatui) TUI applications.
4//!
5//! ## Features
6//!
7//! - 🥧 Simple pie chart with customizable slices
8//! - 🎨 Customizable colors for each slice
9//! - 🔤 Labels and percentages
10//! - 📊 Legend support
11//! - 📦 Optional block wrapper
12//! - ✨ Custom symbols for pie chart and legend
13//! - 🎛️ [`GaugeChart`] with 15 styles, plus [`RadialBar`], [`RingStack`], [`Funnel`],
14//!   [`RadarChart`], [`Treemap`] and [`Waterfall`]
15//! - 🍩 Donut / segmented / HUD / rose pies via [`PieStyle`]
16//! - ⚡ Zero-cost abstractions
17//!
18//! ## Examples
19//!
20//! Basic usage:
21//!
22//! ```no_run
23//! use ratatui::style::Color;
24//! use tui_piechart::{PieChart, PieSlice};
25//!
26//! let slices = vec![
27//!     PieSlice::new("Rust", 45.0, Color::Red),
28//!     PieSlice::new("Go", 30.0, Color::Blue),
29//!     PieSlice::new("Python", 25.0, Color::Green),
30//! ];
31//! let piechart = PieChart::new(slices);
32//! ```
33//!
34//! With custom styling:
35//!
36//! ```no_run
37//! use ratatui::style::{Color, Style};
38//! use tui_piechart::{PieChart, PieSlice};
39//!
40//! let slices = vec![
41//!     PieSlice::new("Rust", 45.0, Color::Red),
42//!     PieSlice::new("Go", 30.0, Color::Blue),
43//! ];
44//! let piechart = PieChart::new(slices)
45//!     .style(Style::default())
46//!     .show_legend(true)
47//!     .show_percentages(true);
48//! ```
49//!
50//! With custom symbols:
51//!
52//! ```no_run
53//! use ratatui::style::Color;
54//! use tui_piechart::{PieChart, PieSlice, symbols};
55//!
56//! let slices = vec![
57//!     PieSlice::new("Rust", 45.0, Color::Red),
58//!     PieSlice::new("Go", 30.0, Color::Blue),
59//! ];
60//!
61//! // Use predefined symbols
62//! let piechart = PieChart::new(slices.clone())
63//!     .pie_char(symbols::PIE_CHAR_BLOCK)
64//!     .legend_marker(symbols::LEGEND_MARKER_CIRCLE);
65//!
66//! // Or use any custom characters
67//! let piechart = PieChart::new(slices)
68//!     .pie_char('█')
69//!     .legend_marker("→");
70//! ```
71//!
72//! With custom border styles:
73//!
74//! ```no_run
75//! use ratatui::style::Color;
76//! use tui_piechart::{PieChart, PieSlice, border_style::BorderStyle};
77//! // Or use backwards-compatible path: use tui_piechart::symbols::BorderStyle;
78//!
79//! let slices = vec![
80//!     PieSlice::new("Rust", 45.0, Color::Red),
81//!     PieSlice::new("Go", 30.0, Color::Blue),
82//! ];
83//!
84//! // Use predefined border styles
85//! let piechart = PieChart::new(slices)
86//!     .block(BorderStyle::Rounded.block().title("My Chart"));
87//! ```
88
89#![warn(missing_docs)]
90#![warn(clippy::pedantic)]
91#![allow(clippy::module_name_repetitions)]
92
93use std::f64::consts::PI;
94
95use ratatui::buffer::Buffer;
96use ratatui::layout::Rect;
97use ratatui::style::{Color, Style, Styled};
98use ratatui::text::{Line, Span};
99use ratatui::widgets::{Block, Widget};
100
101pub mod border_style;
102mod braille;
103pub mod funnel;
104pub mod gauge;
105pub mod legend;
106pub mod pie_style;
107pub mod radar;
108pub mod radial;
109pub mod treemap;
110mod util;
111pub mod waterfall;
112#[macro_use]
113pub mod macros;
114pub mod symbols;
115pub mod title;
116
117// Re-export commonly used types from submodules for convenience
118pub use funnel::{Funnel, FunnelStage, FunnelStyle};
119pub use gauge::{GaugeChart, GaugeStyle};
120pub use legend::{LegendAlignment, LegendLayout, LegendPosition};
121pub use pie_style::PieStyle;
122pub use radar::{RadarChart, RadarSeries};
123pub use radial::{RadialBar, RadialRing, RadialSweep, RingStack};
124pub use title::{BlockExt, TitleAlignment, TitlePosition, TitleStyle};
125pub use treemap::{Treemap, TreemapItem};
126#[cfg(feature = "theme")]
127pub use tui_nuances::{Theme, ThemeProvider, ThemeSwitcher, Themed};
128pub use waterfall::{Waterfall, WaterfallStep};
129
130/// Rendering resolution mode for pie charts.
131///
132/// Different resolution modes provide varying levels of detail by using
133/// different Unicode block drawing characters with different dot densities.
134///
135/// # Examples
136///
137/// ```
138/// use tui_piechart::{PieChart, PieSlice, Resolution};
139/// use ratatui::style::Color;
140///
141/// let slices = vec![PieSlice::new("Rust", 45.0, Color::Red)];
142///
143/// // Standard resolution (1 dot per character)
144/// let standard = PieChart::new(slices.clone())
145///     .resolution(Resolution::Standard);
146///
147/// // High resolution with braille patterns (8 dots per character)
148/// let braille = PieChart::new(slices)
149///     .resolution(Resolution::Braille);
150/// ```
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
152pub enum Resolution {
153    /// Standard resolution using full characters (1 dot per cell).
154    ///
155    /// Uses regular Unicode characters like `●`. This is the default mode.
156    #[default]
157    Standard,
158
159    /// Braille resolution using 2×4 dot patterns (8 dots per cell).
160    ///
161    /// Uses Unicode braille patterns (U+2800-U+28FF) providing 8x resolution.
162    /// This provides the highest resolution available for terminal rendering.
163    Braille,
164}
165
166/// A slice of the pie chart representing a portion of data.
167///
168/// Each slice has a label, a value, and a color.
169///
170/// # Examples
171///
172/// ```
173/// use ratatui::style::Color;
174/// use tui_piechart::PieSlice;
175///
176/// let slice = PieSlice::new("Rust", 45.0, Color::Red);
177/// ```
178#[derive(Debug, Clone, PartialEq)]
179pub struct PieSlice<'a> {
180    /// The label for this slice
181    label: &'a str,
182    /// The value of this slice (will be converted to percentage)
183    value: f64,
184    /// The color of this slice
185    color: Color,
186}
187
188impl<'a> PieSlice<'a> {
189    /// Creates a new pie slice with the given label, value, and color.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use ratatui::style::Color;
195    /// use tui_piechart::PieSlice;
196    ///
197    /// let slice = PieSlice::new("Rust", 45.0, Color::Red);
198    /// ```
199    #[must_use]
200    pub const fn new(label: &'a str, value: f64, color: Color) -> Self {
201        Self {
202            label,
203            value,
204            color,
205        }
206    }
207
208    /// Creates a slice colored from the theme palette (`theme.color(index)`).
209    ///
210    /// ```
211    /// use tui_piechart::{PieSlice, Theme};
212    /// let s = PieSlice::themed("Rust", 45.0, &Theme::nord(), 0);
213    /// assert_eq!(s.color(), Theme::nord().color(0));
214    /// ```
215    #[cfg(feature = "theme")]
216    #[must_use]
217    pub fn themed(label: &'a str, value: f64, theme: &Theme, index: usize) -> Self {
218        Self::new(label, value, theme.color(index))
219    }
220
221    /// Returns the label of this slice.
222    #[must_use]
223    pub const fn label(&self) -> &'a str {
224        self.label
225    }
226
227    /// Returns the value of this slice.
228    #[must_use]
229    pub const fn value(&self) -> f64 {
230        self.value
231    }
232
233    /// Returns the color of this slice.
234    #[must_use]
235    pub const fn color(&self) -> Color {
236        self.color
237    }
238}
239
240#[cfg(feature = "theme")]
241impl Themed for PieChart<'_> {
242    fn apply_theme(self, theme: &Theme) -> Self {
243        self.theme(theme)
244    }
245}
246
247/// A widget that displays a pie chart.
248///
249/// A `PieChart` displays data as slices of a circle, where each slice represents
250/// a proportion of the total.
251///
252/// # Examples
253///
254/// ```
255/// use ratatui::style::Color;
256/// use tui_piechart::{PieChart, PieSlice};
257///
258/// let slices = vec![
259///     PieSlice::new("Rust", 45.0, Color::Red),
260///     PieSlice::new("Go", 30.0, Color::Blue),
261///     PieSlice::new("Python", 25.0, Color::Green),
262/// ];
263/// let piechart = PieChart::new(slices);
264/// ```
265#[derive(Debug, Clone, PartialEq)]
266pub struct PieChart<'a> {
267    /// The slices of the pie chart
268    slices: Vec<PieSlice<'a>>,
269    /// Optional block to wrap the pie chart
270    block: Option<Block<'a>>,
271    /// Base style for the entire widget
272    style: Style,
273    /// Theme-derived base style; lower precedence than `style`
274    theme_style: Style,
275    /// Whether to show the legend
276    show_legend: bool,
277    /// Whether to show percentages on slices
278    show_percentages: bool,
279    /// The character to use for drawing the pie chart
280    pie_char: char,
281    /// The marker to use for legend items
282    legend_marker: &'a str,
283    /// Resolution mode for rendering
284    resolution: Resolution,
285    /// Position of the legend
286    legend_position: LegendPosition,
287    /// Layout of the legend
288    legend_layout: LegendLayout,
289    /// Alignment of legend items
290    legend_alignment: LegendAlignment,
291    /// Look of the pie (solid, segmented ring, HUD, rose)
292    pie_style: PieStyle,
293    /// Hole radius as a fraction of the radius (`0` = pie, `>0` = donut)
294    hole: f64,
295    /// Start angle in turns (`0` = 12 o'clock, clockwise)
296    start: f64,
297    /// Gap between slices in braille dots (canvas styles only)
298    gap: f64,
299    /// Outward shift of every slice in braille dots (canvas styles only)
300    explode: f64,
301    /// Text shown in the donut hole
302    center: Option<&'a str>,
303}
304
305impl Default for PieChart<'_> {
306    /// Returns a default `PieChart` widget.
307    ///
308    /// The default widget has:
309    /// - No slices
310    /// - No block
311    /// - Default style
312    /// - Legend shown
313    /// - Percentages shown
314    /// - Default pie character (●)
315    /// - Default legend marker (■)
316    fn default() -> Self {
317        Self {
318            slices: Vec::new(),
319            block: None,
320            style: Style::default(),
321            theme_style: Style::default(),
322            show_legend: true,
323            show_percentages: true,
324            pie_char: symbols::PIE_CHAR,
325            legend_marker: symbols::LEGEND_MARKER,
326            resolution: Resolution::default(),
327            legend_position: LegendPosition::default(),
328            legend_layout: LegendLayout::default(),
329            legend_alignment: LegendAlignment::default(),
330            pie_style: PieStyle::default(),
331            hole: 0.0,
332            start: 0.0,
333            gap: 0.0,
334            explode: 0.0,
335            center: None,
336        }
337    }
338}
339
340impl<'a> PieChart<'a> {
341    /// Creates a new `PieChart` with the given slices.
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use ratatui::style::Color;
347    /// use tui_piechart::{PieChart, PieSlice};
348    ///
349    /// let slices = vec![
350    ///     PieSlice::new("Rust", 45.0, Color::Red),
351    ///     PieSlice::new("Go", 30.0, Color::Blue),
352    /// ];
353    /// let piechart = PieChart::new(slices);
354    /// ```
355    #[must_use]
356    pub fn new(slices: Vec<PieSlice<'a>>) -> Self {
357        Self {
358            slices,
359            ..Default::default()
360        }
361    }
362
363    /// Sets the slices of the pie chart.
364    ///
365    /// # Examples
366    ///
367    /// ```
368    /// use ratatui::style::Color;
369    /// use tui_piechart::{PieChart, PieSlice};
370    ///
371    /// let slices = vec![
372    ///     PieSlice::new("Rust", 45.0, Color::Red),
373    /// ];
374    /// let piechart = PieChart::default().slices(slices);
375    /// ```
376    #[must_use]
377    pub fn slices(mut self, slices: Vec<PieSlice<'a>>) -> Self {
378        self.slices = slices;
379        self
380    }
381
382    /// Wraps the pie chart with the given block.
383    ///
384    /// # Examples
385    ///
386    /// ```
387    /// use ratatui::style::Color;
388    /// use ratatui::widgets::Block;
389    /// use tui_piechart::{PieChart, PieSlice};
390    ///
391    /// let slices = vec![PieSlice::new("Rust", 45.0, Color::Red)];
392    /// let piechart = PieChart::new(slices)
393    ///     .block(Block::bordered().title("Statistics"));
394    /// ```
395    #[must_use]
396    pub fn block(mut self, block: Block<'a>) -> Self {
397        self.block = Some(block);
398        self
399    }
400
401    /// Sets the base style of the widget.
402    ///
403    /// # Examples
404    ///
405    /// ```
406    /// use ratatui::style::{Color, Style};
407    /// use tui_piechart::PieChart;
408    ///
409    /// let piechart = PieChart::default()
410    ///     .style(Style::default().fg(Color::White));
411    /// ```
412    #[must_use]
413    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
414        self.style = style.into();
415        self
416    }
417
418    /// Applies a shared [`Theme`] (foreground/background of the widget).
419    ///
420    /// Precedence: fields set explicitly with [`style`](Self::style) always win
421    /// over the theme, regardless of call order. Without a theme the look is unchanged.
422    ///
423    /// ```
424    /// use tui_piechart::{PieChart, Theme};
425    /// let chart = PieChart::default().theme(&Theme::nord());
426    /// ```
427    #[cfg(feature = "theme")]
428    #[must_use]
429    pub fn theme(mut self, theme: &Theme) -> Self {
430        self.theme_style = theme.style();
431        self
432    }
433
434    /// Sets whether to show the legend.
435    ///
436    /// # Examples
437    ///
438    /// ```
439    /// use tui_piechart::PieChart;
440    ///
441    /// let piechart = PieChart::default().show_legend(true);
442    /// ```
443    #[must_use]
444    pub const fn show_legend(mut self, show: bool) -> Self {
445        self.show_legend = show;
446        self
447    }
448
449    /// Sets whether to show percentages on slices.
450    ///
451    /// # Examples
452    ///
453    /// ```
454    /// use tui_piechart::PieChart;
455    ///
456    /// let piechart = PieChart::default().show_percentages(true);
457    /// ```
458    #[must_use]
459    pub const fn show_percentages(mut self, show: bool) -> Self {
460        self.show_percentages = show;
461        self
462    }
463
464    /// Sets the character used to draw the pie chart.
465    ///
466    /// You can use any Unicode character for custom visualization.
467    ///
468    /// # Examples
469    ///
470    /// Using a predefined symbol:
471    ///
472    /// ```
473    /// use tui_piechart::{PieChart, symbols};
474    ///
475    /// let piechart = PieChart::default()
476    ///     .pie_char(symbols::PIE_CHAR_BLOCK);
477    /// ```
478    ///
479    /// Using a custom character:
480    ///
481    /// ```
482    /// use tui_piechart::PieChart;
483    ///
484    /// let piechart = PieChart::default().pie_char('█');
485    /// ```
486    #[must_use]
487    pub const fn pie_char(mut self, c: char) -> Self {
488        self.pie_char = c;
489        self
490    }
491
492    /// Sets the marker used for legend items.
493    ///
494    /// You can use any string (including Unicode characters) for custom markers.
495    ///
496    /// # Examples
497    ///
498    /// Using a predefined symbol:
499    ///
500    /// ```
501    /// use tui_piechart::{PieChart, symbols};
502    ///
503    /// let piechart = PieChart::default()
504    ///     .legend_marker(symbols::LEGEND_MARKER_CIRCLE);
505    /// ```
506    ///
507    /// Using custom markers:
508    ///
509    /// ```
510    /// use tui_piechart::PieChart;
511    ///
512    /// // Simple arrow
513    /// let piechart = PieChart::default().legend_marker("→");
514    ///
515    /// // Or any Unicode character
516    /// let piechart = PieChart::default().legend_marker("★");
517    ///
518    /// // Or even multi-character strings
519    /// let piechart = PieChart::default().legend_marker("-->");
520    /// ```
521    #[must_use]
522    pub const fn legend_marker(mut self, marker: &'a str) -> Self {
523        self.legend_marker = marker;
524        self
525    }
526
527    /// Sets the rendering resolution mode.
528    ///
529    /// Different resolution modes provide varying levels of detail:
530    /// - `Standard`: Regular characters (1 dot per cell)
531    /// - `Braille`: 2×4 patterns (8 dots per cell, 8x resolution)
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use tui_piechart::{PieChart, Resolution};
537    ///
538    /// let standard = PieChart::default().resolution(Resolution::Standard);
539    /// let braille = PieChart::default().resolution(Resolution::Braille);
540    /// ```
541    #[must_use]
542    pub const fn resolution(mut self, resolution: Resolution) -> Self {
543        self.resolution = resolution;
544        self
545    }
546
547    /// Sets whether to use high resolution rendering with braille patterns.
548    ///
549    /// This is a convenience method that sets the resolution to `Braille` when enabled,
550    /// or `Standard` when disabled. For more control, use [`resolution`](Self::resolution).
551    ///
552    /// # Examples
553    ///
554    /// ```
555    /// use tui_piechart::PieChart;
556    ///
557    /// let piechart = PieChart::default().high_resolution(true);
558    /// ```
559    #[must_use]
560    pub const fn high_resolution(mut self, enabled: bool) -> Self {
561        self.resolution = if enabled {
562            Resolution::Braille
563        } else {
564            Resolution::Standard
565        };
566        self
567    }
568
569    /// Sets the position of the legend relative to the pie chart.
570    ///
571    /// # Examples
572    ///
573    /// ```
574    /// use tui_piechart::{PieChart, LegendPosition};
575    ///
576    /// let piechart = PieChart::default()
577    ///     .legend_position(LegendPosition::Right);
578    /// ```
579    #[must_use]
580    pub const fn legend_position(mut self, position: LegendPosition) -> Self {
581        self.legend_position = position;
582        self
583    }
584
585    /// Sets the layout mode for the legend.
586    ///
587    /// # Examples
588    ///
589    /// ```
590    /// use tui_piechart::{PieChart, LegendLayout};
591    ///
592    /// // Single horizontal row
593    /// let piechart = PieChart::default()
594    ///     .legend_layout(LegendLayout::Horizontal);
595    ///
596    /// // Vertical stacking (default)
597    /// let piechart = PieChart::default()
598    ///     .legend_layout(LegendLayout::Vertical);
599    /// ```
600    #[must_use]
601    pub const fn legend_layout(mut self, layout: LegendLayout) -> Self {
602        self.legend_layout = layout;
603        self
604    }
605
606    /// Sets the alignment of legend items within the legend area.
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// use tui_piechart::{PieChart, LegendAlignment};
612    ///
613    /// // Center-align legend items
614    /// let piechart = PieChart::default()
615    ///     .legend_alignment(LegendAlignment::Center);
616    ///
617    /// // Right-align legend items
618    /// let piechart = PieChart::default()
619    ///     .legend_alignment(LegendAlignment::Right);
620    /// ```
621    #[must_use]
622    pub const fn legend_alignment(mut self, alignment: LegendAlignment) -> Self {
623        self.legend_alignment = alignment;
624        self
625    }
626
627    /// Draws a donut: the inner `hole` fraction (`0.0..=0.95`) of the radius stays empty.
628    ///
629    /// # Examples
630    ///
631    /// ```
632    /// use tui_piechart::PieChart;
633    ///
634    /// let donut = PieChart::default().hole(0.55).center("42 items");
635    /// ```
636    #[must_use]
637    pub fn hole(mut self, hole: f64) -> Self {
638        self.hole = if hole.is_finite() {
639            hole.clamp(0.0, 0.95)
640        } else {
641            0.0
642        };
643        self
644    }
645
646    /// Sets where the first slice starts, in degrees clockwise from 12 o'clock.
647    ///
648    /// # Examples
649    ///
650    /// ```
651    /// use tui_piechart::PieChart;
652    ///
653    /// let chart = PieChart::default().start_angle(90.0);
654    /// ```
655    #[must_use]
656    pub fn start_angle(mut self, degrees: f64) -> Self {
657        self.start = if degrees.is_finite() {
658            (degrees / 360.0).rem_euclid(1.0)
659        } else {
660            0.0
661        };
662        self
663    }
664
665    /// Leaves a gap of `dots` braille dots between slices (`0` = flush).
666    ///
667    /// Gaps, [`Self::explode`] and the non-solid [`PieStyle`]s are drawn with
668    /// braille dots, whatever the [`Resolution`].
669    #[must_use]
670    pub fn gap(mut self, dots: f64) -> Self {
671        self.gap = if dots.is_finite() {
672            dots.clamp(0.0, 8.0)
673        } else {
674            0.0
675        };
676        self
677    }
678
679    /// Pushes every slice outward by `dots` braille dots (an exploded pie).
680    #[must_use]
681    pub fn explode(mut self, dots: f64) -> Self {
682        self.explode = if dots.is_finite() {
683            dots.clamp(0.0, 8.0)
684        } else {
685            0.0
686        };
687        self
688    }
689
690    /// Chooses how slices are drawn: solid, segmented ring, HUD band or rose.
691    #[must_use]
692    pub const fn pie_style(mut self, style: PieStyle) -> Self {
693        self.pie_style = style;
694        self
695    }
696
697    /// Text shown in the middle of a donut (needs a [`Self::hole`]).
698    #[must_use]
699    pub const fn center(mut self, text: &'a str) -> Self {
700        self.center = Some(text);
701        self
702    }
703
704    fn total_value(&self) -> f64 {
705        self.slices.iter().map(|s| s.value).sum()
706    }
707
708    /// Computes the percentage a value represents of a total.
709    ///
710    /// Returns `0.0` when `total` is not strictly positive, which keeps the
711    /// arithmetic safe for empty charts and all-zero data sets.
712    fn value_percent(value: f64, total: f64) -> f64 {
713        if total > 0.0 {
714            (value / total) * 100.0
715        } else {
716            0.0
717        }
718    }
719
720    /// Calculates the percentage for a given slice.
721    fn percentage(&self, slice: &PieSlice) -> f64 {
722        Self::value_percent(slice.value, self.total_value())
723    }
724}
725
726impl Styled for PieChart<'_> {
727    type Item = Self;
728
729    fn style(&self) -> Style {
730        self.style
731    }
732
733    fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
734        self.style = style.into();
735        self
736    }
737}
738
739impl Widget for PieChart<'_> {
740    fn render(self, area: Rect, buf: &mut Buffer) {
741        Widget::render(&self, area, buf);
742    }
743}
744
745impl Widget for &PieChart<'_> {
746    fn render(self, area: Rect, buf: &mut Buffer) {
747        buf.set_style(area, self.theme_style.patch(self.style));
748        let inner = if let Some(ref block) = self.block {
749            let inner_area = block.inner(area);
750            block.render(area, buf);
751            inner_area
752        } else {
753            area
754        };
755        self.render_piechart(inner, buf);
756    }
757}
758
759impl PieChart<'_> {
760    /// Maximum ratio for vertical legend width (1/3 of available width).
761    const LEGEND_VERTICAL_MAX_RATIO: u16 = 3;
762
763    /// Minimum width for vertical legend to ensure readability.
764    const LEGEND_VERTICAL_MIN_WIDTH: u16 = 20;
765
766    /// Maximum ratio for horizontal legend width (2/5 = 40% of available width).
767    /// This keeps the pie chart proportional and prevents legend from dominating.
768    const LEGEND_HORIZONTAL_MAX_RATIO: u16 = 5;
769
770    /// Absolute maximum width for horizontal legends to prevent excessive space usage.
771    const LEGEND_HORIZONTAL_MAX_WIDTH: u16 = 60;
772
773    /// Absolute maximum height for vertical legends to prevent pie chart from being too small.
774    /// This allows 4 items with spacing (4 items * 2 lines = 8 lines, +1 for padding = 9).
775    const LEGEND_VERTICAL_MAX_HEIGHT: u16 = 9;
776
777    /// Height required for horizontal legend layout (single row with padding).
778    const LEGEND_HORIZONTAL_HEIGHT: u16 = 3;
779
780    /// Space between pie chart and legend areas.
781    const LEGEND_SPACING: u16 = 1;
782
783    /// Inner padding for legend area.
784    const LEGEND_PADDING: u16 = 1;
785
786    fn render_piechart(&self, area: Rect, buf: &mut Buffer) {
787        if area.is_empty() || self.slices.is_empty() {
788            return;
789        }
790
791        let total = self.total_value();
792        if total <= 0.0 {
793            return;
794        }
795
796        if self.needs_canvas() {
797            self.render_piechart_canvas(area, buf);
798            return;
799        }
800
801        match self.resolution {
802            Resolution::Standard => {
803                // Continue with standard rendering below
804            }
805            Resolution::Braille => {
806                self.render_piechart_braille(area, buf);
807                return;
808            }
809        }
810
811        // Calculate layout with legend positioning
812        let (pie_area, legend_area_opt) = self.calculate_layout(area);
813
814        // Calculate the center and radius of the pie chart
815        // Account for terminal character aspect ratio (typically 1:2, chars are twice as tall as wide)
816        let center_x = pie_area.width / 2;
817        let center_y = pie_area.height / 2;
818
819        // Adjust radius for aspect ratio - use width as limiting factor
820        let radius = center_x.min(center_y * 2).saturating_sub(1);
821
822        // Draw the pie chart
823        let mut cumulative_percent = self.start * 100.0;
824        for slice in &self.slices {
825            let percent = self.percentage(slice);
826            self.render_slice(
827                pie_area,
828                buf,
829                center_x,
830                center_y,
831                radius,
832                cumulative_percent,
833                percent,
834                slice.color,
835            );
836            cumulative_percent += percent;
837        }
838
839        self.draw_center(pie_area, buf);
840
841        // Draw legend if enabled
842        if let Some(legend_area) = legend_area_opt {
843            self.render_legend(buf, legend_area);
844        }
845    }
846
847    /// Returns the `(start_angle, end_angle, is_full_circle)` for a slice.
848    ///
849    /// Angles start at the top of the circle (12 o'clock) and increase
850    /// clockwise. A slice covering the whole circle is flagged so callers can
851    /// fill the entire disc instead of collapsing to a single boundary line.
852    fn slice_angles(start_percent: f64, percent: f64) -> (f64, f64, bool) {
853        let start_angle = (start_percent / 100.0) * 2.0 * PI - PI / 2.0;
854        let end_angle = ((start_percent + percent) / 100.0) * 2.0 * PI - PI / 2.0;
855        let is_full_circle = percent >= 100.0 - f64::EPSILON;
856        (start_angle, end_angle, is_full_circle)
857    }
858
859    #[allow(clippy::too_many_arguments, clippy::similar_names)]
860    fn render_slice(
861        &self,
862        area: Rect,
863        buf: &mut Buffer,
864        center_x: u16,
865        center_y: u16,
866        radius: u16,
867        start_percent: f64,
868        percent: f64,
869        color: Color,
870    ) {
871        if radius == 0 || percent <= 0.0 {
872            return;
873        }
874
875        // A slice covering the whole (or effectively the whole) circle must be
876        // rendered as a full disc. Otherwise the start and end angles collapse
877        // to the same value and only the boundary line gets drawn.
878        let (start_angle, end_angle, is_full_circle) = Self::slice_angles(start_percent, percent);
879
880        // Scan the entire area around the center
881        let scan_width = i32::from(radius + 1);
882        let scan_height = i32::from((radius / 2) + 1); // Account for aspect ratio
883
884        for dy in -scan_height..=scan_height {
885            for dx in -scan_width..=scan_width {
886                // Calculate actual position in buffer
887                let x = i32::from(area.x) + i32::from(center_x) + dx;
888                let y = i32::from(area.y) + i32::from(center_y) + dy;
889
890                // Check bounds
891                if x < i32::from(area.x)
892                    || x >= i32::from(area.x + area.width)
893                    || y < i32::from(area.y)
894                    || y >= i32::from(area.y + area.height)
895                {
896                    continue;
897                }
898
899                // Adjust for aspect ratio: multiply y distance by 2
900                #[allow(clippy::cast_precision_loss)]
901                let adjusted_dx = f64::from(dx);
902                #[allow(clippy::cast_precision_loss)]
903                let adjusted_dy = f64::from(dy * 2);
904
905                // Calculate distance from center
906                let distance = (adjusted_dx * adjusted_dx + adjusted_dy * adjusted_dy).sqrt();
907
908                // Check if point is within radius
909                #[allow(clippy::cast_precision_loss)]
910                if distance <= f64::from(radius) && distance >= self.hole * f64::from(radius) {
911                    // Calculate angle from center (0 = right, PI/2 = up, PI = left, 3PI/2 = down)
912                    let angle = adjusted_dy.atan2(adjusted_dx);
913
914                    // Check if angle is within slice
915                    if is_full_circle || Self::is_angle_in_slice(angle, start_angle, end_angle) {
916                        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
917                        {
918                            let cell = &mut buf[(x as u16, y as u16)];
919                            cell.set_char(self.pie_char).set_fg(color);
920                        }
921                    }
922                }
923            }
924        }
925    }
926
927    fn is_angle_in_slice(angle: f64, start: f64, end: f64) -> bool {
928        // Normalize angles to [0, 2π]
929        let normalize = |a: f64| {
930            let mut normalized = a % (2.0 * PI);
931            if normalized < 0.0 {
932                normalized += 2.0 * PI;
933            }
934            normalized
935        };
936
937        let norm_angle = normalize(angle);
938        let norm_start = normalize(start);
939        let norm_end = normalize(end);
940
941        if norm_start <= norm_end {
942            norm_angle >= norm_start && norm_angle <= norm_end
943        } else {
944            // Handle wrap around at 2π/0
945            norm_angle >= norm_start || norm_angle <= norm_end
946        }
947    }
948
949    fn format_legend_text(&self, slice: &PieSlice, total: f64, spacing: &str) -> String {
950        if self.show_percentages {
951            let percent = Self::value_percent(slice.value, total);
952            format!(
953                "{} {} {:.1}%{}",
954                self.legend_marker, slice.label, percent, spacing
955            )
956        } else {
957            format!("{} {}{}", self.legend_marker, slice.label, spacing)
958        }
959    }
960
961    /// Rendered display width of a single legend entry (marker, label, and an
962    /// optional percentage) followed by two spaces of trailing padding.
963    fn legend_item_width(&self, slice: &PieSlice, total: f64) -> u16 {
964        u16::try_from(self.format_legend_text(slice, total, "  ").chars().count())
965            .unwrap_or(u16::MAX)
966    }
967
968    fn calculate_aligned_x(&self, legend_area: Rect, content_width: u16) -> u16 {
969        match self.legend_alignment {
970            LegendAlignment::Left => legend_area.x,
971            LegendAlignment::Center => {
972                legend_area.x + (legend_area.width.saturating_sub(content_width)) / 2
973            }
974            LegendAlignment::Right => {
975                legend_area.x + legend_area.width.saturating_sub(content_width)
976            }
977        }
978    }
979
980    fn render_legend(&self, buf: &mut Buffer, legend_area: Rect) {
981        let total = self.total_value();
982
983        match self.legend_layout {
984            LegendLayout::Vertical => {
985                self.render_vertical_legend(buf, legend_area, total);
986            }
987            LegendLayout::Horizontal => {
988                self.render_horizontal_legend(buf, legend_area, total);
989            }
990        }
991    }
992
993    fn render_vertical_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
994        for (idx, slice) in self.slices.iter().enumerate() {
995            #[allow(clippy::cast_possible_truncation)]
996            let y_offset = (idx as u16) * 2;
997
998            if y_offset >= legend_area.height {
999                break;
1000            }
1001
1002            let legend_text = self.format_legend_text(slice, total, "");
1003            #[allow(clippy::cast_possible_truncation)]
1004            let text_width = u16::try_from(legend_text.chars().count()).unwrap_or(u16::MAX);
1005            let x_pos = self.calculate_aligned_x(legend_area, text_width);
1006
1007            let line = Line::from(vec![Span::styled(
1008                legend_text,
1009                Style::default().fg(slice.color),
1010            )]);
1011            let item_area = Rect {
1012                x: x_pos,
1013                y: legend_area.y + y_offset,
1014                width: text_width.min(legend_area.width),
1015                height: 1,
1016            };
1017
1018            line.render(item_area, buf);
1019        }
1020    }
1021
1022    fn render_horizontal_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
1023        let mut total_width = 0u16;
1024        let mut item_widths = Vec::new();
1025
1026        for slice in &self.slices {
1027            let legend_text = self.format_legend_text(slice, total, "  ");
1028            #[allow(clippy::cast_possible_truncation)]
1029            let text_width = u16::try_from(legend_text.chars().count()).unwrap_or(u16::MAX);
1030            item_widths.push(text_width);
1031            total_width = total_width.saturating_add(text_width);
1032        }
1033
1034        let start_x = self.calculate_aligned_x(legend_area, total_width.min(legend_area.width));
1035        let mut x_offset = 0u16;
1036
1037        for (idx, slice) in self.slices.iter().enumerate() {
1038            if x_offset >= legend_area.width {
1039                break;
1040            }
1041
1042            let legend_text = self.format_legend_text(slice, total, "  ");
1043            let text_width = item_widths[idx];
1044
1045            let line = Line::from(vec![Span::styled(
1046                legend_text,
1047                Style::default().fg(slice.color),
1048            )]);
1049            let item_area = Rect {
1050                x: start_x + x_offset,
1051                y: legend_area.y,
1052                width: text_width.min(legend_area.width.saturating_sub(x_offset)),
1053                height: 1,
1054            };
1055
1056            line.render(item_area, buf);
1057            x_offset = x_offset.saturating_add(text_width);
1058        }
1059    }
1060
1061    #[allow(clippy::too_many_lines)]
1062    fn calculate_layout(&self, area: Rect) -> (Rect, Option<Rect>) {
1063        if !self.show_legend || area.width < 20 || area.height < 10 {
1064            return (area, None);
1065        }
1066
1067        // Vertical layout uses Left/Right positions, Horizontal layout uses Top/Bottom
1068        match (self.legend_position, self.legend_layout) {
1069            // Left/Right with Vertical layout - proper vertical stacking on sides
1070            (LegendPosition::Left | LegendPosition::Right, LegendLayout::Vertical) => {
1071                let legend_width = self
1072                    .calculate_legend_width()
1073                    .min(area.width / Self::LEGEND_VERTICAL_MAX_RATIO)
1074                    .max(Self::LEGEND_VERTICAL_MIN_WIDTH);
1075                let is_left = matches!(self.legend_position, LegendPosition::Left);
1076                Self::layout_horizontal_split(area, legend_width, is_left)
1077            }
1078            // Top/Bottom with Horizontal layout - single row at top/bottom
1079            (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Horizontal) => {
1080                let is_top = matches!(self.legend_position, LegendPosition::Top);
1081                Self::layout_vertical_split(area, Self::LEGEND_HORIZONTAL_HEIGHT, is_top)
1082            }
1083            // Fallback: use horizontal layout for incompatible combinations
1084            (LegendPosition::Left | LegendPosition::Right, LegendLayout::Horizontal) => {
1085                // Horizontal layout on sides - allocate limited width
1086                let legend_width = self
1087                    .calculate_legend_horizontal_width()
1088                    .min(
1089                        (area.width * (Self::LEGEND_HORIZONTAL_MAX_RATIO - 1))
1090                            / Self::LEGEND_HORIZONTAL_MAX_RATIO,
1091                    )
1092                    .min(Self::LEGEND_HORIZONTAL_MAX_WIDTH);
1093                let is_left = matches!(self.legend_position, LegendPosition::Left);
1094                Self::layout_horizontal_split(area, legend_width, is_left)
1095            }
1096            (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Vertical) => {
1097                // Vertical layout at top/bottom - use 2-column grid with minimal height
1098                let legend_height = self.calculate_vertical_grid_height(area.width);
1099                let is_top = matches!(self.legend_position, LegendPosition::Top);
1100                Self::layout_vertical_split(area, legend_height, is_top)
1101            }
1102        }
1103    }
1104
1105    fn calculate_vertical_grid_height(&self, available_width: u16) -> u16 {
1106        // For vertical layout at top/bottom, use 2-column grid
1107        let max_item_width = self.calculate_legend_width();
1108        let columns = (available_width.saturating_sub(Self::LEGEND_PADDING * 2)
1109            / max_item_width.max(1))
1110        .clamp(1, 2);
1111
1112        #[allow(clippy::cast_possible_truncation)]
1113        let num_items = self.slices.len() as u16;
1114
1115        // Calculate rows: ceil(items / columns)
1116        let rows = num_items.div_ceil(columns);
1117        // Each row needs 2 lines (item + spacing), plus account for padding that will be subtracted
1118        (rows * 2 + Self::LEGEND_PADDING).clamp(4, Self::LEGEND_VERTICAL_MAX_HEIGHT)
1119    }
1120
1121    fn layout_horizontal_split(
1122        area: Rect,
1123        legend_width: u16,
1124        legend_on_left: bool,
1125    ) -> (Rect, Option<Rect>) {
1126        if area.width <= legend_width {
1127            return (area, None);
1128        }
1129
1130        let pie_width = area
1131            .width
1132            .saturating_sub(legend_width + Self::LEGEND_SPACING);
1133
1134        if legend_on_left {
1135            (
1136                Rect {
1137                    x: area.x + legend_width + Self::LEGEND_SPACING,
1138                    y: area.y,
1139                    width: pie_width,
1140                    height: area.height,
1141                },
1142                Some(Rect {
1143                    x: area.x,
1144                    y: area.y + Self::LEGEND_PADDING,
1145                    width: legend_width,
1146                    height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
1147                }),
1148            )
1149        } else {
1150            (
1151                Rect {
1152                    x: area.x,
1153                    y: area.y,
1154                    width: pie_width,
1155                    height: area.height,
1156                },
1157                Some(Rect {
1158                    x: area.x + pie_width + Self::LEGEND_SPACING,
1159                    y: area.y + Self::LEGEND_PADDING,
1160                    width: legend_width,
1161                    height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
1162                }),
1163            )
1164        }
1165    }
1166
1167    fn layout_vertical_split(
1168        area: Rect,
1169        legend_height: u16,
1170        legend_on_top: bool,
1171    ) -> (Rect, Option<Rect>) {
1172        if area.height <= legend_height {
1173            return (area, None);
1174        }
1175
1176        let pie_height = area
1177            .height
1178            .saturating_sub(legend_height + Self::LEGEND_SPACING);
1179
1180        if legend_on_top {
1181            (
1182                Rect {
1183                    x: area.x,
1184                    y: area.y + legend_height + Self::LEGEND_SPACING,
1185                    width: area.width,
1186                    height: pie_height,
1187                },
1188                Some(Rect {
1189                    x: area.x + Self::LEGEND_PADDING,
1190                    y: area.y + Self::LEGEND_PADDING,
1191                    width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1192                    height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1193                }),
1194            )
1195        } else {
1196            (
1197                Rect {
1198                    x: area.x,
1199                    y: area.y,
1200                    width: area.width,
1201                    height: pie_height,
1202                },
1203                Some(Rect {
1204                    x: area.x + Self::LEGEND_PADDING,
1205                    y: area.y + pie_height + Self::LEGEND_SPACING,
1206                    width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1207                    height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1208                }),
1209            )
1210        }
1211    }
1212
1213    fn calculate_legend_width(&self) -> u16 {
1214        let total = self.total_value();
1215        let widths = self.slices.iter().map(|s| self.legend_item_width(s, total));
1216
1217        let base = match self.legend_layout {
1218            // For vertical layout, the column is as wide as the widest item.
1219            LegendLayout::Vertical => widths.max().unwrap_or(0),
1220            // For horizontal layout, all items sit on one row.
1221            LegendLayout::Horizontal => widths.fold(0u16, u16::saturating_add),
1222        };
1223
1224        base.saturating_add(2)
1225    }
1226
1227    fn calculate_legend_horizontal_width(&self) -> u16 {
1228        let total = self.total_value();
1229        self.slices
1230            .iter()
1231            .map(|s| self.legend_item_width(s, total))
1232            .fold(0u16, u16::saturating_add)
1233            .saturating_add(2)
1234    }
1235
1236    #[allow(clippy::similar_names)]
1237    fn render_piechart_braille(&self, area: Rect, buf: &mut Buffer) {
1238        // Calculate layout with legend positioning
1239        let (pie_area, legend_area_opt) = self.calculate_layout(area);
1240
1241        // Calculate the center and radius of the pie chart
1242        let center_x_chars = pie_area.width / 2;
1243        let center_y_chars = pie_area.height / 2;
1244
1245        // Each character cell has 2x4 braille dots
1246        let center_x_dots = center_x_chars * 2;
1247        let center_y_dots = center_y_chars * 4;
1248
1249        // Calculate radius in dots
1250        // Braille dots are equally spaced in physical screen space because:
1251        // - Character cells are ~2:1 (height:width)
1252        // - But braille has 2 horizontal dots and 4 vertical dots per character
1253        // - So: horizontal spacing = W/2, vertical spacing = 2W/4 = W/2 (equal!)
1254        let radius = (center_x_dots).min(center_y_dots).saturating_sub(2);
1255
1256        // Create a 2D array to store which slice each braille dot belongs to
1257        let width_dots = pie_area.width * 2;
1258        let height_dots = pie_area.height * 4;
1259
1260        let mut dot_slices: Vec<Vec<Option<usize>>> =
1261            vec![vec![None; width_dots as usize]; height_dots as usize];
1262
1263        // Calculate slice assignments for each dot
1264        let mut cumulative_percent = self.start * 100.0;
1265        for (slice_idx, slice) in self.slices.iter().enumerate() {
1266            let percent = self.percentage(slice);
1267            let (start_angle, end_angle, is_full_circle) =
1268                Self::slice_angles(cumulative_percent, percent);
1269
1270            for dy in 0..height_dots {
1271                for dx in 0..width_dots {
1272                    let rel_x = f64::from(dx) - f64::from(center_x_dots);
1273                    let rel_y = f64::from(dy) - f64::from(center_y_dots);
1274
1275                    // No aspect ratio compensation needed for braille dots
1276                    // They're already equally spaced in physical screen space
1277                    let distance = (rel_x * rel_x + rel_y * rel_y).sqrt();
1278
1279                    if distance <= f64::from(radius) && distance >= self.hole * f64::from(radius) {
1280                        let angle = rel_y.atan2(rel_x);
1281                        if is_full_circle || Self::is_angle_in_slice(angle, start_angle, end_angle)
1282                        {
1283                            dot_slices[dy as usize][dx as usize] = Some(slice_idx);
1284                        }
1285                    }
1286                }
1287            }
1288
1289            cumulative_percent += percent;
1290        }
1291
1292        // Convert dot assignments to braille characters
1293        for char_y in 0..pie_area.height {
1294            for char_x in 0..pie_area.width {
1295                let base_dot_x = char_x * 2;
1296                let base_dot_y = char_y * 4;
1297
1298                // Braille pattern mapping (dots are numbered 1-8)
1299                // Dot positions in a 2x4 grid:
1300                // 1 4
1301                // 2 5
1302                // 3 6
1303                // 7 8
1304                let dot_positions = [
1305                    (0, 0, 0x01), // dot 1
1306                    (0, 1, 0x02), // dot 2
1307                    (0, 2, 0x04), // dot 3
1308                    (1, 0, 0x08), // dot 4
1309                    (1, 1, 0x10), // dot 5
1310                    (1, 2, 0x20), // dot 6
1311                    (0, 3, 0x40), // dot 7
1312                    (1, 3, 0x80), // dot 8
1313                ];
1314
1315                let mut pattern = 0u32;
1316                let mut slice_colors: Vec<(usize, u32)> = Vec::new();
1317
1318                for (dx, dy, bit) in dot_positions {
1319                    let dot_x = base_dot_x + dx;
1320                    let dot_y = base_dot_y + dy;
1321
1322                    if dot_y < height_dots && dot_x < width_dots {
1323                        if let Some(slice_idx) = dot_slices[dot_y as usize][dot_x as usize] {
1324                            pattern |= bit;
1325                            // Track which slice and how many dots
1326                            if let Some(entry) =
1327                                slice_colors.iter_mut().find(|(idx, _)| *idx == slice_idx)
1328                            {
1329                                entry.1 += 1;
1330                            } else {
1331                                slice_colors.push((slice_idx, 1));
1332                            }
1333                        }
1334                    }
1335                }
1336
1337                if pattern > 0 {
1338                    // Use the color of the slice with the most dots in this character
1339                    if let Some((slice_idx, _)) = slice_colors.iter().max_by_key(|(_, count)| count)
1340                    {
1341                        let braille_char = char::from_u32(0x2800 + pattern).unwrap_or('⠀');
1342                        let color = self.slices[*slice_idx].color;
1343
1344                        let cell = &mut buf[(pie_area.x + char_x, pie_area.y + char_y)];
1345                        cell.set_char(braille_char).set_fg(color);
1346                    }
1347                }
1348            }
1349        }
1350
1351        self.draw_center(pie_area, buf);
1352
1353        // Draw legend if enabled
1354        if let Some(legend_area) = legend_area_opt {
1355            self.render_legend(buf, legend_area);
1356        }
1357    }
1358}
1359
1360#[cfg(test)]
1361#[allow(clippy::float_cmp)]
1362#[allow(unnameable_test_items)]
1363mod tests {
1364    use super::*;
1365
1366    #[test]
1367    fn pie_slice_new() {
1368        let slice = PieSlice::new("Test", 50.0, Color::Red);
1369        assert_eq!(slice.label(), "Test");
1370        assert_eq!(slice.value(), 50.0);
1371        assert_eq!(slice.color(), Color::Red);
1372    }
1373
1374    #[test]
1375    fn piechart_new() {
1376        let slices = vec![
1377            PieSlice::new("A", 30.0, Color::Red),
1378            PieSlice::new("B", 70.0, Color::Blue),
1379        ];
1380        let piechart = PieChart::new(slices.clone());
1381        assert_eq!(piechart.slices, slices);
1382    }
1383
1384    #[test]
1385    fn piechart_default() {
1386        let piechart = PieChart::default();
1387        assert_eq!(piechart.slices, [] as [PieSlice<'_>; 0]);
1388        assert!(piechart.show_legend);
1389        assert!(piechart.show_percentages);
1390    }
1391
1392    #[test]
1393    fn piechart_slices() {
1394        let slices = vec![PieSlice::new("Test", 100.0, Color::Green)];
1395        let piechart = PieChart::default().slices(slices.clone());
1396        assert_eq!(piechart.slices, slices);
1397    }
1398
1399    #[test]
1400    fn piechart_style() {
1401        let style = Style::default().fg(Color::Red);
1402        let piechart = PieChart::default().style(style);
1403        assert_eq!(piechart.style, style);
1404    }
1405
1406    #[test]
1407    fn piechart_show_legend() {
1408        let piechart = PieChart::default().show_legend(false);
1409        assert!(!piechart.show_legend);
1410    }
1411
1412    #[test]
1413    fn piechart_show_percentages() {
1414        let piechart = PieChart::default().show_percentages(false);
1415        assert!(!piechart.show_percentages);
1416    }
1417
1418    #[test]
1419    fn piechart_pie_char() {
1420        let piechart = PieChart::default().pie_char('█');
1421        assert_eq!(piechart.pie_char, '█');
1422    }
1423
1424    #[test]
1425    fn piechart_total_value() {
1426        let slices = vec![
1427            PieSlice::new("A", 30.0, Color::Red),
1428            PieSlice::new("B", 70.0, Color::Blue),
1429        ];
1430        let piechart = PieChart::new(slices);
1431        assert_eq!(piechart.total_value(), 100.0);
1432    }
1433
1434    #[test]
1435    fn piechart_percentage() {
1436        let slices = vec![
1437            PieSlice::new("A", 30.0, Color::Red),
1438            PieSlice::new("B", 70.0, Color::Blue),
1439        ];
1440        let piechart = PieChart::new(slices);
1441        assert_eq!(
1442            piechart.percentage(&PieSlice::new("A", 30.0, Color::Red)),
1443            30.0
1444        );
1445    }
1446
1447    // Render tests - using macros for common patterns
1448    render_empty_test!(piechart_render_empty_area, PieChart::default());
1449
1450    render_with_size_test!(
1451        piechart_render_with_block,
1452        {
1453            let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1454            PieChart::new(slices).block(Block::bordered())
1455        },
1456        width: 20,
1457        height: 10
1458    );
1459
1460    render_test!(
1461        piechart_render_basic,
1462        {
1463            let slices = vec![
1464                PieSlice::new("Rust", 45.0, Color::Red),
1465                PieSlice::new("Go", 30.0, Color::Blue),
1466                PieSlice::new("Python", 25.0, Color::Green),
1467            ];
1468            PieChart::new(slices)
1469        },
1470        Rect::new(0, 0, 40, 20)
1471    );
1472
1473    #[test]
1474    fn piechart_styled_trait() {
1475        use ratatui::style::Stylize;
1476        let piechart = PieChart::default().red();
1477        assert_eq!(piechart.style.fg, Some(Color::Red));
1478    }
1479
1480    #[test]
1481    fn piechart_with_multiple_slices() {
1482        let slices = vec![
1483            PieSlice::new("A", 25.0, Color::Red),
1484            PieSlice::new("B", 25.0, Color::Blue),
1485            PieSlice::new("C", 25.0, Color::Green),
1486            PieSlice::new("D", 25.0, Color::Yellow),
1487        ];
1488        let piechart = PieChart::new(slices);
1489        assert_eq!(piechart.total_value(), 100.0);
1490    }
1491
1492    // Using render macro for the visual test
1493    render_with_size_test!(
1494        piechart_multi_slice_render,
1495        {
1496            let slices = vec![
1497                PieSlice::new("A", 25.0, Color::Red),
1498                PieSlice::new("B", 25.0, Color::Blue),
1499                PieSlice::new("C", 25.0, Color::Green),
1500                PieSlice::new("D", 25.0, Color::Yellow),
1501            ];
1502            PieChart::new(slices)
1503        },
1504        width: 50,
1505        height: 30
1506    );
1507
1508    #[test]
1509    fn piechart_zero_values() {
1510        let slices = vec![
1511            PieSlice::new("A", 0.0, Color::Red),
1512            PieSlice::new("B", 0.0, Color::Blue),
1513        ];
1514        let piechart = PieChart::new(slices);
1515        assert_eq!(piechart.total_value(), 0.0);
1516    }
1517
1518    #[test]
1519    fn piechart_method_chaining() {
1520        use ratatui::widgets::Block;
1521
1522        let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1523        let piechart = PieChart::new(slices)
1524            .show_legend(true)
1525            .show_percentages(true)
1526            .pie_char('█')
1527            .block(Block::bordered().title("Test"))
1528            .style(Style::default().fg(Color::White));
1529
1530        assert!(piechart.show_legend);
1531        assert!(piechart.show_percentages);
1532        assert_eq!(piechart.pie_char, '█');
1533        assert!(piechart.block.is_some());
1534        assert_eq!(piechart.style.fg, Some(Color::White));
1535    }
1536
1537    #[test]
1538    fn piechart_custom_symbols() {
1539        use crate::symbols;
1540
1541        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_BLOCK);
1542        assert_eq!(piechart.pie_char, '█');
1543
1544        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_CIRCLE);
1545        assert_eq!(piechart.pie_char, '◉');
1546
1547        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_SQUARE);
1548        assert_eq!(piechart.pie_char, '■');
1549    }
1550
1551    #[test]
1552    fn piechart_is_angle_in_slice() {
1553        use std::f64::consts::PI;
1554
1555        // Test angle in range
1556        assert!(PieChart::is_angle_in_slice(PI / 4.0, 0.0, PI / 2.0));
1557
1558        // Test angle outside range
1559        assert!(!PieChart::is_angle_in_slice(PI, 0.0, PI / 2.0));
1560
1561        // Test wrap around
1562        assert!(PieChart::is_angle_in_slice(0.1, 1.5 * PI, 0.5));
1563    }
1564
1565    // --- Resolution ---
1566
1567    matches_test!(
1568        piechart_resolution_standard,
1569        PieChart::default()
1570            .resolution(Resolution::Standard)
1571            .resolution,
1572        Resolution::Standard
1573    );
1574    matches_test!(
1575        piechart_resolution_braille,
1576        PieChart::default()
1577            .resolution(Resolution::Braille)
1578            .resolution,
1579        Resolution::Braille
1580    );
1581    matches_test!(
1582        piechart_high_resolution_true,
1583        PieChart::default().high_resolution(true).resolution,
1584        Resolution::Braille
1585    );
1586    matches_test!(
1587        piechart_high_resolution_false,
1588        PieChart::default().high_resolution(false).resolution,
1589        Resolution::Standard
1590    );
1591
1592    // --- Legend position / layout / alignment setters ---
1593
1594    matches_test!(
1595        piechart_legend_position_left,
1596        PieChart::default()
1597            .legend_position(LegendPosition::Left)
1598            .legend_position,
1599        LegendPosition::Left
1600    );
1601    matches_test!(
1602        piechart_legend_position_right,
1603        PieChart::default()
1604            .legend_position(LegendPosition::Right)
1605            .legend_position,
1606        LegendPosition::Right
1607    );
1608    matches_test!(
1609        piechart_legend_position_top,
1610        PieChart::default()
1611            .legend_position(LegendPosition::Top)
1612            .legend_position,
1613        LegendPosition::Top
1614    );
1615    matches_test!(
1616        piechart_legend_position_bottom,
1617        PieChart::default()
1618            .legend_position(LegendPosition::Bottom)
1619            .legend_position,
1620        LegendPosition::Bottom
1621    );
1622    matches_test!(
1623        piechart_legend_layout_horizontal,
1624        PieChart::default()
1625            .legend_layout(LegendLayout::Horizontal)
1626            .legend_layout,
1627        LegendLayout::Horizontal
1628    );
1629    matches_test!(
1630        piechart_legend_layout_vertical,
1631        PieChart::default()
1632            .legend_layout(LegendLayout::Vertical)
1633            .legend_layout,
1634        LegendLayout::Vertical
1635    );
1636    matches_test!(
1637        piechart_legend_alignment_left,
1638        PieChart::default()
1639            .legend_alignment(LegendAlignment::Left)
1640            .legend_alignment,
1641        LegendAlignment::Left
1642    );
1643    matches_test!(
1644        piechart_legend_alignment_center,
1645        PieChart::default()
1646            .legend_alignment(LegendAlignment::Center)
1647            .legend_alignment,
1648        LegendAlignment::Center
1649    );
1650    matches_test!(
1651        piechart_legend_alignment_right,
1652        PieChart::default()
1653            .legend_alignment(LegendAlignment::Right)
1654            .legend_alignment,
1655        LegendAlignment::Right
1656    );
1657
1658    // --- legend_marker setter ---
1659
1660    #[test]
1661    fn piechart_legend_marker_custom() {
1662        use crate::symbols::LEGEND_MARKER;
1663        let piechart = PieChart::default().legend_marker(LEGEND_MARKER);
1664        assert_eq!(piechart.legend_marker, LEGEND_MARKER);
1665    }
1666
1667    // --- format_legend_text ---
1668
1669    #[test]
1670    fn piechart_format_legend_text_with_percentage() {
1671        let slices = vec![
1672            PieSlice::new("Rust", 50.0, Color::Red),
1673            PieSlice::new("Go", 50.0, Color::Blue),
1674        ];
1675        let piechart = PieChart::new(slices.clone()).show_percentages(true);
1676        let text = piechart.format_legend_text(&slices[0], 100.0, "");
1677        assert!(text.contains("Rust"));
1678        assert!(text.contains("50.0%"));
1679    }
1680
1681    #[test]
1682    fn piechart_format_legend_text_without_percentage() {
1683        let slices = vec![PieSlice::new("Rust", 50.0, Color::Red)];
1684        let piechart = PieChart::new(slices.clone()).show_percentages(false);
1685        let text = piechart.format_legend_text(&slices[0], 100.0, "");
1686        assert!(text.contains("Rust"));
1687        assert!(!text.contains('%'));
1688    }
1689
1690    #[test]
1691    fn piechart_format_legend_text_zero_total() {
1692        let slices = vec![PieSlice::new("X", 0.0, Color::Red)];
1693        let piechart = PieChart::new(slices.clone()).show_percentages(true);
1694        let text = piechart.format_legend_text(&slices[0], 0.0, "");
1695        assert!(text.contains("0.0%"));
1696    }
1697
1698    // --- calculate_aligned_x ---
1699
1700    #[test]
1701    fn piechart_calculate_aligned_x_left() {
1702        let slices = vec![PieSlice::new("A", 100.0, Color::Red)];
1703        let piechart = PieChart::new(slices).legend_alignment(LegendAlignment::Left);
1704        let area = Rect::new(5, 0, 20, 10);
1705        assert_eq!(piechart.calculate_aligned_x(area, 10), 5);
1706    }
1707
1708    #[test]
1709    fn piechart_calculate_aligned_x_center() {
1710        let slices = vec![PieSlice::new("A", 100.0, Color::Red)];
1711        let piechart = PieChart::new(slices).legend_alignment(LegendAlignment::Center);
1712        let area = Rect::new(0, 0, 20, 10);
1713        // (20 - 10) / 2 = 5
1714        assert_eq!(piechart.calculate_aligned_x(area, 10), 5);
1715    }
1716
1717    #[test]
1718    fn piechart_calculate_aligned_x_right() {
1719        let slices = vec![PieSlice::new("A", 100.0, Color::Red)];
1720        let piechart = PieChart::new(slices).legend_alignment(LegendAlignment::Right);
1721        let area = Rect::new(0, 0, 20, 10);
1722        // 20 - 10 = 10
1723        assert_eq!(piechart.calculate_aligned_x(area, 10), 10);
1724    }
1725
1726    // --- Braille render ---
1727
1728    render_test!(
1729        piechart_render_braille,
1730        {
1731            let slices = vec![
1732                PieSlice::new("Rust", 60.0, Color::Red),
1733                PieSlice::new("Go", 40.0, Color::Blue),
1734            ];
1735            PieChart::new(slices).resolution(Resolution::Braille)
1736        },
1737        Rect::new(0, 0, 40, 20)
1738    );
1739
1740    render_test!(
1741        piechart_render_braille_with_legend,
1742        {
1743            let slices = vec![
1744                PieSlice::new("Rust", 60.0, Color::Red),
1745                PieSlice::new("Go", 40.0, Color::Blue),
1746            ];
1747            PieChart::new(slices)
1748                .resolution(Resolution::Braille)
1749                .show_legend(true)
1750        },
1751        Rect::new(0, 0, 40, 20)
1752    );
1753
1754    // --- Legend layout render paths ---
1755
1756    render_test!(
1757        piechart_render_legend_left,
1758        {
1759            let slices = vec![
1760                PieSlice::new("Alpha", 50.0, Color::Red),
1761                PieSlice::new("Beta", 50.0, Color::Blue),
1762            ];
1763            PieChart::new(slices)
1764                .show_legend(true)
1765                .legend_position(LegendPosition::Left)
1766                .legend_layout(LegendLayout::Vertical)
1767        },
1768        Rect::new(0, 0, 60, 20)
1769    );
1770
1771    render_test!(
1772        piechart_render_legend_right,
1773        {
1774            let slices = vec![
1775                PieSlice::new("Alpha", 50.0, Color::Red),
1776                PieSlice::new("Beta", 50.0, Color::Blue),
1777            ];
1778            PieChart::new(slices)
1779                .show_legend(true)
1780                .legend_position(LegendPosition::Right)
1781                .legend_layout(LegendLayout::Vertical)
1782        },
1783        Rect::new(0, 0, 60, 20)
1784    );
1785
1786    render_test!(
1787        piechart_render_legend_top_horizontal,
1788        {
1789            let slices = vec![
1790                PieSlice::new("Alpha", 50.0, Color::Red),
1791                PieSlice::new("Beta", 50.0, Color::Blue),
1792            ];
1793            PieChart::new(slices)
1794                .show_legend(true)
1795                .legend_position(LegendPosition::Top)
1796                .legend_layout(LegendLayout::Horizontal)
1797        },
1798        Rect::new(0, 0, 60, 20)
1799    );
1800
1801    render_test!(
1802        piechart_render_legend_bottom_horizontal,
1803        {
1804            let slices = vec![
1805                PieSlice::new("Alpha", 50.0, Color::Red),
1806                PieSlice::new("Beta", 50.0, Color::Blue),
1807            ];
1808            PieChart::new(slices)
1809                .show_legend(true)
1810                .legend_position(LegendPosition::Bottom)
1811                .legend_layout(LegendLayout::Horizontal)
1812        },
1813        Rect::new(0, 0, 60, 20)
1814    );
1815
1816    render_test!(
1817        piechart_render_legend_top_vertical,
1818        {
1819            let slices = vec![
1820                PieSlice::new("Alpha", 50.0, Color::Red),
1821                PieSlice::new("Beta", 50.0, Color::Blue),
1822            ];
1823            PieChart::new(slices)
1824                .show_legend(true)
1825                .legend_position(LegendPosition::Top)
1826                .legend_layout(LegendLayout::Vertical)
1827        },
1828        Rect::new(0, 0, 60, 20)
1829    );
1830
1831    render_test!(
1832        piechart_render_legend_bottom_vertical,
1833        {
1834            let slices = vec![
1835                PieSlice::new("Alpha", 50.0, Color::Red),
1836                PieSlice::new("Beta", 50.0, Color::Blue),
1837            ];
1838            PieChart::new(slices)
1839                .show_legend(true)
1840                .legend_position(LegendPosition::Bottom)
1841                .legend_layout(LegendLayout::Vertical)
1842        },
1843        Rect::new(0, 0, 60, 20)
1844    );
1845
1846    render_test!(
1847        piechart_render_legend_left_horizontal,
1848        {
1849            let slices = vec![
1850                PieSlice::new("Alpha", 50.0, Color::Red),
1851                PieSlice::new("Beta", 50.0, Color::Blue),
1852            ];
1853            PieChart::new(slices)
1854                .show_legend(true)
1855                .legend_position(LegendPosition::Left)
1856                .legend_layout(LegendLayout::Horizontal)
1857        },
1858        Rect::new(0, 0, 60, 20)
1859    );
1860
1861    render_test!(
1862        piechart_render_legend_right_horizontal,
1863        {
1864            let slices = vec![
1865                PieSlice::new("Alpha", 50.0, Color::Red),
1866                PieSlice::new("Beta", 50.0, Color::Blue),
1867            ];
1868            PieChart::new(slices)
1869                .show_legend(true)
1870                .legend_position(LegendPosition::Right)
1871                .legend_layout(LegendLayout::Horizontal)
1872        },
1873        Rect::new(0, 0, 60, 20)
1874    );
1875
1876    // --- Legend with percentages ---
1877
1878    render_test!(
1879        piechart_render_legend_with_percentages,
1880        {
1881            let slices = vec![
1882                PieSlice::new("Rust", 45.0, Color::Red),
1883                PieSlice::new("Go", 30.0, Color::Blue),
1884                PieSlice::new("Python", 25.0, Color::Green),
1885            ];
1886            PieChart::new(slices)
1887                .show_legend(true)
1888                .show_percentages(true)
1889        },
1890        Rect::new(0, 0, 60, 20)
1891    );
1892
1893    // Legend alignment render paths
1894    render_test!(
1895        piechart_render_legend_alignment_center,
1896        {
1897            let slices = vec![
1898                PieSlice::new("A", 50.0, Color::Red),
1899                PieSlice::new("B", 50.0, Color::Blue),
1900            ];
1901            PieChart::new(slices)
1902                .show_legend(true)
1903                .legend_alignment(LegendAlignment::Center)
1904        },
1905        Rect::new(0, 0, 60, 20)
1906    );
1907
1908    render_test!(
1909        piechart_render_legend_alignment_right,
1910        {
1911            let slices = vec![
1912                PieSlice::new("A", 50.0, Color::Red),
1913                PieSlice::new("B", 50.0, Color::Blue),
1914            ];
1915            PieChart::new(slices)
1916                .show_legend(true)
1917                .legend_alignment(LegendAlignment::Right)
1918        },
1919        Rect::new(0, 0, 60, 20)
1920    );
1921
1922    // --- calculate_layout: small area returns no legend ---
1923
1924    #[test]
1925    fn piechart_layout_too_small_no_legend() {
1926        let slices = vec![PieSlice::new("A", 100.0, Color::Red)];
1927        let piechart = PieChart::new(slices).show_legend(true);
1928        // area too narrow (< 20)
1929        let area = Rect::new(0, 0, 10, 5);
1930        let (pie_area, legend_opt) = piechart.calculate_layout(area);
1931        assert_eq!(pie_area, area);
1932        assert!(legend_opt.is_none());
1933    }
1934
1935    #[test]
1936    fn piechart_layout_show_legend_false_no_legend() {
1937        let slices = vec![PieSlice::new("A", 100.0, Color::Red)];
1938        let piechart = PieChart::new(slices).show_legend(false);
1939        let area = Rect::new(0, 0, 60, 20);
1940        let (pie_area, legend_opt) = piechart.calculate_layout(area);
1941        assert_eq!(pie_area, area);
1942        assert!(legend_opt.is_none());
1943    }
1944
1945    // --- Render with no slices (empty) ---
1946
1947    render_empty_test!(piechart_render_empty_slices, PieChart::default());
1948
1949    // --- Render single slice ---
1950
1951    render_test!(
1952        piechart_render_single_slice,
1953        PieChart::new(vec![PieSlice::new("Only", 100.0, Color::Cyan)]),
1954        Rect::new(0, 0, 30, 15)
1955    );
1956
1957    // --- Percentage zero total ---
1958
1959    #[test]
1960    fn piechart_percentage_zero_total() {
1961        let slices = vec![PieSlice::new("A", 0.0, Color::Red)];
1962        let piechart = PieChart::new(slices.clone());
1963        assert_eq!(piechart.percentage(&slices[0]), 0.0);
1964    }
1965
1966    // --- calculate_legend_width Vertical: zero total with percentages (line 1041) ---
1967    // Must call the private method directly: render_piechart returns early when total == 0,
1968    // so the zero-total branch inside calculate_legend_width is only reachable this way.
1969
1970    #[test]
1971    fn piechart_calculate_legend_width_vertical_zero_total_with_percentages() {
1972        let slices = vec![
1973            PieSlice::new("A", 0.0, Color::Red),
1974            PieSlice::new("B", 0.0, Color::Blue),
1975        ];
1976        let piechart = PieChart::new(slices)
1977            .legend_layout(LegendLayout::Vertical)
1978            .show_percentages(true);
1979        let width = piechart.calculate_legend_width();
1980        // Each label shows "• A 0.0%  " — width must be > 0
1981        assert!(width > 0);
1982    }
1983
1984    render_test!(
1985        piechart_render_legend_vertical_zero_total_with_percentages,
1986        PieChart::new(vec![
1987            PieSlice::new("A", 0.0, Color::Red),
1988            PieSlice::new("B", 0.0, Color::Blue),
1989        ])
1990        .show_legend(true)
1991        .show_percentages(true)
1992        .legend_position(LegendPosition::Right)
1993        .legend_layout(LegendLayout::Vertical),
1994        Rect::new(0, 0, 60, 20)
1995    );
1996
1997    // --- calculate_legend_width Vertical: show_percentages=false (line 1045) ---
1998
1999    render_test!(
2000        piechart_render_legend_vertical_no_percentages,
2001        PieChart::new(vec![
2002            PieSlice::new("Rust", 60.0, Color::Red),
2003            PieSlice::new("Go", 40.0, Color::Blue),
2004        ])
2005        .show_legend(true)
2006        .show_percentages(false)
2007        .legend_position(LegendPosition::Right)
2008        .legend_layout(LegendLayout::Vertical),
2009        Rect::new(0, 0, 60, 20)
2010    );
2011
2012    // --- render_piechart: total <= 0.0 guard (line 631) ---
2013
2014    render_test!(
2015        piechart_render_all_zero_values,
2016        PieChart::new(vec![
2017            PieSlice::new("A", 0.0, Color::Red),
2018            PieSlice::new("B", 0.0, Color::Blue),
2019        ]),
2020        Rect::new(0, 0, 40, 20)
2021    );
2022
2023    // --- render_slice: percent <= 0.0 guard (line 691) ---
2024
2025    render_test!(
2026        piechart_render_zero_value_slice_in_mix,
2027        PieChart::new(vec![
2028            PieSlice::new("A", 100.0, Color::Red),
2029            PieSlice::new("B", 0.0, Color::Blue),
2030        ]),
2031        Rect::new(0, 0, 40, 20)
2032    );
2033
2034    // --- render_slice: radius == 0 guard — tiny 1×1 area (line 691) ---
2035
2036    render_test!(
2037        piechart_render_tiny_area_radius_zero,
2038        PieChart::new(vec![PieSlice::new("A", 100.0, Color::Red)]),
2039        Rect::new(0, 0, 1, 1)
2040    );
2041
2042    // --- render_vertical_legend: overflow break (line 814) ---
2043    // Many slices in a tall-enough outer area but tiny legend height forces the break.
2044
2045    render_test!(
2046        piechart_render_vertical_legend_overflow,
2047        PieChart::new(vec![
2048            PieSlice::new("Slice1", 10.0, Color::Red),
2049            PieSlice::new("Slice2", 10.0, Color::Blue),
2050            PieSlice::new("Slice3", 10.0, Color::Green),
2051            PieSlice::new("Slice4", 10.0, Color::Yellow),
2052            PieSlice::new("Slice5", 10.0, Color::Cyan),
2053            PieSlice::new("Slice6", 10.0, Color::Magenta),
2054            PieSlice::new("Slice7", 10.0, Color::White),
2055            PieSlice::new("Slice8", 10.0, Color::Red),
2056            PieSlice::new("Slice9", 10.0, Color::Blue),
2057            PieSlice::new("Slice10", 10.0, Color::Green),
2058        ])
2059        .show_legend(true)
2060        .legend_position(LegendPosition::Right)
2061        .legend_layout(LegendLayout::Vertical),
2062        // height=12 → legend area height ≈ 10 → only 5 items fit (each takes 2 rows)
2063        Rect::new(0, 0, 60, 12)
2064    );
2065
2066    // --- render_horizontal_legend: overflow break (line 854) ---
2067    // Many slices with a very narrow area forces the x_offset >= width break.
2068
2069    render_test!(
2070        piechart_render_horizontal_legend_overflow,
2071        PieChart::new(vec![
2072            PieSlice::new("LongLabelA", 20.0, Color::Red),
2073            PieSlice::new("LongLabelB", 20.0, Color::Blue),
2074            PieSlice::new("LongLabelC", 20.0, Color::Green),
2075            PieSlice::new("LongLabelD", 20.0, Color::Yellow),
2076            PieSlice::new("LongLabelE", 20.0, Color::Cyan),
2077        ])
2078        .show_legend(true)
2079        .legend_position(LegendPosition::Bottom)
2080        .legend_layout(LegendLayout::Horizontal),
2081        // width=22 is very narrow relative to the label sizes
2082        Rect::new(0, 0, 22, 15)
2083    );
2084
2085    // --- layout_horizontal_split: early return when area.width <= legend_width (line 988) ---
2086
2087    #[test]
2088    fn piechart_layout_horizontal_split_too_narrow() {
2089        let area = Rect::new(0, 0, 5, 20);
2090        // legend_width (100) > area.width (5) → early return
2091        let (pie_area, legend_opt) = PieChart::<'_>::layout_horizontal_split(area, 100, true);
2092        assert_eq!(pie_area, area);
2093        assert!(legend_opt.is_none());
2094    }
2095
2096    // --- layout_vertical_split: early return when area.height <= legend_height (line 1041) ---
2097
2098    #[test]
2099    fn piechart_layout_vertical_split_too_short() {
2100        let area = Rect::new(0, 0, 60, 3);
2101        // legend_height (100) > area.height (3) → early return
2102        let (pie_area, legend_opt) = PieChart::<'_>::layout_vertical_split(area, 100, true);
2103        assert_eq!(pie_area, area);
2104        assert!(legend_opt.is_none());
2105    }
2106
2107    // --- calculate_legend_width: LegendLayout::Horizontal branch (lines 1057-1076) ---
2108    // This branch is only reachable by calling the method directly in tests.
2109
2110    #[test]
2111    fn piechart_calculate_legend_width_horizontal_with_percentages() {
2112        let slices = vec![
2113            PieSlice::new("Rust", 60.0, Color::Red),
2114            PieSlice::new("Go", 40.0, Color::Blue),
2115        ];
2116        let piechart = PieChart::new(slices)
2117            .legend_layout(LegendLayout::Horizontal)
2118            .show_percentages(true);
2119        let width = piechart.calculate_legend_width();
2120        assert!(width > 0);
2121    }
2122
2123    #[test]
2124    fn piechart_calculate_legend_width_horizontal_without_percentages() {
2125        let slices = vec![
2126            PieSlice::new("Alpha", 50.0, Color::Red),
2127            PieSlice::new("Beta", 50.0, Color::Blue),
2128        ];
2129        let piechart = PieChart::new(slices)
2130            .legend_layout(LegendLayout::Horizontal)
2131            .show_percentages(false);
2132        let width = piechart.calculate_legend_width();
2133        assert!(width > 0);
2134    }
2135
2136    #[test]
2137    fn piechart_calculate_legend_width_horizontal_zero_total() {
2138        let slices = vec![
2139            PieSlice::new("A", 0.0, Color::Red),
2140            PieSlice::new("B", 0.0, Color::Blue),
2141        ];
2142        let piechart = PieChart::new(slices)
2143            .legend_layout(LegendLayout::Horizontal)
2144            .show_percentages(true);
2145        let width = piechart.calculate_legend_width();
2146        // Zero total → 0.0% for each item, but width is still > 0
2147        assert!(width > 0);
2148    }
2149
2150    // --- calculate_legend_horizontal_width: zero total branch (lines 1090, 1094) ---
2151
2152    #[test]
2153    fn piechart_calculate_legend_horizontal_width_zero_total() {
2154        let slices = vec![
2155            PieSlice::new("X", 0.0, Color::Red),
2156            PieSlice::new("Y", 0.0, Color::Blue),
2157        ];
2158        let piechart = PieChart::new(slices).show_percentages(true);
2159        let width = piechart.calculate_legend_horizontal_width();
2160        assert!(width > 0);
2161    }
2162
2163    #[test]
2164    fn piechart_calculate_legend_horizontal_width_without_percentages() {
2165        let slices = vec![PieSlice::new("Item", 100.0, Color::Green)];
2166        let piechart = PieChart::new(slices).show_percentages(false);
2167        let width = piechart.calculate_legend_horizontal_width();
2168        assert!(width > 0);
2169    }
2170
2171    // --- calculate_vertical_grid_height: very narrow width clamps columns to 1 ---
2172
2173    #[test]
2174    fn piechart_calculate_vertical_grid_height_narrow() {
2175        let slices = vec![
2176            PieSlice::new("A", 50.0, Color::Red),
2177            PieSlice::new("B", 50.0, Color::Blue),
2178        ];
2179        let piechart = PieChart::new(slices);
2180        // width=1 → columns clamped to 1 → all items stack in one column
2181        let height = piechart.calculate_vertical_grid_height(1);
2182        assert!(height >= 4);
2183    }
2184
2185    // --- value_percent / slice_angles helpers ---
2186
2187    #[test]
2188    fn piechart_value_percent_positive_total() {
2189        assert_eq!(PieChart::value_percent(25.0, 200.0), 12.5);
2190    }
2191
2192    #[test]
2193    fn piechart_value_percent_zero_total() {
2194        assert_eq!(PieChart::value_percent(25.0, 0.0), 0.0);
2195    }
2196
2197    #[test]
2198    fn piechart_value_percent_negative_total() {
2199        // Non-positive totals are treated as empty to avoid nonsensical output.
2200        assert_eq!(PieChart::value_percent(25.0, -5.0), 0.0);
2201    }
2202
2203    #[test]
2204    fn piechart_slice_angles_full_circle_flag() {
2205        let (_, _, is_full) = PieChart::slice_angles(0.0, 100.0);
2206        assert!(is_full);
2207    }
2208
2209    #[test]
2210    fn piechart_slice_angles_partial_not_full() {
2211        let (start, end, is_full) = PieChart::slice_angles(0.0, 50.0);
2212        assert!(!is_full);
2213        assert!((end - start - PI).abs() < 1e-9);
2214    }
2215
2216    #[test]
2217    fn piechart_legend_item_width_matches_text() {
2218        let slices = vec![PieSlice::new("Rust", 50.0, Color::Red)];
2219        let chart = PieChart::new(slices.clone()).show_percentages(true);
2220        let expected = chart
2221            .format_legend_text(&slices[0], 100.0, "  ")
2222            .chars()
2223            .count();
2224        assert_eq!(
2225            usize::from(chart.legend_item_width(&slices[0], 100.0)),
2226            expected
2227        );
2228    }
2229
2230    #[test]
2231    fn piechart_legend_item_width_unicode_label() {
2232        // Multi-byte labels must be measured by character count, not byte length.
2233        let slices = vec![PieSlice::new("日本語", 100.0, Color::Red)];
2234        let chart = PieChart::new(slices.clone()).show_percentages(false);
2235        // "■ 日本語  " => marker(1) + space(1) + 3 chars + 2 trailing spaces = 7
2236        assert_eq!(chart.legend_item_width(&slices[0], 100.0), 7);
2237    }
2238
2239    // --- Full circle rendering (regression for issue #2) ---
2240
2241    #[test]
2242    fn piechart_full_circle_fills_more_than_a_line() {
2243        fn filled_cells(chart: &PieChart) -> usize {
2244            let mut buffer = Buffer::empty(Rect::new(0, 0, 30, 15));
2245            Widget::render(chart, buffer.area, &mut buffer);
2246            buffer
2247                .content
2248                .iter()
2249                .filter(|c| !c.symbol().trim().is_empty())
2250                .count()
2251        }
2252
2253        let standard = PieChart::new(vec![PieSlice::new("Only", 100.0, Color::Green)])
2254            .show_legend(false)
2255            .show_percentages(false);
2256        assert!(filled_cells(&standard) > 30);
2257
2258        let braille = PieChart::new(vec![PieSlice::new("Only", 100.0, Color::Green)])
2259            .resolution(Resolution::Braille)
2260            .show_legend(false)
2261            .show_percentages(false);
2262        assert!(filled_cells(&braille) > 30);
2263    }
2264}