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::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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum PieStyle {
36 #[default]
38 Solid,
39 Segmented,
41 Hud,
43 Rose,
45}
46
47impl PieStyle {
48 pub const ALL: [PieStyle; 4] = [
50 PieStyle::Solid,
51 PieStyle::Segmented,
52 PieStyle::Hud,
53 PieStyle::Rose,
54 ];
55
56 #[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 pub(crate) fn needs_canvas(&self) -> bool {
73 self.pie_style != PieStyle::Solid || self.gap > 0.0 || self.explode > 0.0
74 }
75
76 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 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 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 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 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 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 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}