1use rich::measure::Measurement;
4use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
5
6use super::axis::{AxisRequest, exact_label, exact_labels, fit};
7use super::scale::{along, units_past};
8use super::{
9 Charset, Line, Scale, ValueFormat, cell_units, cells, has_colour, lines_to_segments,
10 series_key, theme_style, truncate, user_style,
11};
12
13const BREAK: usize = 3;
15const MIN_PLOT: usize = 8;
17
18#[derive(Clone, Debug, PartialEq)]
21#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
22pub struct Span {
23 pub row: String,
25 pub start: f64,
27 pub end: f64,
29 pub style: Option<String>,
32}
33
34impl Span {
35 pub fn new(row: impl Into<String>, start: f64, end: f64) -> Self {
37 let (start, end) = if end < start {
38 (end, start)
39 } else {
40 (start, end)
41 };
42 Span {
43 row: row.into(),
44 start,
45 end,
46 style: None,
47 }
48 }
49
50 pub fn style(mut self, style: impl Into<String>) -> Self {
52 self.style = Some(style.into());
53 self
54 }
55
56 fn finite(&self) -> bool {
57 self.start.is_finite() && self.end.is_finite()
58 }
59}
60
61#[derive(Clone, Debug, PartialEq)]
64pub struct Milestone {
65 pub label: String,
67 pub at: f64,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq)]
74struct Piece {
75 lo: f64,
76 unit: f64,
77 col0: usize,
78 cols: usize,
79}
80
81impl Piece {
82 fn hi(&self) -> f64 {
83 along(self.lo, self.cols.saturating_sub(1) as f64, self.unit)
84 }
85
86 fn columns(&self, start: f64, end: f64) -> Option<(usize, usize)> {
88 if self.unit <= 0.0 {
89 return (start <= self.lo && self.lo <= end).then_some((self.col0, self.col0));
90 }
91 let first = (units_past(start, self.lo, self.unit) - 1e-9)
92 .ceil()
93 .max(0.0);
94 let last = (units_past(end, self.lo, self.unit) + 1e-9)
95 .floor()
96 .min(self.cols as f64 - 1.0);
97 (first <= last).then(|| (self.col0 + first as usize, self.col0 + last as usize))
98 }
99
100 fn nearest(&self, value: f64) -> (usize, f64) {
102 if self.unit <= 0.0 {
103 return (self.col0, (value - self.lo).abs());
104 }
105 let at = units_past(value, self.lo, self.unit);
106 let k = at.round().clamp(0.0, self.cols as f64 - 1.0);
107 let off = (at - k).abs();
108 (self.col0 + k as usize, off)
109 }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq)]
114enum Anchor {
115 Start,
117 Centre,
119 End,
121}
122
123struct Mapping {
126 pieces: Vec<Piece>,
127 labels: Vec<(usize, String, Anchor)>,
129}
130
131impl Mapping {
132 fn span(&self, start: f64, end: f64) -> (usize, usize) {
135 let mut covered: Option<(usize, usize)> = None;
136 for piece in &self.pieces {
137 if let Some((a, b)) = piece.columns(start, end) {
138 covered = Some(match covered {
139 None => (a, b),
140 Some((x, y)) => (x.min(a), y.max(b)),
141 });
142 }
143 }
144 covered.unwrap_or_else(|| {
145 let c = self.point(start * 0.5 + end * 0.5);
146 (c, c)
147 })
148 }
149
150 fn point(&self, value: f64) -> usize {
152 self.pieces
153 .iter()
154 .map(|p| p.nearest(value))
155 .min_by(|a, b| a.1.total_cmp(&b.1))
156 .map_or(0, |(c, _)| c)
157 }
158
159 fn is_break(&self, col: usize) -> bool {
161 self.pieces.len() > 1
162 && !self
163 .pieces
164 .iter()
165 .any(|p| (p.col0..p.col0 + p.cols).contains(&col))
166 }
167}
168
169#[derive(Clone, Debug, PartialEq)]
225#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
226pub struct Timeline {
227 spans: Vec<Span>,
228 milestones: Vec<Milestone>,
229 min: Option<f64>,
230 max: Option<f64>,
231 charset: Charset,
232 format: ValueFormat,
233 unit: String,
234 compress: bool,
235 durations: bool,
236 width: Option<usize>,
237}
238
239impl Default for Timeline {
240 fn default() -> Self {
241 Self::new()
242 }
243}
244
245impl Timeline {
246 pub fn new() -> Self {
248 Timeline {
249 spans: Vec::new(),
250 milestones: Vec::new(),
251 min: None,
252 max: None,
253 charset: Charset::Auto,
254 format: ValueFormat::Compact,
255 unit: String::new(),
256 compress: true,
257 durations: true,
258 width: None,
259 }
260 }
261
262 pub fn span(self, row: impl Into<String>, start: f64, end: f64) -> Self {
264 self.push(Span::new(row, start, end))
265 }
266
267 pub fn push(mut self, span: Span) -> Self {
269 self.spans.push(span);
270 self
271 }
272
273 pub fn milestone(mut self, label: impl Into<String>, at: f64) -> Self {
275 self.milestones.push(Milestone {
276 label: label.into(),
277 at,
278 });
279 self
280 }
281
282 pub fn range(mut self, min: f64, max: f64) -> Self {
284 self.min = Some(min);
285 self.max = Some(max);
286 self
287 }
288
289 pub fn charset(mut self, charset: Charset) -> Self {
292 self.charset = charset;
293 self
294 }
295
296 pub fn format(mut self, format: ValueFormat) -> Self {
298 self.format = format;
299 self
300 }
301
302 pub fn unit(mut self, unit: impl Into<String>) -> Self {
304 self.unit = unit.into();
305 self
306 }
307
308 pub fn compress(mut self, compress: bool) -> Self {
311 self.compress = compress;
312 self
313 }
314
315 pub fn durations(mut self, show: bool) -> Self {
317 self.durations = show;
318 self
319 }
320
321 pub fn width(mut self, width: usize) -> Self {
323 self.width = Some(width.max(1));
324 self
325 }
326
327 pub fn rows(&self) -> Vec<&str> {
329 let mut rows: Vec<&str> = Vec::new();
330 for span in &self.spans {
331 if !rows.contains(&span.row.as_str()) {
332 rows.push(&span.row);
333 }
334 }
335 rows
336 }
337
338 fn lanes(&self, row: &str) -> Vec<Vec<&Span>> {
341 let mut spans: Vec<&Span> = self
342 .spans
343 .iter()
344 .filter(|s| s.row == row && s.finite())
345 .collect();
346 spans.sort_by(|a, b| a.start.total_cmp(&b.start));
347 let mut lanes: Vec<Vec<&Span>> = Vec::new();
348 for span in spans {
349 match lanes
350 .iter_mut()
351 .find(|lane| lane.last().is_some_and(|last| last.end <= span.start))
352 {
353 Some(lane) => lane.push(span),
354 None => lanes.push(vec![span]),
355 }
356 }
357 if lanes.is_empty() {
358 lanes.push(Vec::new());
359 }
360 lanes
361 }
362
363 fn data(&self) -> Option<(f64, f64)> {
364 let points = self
365 .spans
366 .iter()
367 .filter(|s| s.finite())
368 .flat_map(|s| [s.start, s.end])
369 .chain(
370 self.milestones
371 .iter()
372 .map(|m| m.at)
373 .filter(|v| v.is_finite()),
374 );
375 let mut range: Option<(f64, f64)> = None;
376 for v in points {
377 range = Some(match range {
378 None => (v, v),
379 Some((lo, hi)) => (lo.min(v), hi.max(v)),
380 });
381 }
382 range
383 }
384
385 fn text(&self, value: f64) -> String {
387 if value.is_finite() {
388 format!("{}{}", self.format.format(value), self.unit)
389 } else {
390 self.format.format(value)
391 }
392 }
393
394 fn mapping(&self, plot: usize) -> Mapping {
396 let fixed = self.min.is_some() || self.max.is_some();
397 let (lo, hi) = self.data().unwrap_or((0.0, 1.0));
398 let data = Scale::new(lo, hi).bounds(self.min, self.max);
399 if !fixed
400 && self.compress
401 && let Some(mapping) = self.compressed(data, plot)
402 {
403 return mapping;
404 }
405 let cells = plot.saturating_sub(1);
406 let axis = fit(&AxisRequest {
407 data,
408 fixed,
409 cells: &[cells],
410 wanted: (plot / 8).max(2),
411 label_room: true,
412 format: self.format,
413 });
414 let labels = axis
415 .labels
416 .iter()
417 .map(|(p, label)| (*p, format!("{label}{}", self.unit), Anchor::Centre))
418 .collect();
419 Mapping {
420 pieces: vec![Piece {
421 lo: axis.lo,
422 unit: axis.unit,
423 col0: 0,
424 cols: plot.max(1),
425 }],
426 labels,
427 }
428 }
429
430 fn compressed(&self, data: Scale, plot: usize) -> Option<Mapping> {
433 let spans: Vec<(f64, f64)> = self
434 .spans
435 .iter()
436 .filter(|s| s.finite())
437 .map(|s| (s.start, s.end))
438 .chain(
439 self.milestones
440 .iter()
441 .filter(|m| m.at.is_finite())
442 .map(|m| (m.at, m.at)),
443 )
444 .collect();
445 let shortest = self
446 .spans
447 .iter()
448 .filter(|s| s.finite() && s.end > s.start)
449 .map(|s| s.end - s.start)
450 .filter(|length| length.is_finite())
451 .fold(f64::INFINITY, f64::min);
452 if !shortest.is_finite() || plot < 2 * BREAK + 4 {
453 return None;
454 }
455 let per_column = data.span() / (plot - 1) as f64;
456 if shortest >= per_column {
457 return None;
458 }
459 let mut sorted = spans;
461 sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
462 let mut busy: Vec<(f64, f64)> = Vec::new();
463 let min_gap = (shortest * 4.0).max(per_column * BREAK as f64);
464 for (start, end) in sorted {
465 match busy.last_mut() {
466 Some(last) if start - last.1 <= min_gap => last.1 = last.1.max(end),
467 _ => busy.push((start, end)),
468 }
469 }
470 if busy.len() < 2 {
471 return None;
472 }
473 let breaks = busy.len() - 1;
474 let room = plot.checked_sub(breaks * BREAK)?;
475 if room < busy.len() * 2 {
476 return None;
477 }
478 let total: f64 = busy.iter().map(|(a, b)| b - a).sum();
480 let spare = room - busy.len() * 2;
481 let mut cols: Vec<usize> = busy
482 .iter()
483 .map(|(a, b)| {
484 let share = if total > 0.0 {
485 (b - a) / total * spare as f64
486 } else {
487 0.0
488 };
489 2 + share.floor() as usize
490 })
491 .collect();
492 let mut left = room - cols.iter().sum::<usize>();
493 let mut order: Vec<usize> = (0..busy.len()).collect();
494 order.sort_by(|&i, &j| {
495 let frac = |k: usize| {
496 let (a, b) = busy[k];
497 let share = if total > 0.0 {
498 (b - a) / total * spare as f64
499 } else {
500 0.0
501 };
502 share - share.floor()
503 };
504 frac(j).total_cmp(&frac(i))
505 });
506 for i in order.into_iter().cycle() {
507 if left == 0 {
508 break;
509 }
510 cols[i] += 1;
511 left -= 1;
512 }
513 let mut pieces = Vec::new();
514 let mut col0 = 0;
515 for ((start, end), n) in busy.iter().zip(cols) {
516 let unit = (end - start) / (n - 1) as f64;
517 pieces.push(Piece {
518 lo: *start,
519 unit,
520 col0,
521 cols: n,
522 });
523 col0 += n + BREAK;
524 }
525 let ends: Vec<f64> = pieces.iter().flat_map(|p| [p.lo, p.hi()]).collect();
528 let written = exact_labels(self.format, &ends)
529 .map(|(labels, _)| labels)
530 .unwrap_or_else(|| ends.iter().map(|&v| exact_label(self.format, v)).collect());
531 let mut labels = Vec::new();
532 for (piece, ends) in pieces.iter().zip(written.chunks(2)) {
533 labels.push((
534 piece.col0,
535 format!("{}{}", ends[0], self.unit),
536 Anchor::Start,
537 ));
538 let last = piece.col0 + piece.cols - 1;
539 labels.push((last, format!("{}{}", ends[1], self.unit), Anchor::End));
540 }
541 Some(Mapping { pieces, labels })
542 }
543
544 fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
545 let given = self
546 .width
547 .unwrap_or(options.max_width)
548 .min(options.max_width);
549 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
550 let colour = has_colour(console);
551 if self.data().is_none() {
552 let mut line = Line::new();
553 line.push(&truncate("no data", given, ascii), None);
554 return vec![line];
555 }
556 let rows = self.rows();
557 let natural = rows.iter().map(|r| cells(r)).max().unwrap_or(0);
558 let part = |l: usize| l + usize::from(l > 0);
559 let mut label_w = natural;
560 if given < part(label_w) + MIN_PLOT {
561 label_w = given.saturating_sub(MIN_PLOT + 1).min(natural);
562 label_w = label_w.max(natural.min(3));
563 if given < part(label_w) + 2 {
564 label_w = 0;
565 }
566 }
567 let plot = given.saturating_sub(part(label_w)).max(1);
568 let mapping = self.mapping(plot);
569 let label_style = colour.then(|| theme_style(console, "chart.label"));
570 let value_style = colour.then(|| theme_style(console, "chart.value"));
571 let axis_style = colour.then(|| theme_style(console, "chart.axis"));
572 let (full, alt) = if ascii { ('#', '=') } else { ('█', '▓') };
573 let mut out = Vec::new();
574
575 for (index, row) in rows.iter().enumerate() {
576 let row_style = colour.then(|| theme_style(console, &series_key(index)));
577 for (lane_no, lane) in self.lanes(row).into_iter().enumerate() {
578 let mut cells_row: Vec<(String, Option<Style>)> =
579 vec![(" ".to_string(), None); plot];
580 let placed: Vec<(usize, usize)> =
581 lane.iter().map(|s| mapping.span(s.start, s.end)).collect();
582 let mut glyph = full;
583 for (k, span) in lane.iter().enumerate() {
584 let (a, b) = placed[k];
585 let touching = k > 0 && placed[k - 1].1 + 1 >= a;
587 glyph = match (touching, glyph == full) {
588 (true, true) => alt,
589 _ => full,
590 };
591 let style = colour.then(|| match &span.style {
592 Some(s) => user_style(console, s),
593 None => row_style.clone().unwrap_or_default(),
594 });
595 for cell in cells_row.iter_mut().take(b + 1).skip(a) {
596 *cell = (glyph.to_string(), style.clone());
597 }
598 }
599 if self.durations {
600 for (k, span) in lane.iter().enumerate() {
601 let text = self.text(span.end - span.start);
602 let text = truncate(&text, usize::MAX, ascii);
603 let start = placed[k].1 + 2;
604 let limit = placed.get(k + 1).map_or(plot, |n| n.0.saturating_sub(1));
605 if start + cells(&text) <= limit {
606 for (i, unit) in cell_units(&text).into_iter().enumerate() {
607 cells_row[start + i] = (unit, value_style.clone());
608 }
609 }
610 }
611 }
612 let mut line = Line::new();
613 if label_w > 0 {
614 let text = if lane_no == 0 {
615 truncate(row, label_w, ascii)
616 } else {
617 String::new()
618 };
619 let pad = label_w - cells(&text);
620 line.push(&text, label_style.clone());
621 line.pad(pad + 1);
622 }
623 for (unit, style) in cells_row {
624 line.push(&unit, style);
625 }
626 out.push(line);
627 }
628 }
629
630 let mut marks: Vec<(usize, &Milestone)> = self
632 .milestones
633 .iter()
634 .filter(|m| m.at.is_finite())
635 .map(|m| (mapping.point(m.at), m))
636 .collect();
637 if !marks.is_empty() {
638 marks.sort_by_key(|(c, _)| *c);
639 let style = colour.then(|| theme_style(console, "chart.milestone"));
640 let mut row: Vec<(String, Option<Style>)> = vec![(" ".to_string(), None); plot];
641 let marker = if ascii { '*' } else { '◆' };
642 for (col, _) in &marks {
643 row[*col] = (marker.to_string(), style.clone());
644 }
645 let mut free = 0;
647 for (k, (col, mark)) in marks.iter().enumerate() {
648 let limit = marks.get(k + 1).map_or(plot, |n| n.0.saturating_sub(1));
649 let after = limit.saturating_sub(col + 2);
650 let before = col.saturating_sub(free + 1);
651 let len = cells(&truncate(&mark.label, usize::MAX, ascii));
652 let (start, room) = if len <= after || after >= before {
653 (col + 2, after)
654 } else {
655 (col - 1 - len.min(before), before)
656 };
657 if room == 0 {
658 free = col + 2;
659 continue;
660 }
661 let text = truncate(&mark.label, room, ascii);
662 for (i, unit) in cell_units(&text).into_iter().enumerate() {
663 row[start + i] = (unit, label_style.clone());
664 }
665 free = (start + cells(&text)).max(col + 1) + 1;
666 }
667 let mut line = Line::new();
668 line.pad(part(label_w));
669 for (unit, style) in row {
670 line.push(&unit, style);
671 }
672 out.push(line);
673 }
674
675 let (rule, tick, gap) = if ascii {
677 ('-', '+', '~')
678 } else {
679 ('─', '┬', '≈')
680 };
681 let mut axis: Vec<char> = (0..plot)
682 .map(|c| {
683 if mapping.is_break(c) {
684 if mapping.is_break(c.wrapping_sub(1)) && mapping.is_break(c + 1) {
685 gap
686 } else {
687 ' '
688 }
689 } else {
690 rule
691 }
692 })
693 .collect();
694 let mut labels: Vec<String> = vec![" ".to_string(); plot];
695 let mut taken: Vec<(usize, usize)> = Vec::new();
696 for (col, text, anchor) in &mapping.labels {
697 let text = truncate(text, plot, ascii);
698 let len = cells(&text);
699 let start = match anchor {
701 Anchor::Start => *col,
702 Anchor::Centre => col.saturating_sub(len / 2),
703 Anchor::End => (col + 1).saturating_sub(len),
704 }
705 .min(plot.saturating_sub(len));
706 let end = start + len;
707 if taken.iter().any(|&(a, b)| start < b + 1 && a < end + 1) {
708 continue;
709 }
710 for (i, unit) in cell_units(&text).into_iter().enumerate() {
711 labels[start + i] = unit;
712 }
713 taken.push((start, end));
714 if *col < plot {
715 axis[*col] = tick;
716 }
717 }
718 let mut axis_line = Line::new();
719 axis_line.pad(part(label_w));
720 axis_line.push(&axis.into_iter().collect::<String>(), axis_style);
721 out.push(axis_line);
722 let mut label_line = Line::new();
723 label_line.pad(part(label_w));
724 label_line.push(&labels.concat(), label_style);
725 out.push(label_line);
726 out
727 }
728}
729
730impl crate::surface::Draw for Timeline {
731 fn draw(
732 &self,
733 console: &Console,
734 options: &ConsoleOptions,
735 screen: &mut crate::surface::Screen,
736 rect: crate::surface::Rect,
737 ) {
738 super::draw_lines(
739 self.lines(console, options),
740 options.max_width,
741 options,
742 screen,
743 rect,
744 );
745 }
746}
747
748impl Renderable for Timeline {
749 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
750 lines_to_segments(self.lines(console, options), options.max_width)
751 }
752
753 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
754 let max = self.width.unwrap_or(options.max_width);
755 Measurement::new(max.min(12), max)
756 .with_maximum(options.max_width)
757 .normalize()
758 }
759}
760
761impl crate::a11y::AccessibleText for Timeline {
765 fn accessible_text(&self, _width: usize) -> String {
766 use super::describe::{count, counted};
767 if self.spans.is_empty() && self.milestones.is_empty() {
768 return "Timeline, no ranges".to_string();
769 }
770 let mut head = format!(
771 "Timeline, {}, {}",
772 counted(self.rows().len(), "row", "rows"),
773 counted(self.spans.len(), "range", "ranges")
774 );
775 if !self.milestones.is_empty() {
776 head.push_str(&format!(
777 ", {}",
778 counted(self.milestones.len(), "milestone", "milestones")
779 ));
780 }
781 if let Some((lo, hi)) = self.data() {
782 head.push_str(&format!(", from {} to {}", self.text(lo), self.text(hi)));
783 }
784 let mut out = vec![head];
785 const CAP: usize = 20;
786 for span in self.spans.iter().take(CAP) {
787 let row = if span.row.is_empty() {
788 "range".to_string()
789 } else {
790 span.row.clone()
791 };
792 if span.finite() {
793 out.push(format!(
794 "{row} from {} to {}, {}",
795 self.text(span.start),
796 self.text(span.end),
797 self.text(span.end - span.start)
798 ));
799 } else {
800 out.push(format!("{row}: no range"));
801 }
802 }
803 if self.spans.len() > CAP {
804 out.push(format!("and {} more ranges", count(self.spans.len() - CAP)));
805 }
806 for m in &self.milestones {
807 out.push(format!("Milestone {} at {}", m.label, self.text(m.at)));
808 }
809 out.join("\n")
810 }
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816 use crate::a11y::AccessibleText;
817
818 #[test]
819 fn accessible_text_lists_the_ranges() {
820 let build = Timeline::new()
821 .span("fetch", 0.0, 4.0)
822 .span("compile", 4.0, 31.5)
823 .span("test", 31.5, 52.0)
824 .milestone("tagged", 52.0)
825 .unit("s");
826 assert_eq!(
827 build.accessible_text(80),
828 "Timeline, 3 rows, 3 ranges, 1 milestone, from 0s to 52s\n\
829 fetch from 0s to 4s, 4s\n\
830 compile from 4s to 31.5s, 27.5s\n\
831 test from 31.5s to 52s, 20.5s\n\
832 Milestone tagged at 52s"
833 );
834 assert_eq!(Timeline::new().accessible_text(80), "Timeline, no ranges");
835 }
836}