1#![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#[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 #[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 #[must_use]
44 pub const fn color(mut self, color: Color) -> Self {
45 self.color = Some(color);
46 self
47 }
48
49 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum RadialSweep {
63 #[default]
65 Arc,
66 Full,
68}
69
70#[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 #[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 #[must_use]
109 pub const fn sweep(mut self, sweep: RadialSweep) -> Self {
110 self.sweep = sweep;
111 self
112 }
113
114 #[must_use]
116 pub const fn legend(mut self, show: bool) -> Self {
117 self.legend = show;
118 self
119 }
120
121 #[must_use]
123 pub const fn track_color(mut self, color: Color) -> Self {
124 self.track = color;
125 self
126 }
127
128 #[must_use]
130 pub fn block(mut self, block: Block<'a>) -> Self {
131 self.block = Some(block);
132 self
133 }
134
135 #[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
210fn 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 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#[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 #[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 #[must_use]
276 pub fn colors(mut self, colors: Vec<Color>) -> Self {
277 self.colors = colors;
278 self
279 }
280
281 #[must_use]
283 pub fn legend(mut self, show: bool) -> Self {
284 self.inner = self.inner.legend(show);
285 self
286 }
287
288 #[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}