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