Skip to main content

tui_piechart/
pie_style.rs

1//! Pie looks beyond the plain disc: segmented ring, HUD band and rose (polar area),
2//! plus gaps and exploded slices. All drawn with braille dots.
3
4#![allow(
5    clippy::cast_possible_truncation,
6    clippy::cast_precision_loss,
7    clippy::cast_sign_loss,
8    clippy::many_single_char_names,
9    clippy::similar_names,
10    clippy::too_many_lines
11)]
12
13use std::f64::consts::TAU;
14
15use ratatui::buffer::Buffer;
16use ratatui::layout::Rect;
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::Span;
19
20use crate::braille::{polar, Braille};
21use crate::PieChart;
22
23/// How a [`PieChart`] draws its slices.
24///
25/// Everything except [`PieStyle::Solid`] is drawn with braille dots.
26///
27/// # Examples
28///
29/// ```
30/// use tui_piechart::{PieChart, PieStyle};
31///
32/// let chart = PieChart::default().pie_style(PieStyle::Hud).center("CPU");
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum PieStyle {
36    /// Solid wedges (default); honours `hole`, `start_angle` and the resolution.
37    #[default]
38    Solid,
39    /// A ring built from separate cells, like a segmented readout.
40    Segmented,
41    /// Thin band between an outer tick scale and a dashed inner ring.
42    Hud,
43    /// Equal angles, radius follows the value (a polar-area / rose chart).
44    Rose,
45}
46
47impl PieStyle {
48    /// Every style, in showcase order.
49    pub const ALL: [PieStyle; 4] = [
50        PieStyle::Solid,
51        PieStyle::Segmented,
52        PieStyle::Hud,
53        PieStyle::Rose,
54    ];
55
56    /// Display name.
57    #[must_use]
58    pub const fn name(self) -> &'static str {
59        match self {
60            PieStyle::Solid => "Solid",
61            PieStyle::Segmented => "Segmented",
62            PieStyle::Hud => "Hud",
63            PieStyle::Rose => "Rose",
64        }
65    }
66}
67
68const GUIDE: Color = Color::DarkGray;
69
70impl PieChart<'_> {
71    /// Whether the braille canvas renderer must be used.
72    pub(crate) fn needs_canvas(&self) -> bool {
73        self.pie_style != PieStyle::Solid || self.gap > 0.0 || self.explode > 0.0
74    }
75
76    /// Draws the donut-hole text, if any.
77    pub(crate) fn draw_center(&self, area: Rect, buf: &mut Buffer) {
78        let Some(text) = self.center else { return };
79        let has_hole =
80            self.hole > 0.0 || matches!(self.pie_style, PieStyle::Segmented | PieStyle::Hud);
81        let w = Span::raw(text).width();
82        if !has_hole || text.is_empty() || area.is_empty() || w > usize::from(area.width) {
83            return;
84        }
85        let x = area.x + ((usize::from(area.width) - w) / 2) as u16;
86        let y = area.y + area.height / 2;
87        buf.set_string(x, y, text, Style::default().add_modifier(Modifier::BOLD));
88    }
89
90    /// `(start_turn, len_turns, color, value)` for every slice with a positive share.
91    fn arcs(&self) -> Vec<(f64, f64, Color, f64)> {
92        let mut at = self.start;
93        let mut out = Vec::with_capacity(self.slices.len());
94        for s in &self.slices {
95            let len = self.percentage(s) / 100.0;
96            if len > 0.0 {
97                out.push((at, len, s.color, s.value));
98            }
99            at += len;
100        }
101        out
102    }
103
104    /// True when a dot at `dist` on the arc `pos` (0..1) of a `len`-turn slice lies in the gap.
105    fn in_gap(&self, pos: f64, len: f64, dist: f64) -> bool {
106        if self.gap <= 0.0 || len >= 0.999 {
107            return false;
108        }
109        let half = self.gap / 2.0;
110        let arc = len * TAU * dist;
111        pos * arc < half || (1.0 - pos) * arc < half
112    }
113
114    pub(crate) fn render_piechart_canvas(&self, area: Rect, buf: &mut Buffer) {
115        let (pie_area, legend_area) = self.calculate_layout(area);
116        if !pie_area.is_empty() {
117            let mut g = Braille::new(pie_area.width, pie_area.height);
118            let (cx, cy) = (g.dots_w() / 2.0, g.dots_h() / 2.0);
119            let r = cx.min(cy) - 1.0 - self.explode;
120            if r >= 2.0 {
121                let arcs = self.arcs();
122                match self.pie_style {
123                    PieStyle::Solid => self.canvas_solid(&mut g, (cx, cy), r, &arcs),
124                    PieStyle::Segmented => self.canvas_segmented(&mut g, (cx, cy), r, &arcs),
125                    PieStyle::Hud => self.canvas_hud(&mut g, (cx, cy), r, &arcs),
126                    PieStyle::Rose => self.canvas_rose(&mut g, (cx, cy), r),
127                }
128                g.blit(pie_area, buf);
129            }
130            self.draw_center(pie_area, buf);
131        }
132        if let Some(legend_area) = legend_area {
133            self.render_legend(buf, legend_area);
134        }
135    }
136
137    fn slice_center(&self, c: (f64, f64), start: f64, len: f64) -> (f64, f64) {
138        if self.explode > 0.0 && len < 0.999 {
139            polar(c.0, c.1, self.explode, start + len / 2.0)
140        } else {
141            c
142        }
143    }
144
145    fn canvas_solid(
146        &self,
147        g: &mut Braille,
148        c: (f64, f64),
149        r: f64,
150        arcs: &[(f64, f64, Color, f64)],
151    ) {
152        for &(start, len, color, _) in arcs {
153            let sc = self.slice_center(c, start, len);
154            g.ring_dist(sc, (self.hole * r, r), (start, len), |pos, d| {
155                (!self.in_gap(pos, len, d)).then_some((color, 1))
156            });
157        }
158    }
159
160    fn canvas_segmented(
161        &self,
162        g: &mut Braille,
163        c: (f64, f64),
164        r: f64,
165        arcs: &[(f64, f64, Color, f64)],
166    ) {
167        let inner = self.hole.max(0.55) * r;
168        let cells = ((r * 1.5) as usize).clamp(24, 72) as f64;
169        for &(start, len, color, _) in arcs {
170            let sc = self.slice_center(c, start, len);
171            g.ring(sc, (inner, r), (start, len), |pos| {
172                let turn = (start + pos * len) * cells;
173                let local = turn - turn.floor();
174                (0.15..=0.85).contains(&local).then_some((color, 1))
175            });
176        }
177    }
178
179    fn canvas_hud(&self, g: &mut Braille, c: (f64, f64), r: f64, arcs: &[(f64, f64, Color, f64)]) {
180        // Outer tick scale.
181        for k in 0..60 {
182            let turn = f64::from(k) / 60.0;
183            let len = if k % 5 == 0 { 3.0 } else { 1.5 };
184            g.line(
185                polar(c.0, c.1, r, turn),
186                polar(c.0, c.1, r - len, turn),
187                GUIDE,
188                0,
189            );
190        }
191        // Thin colored band.
192        let band_out = (r - 4.5).max(2.0);
193        let band_in = (band_out - 3.0).max(0.0);
194        for &(start, len, color, _) in arcs {
195            let sc = self.slice_center(c, start, len);
196            g.ring_dist(sc, (band_in, band_out), (start, len), |pos, d| {
197                (!self.in_gap(pos, len, d)).then_some((color, 1))
198            });
199        }
200        // Dashed inner ring.
201        let dash = band_in - 4.0;
202        if dash > 3.0 {
203            g.ring(c, (dash - 1.5, dash), (0.0, 1.0), |pos| {
204                ((pos * 36.0).fract() < 0.5).then_some((GUIDE, 0))
205            });
206        }
207    }
208
209    fn canvas_rose(&self, g: &mut Braille, c: (f64, f64), r: f64) {
210        let shown: Vec<_> = self.slices.iter().filter(|s| s.value > 0.0).collect();
211        if shown.is_empty() {
212            return;
213        }
214        let max = shown.iter().map(|s| s.value).fold(0.0_f64, f64::max);
215        // Guide rings.
216        for k in 1..=3 {
217            let rad = r * f64::from(k) / 3.0;
218            g.ring(c, (rad - 1.5, rad), (0.0, 1.0), |pos| {
219                ((pos * 48.0).fract() < 0.35).then_some((GUIDE, 0))
220            });
221        }
222        let len = 1.0 / shown.len() as f64;
223        for (i, s) in shown.iter().enumerate() {
224            let start = self.start + i as f64 * len;
225            let rad = r * (s.value / max).sqrt();
226            let sc = self.slice_center(c, start, len);
227            g.ring_dist(sc, (self.hole * rad, rad), (start, len), |pos, d| {
228                (!self.in_gap(pos, len, d)).then_some((s.color, 1))
229            });
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::{PieSlice, Resolution};
238    use ratatui::widgets::Widget;
239
240    fn slices() -> Vec<PieSlice<'static>> {
241        vec![
242            PieSlice::new("A", 50.0, Color::Red),
243            PieSlice::new("B", 30.0, Color::Green),
244            PieSlice::new("C", 20.0, Color::Blue),
245        ]
246    }
247
248    fn draw(chart: PieChart<'_>, w: u16, h: u16) -> Buffer {
249        let area = Rect::new(0, 0, w, h);
250        let mut buf = Buffer::empty(area);
251        chart.render(area, &mut buf);
252        buf
253    }
254
255    fn colors(buf: &Buffer) -> Vec<Color> {
256        let mut v: Vec<Color> = buf
257            .content()
258            .iter()
259            .filter(|c| c.symbol() != " ")
260            .map(|c| c.fg)
261            .collect();
262        v.sort_by_key(|c| format!("{c:?}"));
263        v.dedup();
264        v
265    }
266
267    fn dots(buf: &Buffer) -> usize {
268        buf.content().iter().filter(|c| c.symbol() != " ").count()
269    }
270
271    #[test]
272    fn every_style_renders_all_slice_colors() {
273        for style in PieStyle::ALL {
274            let b = draw(
275                PieChart::new(slices()).pie_style(style).show_legend(false),
276                30,
277                15,
278            );
279            let cs = colors(&b);
280            for c in [Color::Red, Color::Green, Color::Blue] {
281                assert!(cs.contains(&c), "{style:?} lacks {c:?}");
282            }
283        }
284    }
285
286    #[test]
287    fn styles_do_not_panic_on_odd_input() {
288        for style in PieStyle::ALL {
289            for (w, h) in [(0, 0), (1, 1), (3, 2), (10, 5), (80, 40)] {
290                for sl in [
291                    vec![],
292                    vec![PieSlice::new("z", 0.0, Color::Red)],
293                    slices(),
294                    vec![PieSlice::new("n", f64::NAN, Color::Red)],
295                    vec![PieSlice::new("one", 5.0, Color::Red)],
296                ] {
297                    let c = PieChart::new(sl)
298                        .pie_style(style)
299                        .gap(3.0)
300                        .explode(4.0)
301                        .hole(0.5)
302                        .center("x");
303                    let _ = draw(c, w, h);
304                }
305            }
306        }
307    }
308
309    #[test]
310    fn hole_removes_center_dots() {
311        let solid = dots(&draw(
312            PieChart::new(slices())
313                .resolution(Resolution::Braille)
314                .show_legend(false),
315            30,
316            15,
317        ));
318        let donut = dots(&draw(
319            PieChart::new(slices())
320                .resolution(Resolution::Braille)
321                .hole(0.6)
322                .show_legend(false),
323            30,
324            15,
325        ));
326        assert!(donut < solid);
327        let solid = dots(&draw(PieChart::new(slices()).show_legend(false), 30, 15));
328        let donut = dots(&draw(
329            PieChart::new(slices()).hole(0.6).show_legend(false),
330            30,
331            15,
332        ));
333        assert!(donut < solid);
334    }
335
336    #[test]
337    fn gap_and_explode_change_the_picture() {
338        let base = draw(
339            PieChart::new(slices())
340                .pie_style(PieStyle::Solid)
341                .gap(0.1)
342                .show_legend(false),
343            30,
344            15,
345        );
346        let gapped = draw(PieChart::new(slices()).gap(4.0).show_legend(false), 30, 15);
347        assert!(dots(&gapped) <= dots(&base));
348        assert_ne!(base, gapped);
349        let exploded = draw(
350            PieChart::new(slices()).explode(4.0).show_legend(false),
351            30,
352            15,
353        );
354        assert_ne!(base, exploded);
355    }
356
357    #[test]
358    fn start_angle_rotates() {
359        let a = draw(PieChart::new(slices()).show_legend(false), 30, 15);
360        let b = draw(
361            PieChart::new(slices()).start_angle(90.0).show_legend(false),
362            30,
363            15,
364        );
365        assert_ne!(a, b);
366        assert_eq!(
367            draw(
368                PieChart::new(slices())
369                    .start_angle(360.0)
370                    .show_legend(false),
371                30,
372                15
373            ),
374            a
375        );
376    }
377
378    #[test]
379    fn center_text_needs_a_hole() {
380        let t = |c: PieChart<'_>| {
381            draw(c, 30, 15)
382                .content()
383                .iter()
384                .map(ratatui::buffer::Cell::symbol)
385                .collect::<String>()
386        };
387        assert!(t(PieChart::new(slices())
388            .hole(0.6)
389            .center("HELLO")
390            .show_legend(false))
391        .contains("HELLO"));
392        assert!(!t(PieChart::new(slices()).center("HELLO").show_legend(false)).contains("HELLO"));
393        assert!(t(PieChart::new(slices())
394            .pie_style(PieStyle::Hud)
395            .center("HELLO")
396            .show_legend(false))
397        .contains("HELLO"));
398    }
399
400    #[test]
401    fn rose_radius_follows_value() {
402        let big = vec![
403            PieSlice::new("a", 100.0, Color::Red),
404            PieSlice::new("b", 1.0, Color::Blue),
405        ];
406        let b = draw(
407            PieChart::new(big)
408                .pie_style(PieStyle::Rose)
409                .show_legend(false),
410            30,
411            15,
412        );
413        let count = |c: Color| {
414            b.content()
415                .iter()
416                .filter(|x| x.symbol() != " " && x.fg == c)
417                .count()
418        };
419        assert!(count(Color::Red) > count(Color::Blue) * 3);
420    }
421
422    #[test]
423    fn builders_sanitise() {
424        let c = PieChart::default()
425            .hole(5.0)
426            .gap(f64::NAN)
427            .explode(-3.0)
428            .start_angle(f64::INFINITY);
429        assert!((c.hole - 0.95).abs() < 1e-12);
430        assert_eq!((c.gap, c.explode, c.start), (0.0, 0.0, 0.0));
431    }
432
433    #[test]
434    fn style_names() {
435        for s in PieStyle::ALL {
436            assert_ne!(s.name(), "");
437        }
438    }
439}