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