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