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    /// Calculates the percentage for a given slice.
554    fn percentage(&self, slice: &PieSlice) -> f64 {
555        let total = self.total_value();
556        if total > 0.0 {
557            (slice.value / total) * 100.0
558        } else {
559            0.0
560        }
561    }
562}
563
564impl Styled for PieChart<'_> {
565    type Item = Self;
566
567    fn style(&self) -> Style {
568        self.style
569    }
570
571    fn set_style<S: Into<Style>>(mut self, style: S) -> Self::Item {
572        self.style = style.into();
573        self
574    }
575}
576
577impl Widget for PieChart<'_> {
578    fn render(self, area: Rect, buf: &mut Buffer) {
579        Widget::render(&self, area, buf);
580    }
581}
582
583impl Widget for &PieChart<'_> {
584    fn render(self, area: Rect, buf: &mut Buffer) {
585        buf.set_style(area, self.style);
586        let inner = if let Some(ref block) = self.block {
587            let inner_area = block.inner(area);
588            block.render(area, buf);
589            inner_area
590        } else {
591            area
592        };
593        self.render_piechart(inner, buf);
594    }
595}
596
597impl PieChart<'_> {
598    /// Maximum ratio for vertical legend width (1/3 of available width).
599    const LEGEND_VERTICAL_MAX_RATIO: u16 = 3;
600
601    /// Minimum width for vertical legend to ensure readability.
602    const LEGEND_VERTICAL_MIN_WIDTH: u16 = 20;
603
604    /// Maximum ratio for horizontal legend width (2/5 = 40% of available width).
605    /// This keeps the pie chart proportional and prevents legend from dominating.
606    const LEGEND_HORIZONTAL_MAX_RATIO: u16 = 5;
607
608    /// Absolute maximum width for horizontal legends to prevent excessive space usage.
609    const LEGEND_HORIZONTAL_MAX_WIDTH: u16 = 60;
610
611    /// Absolute maximum height for vertical legends to prevent pie chart from being too small.
612    /// This allows 4 items with spacing (4 items * 2 lines = 8 lines, +1 for padding = 9).
613    const LEGEND_VERTICAL_MAX_HEIGHT: u16 = 9;
614
615    /// Height required for horizontal legend layout (single row with padding).
616    const LEGEND_HORIZONTAL_HEIGHT: u16 = 3;
617
618    /// Space between pie chart and legend areas.
619    const LEGEND_SPACING: u16 = 1;
620
621    /// Inner padding for legend area.
622    const LEGEND_PADDING: u16 = 1;
623
624    fn render_piechart(&self, area: Rect, buf: &mut Buffer) {
625        if area.is_empty() || self.slices.is_empty() {
626            return;
627        }
628
629        let total = self.total_value();
630        if total <= 0.0 {
631            return;
632        }
633
634        match self.resolution {
635            Resolution::Standard => {
636                // Continue with standard rendering below
637            }
638            Resolution::Braille => {
639                self.render_piechart_braille(area, buf);
640                return;
641            }
642        }
643
644        // Calculate layout with legend positioning
645        let (pie_area, legend_area_opt) = self.calculate_layout(area);
646
647        // Calculate the center and radius of the pie chart
648        // Account for terminal character aspect ratio (typically 1:2, chars are twice as tall as wide)
649        let center_x = pie_area.width / 2;
650        let center_y = pie_area.height / 2;
651
652        // Adjust radius for aspect ratio - use width as limiting factor
653        let radius = center_x.min(center_y * 2).saturating_sub(1);
654
655        // Draw the pie chart
656        let mut cumulative_percent = 0.0;
657        for slice in &self.slices {
658            let percent = self.percentage(slice);
659            self.render_slice(
660                pie_area,
661                buf,
662                center_x,
663                center_y,
664                radius,
665                cumulative_percent,
666                percent,
667                slice.color,
668            );
669            cumulative_percent += percent;
670        }
671
672        // Draw legend if enabled
673        if let Some(legend_area) = legend_area_opt {
674            self.render_legend(buf, legend_area);
675        }
676    }
677
678    #[allow(clippy::too_many_arguments, clippy::similar_names)]
679    fn render_slice(
680        &self,
681        area: Rect,
682        buf: &mut Buffer,
683        center_x: u16,
684        center_y: u16,
685        radius: u16,
686        start_percent: f64,
687        percent: f64,
688        color: Color,
689    ) {
690        if radius == 0 || percent <= 0.0 {
691            return;
692        }
693
694        // Start angle at top (90 degrees) and go clockwise
695        let start_angle = (start_percent / 100.0) * 2.0 * PI - PI / 2.0;
696        let end_angle = ((start_percent + percent) / 100.0) * 2.0 * PI - PI / 2.0;
697
698        // Scan the entire area around the center
699        let scan_width = i32::from(radius + 1);
700        let scan_height = i32::from((radius / 2) + 1); // Account for aspect ratio
701
702        for dy in -scan_height..=scan_height {
703            for dx in -scan_width..=scan_width {
704                // Calculate actual position in buffer
705                let x = i32::from(area.x) + i32::from(center_x) + dx;
706                let y = i32::from(area.y) + i32::from(center_y) + dy;
707
708                // Check bounds
709                if x < i32::from(area.x)
710                    || x >= i32::from(area.x + area.width)
711                    || y < i32::from(area.y)
712                    || y >= i32::from(area.y + area.height)
713                {
714                    continue;
715                }
716
717                // Adjust for aspect ratio: multiply y distance by 2
718                #[allow(clippy::cast_precision_loss)]
719                let adjusted_dx = f64::from(dx);
720                #[allow(clippy::cast_precision_loss)]
721                let adjusted_dy = f64::from(dy * 2);
722
723                // Calculate distance from center
724                let distance = (adjusted_dx * adjusted_dx + adjusted_dy * adjusted_dy).sqrt();
725
726                // Check if point is within radius
727                #[allow(clippy::cast_precision_loss)]
728                if distance <= f64::from(radius) {
729                    // Calculate angle from center (0 = right, PI/2 = up, PI = left, 3PI/2 = down)
730                    let angle = adjusted_dy.atan2(adjusted_dx);
731
732                    // Check if angle is within slice
733                    if Self::is_angle_in_slice(angle, start_angle, end_angle) {
734                        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
735                        {
736                            let cell = &mut buf[(x as u16, y as u16)];
737                            cell.set_char(self.pie_char).set_fg(color);
738                        }
739                    }
740                }
741            }
742        }
743    }
744
745    fn is_angle_in_slice(angle: f64, start: f64, end: f64) -> bool {
746        // Normalize angles to [0, 2π]
747        let normalize = |a: f64| {
748            let mut normalized = a % (2.0 * PI);
749            if normalized < 0.0 {
750                normalized += 2.0 * PI;
751            }
752            normalized
753        };
754
755        let norm_angle = normalize(angle);
756        let norm_start = normalize(start);
757        let norm_end = normalize(end);
758
759        if norm_start <= norm_end {
760            norm_angle >= norm_start && norm_angle <= norm_end
761        } else {
762            // Handle wrap around at 2π/0
763            norm_angle >= norm_start || norm_angle <= norm_end
764        }
765    }
766
767    fn format_legend_text(&self, slice: &PieSlice, total: f64, spacing: &str) -> String {
768        if self.show_percentages {
769            let percent = if total > 0.0 {
770                (slice.value / total) * 100.0
771            } else {
772                0.0
773            };
774            format!(
775                "{} {} {:.1}%{}",
776                self.legend_marker, slice.label, percent, spacing
777            )
778        } else {
779            format!("{} {}{}", self.legend_marker, slice.label, spacing)
780        }
781    }
782
783    fn calculate_aligned_x(&self, legend_area: Rect, content_width: u16) -> u16 {
784        match self.legend_alignment {
785            LegendAlignment::Left => legend_area.x,
786            LegendAlignment::Center => {
787                legend_area.x + (legend_area.width.saturating_sub(content_width)) / 2
788            }
789            LegendAlignment::Right => {
790                legend_area.x + legend_area.width.saturating_sub(content_width)
791            }
792        }
793    }
794
795    fn render_legend(&self, buf: &mut Buffer, legend_area: Rect) {
796        let total = self.total_value();
797
798        match self.legend_layout {
799            LegendLayout::Vertical => {
800                self.render_vertical_legend(buf, legend_area, total);
801            }
802            LegendLayout::Horizontal => {
803                self.render_horizontal_legend(buf, legend_area, total);
804            }
805        }
806    }
807
808    fn render_vertical_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
809        for (idx, slice) in self.slices.iter().enumerate() {
810            #[allow(clippy::cast_possible_truncation)]
811            let y_offset = (idx as u16) * 2;
812
813            if y_offset >= legend_area.height {
814                break;
815            }
816
817            let legend_text = self.format_legend_text(slice, total, "");
818            #[allow(clippy::cast_possible_truncation)]
819            let text_width = legend_text.len() as u16;
820            let x_pos = self.calculate_aligned_x(legend_area, text_width);
821
822            let line = Line::from(vec![Span::styled(
823                legend_text,
824                Style::default().fg(slice.color),
825            )]);
826            let item_area = Rect {
827                x: x_pos,
828                y: legend_area.y + y_offset,
829                width: text_width.min(legend_area.width),
830                height: 1,
831            };
832
833            line.render(item_area, buf);
834        }
835    }
836
837    fn render_horizontal_legend(&self, buf: &mut Buffer, legend_area: Rect, total: f64) {
838        let mut total_width = 0u16;
839        let mut item_widths = Vec::new();
840
841        for slice in &self.slices {
842            let legend_text = self.format_legend_text(slice, total, "  ");
843            #[allow(clippy::cast_possible_truncation)]
844            let text_width = legend_text.len() as u16;
845            item_widths.push(text_width);
846            total_width = total_width.saturating_add(text_width);
847        }
848
849        let start_x = self.calculate_aligned_x(legend_area, total_width.min(legend_area.width));
850        let mut x_offset = 0u16;
851
852        for (idx, slice) in self.slices.iter().enumerate() {
853            if x_offset >= legend_area.width {
854                break;
855            }
856
857            let legend_text = self.format_legend_text(slice, total, "  ");
858            let text_width = item_widths[idx];
859
860            let line = Line::from(vec![Span::styled(
861                legend_text,
862                Style::default().fg(slice.color),
863            )]);
864            let item_area = Rect {
865                x: start_x + x_offset,
866                y: legend_area.y,
867                width: text_width.min(legend_area.width.saturating_sub(x_offset)),
868                height: 1,
869            };
870
871            line.render(item_area, buf);
872            x_offset = x_offset.saturating_add(text_width);
873        }
874    }
875
876    #[allow(clippy::too_many_lines)]
877    fn calculate_layout(&self, area: Rect) -> (Rect, Option<Rect>) {
878        if !self.show_legend || area.width < 20 || area.height < 10 {
879            return (area, None);
880        }
881
882        // Vertical layout uses Left/Right positions, Horizontal layout uses Top/Bottom
883        match (self.legend_position, self.legend_layout) {
884            // Left/Right with Vertical layout - proper vertical stacking on sides
885            (LegendPosition::Left | LegendPosition::Right, LegendLayout::Vertical) => {
886                let legend_width = self
887                    .calculate_legend_width()
888                    .min(area.width / Self::LEGEND_VERTICAL_MAX_RATIO)
889                    .max(Self::LEGEND_VERTICAL_MIN_WIDTH);
890                let is_left = matches!(self.legend_position, LegendPosition::Left);
891                Self::layout_horizontal_split(area, legend_width, is_left)
892            }
893            // Top/Bottom with Horizontal layout - single row at top/bottom
894            (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Horizontal) => {
895                let is_top = matches!(self.legend_position, LegendPosition::Top);
896                Self::layout_vertical_split(area, Self::LEGEND_HORIZONTAL_HEIGHT, is_top)
897            }
898            // Fallback: use horizontal layout for incompatible combinations
899            (LegendPosition::Left | LegendPosition::Right, LegendLayout::Horizontal) => {
900                // Horizontal layout on sides - allocate limited width
901                let legend_width = self
902                    .calculate_legend_horizontal_width()
903                    .min(
904                        (area.width * (Self::LEGEND_HORIZONTAL_MAX_RATIO - 1))
905                            / Self::LEGEND_HORIZONTAL_MAX_RATIO,
906                    )
907                    .min(Self::LEGEND_HORIZONTAL_MAX_WIDTH);
908                let is_left = matches!(self.legend_position, LegendPosition::Left);
909                Self::layout_horizontal_split(area, legend_width, is_left)
910            }
911            (LegendPosition::Top | LegendPosition::Bottom, LegendLayout::Vertical) => {
912                // Vertical layout at top/bottom - use 2-column grid with minimal height
913                let legend_height = self.calculate_vertical_grid_height(area.width);
914                let is_top = matches!(self.legend_position, LegendPosition::Top);
915                Self::layout_vertical_split(area, legend_height, is_top)
916            }
917        }
918    }
919
920    fn calculate_vertical_grid_height(&self, available_width: u16) -> u16 {
921        // For vertical layout at top/bottom, use 2-column grid
922        let max_item_width = self.calculate_legend_width();
923        let columns = (available_width.saturating_sub(Self::LEGEND_PADDING * 2)
924            / max_item_width.max(1))
925        .clamp(1, 2);
926
927        #[allow(clippy::cast_possible_truncation)]
928        let num_items = self.slices.len() as u16;
929
930        // Calculate rows: ceil(items / columns)
931        let rows = num_items.div_ceil(columns);
932        // Each row needs 2 lines (item + spacing), plus account for padding that will be subtracted
933        (rows * 2 + Self::LEGEND_PADDING).clamp(4, Self::LEGEND_VERTICAL_MAX_HEIGHT)
934    }
935
936    fn layout_horizontal_split(
937        area: Rect,
938        legend_width: u16,
939        legend_on_left: bool,
940    ) -> (Rect, Option<Rect>) {
941        if area.width <= legend_width {
942            return (area, None);
943        }
944
945        let pie_width = area
946            .width
947            .saturating_sub(legend_width + Self::LEGEND_SPACING);
948
949        if legend_on_left {
950            (
951                Rect {
952                    x: area.x + legend_width + Self::LEGEND_SPACING,
953                    y: area.y,
954                    width: pie_width,
955                    height: area.height,
956                },
957                Some(Rect {
958                    x: area.x,
959                    y: area.y + Self::LEGEND_PADDING,
960                    width: legend_width,
961                    height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
962                }),
963            )
964        } else {
965            (
966                Rect {
967                    x: area.x,
968                    y: area.y,
969                    width: pie_width,
970                    height: area.height,
971                },
972                Some(Rect {
973                    x: area.x + pie_width + Self::LEGEND_SPACING,
974                    y: area.y + Self::LEGEND_PADDING,
975                    width: legend_width,
976                    height: area.height.saturating_sub(Self::LEGEND_PADDING * 2),
977                }),
978            )
979        }
980    }
981
982    fn layout_vertical_split(
983        area: Rect,
984        legend_height: u16,
985        legend_on_top: bool,
986    ) -> (Rect, Option<Rect>) {
987        if area.height <= legend_height {
988            return (area, None);
989        }
990
991        let pie_height = area
992            .height
993            .saturating_sub(legend_height + Self::LEGEND_SPACING);
994
995        if legend_on_top {
996            (
997                Rect {
998                    x: area.x,
999                    y: area.y + legend_height + Self::LEGEND_SPACING,
1000                    width: area.width,
1001                    height: pie_height,
1002                },
1003                Some(Rect {
1004                    x: area.x + Self::LEGEND_PADDING,
1005                    y: area.y + Self::LEGEND_PADDING,
1006                    width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1007                    height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1008                }),
1009            )
1010        } else {
1011            (
1012                Rect {
1013                    x: area.x,
1014                    y: area.y,
1015                    width: area.width,
1016                    height: pie_height,
1017                },
1018                Some(Rect {
1019                    x: area.x + Self::LEGEND_PADDING,
1020                    y: area.y + pie_height + Self::LEGEND_SPACING,
1021                    width: area.width.saturating_sub(Self::LEGEND_PADDING * 2),
1022                    height: legend_height.saturating_sub(Self::LEGEND_PADDING),
1023                }),
1024            )
1025        }
1026    }
1027
1028    fn calculate_legend_width(&self) -> u16 {
1029        let total = self.total_value();
1030
1031        match self.legend_layout {
1032            LegendLayout::Vertical => {
1033                // For vertical layout, find the maximum width of a single item
1034                let mut max_width = 0u16;
1035
1036                for slice in &self.slices {
1037                    let text = if self.show_percentages {
1038                        let percent = if total > 0.0 {
1039                            (slice.value / total) * 100.0
1040                        } else {
1041                            0.0
1042                        };
1043                        format!("{} {} {:.1}%  ", self.legend_marker, slice.label, percent)
1044                    } else {
1045                        format!("{} {}  ", self.legend_marker, slice.label)
1046                    };
1047
1048                    #[allow(clippy::cast_possible_truncation)]
1049                    let text_width = text.len() as u16;
1050                    max_width = max_width.max(text_width);
1051                }
1052
1053                max_width.saturating_add(2)
1054            }
1055            LegendLayout::Horizontal => {
1056                // For horizontal layout, sum the width of all items
1057                let mut total_width = 0u16;
1058
1059                for slice in &self.slices {
1060                    let text = if self.show_percentages {
1061                        let percent = if total > 0.0 {
1062                            (slice.value / total) * 100.0
1063                        } else {
1064                            0.0
1065                        };
1066                        format!("{} {} {:.1}%  ", self.legend_marker, slice.label, percent)
1067                    } else {
1068                        format!("{} {}  ", self.legend_marker, slice.label)
1069                    };
1070
1071                    #[allow(clippy::cast_possible_truncation)]
1072                    let text_width = text.len() as u16;
1073                    total_width = total_width.saturating_add(text_width);
1074                }
1075
1076                total_width.saturating_add(2)
1077            }
1078        }
1079    }
1080
1081    fn calculate_legend_horizontal_width(&self) -> u16 {
1082        let total = self.total_value();
1083        let mut total_width = 0u16;
1084
1085        for slice in &self.slices {
1086            let text = if self.show_percentages {
1087                let percent = if total > 0.0 {
1088                    (slice.value / total) * 100.0
1089                } else {
1090                    0.0
1091                };
1092                format!("{} {} {:.1}%  ", self.legend_marker, slice.label, percent)
1093            } else {
1094                format!("{} {}  ", self.legend_marker, slice.label)
1095            };
1096
1097            #[allow(clippy::cast_possible_truncation)]
1098            let text_width = text.len() as u16;
1099            total_width = total_width.saturating_add(text_width);
1100        }
1101
1102        total_width.saturating_add(2)
1103    }
1104
1105    #[allow(clippy::similar_names)]
1106    fn render_piechart_braille(&self, area: Rect, buf: &mut Buffer) {
1107        // Calculate layout with legend positioning
1108        let (pie_area, legend_area_opt) = self.calculate_layout(area);
1109
1110        // Calculate the center and radius of the pie chart
1111        let center_x_chars = pie_area.width / 2;
1112        let center_y_chars = pie_area.height / 2;
1113
1114        // Each character cell has 2x4 braille dots
1115        let center_x_dots = center_x_chars * 2;
1116        let center_y_dots = center_y_chars * 4;
1117
1118        // Calculate radius in dots
1119        // Braille dots are equally spaced in physical screen space because:
1120        // - Character cells are ~2:1 (height:width)
1121        // - But braille has 2 horizontal dots and 4 vertical dots per character
1122        // - So: horizontal spacing = W/2, vertical spacing = 2W/4 = W/2 (equal!)
1123        let radius = (center_x_dots).min(center_y_dots).saturating_sub(2);
1124
1125        // Create a 2D array to store which slice each braille dot belongs to
1126        let width_dots = pie_area.width * 2;
1127        let height_dots = pie_area.height * 4;
1128
1129        let mut dot_slices: Vec<Vec<Option<usize>>> =
1130            vec![vec![None; width_dots as usize]; height_dots as usize];
1131
1132        // Calculate slice assignments for each dot
1133        let mut cumulative_percent = 0.0;
1134        for (slice_idx, slice) in self.slices.iter().enumerate() {
1135            let percent = self.percentage(slice);
1136            let start_angle = (cumulative_percent / 100.0) * 2.0 * PI - PI / 2.0;
1137            let end_angle = ((cumulative_percent + percent) / 100.0) * 2.0 * PI - PI / 2.0;
1138
1139            for dy in 0..height_dots {
1140                for dx in 0..width_dots {
1141                    let rel_x = f64::from(dx) - f64::from(center_x_dots);
1142                    let rel_y = f64::from(dy) - f64::from(center_y_dots);
1143
1144                    // No aspect ratio compensation needed for braille dots
1145                    // They're already equally spaced in physical screen space
1146                    let distance = (rel_x * rel_x + rel_y * rel_y).sqrt();
1147
1148                    if distance <= f64::from(radius) {
1149                        let angle = rel_y.atan2(rel_x);
1150                        if Self::is_angle_in_slice(angle, start_angle, end_angle) {
1151                            dot_slices[dy as usize][dx as usize] = Some(slice_idx);
1152                        }
1153                    }
1154                }
1155            }
1156
1157            cumulative_percent += percent;
1158        }
1159
1160        // Convert dot assignments to braille characters
1161        for char_y in 0..pie_area.height {
1162            for char_x in 0..pie_area.width {
1163                let base_dot_x = char_x * 2;
1164                let base_dot_y = char_y * 4;
1165
1166                // Braille pattern mapping (dots are numbered 1-8)
1167                // Dot positions in a 2x4 grid:
1168                // 1 4
1169                // 2 5
1170                // 3 6
1171                // 7 8
1172                let dot_positions = [
1173                    (0, 0, 0x01), // dot 1
1174                    (0, 1, 0x02), // dot 2
1175                    (0, 2, 0x04), // dot 3
1176                    (1, 0, 0x08), // dot 4
1177                    (1, 1, 0x10), // dot 5
1178                    (1, 2, 0x20), // dot 6
1179                    (0, 3, 0x40), // dot 7
1180                    (1, 3, 0x80), // dot 8
1181                ];
1182
1183                let mut pattern = 0u32;
1184                let mut slice_colors: Vec<(usize, u32)> = Vec::new();
1185
1186                for (dx, dy, bit) in dot_positions {
1187                    let dot_x = base_dot_x + dx;
1188                    let dot_y = base_dot_y + dy;
1189
1190                    if dot_y < height_dots && dot_x < width_dots {
1191                        if let Some(slice_idx) = dot_slices[dot_y as usize][dot_x as usize] {
1192                            pattern |= bit;
1193                            // Track which slice and how many dots
1194                            if let Some(entry) =
1195                                slice_colors.iter_mut().find(|(idx, _)| *idx == slice_idx)
1196                            {
1197                                entry.1 += 1;
1198                            } else {
1199                                slice_colors.push((slice_idx, 1));
1200                            }
1201                        }
1202                    }
1203                }
1204
1205                if pattern > 0 {
1206                    // Use the color of the slice with the most dots in this character
1207                    if let Some((slice_idx, _)) = slice_colors.iter().max_by_key(|(_, count)| count)
1208                    {
1209                        let braille_char = char::from_u32(0x2800 + pattern).unwrap_or('⠀');
1210                        let color = self.slices[*slice_idx].color;
1211
1212                        let cell = &mut buf[(pie_area.x + char_x, pie_area.y + char_y)];
1213                        cell.set_char(braille_char).set_fg(color);
1214                    }
1215                }
1216            }
1217        }
1218
1219        // Draw legend if enabled
1220        if let Some(legend_area) = legend_area_opt {
1221            self.render_legend(buf, legend_area);
1222        }
1223    }
1224}
1225
1226#[cfg(test)]
1227#[allow(clippy::float_cmp)]
1228mod tests {
1229    use super::*;
1230
1231    #[test]
1232    fn pie_slice_new() {
1233        let slice = PieSlice::new("Test", 50.0, Color::Red);
1234        assert_eq!(slice.label(), "Test");
1235        assert_eq!(slice.value(), 50.0);
1236        assert_eq!(slice.color(), Color::Red);
1237    }
1238
1239    #[test]
1240    fn piechart_new() {
1241        let slices = vec![
1242            PieSlice::new("A", 30.0, Color::Red),
1243            PieSlice::new("B", 70.0, Color::Blue),
1244        ];
1245        let piechart = PieChart::new(slices.clone());
1246        assert_eq!(piechart.slices, slices);
1247    }
1248
1249    #[test]
1250    fn piechart_default() {
1251        let piechart = PieChart::default();
1252        assert!(piechart.slices.is_empty());
1253        assert!(piechart.show_legend);
1254        assert!(piechart.show_percentages);
1255    }
1256
1257    #[test]
1258    fn piechart_slices() {
1259        let slices = vec![PieSlice::new("Test", 100.0, Color::Green)];
1260        let piechart = PieChart::default().slices(slices.clone());
1261        assert_eq!(piechart.slices, slices);
1262    }
1263
1264    #[test]
1265    fn piechart_style() {
1266        let style = Style::default().fg(Color::Red);
1267        let piechart = PieChart::default().style(style);
1268        assert_eq!(piechart.style, style);
1269    }
1270
1271    #[test]
1272    fn piechart_show_legend() {
1273        let piechart = PieChart::default().show_legend(false);
1274        assert!(!piechart.show_legend);
1275    }
1276
1277    #[test]
1278    fn piechart_show_percentages() {
1279        let piechart = PieChart::default().show_percentages(false);
1280        assert!(!piechart.show_percentages);
1281    }
1282
1283    #[test]
1284    fn piechart_pie_char() {
1285        let piechart = PieChart::default().pie_char('█');
1286        assert_eq!(piechart.pie_char, '█');
1287    }
1288
1289    #[test]
1290    fn piechart_total_value() {
1291        let slices = vec![
1292            PieSlice::new("A", 30.0, Color::Red),
1293            PieSlice::new("B", 70.0, Color::Blue),
1294        ];
1295        let piechart = PieChart::new(slices);
1296        assert_eq!(piechart.total_value(), 100.0);
1297    }
1298
1299    #[test]
1300    fn piechart_percentage() {
1301        let slices = vec![
1302            PieSlice::new("A", 30.0, Color::Red),
1303            PieSlice::new("B", 70.0, Color::Blue),
1304        ];
1305        let piechart = PieChart::new(slices);
1306        assert_eq!(
1307            piechart.percentage(&PieSlice::new("A", 30.0, Color::Red)),
1308            30.0
1309        );
1310    }
1311
1312    // Render tests - using macros for common patterns
1313    render_empty_test!(piechart_render_empty_area, PieChart::default());
1314
1315    render_with_size_test!(
1316        piechart_render_with_block,
1317        {
1318            let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1319            PieChart::new(slices).block(Block::bordered())
1320        },
1321        width: 20,
1322        height: 10
1323    );
1324
1325    render_test!(
1326        piechart_render_basic,
1327        {
1328            let slices = vec![
1329                PieSlice::new("Rust", 45.0, Color::Red),
1330                PieSlice::new("Go", 30.0, Color::Blue),
1331                PieSlice::new("Python", 25.0, Color::Green),
1332            ];
1333            PieChart::new(slices)
1334        },
1335        Rect::new(0, 0, 40, 20)
1336    );
1337
1338    #[test]
1339    fn piechart_styled_trait() {
1340        use ratatui::style::Stylize;
1341        let piechart = PieChart::default().red();
1342        assert_eq!(piechart.style.fg, Some(Color::Red));
1343    }
1344
1345    #[test]
1346    fn piechart_with_multiple_slices() {
1347        let slices = vec![
1348            PieSlice::new("A", 25.0, Color::Red),
1349            PieSlice::new("B", 25.0, Color::Blue),
1350            PieSlice::new("C", 25.0, Color::Green),
1351            PieSlice::new("D", 25.0, Color::Yellow),
1352        ];
1353        let piechart = PieChart::new(slices);
1354        assert_eq!(piechart.total_value(), 100.0);
1355    }
1356
1357    // Using render macro for the visual test
1358    render_with_size_test!(
1359        piechart_multi_slice_render,
1360        {
1361            let slices = vec![
1362                PieSlice::new("A", 25.0, Color::Red),
1363                PieSlice::new("B", 25.0, Color::Blue),
1364                PieSlice::new("C", 25.0, Color::Green),
1365                PieSlice::new("D", 25.0, Color::Yellow),
1366            ];
1367            PieChart::new(slices)
1368        },
1369        width: 50,
1370        height: 30
1371    );
1372
1373    #[test]
1374    fn piechart_zero_values() {
1375        let slices = vec![
1376            PieSlice::new("A", 0.0, Color::Red),
1377            PieSlice::new("B", 0.0, Color::Blue),
1378        ];
1379        let piechart = PieChart::new(slices);
1380        assert_eq!(piechart.total_value(), 0.0);
1381    }
1382
1383    #[test]
1384    fn piechart_method_chaining() {
1385        use ratatui::widgets::Block;
1386
1387        let slices = vec![PieSlice::new("Test", 100.0, Color::Red)];
1388        let piechart = PieChart::new(slices)
1389            .show_legend(true)
1390            .show_percentages(true)
1391            .pie_char('█')
1392            .block(Block::bordered().title("Test"))
1393            .style(Style::default().fg(Color::White));
1394
1395        assert!(piechart.show_legend);
1396        assert!(piechart.show_percentages);
1397        assert_eq!(piechart.pie_char, '█');
1398        assert!(piechart.block.is_some());
1399        assert_eq!(piechart.style.fg, Some(Color::White));
1400    }
1401
1402    #[test]
1403    fn piechart_custom_symbols() {
1404        use crate::symbols;
1405
1406        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_BLOCK);
1407        assert_eq!(piechart.pie_char, '█');
1408
1409        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_CIRCLE);
1410        assert_eq!(piechart.pie_char, '◉');
1411
1412        let piechart = PieChart::default().pie_char(symbols::PIE_CHAR_SQUARE);
1413        assert_eq!(piechart.pie_char, '■');
1414    }
1415
1416    #[test]
1417    fn piechart_is_angle_in_slice() {
1418        use std::f64::consts::PI;
1419
1420        // Test angle in range
1421        assert!(PieChart::is_angle_in_slice(PI / 4.0, 0.0, PI / 2.0));
1422
1423        // Test angle outside range
1424        assert!(!PieChart::is_angle_in_slice(PI, 0.0, PI / 2.0));
1425
1426        // Test wrap around
1427        assert!(PieChart::is_angle_in_slice(0.1, 1.5 * PI, 0.5));
1428    }
1429}