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