Skip to main content

tui_piechart/
radial.rs

1//! Concentric progress rings: [`RadialBar`] (value / max per ring) and
2//! [`RingStack`] (percentages, always a 270° sweep).
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::too_many_lines,
10    clippy::cast_possible_wrap
11)]
12
13use ratatui::buffer::Buffer;
14use ratatui::layout::Rect;
15use ratatui::style::{Color, Modifier, Style};
16use ratatui::widgets::{Block, Widget};
17
18use crate::braille::Braille;
19use crate::util::{block_inner, palette, text_width, truncate};
20
21/// One ring of a [`RadialBar`].
22#[derive(Debug, Clone, PartialEq)]
23pub struct RadialRing<'a> {
24    label: &'a str,
25    value: f64,
26    max: f64,
27    color: Option<Color>,
28}
29
30impl<'a> RadialRing<'a> {
31    /// A ring showing `value` out of `max`.
32    #[must_use]
33    pub const fn new(label: &'a str, value: f64, max: f64) -> Self {
34        Self {
35            label,
36            value,
37            max,
38            color: None,
39        }
40    }
41
42    /// Fixes the ring color (default: the palette, by position).
43    #[must_use]
44    pub const fn color(mut self, color: Color) -> Self {
45        self.color = Some(color);
46        self
47    }
48
49    /// Fraction of the ring that is filled, `0..=1`.
50    #[must_use]
51    pub fn fraction(&self) -> f64 {
52        if self.max.is_finite() && self.max > 0.0 && self.value.is_finite() {
53            (self.value / self.max).clamp(0.0, 1.0)
54        } else {
55            0.0
56        }
57    }
58}
59
60/// Sweep of a [`RadialBar`].
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum RadialSweep {
63    /// 270° with the gap at the bottom (default).
64    #[default]
65    Arc,
66    /// Full 360° rings starting at 12 o'clock.
67    Full,
68}
69
70/// Concentric progress rings, one per [`RadialRing`], with an optional legend.
71///
72/// # Examples
73///
74/// ```
75/// use tui_piechart::{RadialBar, RadialRing};
76///
77/// let chart = RadialBar::new(vec![
78///     RadialRing::new("Move", 420.0, 600.0),
79///     RadialRing::new("Exercise", 25.0, 30.0),
80///     RadialRing::new("Stand", 9.0, 12.0),
81/// ]);
82/// ```
83#[derive(Debug, Clone)]
84pub struct RadialBar<'a> {
85    rings: Vec<RadialRing<'a>>,
86    sweep: RadialSweep,
87    legend: bool,
88    track: Color,
89    block: Option<Block<'a>>,
90    style: Style,
91}
92
93impl<'a> RadialBar<'a> {
94    /// Creates a chart from rings, outermost first.
95    #[must_use]
96    pub fn new(rings: Vec<RadialRing<'a>>) -> Self {
97        Self {
98            rings,
99            sweep: RadialSweep::default(),
100            legend: true,
101            track: Color::DarkGray,
102            block: None,
103            style: Style::default(),
104        }
105    }
106
107    /// Chooses a 270° or full sweep.
108    #[must_use]
109    pub const fn sweep(mut self, sweep: RadialSweep) -> Self {
110        self.sweep = sweep;
111        self
112    }
113
114    /// Shows or hides the legend (shown by default when there is room).
115    #[must_use]
116    pub const fn legend(mut self, show: bool) -> Self {
117        self.legend = show;
118        self
119    }
120
121    /// Color of the unfilled track.
122    #[must_use]
123    pub const fn track_color(mut self, color: Color) -> Self {
124        self.track = color;
125        self
126    }
127
128    /// Wraps the chart in a block.
129    #[must_use]
130    pub fn block(mut self, block: Block<'a>) -> Self {
131        self.block = Some(block);
132        self
133    }
134
135    /// Base style of the whole area.
136    #[must_use]
137    pub const fn base_style(mut self, style: Style) -> Self {
138        self.style = style;
139        self
140    }
141}
142
143fn ring_color(r: &RadialRing<'_>, i: usize) -> Color {
144    r.color.unwrap_or_else(|| palette(i))
145}
146
147fn draw_rings(
148    buf: &mut Buffer,
149    area: Rect,
150    rings: &[RadialRing<'_>],
151    sweep: RadialSweep,
152    track: Color,
153) {
154    if rings.is_empty() || area.is_empty() {
155        return;
156    }
157    let mut g = Braille::new(area.width, area.height);
158    let (w, h) = (g.dots_w(), g.dots_h());
159    let c = (w / 2.0, h / 2.0);
160    let r = w.min(h) / 2.0 - 0.5;
161    let n = rings.len() as f64;
162    let pitch = (r * 0.85) / n;
163    if pitch >= 1.5 {
164        let thick = (pitch - 1.2).max(1.5).min(pitch);
165        let sw = match sweep {
166            RadialSweep::Arc => (0.625, 0.75),
167            RadialSweep::Full => (0.0, 1.0),
168        };
169        for (i, ring) in rings.iter().enumerate() {
170            let outer = r - i as f64 * pitch;
171            let inner = (outer - thick).max(0.0);
172            let frac = ring.fraction();
173            let color = ring_color(ring, i);
174            g.ring(c, (inner, outer), sw, |pos| {
175                Some(if frac >= 1.0 || pos < frac {
176                    (color, 1)
177                } else {
178                    (track, 0)
179                })
180            });
181        }
182    }
183    g.blit(area, buf);
184}
185
186fn draw_legend(buf: &mut Buffer, area: Rect, rings: &[RadialRing<'_>]) {
187    let label_w = area.width.saturating_sub(7) as usize;
188    for (i, ring) in rings.iter().enumerate() {
189        let y = area.y + i as u16;
190        if y >= area.bottom() {
191            break;
192        }
193        let color = ring_color(ring, i);
194        buf.set_string(area.x, y, "■", Style::default().fg(color));
195        let label = truncate(ring.label, label_w);
196        buf.set_string(area.x + 2, y, &label, Style::default());
197        let pct = format!("{:.0}%", ring.fraction() * 100.0);
198        let px = area.right().saturating_sub(pct.len() as u16);
199        if px > area.x + 2 + text_width(&label) as u16 {
200            buf.set_string(
201                px,
202                y,
203                &pct,
204                Style::default().fg(color).add_modifier(Modifier::BOLD),
205            );
206        }
207    }
208}
209
210/// Splits `inner` into the rings area and an optional legend area.
211fn split(inner: Rect, rings: &[RadialRing<'_>], legend: bool) -> (Rect, Option<Rect>) {
212    if !legend || rings.is_empty() {
213        return (inner, None);
214    }
215    let longest = rings.iter().map(|r| text_width(r.label)).max().unwrap_or(0);
216    let lw = (longest + 8).clamp(14, 32) as u16;
217    // Keep at least a 12-column chart.
218    if inner.width < lw + 12 {
219        return (inner, None);
220    }
221    let chart_w = inner.width - lw - 1;
222    let chart = Rect::new(inner.x, inner.y, chart_w, inner.height);
223    let ly = inner.y + inner.height.saturating_sub(rings.len() as u16) / 2;
224    let leg = Rect::new(
225        inner.x + chart_w + 1,
226        ly,
227        lw,
228        (rings.len() as u16).min(inner.bottom() - ly),
229    );
230    (chart, Some(leg))
231}
232
233impl Widget for RadialBar<'_> {
234    fn render(self, area: Rect, buf: &mut Buffer) {
235        buf.set_style(area, self.style);
236        let inner = block_inner(self.block.as_ref(), area, buf);
237        let (chart, legend) = split(inner, &self.rings, self.legend);
238        draw_rings(buf, chart, &self.rings, self.sweep, self.track);
239        if let Some(l) = legend {
240            draw_legend(buf, l, &self.rings);
241        }
242    }
243}
244
245/// Concentric 270° rings, one per `(label, percent)`, with a legend.
246///
247/// A convenience over [`RadialBar`] for percentage readouts.
248///
249/// # Examples
250///
251/// ```
252/// use tui_piechart::RingStack;
253///
254/// let stack = RingStack::new(vec![("CPU", 72.0), ("RAM", 48.0), ("Disk", 91.0)]);
255/// ```
256#[derive(Debug, Clone)]
257pub struct RingStack<'a> {
258    items: Vec<(&'a str, f64)>,
259    colors: Vec<Color>,
260    inner: RadialBar<'a>,
261}
262
263impl<'a> RingStack<'a> {
264    /// Creates a ring stack from `(label, percent)` pairs, outermost first.
265    #[must_use]
266    pub fn new(items: Vec<(&'a str, f64)>) -> Self {
267        Self {
268            items,
269            colors: Vec::new(),
270            inner: RadialBar::new(Vec::new()),
271        }
272    }
273
274    /// Ring colors, by position (the palette fills in the rest).
275    #[must_use]
276    pub fn colors(mut self, colors: Vec<Color>) -> Self {
277        self.colors = colors;
278        self
279    }
280
281    /// Shows or hides the legend.
282    #[must_use]
283    pub fn legend(mut self, show: bool) -> Self {
284        self.inner = self.inner.legend(show);
285        self
286    }
287
288    /// Wraps the chart in a block.
289    #[must_use]
290    pub fn block(mut self, block: Block<'a>) -> Self {
291        self.inner = self.inner.block(block);
292        self
293    }
294}
295
296impl Widget for RingStack<'_> {
297    fn render(self, area: Rect, buf: &mut Buffer) {
298        let rings = self
299            .items
300            .iter()
301            .enumerate()
302            .map(|(i, (label, pct))| {
303                RadialRing::new(label, *pct, 100.0)
304                    .color(self.colors.get(i).copied().unwrap_or_else(|| palette(i)))
305            })
306            .collect();
307        RadialBar {
308            rings,
309            ..self.inner
310        }
311        .render(area, buf);
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    #![allow(clippy::assert_is_empty, clippy::float_cmp)]
318    use super::*;
319
320    fn draw(w: impl Widget, width: u16, height: u16) -> Buffer {
321        let a = Rect::new(0, 0, width, height);
322        let mut b = Buffer::empty(a);
323        w.render(a, &mut b);
324        b
325    }
326
327    fn text(b: &Buffer) -> String {
328        b.content()
329            .iter()
330            .map(ratatui::buffer::Cell::symbol)
331            .collect()
332    }
333
334    fn rings() -> Vec<RadialRing<'static>> {
335        vec![
336            RadialRing::new("Move", 420.0, 600.0),
337            RadialRing::new("Exercise", 25.0, 30.0).color(Color::Red),
338            RadialRing::new("Stand", 9.0, 12.0),
339        ]
340    }
341
342    #[test]
343    fn fraction_guards() {
344        assert_eq!(RadialRing::new("a", 5.0, 0.0).fraction(), 0.0);
345        assert_eq!(RadialRing::new("a", f64::NAN, 10.0).fraction(), 0.0);
346        assert_eq!(RadialRing::new("a", 50.0, 10.0).fraction(), 1.0);
347        assert_eq!(RadialRing::new("a", -5.0, 10.0).fraction(), 0.0);
348        assert!((RadialRing::new("a", 3.0, 12.0).fraction() - 0.25).abs() < 1e-12);
349    }
350
351    #[test]
352    fn renders_rings_and_legend() {
353        let b = draw(RadialBar::new(rings()), 50, 14);
354        let t = text(&b);
355        assert!(t.contains("Move") && t.contains("Exercise") && t.contains("70%"));
356        assert!(b
357            .content()
358            .iter()
359            .any(|c| c.fg == Color::Red && c.symbol() != " "));
360    }
361
362    #[test]
363    fn legend_can_be_hidden() {
364        let t = text(&draw(RadialBar::new(rings()).legend(false), 50, 14));
365        assert!(!t.contains("Move"));
366    }
367
368    #[test]
369    fn full_sweep_differs_from_arc() {
370        let a = draw(RadialBar::new(rings()).legend(false), 30, 14);
371        let b = draw(
372            RadialBar::new(rings())
373                .legend(false)
374                .sweep(RadialSweep::Full),
375            30,
376            14,
377        );
378        assert_ne!(a, b);
379    }
380
381    #[test]
382    fn value_changes_picture() {
383        let lo = draw(
384            RadialBar::new(vec![RadialRing::new("a", 10.0, 100.0)]).legend(false),
385            20,
386            10,
387        );
388        let hi = draw(
389            RadialBar::new(vec![RadialRing::new("a", 90.0, 100.0)]).legend(false),
390            20,
391            10,
392        );
393        assert_ne!(lo, hi);
394    }
395
396    #[test]
397    fn robust_to_odd_sizes_and_input() {
398        for (w, h) in [(0, 0), (1, 1), (4, 2), (13, 3), (60, 30)] {
399            let _ = draw(RadialBar::new(rings()), w, h);
400            let _ = draw(RadialBar::new(vec![]), w, h);
401            let _ = draw(
402                RadialBar::new(vec![RadialRing::new("x", f64::NAN, f64::NAN)]),
403                w,
404                h,
405            );
406            let many = (0..40)
407                .map(|i| RadialRing::new("r", f64::from(i), 40.0))
408                .collect();
409            let _ = draw(RadialBar::new(many), w, h);
410        }
411    }
412
413    #[test]
414    fn ring_stack_renders_percentages() {
415        let t = text(&draw(
416            RingStack::new(vec![("CPU", 72.0), ("RAM", 48.0)])
417                .block(Block::bordered().title("Sys")),
418            50,
419            14,
420        ));
421        assert!(t.contains("CPU") && t.contains("72%") && t.contains("Sys"));
422    }
423
424    #[test]
425    fn ring_stack_custom_colors() {
426        let b = draw(
427            RingStack::new(vec![("A", 100.0)])
428                .colors(vec![Color::Magenta])
429                .legend(false),
430            20,
431            10,
432        );
433        assert!(b
434            .content()
435            .iter()
436            .any(|c| c.fg == Color::Magenta && c.symbol() != " "));
437    }
438}