indicatif/
draw_target.rs

1use std::io;
2use std::ops::{Add, AddAssign, Sub};
3use std::slice::SliceIndex;
4use std::sync::{Arc, RwLock, RwLockWriteGuard};
5use std::thread::panicking;
6use std::time::Duration;
7#[cfg(not(target_arch = "wasm32"))]
8use std::time::Instant;
9
10use console::Term;
11#[cfg(target_arch = "wasm32")]
12use web_time::Instant;
13
14use crate::multi::{MultiProgressAlignment, MultiState};
15use crate::TermLike;
16
17/// Target for draw operations
18///
19/// This tells a [`ProgressBar`](crate::ProgressBar) or a
20/// [`MultiProgress`](crate::MultiProgress) object where to paint to.
21/// The draw target is a stateful wrapper over a drawing destination and
22/// internally optimizes how often the state is painted to the output
23/// device.
24#[derive(Debug)]
25pub struct ProgressDrawTarget {
26    kind: TargetKind,
27}
28
29impl ProgressDrawTarget {
30    /// Draw to a buffered stdout terminal at a max of 20 times a second.
31    ///
32    /// For more information see [`ProgressDrawTarget::term`].
33    pub fn stdout() -> Self {
34        Self::term(Term::buffered_stdout(), 20)
35    }
36
37    /// Draw to a buffered stderr terminal at a max of 20 times a second.
38    ///
39    /// This is the default draw target for progress bars.  For more
40    /// information see [`ProgressDrawTarget::term`].
41    pub fn stderr() -> Self {
42        Self::term(Term::buffered_stderr(), 20)
43    }
44
45    /// Draw to a buffered stdout terminal at a max of `refresh_rate` times a second.
46    ///
47    /// For more information see [`ProgressDrawTarget::term`].
48    pub fn stdout_with_hz(refresh_rate: u8) -> Self {
49        Self::term(Term::buffered_stdout(), refresh_rate)
50    }
51
52    /// Draw to a buffered stderr terminal at a max of `refresh_rate` times a second.
53    ///
54    /// For more information see [`ProgressDrawTarget::term`].
55    pub fn stderr_with_hz(refresh_rate: u8) -> Self {
56        Self::term(Term::buffered_stderr(), refresh_rate)
57    }
58
59    pub(crate) fn new_remote(state: Arc<RwLock<MultiState>>, idx: usize) -> Self {
60        Self {
61            kind: TargetKind::Multi { state, idx },
62        }
63    }
64
65    /// Draw to a terminal, with a specific refresh rate.
66    ///
67    /// Progress bars are by default drawn to terminals however if the
68    /// terminal is not user attended the entire progress bar will be
69    /// hidden.  This is done so that piping to a file will not produce
70    /// useless escape codes in that file.
71    ///
72    /// Will panic if `refresh_rate` is `0`.
73    pub fn term(term: Term, refresh_rate: u8) -> Self {
74        Self {
75            kind: TargetKind::Term {
76                term,
77                last_line_count: VisualLines::default(),
78                rate_limiter: RateLimiter::new(refresh_rate),
79                draw_state: DrawState::default(),
80            },
81        }
82    }
83
84    /// Draw to a boxed object that implements the [`TermLike`] trait.
85    pub fn term_like(term_like: Box<dyn TermLike>) -> Self {
86        Self {
87            kind: TargetKind::TermLike {
88                inner: term_like,
89                last_line_count: VisualLines::default(),
90                rate_limiter: None,
91                draw_state: DrawState::default(),
92            },
93        }
94    }
95
96    /// Draw to a boxed object that implements the [`TermLike`] trait,
97    /// with a specific refresh rate.
98    pub fn term_like_with_hz(term_like: Box<dyn TermLike>, refresh_rate: u8) -> Self {
99        Self {
100            kind: TargetKind::TermLike {
101                inner: term_like,
102                last_line_count: VisualLines::default(),
103                rate_limiter: Option::from(RateLimiter::new(refresh_rate)),
104                draw_state: DrawState::default(),
105            },
106        }
107    }
108
109    /// A hidden draw target.
110    ///
111    /// This forces a progress bar to be not rendered at all.
112    pub fn hidden() -> Self {
113        Self {
114            kind: TargetKind::Hidden,
115        }
116    }
117
118    /// Returns true if the draw target is hidden.
119    ///
120    /// This is internally used in progress bars to figure out if overhead
121    /// from drawing can be prevented.
122    pub fn is_hidden(&self) -> bool {
123        match self.kind {
124            TargetKind::Hidden => true,
125            TargetKind::Term { ref term, .. } => !term.is_term(),
126            TargetKind::Multi { ref state, .. } => state.read().unwrap().is_hidden(),
127            _ => false,
128        }
129    }
130
131    /// Returns the current width of the draw target.
132    pub(crate) fn width(&self) -> Option<u16> {
133        match self.kind {
134            TargetKind::Term { ref term, .. } => Some(term.size().1),
135            TargetKind::Multi { ref state, .. } => state.read().unwrap().width(),
136            TargetKind::TermLike { ref inner, .. } => Some(inner.width()),
137            TargetKind::Hidden => None,
138        }
139    }
140
141    /// Notifies the backing `MultiProgress` (if applicable) that the associated progress bar should
142    /// be marked a zombie.
143    pub(crate) fn mark_zombie(&self) {
144        if let TargetKind::Multi { idx, state } = &self.kind {
145            state.write().unwrap().mark_zombie(*idx);
146        }
147    }
148
149    /// Set whether or not to just move cursor instead of clearing lines
150    pub(crate) fn set_move_cursor(&mut self, move_cursor: bool) {
151        match &mut self.kind {
152            TargetKind::Term { draw_state, .. } => draw_state.move_cursor = move_cursor,
153            TargetKind::TermLike { draw_state, .. } => draw_state.move_cursor = move_cursor,
154            _ => {}
155        }
156    }
157
158    /// Apply the given draw state (draws it).
159    pub(crate) fn drawable(&mut self, force_draw: bool, now: Instant) -> Option<Drawable<'_>> {
160        match &mut self.kind {
161            TargetKind::Term {
162                term,
163                last_line_count,
164                rate_limiter,
165                draw_state,
166            } => {
167                if !term.is_term() {
168                    return None;
169                }
170
171                match force_draw || rate_limiter.allow(now) {
172                    true => Some(Drawable::Term {
173                        term,
174                        last_line_count,
175                        draw_state,
176                    }),
177                    false => None, // rate limited
178                }
179            }
180            TargetKind::Multi { idx, state, .. } => {
181                let state = state.write().unwrap();
182                Some(Drawable::Multi {
183                    idx: *idx,
184                    state,
185                    force_draw,
186                    now,
187                })
188            }
189            TargetKind::TermLike {
190                inner,
191                last_line_count,
192                rate_limiter,
193                draw_state,
194            } => match force_draw || rate_limiter.as_mut().map_or(true, |r| r.allow(now)) {
195                true => Some(Drawable::TermLike {
196                    term_like: &**inner,
197                    last_line_count,
198                    draw_state,
199                }),
200                false => None, // rate limited
201            },
202            // Hidden, finished, or no need to refresh yet
203            _ => None,
204        }
205    }
206
207    /// Properly disconnects from the draw target
208    pub(crate) fn disconnect(&self, now: Instant) {
209        match self.kind {
210            TargetKind::Term { .. } => {}
211            TargetKind::Multi { idx, ref state, .. } => {
212                let state = state.write().unwrap();
213                let _ = Drawable::Multi {
214                    state,
215                    idx,
216                    force_draw: true,
217                    now,
218                }
219                .clear();
220            }
221            TargetKind::Hidden => {}
222            TargetKind::TermLike { .. } => {}
223        };
224    }
225
226    pub(crate) fn remote(&self) -> Option<(&Arc<RwLock<MultiState>>, usize)> {
227        match &self.kind {
228            TargetKind::Multi { state, idx } => Some((state, *idx)),
229            _ => None,
230        }
231    }
232
233    pub(crate) fn adjust_last_line_count(&mut self, adjust: LineAdjust) {
234        self.kind.adjust_last_line_count(adjust);
235    }
236}
237
238#[derive(Debug)]
239enum TargetKind {
240    Term {
241        term: Term,
242        last_line_count: VisualLines,
243        rate_limiter: RateLimiter,
244        draw_state: DrawState,
245    },
246    Multi {
247        state: Arc<RwLock<MultiState>>,
248        idx: usize,
249    },
250    Hidden,
251    TermLike {
252        inner: Box<dyn TermLike>,
253        last_line_count: VisualLines,
254        rate_limiter: Option<RateLimiter>,
255        draw_state: DrawState,
256    },
257}
258
259impl TargetKind {
260    /// Adjust `last_line_count` such that the next draw operation keeps/clears additional lines
261    fn adjust_last_line_count(&mut self, adjust: LineAdjust) {
262        let last_line_count = match self {
263            Self::Term {
264                last_line_count, ..
265            } => last_line_count,
266            Self::TermLike {
267                last_line_count, ..
268            } => last_line_count,
269            _ => return,
270        };
271
272        match adjust {
273            LineAdjust::Clear(count) => *last_line_count = last_line_count.saturating_add(count),
274            LineAdjust::Keep(count) => *last_line_count = last_line_count.saturating_sub(count),
275        }
276    }
277}
278
279pub(crate) enum Drawable<'a> {
280    Term {
281        term: &'a Term,
282        last_line_count: &'a mut VisualLines,
283        draw_state: &'a mut DrawState,
284    },
285    Multi {
286        state: RwLockWriteGuard<'a, MultiState>,
287        idx: usize,
288        force_draw: bool,
289        now: Instant,
290    },
291    TermLike {
292        term_like: &'a dyn TermLike,
293        last_line_count: &'a mut VisualLines,
294        draw_state: &'a mut DrawState,
295    },
296}
297
298impl Drawable<'_> {
299    /// Adjust `last_line_count` such that the next draw operation keeps/clears additional lines
300    pub(crate) fn adjust_last_line_count(&mut self, adjust: LineAdjust) {
301        let last_line_count: &mut VisualLines = match self {
302            Drawable::Term {
303                last_line_count, ..
304            } => last_line_count,
305            Drawable::TermLike {
306                last_line_count, ..
307            } => last_line_count,
308            _ => return,
309        };
310
311        match adjust {
312            LineAdjust::Clear(count) => *last_line_count = last_line_count.saturating_add(count),
313            LineAdjust::Keep(count) => *last_line_count = last_line_count.saturating_sub(count),
314        }
315    }
316
317    pub(crate) fn state(&mut self) -> DrawStateWrapper<'_> {
318        let mut state = match self {
319            Drawable::Term { draw_state, .. } => DrawStateWrapper::for_term(draw_state),
320            Drawable::Multi { state, idx, .. } => state.draw_state(*idx),
321            Drawable::TermLike { draw_state, .. } => DrawStateWrapper::for_term(draw_state),
322        };
323
324        state.reset();
325        state
326    }
327
328    pub(crate) fn clear(mut self) -> io::Result<()> {
329        let state = self.state();
330        drop(state);
331        self.draw()
332    }
333
334    pub(crate) fn draw(self) -> io::Result<()> {
335        match self {
336            Drawable::Term {
337                term,
338                last_line_count,
339                draw_state,
340            } => draw_state.draw_to_term(term, last_line_count),
341            Drawable::Multi {
342                mut state,
343                force_draw,
344                now,
345                ..
346            } => state.draw(force_draw, None, now),
347            Drawable::TermLike {
348                term_like,
349                last_line_count,
350                draw_state,
351            } => draw_state.draw_to_term(term_like, last_line_count),
352        }
353    }
354
355    pub(crate) fn width(&self) -> Option<u16> {
356        match self {
357            Self::Term { term, .. } => Some(term.size().1),
358            Self::Multi { state, .. } => state.width(),
359            Self::TermLike { term_like, .. } => Some(term_like.width()),
360        }
361    }
362}
363
364pub(crate) enum LineAdjust {
365    /// Adds to `last_line_count` so that the next draw also clears those lines
366    Clear(VisualLines),
367    /// Subtracts from `last_line_count` so that the next draw retains those lines
368    Keep(VisualLines),
369}
370
371pub(crate) struct DrawStateWrapper<'a> {
372    state: &'a mut DrawState,
373    orphan_lines: Option<&'a mut Vec<LineType>>,
374}
375
376impl<'a> DrawStateWrapper<'a> {
377    pub(crate) fn for_term(state: &'a mut DrawState) -> Self {
378        Self {
379            state,
380            orphan_lines: None,
381        }
382    }
383
384    pub(crate) fn for_multi(state: &'a mut DrawState, orphan_lines: &'a mut Vec<LineType>) -> Self {
385        Self {
386            state,
387            orphan_lines: Some(orphan_lines),
388        }
389    }
390}
391
392impl std::ops::Deref for DrawStateWrapper<'_> {
393    type Target = DrawState;
394
395    fn deref(&self) -> &Self::Target {
396        self.state
397    }
398}
399
400impl std::ops::DerefMut for DrawStateWrapper<'_> {
401    fn deref_mut(&mut self) -> &mut Self::Target {
402        self.state
403    }
404}
405
406impl Drop for DrawStateWrapper<'_> {
407    fn drop(&mut self) {
408        if let Some(text_lines) = &mut self.orphan_lines {
409            // Filter out the lines that do not contain progress information
410            // Store the filtered out lines in orphaned
411            let mut lines = Vec::new();
412
413            for line in self.state.lines.drain(..) {
414                match &line {
415                    LineType::Text(_) | LineType::Empty => text_lines.push(line),
416                    _ => lines.push(line),
417                }
418            }
419
420            self.state.lines = lines;
421        }
422    }
423}
424
425#[derive(Debug)]
426struct RateLimiter {
427    interval: u16, // in milliseconds
428    capacity: u8,
429    prev: Instant,
430}
431
432/// Rate limit but allow occasional bursts above desired rate
433impl RateLimiter {
434    fn new(rate: u8) -> Self {
435        Self {
436            interval: 1000 / (rate as u16), // between 3 and 1000 milliseconds
437            capacity: MAX_BURST,
438            prev: Instant::now(),
439        }
440    }
441
442    fn allow(&mut self, now: Instant) -> bool {
443        if now < self.prev {
444            return false;
445        }
446
447        let elapsed = now - self.prev;
448        // If `capacity` is 0 and not enough time (`self.interval` ms) has passed since
449        // `self.prev` to add new capacity, return `false`. The goal of this method is to
450        // make this decision as efficient as possible.
451        if self.capacity == 0 && elapsed < Duration::from_millis(self.interval as u64) {
452            return false;
453        }
454
455        // We now calculate `new`, the number of ms, since we last returned `true`,
456        // and `remainder`, which represents a number of ns less than 1ms which we cannot
457        // convert into capacity now, so we're saving it for later.
458        let (new, remainder) = (
459            elapsed.as_millis() / self.interval as u128,
460            elapsed.as_nanos() % (self.interval as u128 * 1_000_000),
461        );
462
463        // We add `new` to `capacity`, subtract one for returning `true` from here,
464        // then make sure it does not exceed a maximum of `MAX_BURST`, then store it.
465        self.capacity = Ord::min(MAX_BURST as u128, (self.capacity as u128) + new - 1) as u8;
466        // Store `prev` for the next iteration after subtracting the `remainder`.
467        // Just use `unwrap` here because it shouldn't be possible for this to underflow.
468        self.prev = now
469            .checked_sub(Duration::from_nanos(remainder as u64))
470            .unwrap();
471        true
472    }
473}
474
475const MAX_BURST: u8 = 20;
476
477/// The drawn state of an element.
478#[derive(Clone, Debug, Default)]
479pub(crate) struct DrawState {
480    /// The lines to print (can contain ANSI codes)
481    pub(crate) lines: Vec<LineType>,
482    /// True if we should move the cursor up when possible instead of clearing lines.
483    pub(crate) move_cursor: bool,
484    /// Controls how the multi progress is aligned if some of its progress bars get removed, default is `Top`
485    pub(crate) alignment: MultiProgressAlignment,
486}
487
488impl DrawState {
489    /// Draw the current state to the terminal
490    /// We expect a few things:
491    /// - self.lines contains n lines of text/empty then m lines of bars
492    /// - None of those lines contain newlines
493    fn draw_to_term(
494        &mut self,
495        term: &(impl TermLike + ?Sized),
496        bar_count: &mut VisualLines, // The number of dynamic lines printed at the previous tick
497    ) -> io::Result<()> {
498        if panicking() {
499            return Ok(());
500        }
501
502        if !self.lines.is_empty() && self.move_cursor {
503            // Move up to first line (assuming the last line doesn't contain a '\n') and then move to then front of the line
504            term.move_cursor_up(bar_count.as_usize().saturating_sub(1))?;
505            term.write_str("\r")?;
506        } else {
507            // Fork of console::clear_last_lines that assumes that the last line doesn't contain a '\n'
508            let n = bar_count.as_usize();
509            term.move_cursor_up(n.saturating_sub(1))?;
510            for i in 0..n {
511                term.clear_line()?;
512                if i + 1 != n {
513                    term.move_cursor_down(1)?;
514                }
515            }
516            term.move_cursor_up(n.saturating_sub(1))?;
517        }
518
519        let term_width = term.width() as usize;
520
521        // Here we calculate the terminal vertical real estate that the state requires
522        let full_height = self.visual_line_count(.., term_width);
523
524        let shift = match self.alignment {
525            // If we align to the bottom and the new height is less than before, clear the lines
526            // that are not used by the new content.
527            MultiProgressAlignment::Bottom if full_height < *bar_count => {
528                let shift = *bar_count - full_height;
529                for _ in 0..shift.as_usize() {
530                    term.write_line("")?;
531                }
532                shift
533            }
534            _ => VisualLines::default(),
535        };
536
537        // Accumulate the displayed height in here. This differs from `full_height` in that it will
538        // accurately reflect the number of lines that have been displayed on the terminal, if the
539        // full height exceeds the terminal height.
540        let mut real_height = VisualLines::default();
541
542        for (idx, line) in self.lines.iter().enumerate() {
543            let line_height = line.wrapped_height(term_width);
544
545            // Check here for bar lines that exceed the terminal height
546            if matches!(line, LineType::Bar(_)) {
547                // Stop here if printing this bar would exceed the terminal height
548                if real_height + line_height > term.height().into() {
549                    break;
550                }
551
552                real_height += line_height;
553            }
554
555            // Print a new line if this is not the first line printed this tick
556            // the first line will automatically wrap due to the filler below
557            if idx != 0 {
558                term.write_line("")?;
559            }
560
561            term.write_str(line.as_ref())?;
562
563            if idx + 1 == self.lines.len() {
564                // For the last line of the output, keep the cursor on the right terminal
565                // side so that next user writes/prints will happen on the next line
566                let last_line_filler = line_height.as_usize() * term_width - line.console_width();
567                term.write_str(&" ".repeat(last_line_filler))?;
568            }
569        }
570
571        term.flush()?;
572        *bar_count = real_height + shift;
573
574        Ok(())
575    }
576
577    fn reset(&mut self) {
578        self.lines.clear();
579    }
580
581    pub(crate) fn visual_line_count(
582        &self,
583        range: impl SliceIndex<[LineType], Output = [LineType]>,
584        width: usize,
585    ) -> VisualLines {
586        visual_line_count(&self.lines[range], width)
587    }
588}
589
590#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
591pub(crate) struct VisualLines(usize);
592
593impl VisualLines {
594    pub(crate) fn saturating_add(&self, other: Self) -> Self {
595        Self(self.0.saturating_add(other.0))
596    }
597
598    pub(crate) fn saturating_sub(&self, other: Self) -> Self {
599        Self(self.0.saturating_sub(other.0))
600    }
601
602    pub(crate) fn as_usize(&self) -> usize {
603        self.0
604    }
605}
606
607impl Add for VisualLines {
608    type Output = Self;
609
610    fn add(self, rhs: Self) -> Self::Output {
611        Self(self.0 + rhs.0)
612    }
613}
614
615impl AddAssign for VisualLines {
616    fn add_assign(&mut self, rhs: Self) {
617        self.0 += rhs.0;
618    }
619}
620
621impl<T: Into<usize>> From<T> for VisualLines {
622    fn from(value: T) -> Self {
623        Self(value.into())
624    }
625}
626
627impl Sub for VisualLines {
628    type Output = Self;
629
630    fn sub(self, rhs: Self) -> Self::Output {
631        Self(self.0 - rhs.0)
632    }
633}
634
635/// Calculate the number of visual lines in the given lines, after
636/// accounting for line wrapping and non-printable characters.
637pub(crate) fn visual_line_count(lines: &[LineType], width: usize) -> VisualLines {
638    lines.iter().fold(VisualLines::default(), |acc, line| {
639        acc.saturating_add(line.wrapped_height(width))
640    })
641}
642
643#[derive(Clone, Debug)]
644pub(crate) enum LineType {
645    Text(String),
646    Bar(String),
647    Empty,
648}
649
650impl LineType {
651    fn wrapped_height(&self, width: usize) -> VisualLines {
652        // Calculate real length based on terminal width
653        // This take in account linewrap from terminal
654        let terminal_len = (self.console_width() as f64 / width as f64).ceil() as usize;
655
656        // If the line is effectively empty (for example when it consists
657        // solely of ANSI color code sequences, count it the same as a
658        // new line. If the line is measured to be len = 0, we will
659        // subtract with overflow later.
660        usize::max(terminal_len, 1).into()
661    }
662
663    fn console_width(&self) -> usize {
664        console::measure_text_width(self.as_ref())
665    }
666}
667
668impl AsRef<str> for LineType {
669    fn as_ref(&self) -> &str {
670        match self {
671            LineType::Text(s) | LineType::Bar(s) => s,
672            LineType::Empty => "",
673        }
674    }
675}
676
677impl PartialEq<str> for LineType {
678    fn eq(&self, other: &str) -> bool {
679        self.as_ref() == other
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use crate::draw_target::LineType;
686    use crate::{MultiProgress, ProgressBar, ProgressDrawTarget};
687
688    #[test]
689    fn multi_is_hidden() {
690        let mp = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
691
692        let pb = mp.add(ProgressBar::new(100));
693        assert!(mp.is_hidden());
694        assert!(pb.is_hidden());
695    }
696
697    #[test]
698    fn real_line_count_test() {
699        #[derive(Debug)]
700        struct Case {
701            lines: &'static [&'static str],
702            expectation: usize,
703            width: usize,
704        }
705
706        let lines_and_expectations = [
707            Case {
708                lines: &["1234567890"],
709                expectation: 1,
710                width: 10,
711            },
712            Case {
713                lines: &["1234567890"],
714                expectation: 2,
715                width: 5,
716            },
717            Case {
718                lines: &["1234567890"],
719                expectation: 3,
720                width: 4,
721            },
722            Case {
723                lines: &["1234567890"],
724                expectation: 4,
725                width: 3,
726            },
727            Case {
728                lines: &["1234567890", "", "1234567890"],
729                expectation: 3,
730                width: 10,
731            },
732            Case {
733                lines: &["1234567890", "", "1234567890"],
734                expectation: 5,
735                width: 5,
736            },
737            Case {
738                lines: &["1234567890", "", "1234567890"],
739                expectation: 7,
740                width: 4,
741            },
742            Case {
743                lines: &["aaaaaaaaaaaaa", "", "bbbbbbbbbbbbbbbbb", "", "ccccccc"],
744                expectation: 8,
745                width: 7,
746            },
747            Case {
748                lines: &["", "", "", "", ""],
749                expectation: 5,
750                width: 6,
751            },
752            Case {
753                // These lines contain only ANSI escape sequences, so they should only count as 1 line
754                lines: &["\u{1b}[1m\u{1b}[1m\u{1b}[1m", "\u{1b}[1m\u{1b}[1m\u{1b}[1m"],
755                expectation: 2,
756                width: 5,
757            },
758            Case {
759                // These lines contain  ANSI escape sequences and two effective chars, so they should only count as 1 line still
760                lines: &[
761                    "a\u{1b}[1m\u{1b}[1m\u{1b}[1ma",
762                    "a\u{1b}[1m\u{1b}[1m\u{1b}[1ma",
763                ],
764                expectation: 2,
765                width: 5,
766            },
767            Case {
768                // These lines contain ANSI escape sequences and six effective chars, so they should count as 2 lines each
769                lines: &[
770                    "aa\u{1b}[1m\u{1b}[1m\u{1b}[1mabcd",
771                    "aa\u{1b}[1m\u{1b}[1m\u{1b}[1mabcd",
772                ],
773                expectation: 4,
774                width: 5,
775            },
776        ];
777
778        for case in lines_and_expectations.iter() {
779            let result = super::visual_line_count(
780                &case
781                    .lines
782                    .iter()
783                    .map(|s| LineType::Text(s.to_string()))
784                    .collect::<Vec<_>>(),
785                case.width,
786            );
787            assert_eq!(result, case.expectation.into(), "case: {:?}", case);
788        }
789    }
790}