Skip to main content

rich_ext/chart/
timeline.rs

1//! Timelines and Gantt strips: labelled ranges on a numeric scale.
2
3use 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
13/// Columns an elided gap takes.
14const BREAK: usize = 3;
15/// The narrowest plot that keeps the full row labels.
16const MIN_PLOT: usize = 8;
17
18/// A range on a [`Timeline`]: the row it goes on, where it starts and where
19/// it ends.
20#[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    /// The row's label. Spans with the same label share a row.
24    pub row: String,
25    /// Where the range starts.
26    pub start: f64,
27    /// Where the range ends (at or after `start`).
28    pub end: f64,
29    /// A theme key or style definition, instead of the row's
30    /// `chart.series.N`.
31    pub style: Option<String>,
32}
33
34impl Span {
35    /// A range on row `row` from `start` to `end` (swapped if reversed).
36    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    /// This range's style: a theme key or a definition.
51    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/// A point on a [`Timeline`], drawn `◆` (`*` in ASCII) on its own line
62/// under the ranges, with its label beside it when there is room.
63#[derive(Clone, Debug, PartialEq)]
64pub struct Milestone {
65    /// Written after the marker.
66    pub label: String,
67    /// Where it is.
68    pub at: f64,
69}
70
71/// Columns of the plot that run at one rate: column `col0 + k` stands for
72/// `lo + k * unit`, at its centre.
73#[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    /// The columns whose centre falls in `start..=end`.
87    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    /// The column nearest `value`, and how far it is in columns.
101    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/// Where an axis label sits against its column.
113#[derive(Clone, Copy, Debug, PartialEq)]
114enum Anchor {
115    /// Starting at the column: a stretch's first value.
116    Start,
117    /// Centred on it.
118    Centre,
119    /// Ending at it: a stretch's last value.
120    End,
121}
122
123/// How the plot's columns map to values: one piece, or several with the
124/// idle gaps between them elided.
125struct Mapping {
126    pieces: Vec<Piece>,
127    /// `(column, label, anchor)` for the axis.
128    labels: Vec<(usize, String, Anchor)>,
129}
130
131impl Mapping {
132    /// The columns a range covers: every column whose centre is inside it,
133    /// or the nearest one.
134    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    /// The column nearest `value`.
151    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    /// Whether `col` is in an elided gap.
160    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/// Labelled ranges on a numeric scale, such as the steps of a build in
170/// seconds: a Gantt strip when every range has its own row, a timeline of
171/// lanes when ranges share a row.
172///
173/// - Each row is labelled on the left. Ranges on the same row that overlap
174///   are **stacked** onto extra lines under it, so none hides another.
175/// - Ranges are drawn `█`, or `▓` when one follows another on the same
176///   line (`#` and `=` in ASCII), in their row's `chart.series.N` colour. A
177///   range covers every column whose centre value is inside it, and at
178///   least one.
179/// - Each range's length is written after it when there is room before
180///   the next one ([`durations`](Self::durations), on by default), so the
181///   chart reads without the axis.
182/// - [`milestone`](Self::milestone) marks a point with `◆` (`*`) on a line
183///   under the ranges, its label beside it when it fits.
184/// - The axis runs along the bottom. Its labels sit on evenly spaced
185///   columns and name each column's exact value, as on a
186///   [`LineChart`](super::LineChart); [`unit`](Self::unit) is written after
187///   each value (`20s`).
188/// - **Compression:** when the range is wider than the plot, so that the
189///   shortest range would get less than a column of its own, idle gaps
190///   longer than four times the shortest range, and longer than three
191///   columns' worth of the uncut scale, are cut out
192///   ([`compress`](Self::compress), on by default). Each cut is three
193///   columns with `≈` (`~`) on the axis, and the axis labels each stretch
194///   at its start and end.
195/// - It fills the width given, or [`width`](Self::width). Given less, the
196///   row labels are cut so the plot keeps 8 columns.
197///
198/// ```
199/// use rich::Console;
200/// use rich_ext::chart::Timeline;
201///
202/// let console = Console::builder().width(40).color_system(None).build();
203/// let build = Timeline::new()
204///     .span("fetch", 0.0, 4.0)
205///     .span("compile", 4.0, 26.0)
206///     .span("test", 12.0, 30.0)
207///     .span("test", 20.0, 34.0)
208///     .milestone("ship", 36.0)
209///     .unit("s")
210///     .width(40);
211/// assert_eq!(
212///     console.render_export(&build),
213///     concat!(
214///         "fetch   ████ 4s                         \n",
215///         "compile     █████████████████ 22s       \n",
216///         "test              ███████████████ 18s   \n",
217///         "                        ████████████ 14s\n",
218///         "                                ship ◆  \n",
219///         "        ┬───────┬───────┬───────┬───────\n",
220///         "        0s     10s     20s     30s      \n",
221///     )
222/// );
223/// ```
224#[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    /// An empty timeline; add ranges with [`span`](Self::span).
247    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    /// Add a range on row `row` from `start` to `end`.
263    pub fn span(self, row: impl Into<String>, start: f64, end: f64) -> Self {
264        self.push(Span::new(row, start, end))
265    }
266
267    /// Add a [`Span`] built with its own style.
268    pub fn push(mut self, span: Span) -> Self {
269        self.spans.push(span);
270        self
271    }
272
273    /// Add a milestone at `at`.
274    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    /// Fix the scale instead of fitting it to the ranges (no compression).
283    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    /// Glyphs to draw with: [`Charset::Ascii`] asks for ASCII; anything else
290    /// draws blocks (ASCII on an ASCII-only console).
291    pub fn charset(mut self, charset: Charset) -> Self {
292        self.charset = charset;
293        self
294    }
295
296    /// How values are written (default [`ValueFormat::Compact`]).
297    pub fn format(mut self, format: ValueFormat) -> Self {
298        self.format = format;
299        self
300    }
301
302    /// Written after every value on the axis and every length, such as `s`.
303    pub fn unit(mut self, unit: impl Into<String>) -> Self {
304        self.unit = unit.into();
305        self
306    }
307
308    /// Cut long idle gaps when the range is wider than the plot (default
309    /// on).
310    pub fn compress(mut self, compress: bool) -> Self {
311        self.compress = compress;
312        self
313    }
314
315    /// Write each range's length after it (default on).
316    pub fn durations(mut self, show: bool) -> Self {
317        self.durations = show;
318        self
319    }
320
321    /// Fix the width (default: the width given).
322    pub fn width(mut self, width: usize) -> Self {
323        self.width = Some(width.max(1));
324        self
325    }
326
327    /// The row labels, in the order they first appear.
328    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    /// The spans of `row` packed into lines: each line's spans in order,
339    /// none overlapping another on its line.
340    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    /// `value` and the unit; a length past `f64::MAX` is `-`.
386    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    /// The plot's mapping for `plot` columns.
395    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    /// The mapping with long idle gaps cut out, when the shortest range
431    /// would get less than a column and there are gaps worth cutting.
432    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        // Busy stretches: ranges merged where they touch or overlap.
460        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        // Share the columns by length, each stretch at least two.
479        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        // Every stretch's ends, written alike: all in full when compact
526        // cannot write one of them exactly.
527        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                    // Touching the one before: the other glyph.
586                    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        // Milestones, each with its label when it fits before the next.
631        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            // After the marker when it fits, else before it, else cut.
646            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        // The axis and its labels.
676        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            // Against its column, kept inside the plot.
700            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
761/// `Timeline, 3 rows, 4 ranges, 1 milestone, from 0s to 95s`, then each
762/// range (`compile from 0s to 12s, 12s`), twenty at most, and each
763/// milestone (`Milestone release at 90s`).
764impl 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}