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