Skip to main content

uncurses/text/
painter.rs

1//! [`Painter`] — styled string painting into a mutable surface.
2//!
3//! A painter owns no cells and no style. It temporarily binds a
4//! [`SurfaceMut`](crate::buffer::SurfaceMut), a [`WidthMode`], and an
5//! East-Asian Ambiguous policy. Calls to
6//! [`set_str`](Painter::set_str) or [`set_str_rect`](Painter::set_str_rect)
7//! tokenize the input into text clusters, inline escapes, and control bytes,
8//! then write terminal cells into the target.
9//!
10//! Construct a painter over any [`TextSurface`]:
11//!
12//! ```rust,ignore
13//! use uncurses::text::Painter;
14//! use uncurses::style::Style;
15//!
16//! Painter::new(&mut surface)
17//!     .set_str((0, 0), "hello \x1b[1mworld\x1b[m", Style::default());
18//! ```
19//!
20//! ## Style and hyperlink state
21//!
22//! Each paint call takes a base [`Style`]. Inline SGR and OSC 8 sequences in
23//! the input build a separate pen as the string is scanned, and each cell is
24//! that pen inherited onto the base: the pen's own fields win, the base fills
25//! anything the pen leaves unset. An inline reset (`\x1b[0m`) clears the pen,
26//! so cells after it fall back to the base rather than to the terminal default.
27//! The painter keeps no style of its own between calls: every call starts with
28//! an empty pen over the base it is given, so calls are independent.
29//!
30//! ## Cells, clipping, and wrapping
31//!
32//! Each non-zero-width grapheme cluster becomes one
33//! [`Cell`](crate::cell::Cell) holding the whole cluster, with its measured
34//! width. A cluster wider than one column occupies a primary cell plus the
35//! `width - 1` continuation cells maintained by the buffer layer.
36//! Zero-width clusters are appended to the previous pending cluster before it
37//! is flushed.
38//!
39//! ```text
40//! input clusters      pending cell       surface cells
41//! ┌────┬──────┐       ┌────────────┐         ┌────┬────┬────┬────┐
42//! │ e  │ ◌́    │ ───▶  │ "e\u{301}" │ ─────▶  │ é  │    │    │    │
43//! └────┴──────┘       └────────────┘         └────┴────┴────┴────┘
44//!
45//! ┌────┐              ┌─────────┐        ┌────┬────┬────┬────┐
46//! │ 中 │ ─────────▶   │ width 2 │ ────▶  │ 中 │ ▶  │    │    │
47//! └────┘              └─────────┘        └────┴────┴────┴────┘
48//!
49//! ┌────┐              ┌─────────┐        ┌────┬────┬────┬────┐
50//! │ 👍🏽 │ ─────────▶   │ width 4 │ ────▶  │ 👍🏽 │ ▶  │ ▶  │ ▶  │
51//! └────┘              └─────────┘        └────┴────┴────┴────┘
52//! ```
53//!
54//! The last row measures under [`WidthMode::Wc`], which sums the code points
55//! of a cluster: the thumbs-up and the skin tone own two columns each. The
56//! same cluster measures two under [`WidthMode::Grapheme`], and the painter
57//! lays out whatever width the mode reports.
58//!
59//! Painting is clipped to either the target bounds or the intersection of a
60//! supplied rectangle with those bounds. [`WrapMode`] applies only when a
61//! non-zero-width cluster would cross the right edge.
62
63use crate::ansi::hyperlink::parse_hyperlink;
64use crate::ansi::params::Params;
65use crate::ansi::text::{Token, string_width, tokenize};
66use crate::buffer::{Bounded, Surface, SurfaceMut};
67use crate::cell::Cell;
68use crate::layout::{Position, Rect, overruns};
69use crate::style::{Style, read_style};
70
71use super::{TextSurface, WidthMode, WrapMode};
72
73/// Paint styled strings into a [`TextSurface`].
74///
75/// The painter snapshots its target's [`WidthMode`] and `eaw_wide` policy at
76/// construction, both fixed for the painter's lifetime. It holds no style of
77/// its own: each paint call starts with an empty pen over the base style it is
78/// given, parses the input's inline SGR and OSC 8 sequences into that pen, and
79/// writes each cell as the pen inherited onto the base. Text is written into
80/// the borrowed target surface; dropping a painter has no side effects.
81pub struct Painter<'s, S: TextSurface + ?Sized> {
82    target: &'s mut S,
83    /// Width measurement policy, snapshotted from the target at construction.
84    mode: WidthMode,
85    /// Whether East Asian Ambiguous characters are treated as wide,
86    /// snapshotted from the target at construction.
87    eaw_wide: bool,
88}
89
90impl<'s, S: TextSurface + ?Sized> Painter<'s, S> {
91    /// Create a new painter over `target`.
92    ///
93    /// # Parameters
94    ///
95    /// * `target` — mutable surface receiving painted cells.
96    ///
97    /// # Returns
98    ///
99    /// A painter bound to `target`.
100    ///
101    /// # Errors and panics
102    ///
103    /// This constructor does not fail or intentionally panic.
104    pub fn new(target: &'s mut S) -> Self {
105        let mode = target.width_mode();
106        let eaw_wide = target.eaw_wide();
107        Self {
108            target,
109            mode,
110            eaw_wide,
111        }
112    }
113
114    /// Paint `s` with [`WrapMode::Truncate`], stamping `tail` on overflow.
115    ///
116    /// Falls back to a plain hard truncate when the tail is empty or cannot
117    /// fit within `clip`.
118    fn paint_truncate(
119        &mut self,
120        start: Position,
121        clip: Rect,
122        s: &str,
123        s_style: Style,
124        tail_text: &str,
125        tail_style: Style,
126    ) -> Position {
127        if clip.is_empty() {
128            return start;
129        }
130        let tail_w = string_width(tail_text.as_bytes(), self.mode, self.eaw_wide) as u16;
131        let tail = if tail_w == 0 || tail_w > clip.width {
132            None
133        } else {
134            Some(Tail {
135                text: tail_text,
136                style: &tail_style,
137                width: tail_w,
138            })
139        };
140        self.paint_inner(start, clip, s, WrapMode::Truncate, tail, s_style)
141    }
142
143    /// Stamp `tail` over the trailing `tail.width` columns of row `y`, ending
144    /// at `clip`'s right edge, painted with the tail's starting style.
145    fn paint_tail(&mut self, tail: Tail<'_>, clip: Rect, y: u16) {
146        let tail_x = clip.right().saturating_sub(tail.width);
147        let sub = Rect::new(tail_x, y, tail.width, 1).intersection(clip);
148        self.paint_inner(
149            Position::new(tail_x, y),
150            sub,
151            tail.text,
152            WrapMode::Truncate,
153            None,
154            tail.style.clone(),
155        );
156    }
157
158    fn paint(
159        &mut self,
160        start: Position,
161        clip: Rect,
162        s: &str,
163        wrap: WrapMode,
164        style: Style,
165    ) -> Position {
166        self.paint_inner(start, clip, s, wrap, None, style)
167    }
168
169    fn paint_inner(
170        &mut self,
171        start: Position,
172        clip: Rect,
173        s: &str,
174        wrap: WrapMode,
175        tail: Option<Tail<'_>>,
176        base: Style,
177    ) -> Position {
178        if clip.is_empty() {
179            return start;
180        }
181        // `y` only ever advances, so a start below the clip can never paint.
182        if start.y >= clip.bottom() {
183            return start;
184        }
185        let mut x = start.x;
186        let mut y = start.y;
187        // `pen` accumulates the inline SGR/OSC 8 state, starting empty; an
188        // inline reset clears it, so the cells after a reset fall back to
189        // `base`. `pending` is the cell currently being built: its origin,
190        // text, and width. A trailing zero-width grapheme (a combining mark)
191        // joins it instead of starting a new cell, so the cell is held until
192        // the next non-zero-width token finalizes it.
193        let mut pen = Style::default();
194        let mut pending: Option<(u16, u16, String, u8)> = None;
195        // Truncation is per row: once a row overflows, clusters are dropped
196        // until `\n` or `\r` puts the cursor back inside the clip. Escapes
197        // still run, so the pen carries over to the next row.
198        let mut truncated = false;
199
200        for tok in tokenize(s.as_bytes(), self.mode, self.eaw_wide) {
201            // A zero-width grapheme appends to the pending cell without
202            // finalizing it. Everything else finalizes the pending cell first,
203            // writing it with the current style: the pen inherited onto base.
204            if !matches!(tok, Token::Text { width: 0, .. })
205                && let Some((px, py, content, w)) = pending.take()
206                && clip.contains(Position::new(px, py))
207            {
208                let cell = Cell::new(&content, w);
209                self.target
210                    .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
211            }
212
213            match tok {
214                // SAFETY (all `from_utf8_unchecked`): the tokenizer cuts on
215                // grapheme-cluster boundaries of a valid `&str`, so each slice
216                // is valid UTF-8. Checked under `debug_assert!` for the same
217                // reason as `ansi::wrap::bs` - the invariant is the tokenizer's
218                // to keep, and when it stopped keeping it this was UB.
219                Token::Text { text, width: 0 } => {
220                    if let Some((_, _, ref mut content, _)) = pending {
221                        debug_assert!(std::str::from_utf8(text).is_ok());
222                        content.push_str(unsafe { std::str::from_utf8_unchecked(text) });
223                    }
224                }
225                Token::Text { text, width } => {
226                    debug_assert!(std::str::from_utf8(text).is_ok());
227                    if truncated {
228                        continue;
229                    }
230                    let g = unsafe { std::str::from_utf8_unchecked(text) };
231                    let cw = width as u8;
232                    if overruns(x, u16::from(cw), clip.right()) {
233                        match wrap {
234                            WrapMode::Truncate => {
235                                if let Some(tail) = tail {
236                                    self.paint_tail(tail, clip, y);
237                                    x = clip.right();
238                                }
239                                truncated = true;
240                                continue;
241                            }
242                            WrapMode::Wrap => {
243                                y = y.saturating_add(1);
244                                x = clip.left();
245                                if y >= clip.bottom() {
246                                    return Position::new(x, y);
247                                }
248                                if overruns(x, u16::from(cw), clip.right()) {
249                                    return Position::new(x, y);
250                                }
251                            }
252                        }
253                    }
254                    pending = Some((x, y, g.to_string(), cw));
255                    x += cw as u16;
256                }
257                Token::Escape(seq) => {
258                    if seq.last() == Some(&b'm')
259                        && let Some(body) = csi_body(seq)
260                    {
261                        read_style(Params::from_raw(body), &mut pen);
262                    } else if let Some(body) = osc_body(seq)
263                        && let Some((params, url)) = parse_hyperlink(body)
264                    {
265                        pen = pen.link(url, params);
266                    }
267                }
268                Token::Control(0x0A) => {
269                    y = y.saturating_add(1);
270                    x = clip.left();
271                    truncated = false;
272                    if y >= clip.bottom() {
273                        return Position::new(x, y);
274                    }
275                }
276                Token::Control(0x0D) => {
277                    x = clip.left();
278                    truncated = false;
279                }
280                Token::Control(_) => {}
281            }
282        }
283
284        // Finalize the last cell.
285        if let Some((px, py, content, w)) = pending.take()
286            && clip.contains(Position::new(px, py))
287        {
288            let cell = Cell::new(&content, w);
289            self.target
290                .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
291        }
292        Position::new(x, y)
293    }
294}
295
296impl<'s, S: TextSurface + ?Sized> Bounded for Painter<'s, S> {
297    fn bounds(&self) -> Rect {
298        self.target.bounds()
299    }
300}
301
302impl<'s, S: TextSurface + ?Sized> Surface for Painter<'s, S> {
303    fn cell(&self, pos: Position) -> Option<&Cell> {
304        self.target.cell(pos)
305    }
306}
307
308impl<'s, S: TextSurface + ?Sized> SurfaceMut for Painter<'s, S> {
309    fn set_cell(&mut self, pos: Position, cell: &Cell) {
310        self.target.set_cell(pos, cell);
311    }
312
313    fn cell_mut(&mut self, pos: Position) -> Option<&mut Cell> {
314        self.target.cell_mut(pos)
315    }
316
317    fn insert_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
318        self.target.insert_lines(y, count, bounds_bottom, fill);
319    }
320
321    fn delete_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
322        self.target.delete_lines(y, count, bounds_bottom, fill);
323    }
324
325    fn insert_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
326        self.target.insert_cells(pos, count, bounds_right, fill);
327    }
328
329    fn delete_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
330        self.target.delete_cells(pos, count, bounds_right, fill);
331    }
332}
333
334/// A [`Painter`] is itself a [`TextSurface`] whose `set_str` family recognizes
335/// inline SGR and OSC 8 hyperlink sequences, updating the running
336/// the running style as the input is parsed. This is the escape-aware
337/// counterpart to the literal painting of the default [`TextSurface`] methods.
338impl<'s, S: TextSurface + ?Sized> TextSurface for Painter<'s, S> {
339    fn width_mode(&self) -> WidthMode {
340        self.mode
341    }
342
343    fn eaw_wide(&self) -> bool {
344        self.eaw_wide
345    }
346
347    /// Measure `s`, skipping recognized inline SGR and OSC 8 escape
348    /// sequences so they contribute no width. This is the escape-aware
349    /// counterpart to the literal default
350    /// [`str_width`](crate::text::TextSurface::str_width).
351    fn str_width(&self, s: &str) -> u16 {
352        string_width(s.as_bytes(), self.mode, self.eaw_wide).min(u16::MAX as usize) as u16
353    }
354
355    /// Paint `s` starting at `pos`, clipped to the target bounds.
356    ///
357    /// The painter's running [`Style`] takes precedence and inherits any unset
358    /// fields from `style`, so the running style carries across calls and
359    /// `style` only fills in what it has not set. Inline SGR and OSC 8 sequences
360    /// then update the running style as the input is processed. Newline advances
361    /// to the next row at the bounds' left edge; carriage return returns to that
362    /// left edge on the current row. Right-edge behavior is
363    /// [`WrapMode::Truncate`].
364    ///
365    /// # Parameters
366    ///
367    /// * `pos` — starting cell position.
368    /// * `s` — UTF-8 input string.
369    /// * `style` — base style the running style inherits unset fields from.
370    ///
371    /// # Returns
372    ///
373    /// The cursor position immediately after the last written cell, or where
374    /// painting stopped.
375    ///
376    /// # Errors and panics
377    ///
378    /// This method does not return errors and does not intentionally panic.
379    fn set_str(&mut self, pos: impl Into<Position>, s: &str, style: impl Into<Style>) -> Position {
380        let clip = self.target.bounds();
381        self.paint(pos.into(), clip, s, WrapMode::default(), style.into())
382    }
383
384    /// Paint `s` starting at `pos` with explicit wrapping behavior.
385    ///
386    /// The target bounds are the clipping rectangle. [`WrapMode::Truncate`]
387    /// drops the rest of the row at the right edge and resumes on the next
388    /// row; [`WrapMode::Wrap`] continues on the next row
389    /// at the bounds' left edge until the bottom edge is reached.
390    ///
391    /// # Parameters
392    ///
393    /// * `pos` — starting cell position.
394    /// * `s` — UTF-8 input string.
395    /// * `wrap` — right-edge behavior for non-zero-width clusters.
396    /// * `style` — initial style for this call.
397    ///
398    /// # Returns
399    ///
400    /// The cursor position immediately after the last written cell, or where
401    /// painting stopped.
402    ///
403    /// # Errors and panics
404    ///
405    /// This method does not return errors and does not intentionally panic.
406    fn set_str_wrap(
407        &mut self,
408        pos: impl Into<Position>,
409        s: &str,
410        wrap: WrapMode,
411        style: impl Into<Style>,
412    ) -> Position {
413        let clip = self.target.bounds();
414        self.paint(pos.into(), clip, s, wrap, style.into())
415    }
416
417    /// Paint `s` into `rect`, clipped to `rect ∩ target.bounds()`.
418    ///
419    /// Painting starts at `rect`'s top-left. Newline and carriage return use
420    /// `rect`'s left edge as the return column. Right-edge behavior is
421    /// [`WrapMode::Truncate`].
422    ///
423    /// # Parameters
424    ///
425    /// * `rect` — origin and clipping rectangle.
426    /// * `s` — UTF-8 input string.
427    /// * `style` — initial style for this call.
428    ///
429    /// # Returns
430    ///
431    /// The cursor position immediately after the last written cell, or where
432    /// painting stopped.
433    ///
434    /// # Errors and panics
435    ///
436    /// This method does not return errors and does not intentionally panic.
437    fn set_str_rect(
438        &mut self,
439        rect: impl Into<Rect>,
440        s: &str,
441        style: impl Into<Style>,
442    ) -> Position {
443        let rect = rect.into();
444        let clip = rect.intersection(self.target.bounds());
445        self.paint(rect.position(), clip, s, WrapMode::default(), style.into())
446    }
447
448    /// Paint `s` into `rect` with explicit wrapping behavior.
449    ///
450    /// The clipping rectangle is `rect ∩ target.bounds()`. [`WrapMode::Wrap`]
451    /// flows down inside `rect`; [`WrapMode::Truncate`] stops at `rect`'s
452    /// right edge.
453    ///
454    /// # Parameters
455    ///
456    /// * `rect` — origin and clipping rectangle.
457    /// * `s` — UTF-8 input string.
458    /// * `wrap` — right-edge behavior for non-zero-width clusters.
459    /// * `style` — initial style for this call.
460    ///
461    /// # Returns
462    ///
463    /// The cursor position immediately after the last written cell, or where
464    /// painting stopped.
465    ///
466    /// # Errors and panics
467    ///
468    /// This method does not return errors and does not intentionally panic.
469    fn set_str_rect_wrap(
470        &mut self,
471        rect: impl Into<Rect>,
472        s: &str,
473        wrap: WrapMode,
474        style: impl Into<Style>,
475    ) -> Position {
476        let rect = rect.into();
477        let clip = rect.intersection(self.target.bounds());
478        self.paint(rect.position(), clip, s, wrap, style.into())
479    }
480
481    /// Paint `s` starting at `pos`, truncating with a `tail` indicator.
482    ///
483    /// Text is painted across the target bounds. When a non-zero-width cluster
484    /// would cross the right edge, the rest of that row is dropped and `tail`
485    /// is stamped over its trailing columns so it ends exactly at the right
486    /// edge. Painting resumes on the next row if the text continues past a
487    /// newline, so a multi-line `s` can stamp one tail per overflowing row.
488    /// The tail appears only on rows that actually overflow; a row that fits
489    /// is left untouched.
490    ///
491    /// `tail` is painted with `tail_style` as its starting style and may carry
492    /// its own inline escape sequences, so it can be a single glyph (`"…"`), a
493    /// word (`" more"`), or a multi-style span. If the tail is wider than the
494    /// available space, it is dropped and the text is hard-truncated instead.
495    ///
496    /// # Parameters
497    ///
498    /// * `pos` — starting cell position.
499    /// * `s` — UTF-8 string to paint.
500    /// * `s_style` — starting style for the main text; inline escapes can change it.
501    /// * `tail` — truncation indicator, painted when `s` overflows.
502    /// * `tail_style` — starting style for the tail.
503    ///
504    /// # Returns
505    ///
506    /// The cursor position immediately after the last written cell, or where
507    /// painting stopped.
508    ///
509    /// # Errors and panics
510    ///
511    /// This method does not return errors and does not intentionally panic.
512    fn set_str_truncate(
513        &mut self,
514        pos: impl Into<Position>,
515        s: &str,
516        s_style: impl Into<Style>,
517        tail: &str,
518        tail_style: impl Into<Style>,
519    ) -> Position {
520        let clip = self.target.bounds();
521        self.paint_truncate(pos.into(), clip, s, s_style.into(), tail, tail_style.into())
522    }
523
524    /// Paint `s` inside `rect`, truncating with a `tail` indicator.
525    ///
526    /// This is the rectangular form of
527    /// [`set_str_truncate`](Self::set_str_truncate): the clip rectangle is
528    /// `rect ∩ target.bounds()`, and a tail is stamped at `rect`'s right
529    /// edge on each row that overflows it.
530    ///
531    /// # Parameters
532    ///
533    /// * `rect` — clipping rectangle and starting origin.
534    /// * `s` — UTF-8 string to paint.
535    /// * `s_style` — starting style for the main text; inline escapes can change it.
536    /// * `tail` — truncation indicator, painted when `s` overflows.
537    /// * `tail_style` — starting style for the tail.
538    ///
539    /// # Returns
540    ///
541    /// The cursor position immediately after the last written cell, or where
542    /// painting stopped.
543    ///
544    /// # Errors and panics
545    ///
546    /// This method does not return errors and does not intentionally panic.
547    fn set_str_rect_truncate(
548        &mut self,
549        rect: impl Into<Rect>,
550        s: &str,
551        s_style: impl Into<Style>,
552        tail: &str,
553        tail_style: impl Into<Style>,
554    ) -> Position {
555        let rect = rect.into();
556        let clip = rect.intersection(self.target.bounds());
557        self.paint_truncate(
558            rect.position(),
559            clip,
560            s,
561            s_style.into(),
562            tail,
563            tail_style.into(),
564        )
565    }
566}
567
568/// A truncation tail: borrowed indicator text, its starting style, and its
569/// measured cell width. `Copy` so the overflow branch can hand it to
570/// [`Painter::paint_tail`] without moving out of the `Option`.
571#[derive(Clone, Copy)]
572struct Tail<'a> {
573    text: &'a str,
574    style: &'a Style,
575    width: u16,
576}
577
578/// Return the body of a CSI sequence (between introducer and final byte).
579///
580/// Recognises both `\x1b[ … <final>` (7-bit) and `\x9b … <final>` (8-bit)
581/// forms where `<final>` is in `0x40..=0x7e`. Returns `None` for any
582/// other escape or for an incomplete sequence missing its final byte.
583fn csi_body(seq: &[u8]) -> Option<&[u8]> {
584    let body_start = if seq.len() >= 2 && seq[0] == 0x1b && seq[1] == b'[' {
585        2
586    } else if !seq.is_empty() && seq[0] == 0x9b {
587        1
588    } else {
589        return None;
590    };
591    let last = *seq.last()?;
592    if !(0x40..=0x7e).contains(&last) || seq.len() <= body_start {
593        return None;
594    }
595    Some(&seq[body_start..seq.len() - 1])
596}
597
598/// Return the body of an OSC sequence (between introducer and string
599/// terminator). Recognises `\x1b] … (BEL | ESC \\ | 0x9c)?` (7-bit) and
600/// `\x9d … (BEL | 0x9c | ESC \\)?` (8-bit) forms. An incomplete sequence
601/// missing its terminator still returns its content; a non-OSC sequence
602/// returns `None`.
603fn osc_body(seq: &[u8]) -> Option<&[u8]> {
604    let body_start = if seq.len() >= 2 && seq[0] == 0x1b && seq[1] == b']' {
605        2
606    } else if !seq.is_empty() && seq[0] == 0x9d {
607        1
608    } else {
609        return None;
610    };
611    if seq.len() <= body_start {
612        return Some(&[]);
613    }
614    let end = if seq.ends_with(b"\x1b\\") {
615        seq.len() - 2
616    } else if matches!(seq.last(), Some(0x07 | 0x9c)) {
617        seq.len() - 1
618    } else {
619        seq.len()
620    };
621    if end < body_start {
622        return Some(&[]);
623    }
624    Some(&seq[body_start..end])
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::buffer::{Surface, TextBuffer};
631    use crate::color::Color;
632    use crate::style::AttrFlags;
633
634    fn buf(width: u16, height: u16) -> TextBuffer {
635        TextBuffer::new(width, height)
636    }
637
638    fn cell_at(b: &TextBuffer, x: u16, y: u16) -> Cell {
639        b.cell(Position::new(x, y)).cloned().unwrap()
640    }
641
642    fn link_of(s: &crate::style::Style) -> Option<(&str, &str)> {
643        s.link
644            .as_deref()
645            .map(|l| (l.url.as_str(), l.params.as_str()))
646    }
647
648    #[test]
649    fn plain_text() {
650        let mut b = buf(10, 1);
651        let end =
652            Painter::new(&mut b).set_str_wrap((0, 0), "abc", WrapMode::Truncate, Style::default());
653        assert_eq!(end, Position::new(3, 0));
654        assert_eq!(cell_at(&b, 0, 0).content(), "a");
655        assert_eq!(cell_at(&b, 2, 0).content(), "c");
656    }
657
658    #[test]
659    fn sgr_updates_style_mid_stream() {
660        let mut b = buf(10, 1);
661        let mut p = Painter::new(&mut b);
662        let end = p.set_str_wrap(
663            (0, 0),
664            "a\x1b[1mb\x1b[mc",
665            WrapMode::Truncate,
666            Style::default(),
667        );
668        assert_eq!(end, Position::new(3, 0));
669        let c0 = cell_at(&b, 0, 0);
670        let c1 = cell_at(&b, 1, 0);
671        let c2 = cell_at(&b, 2, 0);
672        assert!(!c0.style.attrs.contains(AttrFlags::BOLD));
673        assert!(c1.style.attrs.contains(AttrFlags::BOLD));
674        assert!(!c2.style.attrs.contains(AttrFlags::BOLD));
675    }
676
677    #[test]
678    fn sgr_color() {
679        let mut b = buf(5, 1);
680        Painter::new(&mut b).set_str_wrap(
681            (0, 0),
682            "\x1b[31mr",
683            WrapMode::Truncate,
684            Style::default(),
685        );
686        assert_eq!(cell_at(&b, 0, 0).style.fg, Some(Color::Red));
687    }
688
689    /// The painter's `from_utf8_unchecked` on a text token, driven by the
690    /// sequences that used to break the tokenizer's promise.
691    ///
692    /// Every other painter test uses ASCII-only escape payloads, so reverting
693    /// the scanner fix left them all green while the painter took ill-formed
694    /// bytes on trust. `\u{2705}` is `E2 9C 85` and carries an 8-bit ST byte;
695    /// `\u{9c}` and `\u{9d}` encode the ST and OSC bytes themselves.
696    #[test]
697    fn utf8_payloads_in_sequences_paint_the_text_after_them() {
698        for input in [
699            "\x1b]0;\u{2705}\x07ab",
700            "\x1b]0;x\u{9c}y\x07ab",
701            "\x1b]0;x\u{9d}y\x07ab",
702            "\x1bP1$r\u{2705}\x1b\\ab",
703            "\x1b_G\u{2705}\x1b\\ab",
704            "\x1b#\u{2705}ab",
705        ] {
706            let mut b = buf(10, 1);
707            let end = Painter::new(&mut b).set_str_wrap(
708                (0, 0),
709                input,
710                WrapMode::Truncate,
711                Style::default(),
712            );
713            assert_eq!(
714                end,
715                Position::new(2, 0),
716                "{input:?} painted the wrong width"
717            );
718            assert_eq!(cell_at(&b, 0, 0).content(), "a", "{input:?}");
719            assert_eq!(cell_at(&b, 1, 0).content(), "b", "{input:?}");
720        }
721    }
722
723    /// An OSC 8 whose URL carries a C1 continuation byte still yields a link
724    /// with the whole URL, and the styled text after it.
725    #[test]
726    fn osc8_with_a_utf8_url() {
727        let mut b = buf(10, 1);
728        Painter::new(&mut b).set_str_wrap(
729            (0, 0),
730            "\x1b]8;;https://x/\u{2705}\x1b\\a\x1b]8;;\x1b\\b",
731            WrapMode::Truncate,
732            Style::default(),
733        );
734        assert_eq!(
735            link_of(&cell_at(&b, 0, 0).style),
736            Some(("https://x/\u{2705}", ""))
737        );
738        assert_eq!(cell_at(&b, 0, 0).content(), "a");
739        assert!(cell_at(&b, 1, 0).style.link.is_none());
740        assert_eq!(cell_at(&b, 1, 0).content(), "b");
741    }
742
743    #[test]
744    fn osc8_toggles_link() {
745        let mut b = buf(10, 1);
746        Painter::new(&mut b).set_str_wrap(
747            (0, 0),
748            "\x1b]8;;https://x\x1b\\a\x1b]8;;\x1b\\b",
749            WrapMode::Truncate,
750            Style::default(),
751        );
752        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
753        assert!(cell_at(&b, 1, 0).style.link.is_none());
754    }
755
756    #[test]
757    fn osc8_malformed_ignored() {
758        // Missing the second `;` -> not a valid OSC 8; should not affect
759        // the currently active link.
760        let mut b = buf(10, 1);
761        let mut p = Painter::new(&mut b);
762        p.set_str_wrap(
763            (0, 0),
764            "\x1b]8;;https://x\x1b\\a\x1b]8;garbage\x1b\\b",
765            WrapMode::Truncate,
766            Style::default(),
767        );
768        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
769        assert_eq!(link_of(&cell_at(&b, 1, 0).style), Some(("https://x", "")));
770    }
771
772    #[test]
773    fn newline_advances_row() {
774        let mut b = buf(5, 3);
775        let end = Painter::new(&mut b).set_str_wrap(
776            (0, 0),
777            "ab\ncd",
778            WrapMode::Truncate,
779            Style::default(),
780        );
781        assert_eq!(cell_at(&b, 0, 0).content(), "a");
782        assert_eq!(cell_at(&b, 1, 0).content(), "b");
783        assert_eq!(cell_at(&b, 0, 1).content(), "c");
784        assert_eq!(cell_at(&b, 1, 1).content(), "d");
785        assert_eq!(end, Position::new(2, 1));
786    }
787
788    #[test]
789    fn cr_returns_to_left() {
790        let mut b = buf(5, 1);
791        Painter::new(&mut b).set_str_wrap((0, 0), "abc\rXY", WrapMode::Truncate, Style::default());
792        // 'X' overwrites 'a', 'Y' overwrites 'b', 'c' remains.
793        assert_eq!(cell_at(&b, 0, 0).content(), "X");
794        assert_eq!(cell_at(&b, 1, 0).content(), "Y");
795        assert_eq!(cell_at(&b, 2, 0).content(), "c");
796    }
797
798    #[test]
799    fn newline_past_bottom_returns() {
800        let mut b = buf(5, 2);
801        let end = Painter::new(&mut b).set_str_wrap(
802            (0, 0),
803            "a\nb\nc",
804            WrapMode::Truncate,
805            Style::default(),
806        );
807        assert_eq!(end, Position::new(0, 2));
808        assert_eq!(cell_at(&b, 0, 0).content(), "a");
809        assert_eq!(cell_at(&b, 0, 1).content(), "b");
810        // Row 2 is out of bounds; "c" never lands.
811    }
812
813    #[test]
814    fn truncate_at_right_edge() {
815        let mut b = buf(3, 1);
816        let end = Painter::new(&mut b).set_str_wrap(
817            (0, 0),
818            "abcdef",
819            WrapMode::Truncate,
820            Style::default(),
821        );
822        assert_eq!(end, Position::new(3, 0));
823        assert_eq!(cell_at(&b, 0, 0).content(), "a");
824        assert_eq!(cell_at(&b, 2, 0).content(), "c");
825    }
826
827    #[test]
828    fn truncate_resumes_on_next_row() {
829        let mut b = buf(3, 2);
830        let end = Painter::new(&mut b).set_str_wrap(
831            (0, 0),
832            "abcdef\nxy",
833            WrapMode::Truncate,
834            Style::default(),
835        );
836        assert_eq!(cell_at(&b, 2, 0).content(), "c");
837        assert_eq!(cell_at(&b, 0, 1).content(), "x");
838        assert_eq!(cell_at(&b, 1, 1).content(), "y");
839        assert_eq!(end, Position::new(2, 1));
840    }
841
842    #[test]
843    fn truncate_tail_stamped_per_row() {
844        let mut b = buf(4, 2);
845        Painter::new(&mut b).set_str_truncate(
846            (0, 0),
847            "abcdef\nghijkl",
848            Style::default(),
849            "…",
850            Style::default(),
851        );
852        assert_eq!(cell_at(&b, 3, 0).content(), "…");
853        assert_eq!(cell_at(&b, 0, 1).content(), "g");
854        assert_eq!(cell_at(&b, 3, 1).content(), "…");
855    }
856
857    #[test]
858    fn literal_truncate_resumes_on_next_row() {
859        let mut b = buf(3, 2);
860        b.set_str((0, 0), "abcdef\nxy", Style::default());
861        assert_eq!(cell_at(&b, 2, 0).content(), "c");
862        assert_eq!(cell_at(&b, 0, 1).content(), "x");
863        assert_eq!(cell_at(&b, 1, 1).content(), "y");
864    }
865
866    #[test]
867    fn cr_clears_truncation_for_the_row() {
868        let mut b = buf(3, 1);
869        Painter::new(&mut b).set_str_wrap(
870            (0, 0),
871            "abcdef\rXY",
872            WrapMode::Truncate,
873            Style::default(),
874        );
875        assert_eq!(cell_at(&b, 0, 0).content(), "X");
876        assert_eq!(cell_at(&b, 1, 0).content(), "Y");
877        assert_eq!(cell_at(&b, 2, 0).content(), "c");
878    }
879
880    #[test]
881    fn overflow_drops_rest_of_row_without_backfill() {
882        let mut b = buf(3, 1);
883        let end = Painter::new(&mut b).set_str_wrap(
884            (0, 0),
885            "ab中c",
886            WrapMode::Truncate,
887            Style::default(),
888        );
889        assert_eq!(cell_at(&b, 0, 0).content(), "a");
890        assert_eq!(cell_at(&b, 1, 0).content(), "b");
891        // "中" needs two columns and only one is left; "c" must not slot into
892        // the gap ahead of it.
893        assert_eq!(cell_at(&b, 2, 0).content(), " ");
894        assert_eq!(end, Position::new(2, 0));
895    }
896
897    #[test]
898    fn literal_crlf_breaks_the_line() {
899        // Extended grapheme segmentation joins CR LF into one zero-width
900        // cluster, so it has to be matched explicitly to break the line.
901        let mut b = buf(3, 2);
902        b.set_str((0, 0), "abcdef\r\nxy", Style::default());
903        assert_eq!(cell_at(&b, 2, 0).content(), "c");
904        assert_eq!(cell_at(&b, 0, 1).content(), "x");
905        assert_eq!(cell_at(&b, 1, 1).content(), "y");
906    }
907
908    #[test]
909    fn crlf_breaks_the_line() {
910        let mut b = buf(3, 2);
911        Painter::new(&mut b).set_str_wrap(
912            (0, 0),
913            "abcdef\r\nxy",
914            WrapMode::Truncate,
915            Style::default(),
916        );
917        assert_eq!(cell_at(&b, 2, 0).content(), "c");
918        assert_eq!(cell_at(&b, 0, 1).content(), "x");
919        assert_eq!(cell_at(&b, 1, 1).content(), "y");
920    }
921
922    #[test]
923    fn escapes_still_apply_across_a_truncated_row() {
924        // The pen keeps advancing through the dropped part of the row, so a
925        // style opened there lands on the next row.
926        let mut b = buf(2, 2);
927        Painter::new(&mut b).set_str_wrap(
928            (0, 0),
929            "ab\x1b[1mcd\x1b]8;;https://x\x1b\\\nz",
930            WrapMode::Truncate,
931            Style::default(),
932        );
933        assert!(!cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
934        let z = cell_at(&b, 0, 1);
935        assert_eq!(z.content(), "z");
936        assert!(z.style.attrs.contains(AttrFlags::BOLD));
937        assert_eq!(link_of(&z.style), Some(("https://x", "")));
938    }
939
940    #[test]
941    fn start_below_clip_paints_nothing() {
942        let mut b = buf(3, 2);
943        let end = Painter::new(&mut b).set_str_wrap(
944            (0, 5),
945            "abcdef\nghi",
946            WrapMode::Truncate,
947            Style::default(),
948        );
949        assert_eq!(end, Position::new(0, 5));
950        for y in 0..2 {
951            for x in 0..3 {
952                assert_eq!(cell_at(&b, x, y).content(), " ");
953            }
954        }
955    }
956
957    #[test]
958    fn literal_start_below_clip_paints_nothing() {
959        let mut b = buf(3, 2);
960        let end = b.set_str((0, 5), "abcdef\nghi", Style::default());
961        assert_eq!(end, Position::new(0, 5));
962        assert_eq!(cell_at(&b, 0, 0).content(), " ");
963        assert_eq!(cell_at(&b, 0, 1).content(), " ");
964    }
965
966    #[test]
967    fn wrap_breaks_on_crlf() {
968        // The CRLF fix is shared with WrapMode::Wrap: a joined cluster has to
969        // break the line there too, not read as zero-width filler.
970        let mut b = buf(4, 3);
971        Painter::new(&mut b).set_str_wrap((0, 0), "ab\r\ncd", WrapMode::Wrap, Style::default());
972        assert_eq!(cell_at(&b, 0, 0).content(), "a");
973        assert_eq!(cell_at(&b, 1, 0).content(), "b");
974        assert_eq!(cell_at(&b, 0, 1).content(), "c");
975        assert_eq!(cell_at(&b, 1, 1).content(), "d");
976    }
977
978    #[test]
979    fn literal_wrap_breaks_on_crlf() {
980        let mut b = buf(4, 3);
981        b.set_str_wrap((0, 0), "ab\r\ncd", WrapMode::Wrap, Style::default());
982        assert_eq!(cell_at(&b, 0, 0).content(), "a");
983        assert_eq!(cell_at(&b, 1, 0).content(), "b");
984        assert_eq!(cell_at(&b, 0, 1).content(), "c");
985        assert_eq!(cell_at(&b, 1, 1).content(), "d");
986    }
987
988    #[test]
989    fn wrap_at_right_edge() {
990        let mut b = buf(3, 3);
991        let end =
992            Painter::new(&mut b).set_str_wrap((0, 0), "abcdef", WrapMode::Wrap, Style::default());
993        assert_eq!(end, Position::new(3, 1));
994        assert_eq!(cell_at(&b, 0, 0).content(), "a");
995        assert_eq!(cell_at(&b, 2, 0).content(), "c");
996        assert_eq!(cell_at(&b, 0, 1).content(), "d");
997        assert_eq!(cell_at(&b, 2, 1).content(), "f");
998    }
999
1000    #[test]
1001    fn rect_clip_and_origin() {
1002        let mut b = buf(10, 5);
1003        let end = Painter::new(&mut b).set_str_rect_wrap(
1004            Rect::new(2, 1, 3, 2),
1005            "abcdef",
1006            WrapMode::Wrap,
1007            Style::default(),
1008        );
1009        assert_eq!(end, Position::new(5, 2));
1010        assert_eq!(cell_at(&b, 2, 1).content(), "a");
1011        assert_eq!(cell_at(&b, 4, 1).content(), "c");
1012        assert_eq!(cell_at(&b, 2, 2).content(), "d");
1013        assert_eq!(cell_at(&b, 4, 2).content(), "f");
1014        // Outside the rect must remain blank.
1015        assert_eq!(cell_at(&b, 0, 0).content(), " ");
1016        assert_eq!(cell_at(&b, 5, 1).content(), " ");
1017    }
1018
1019    #[test]
1020    fn rect_newline_uses_rect_left() {
1021        let mut b = buf(10, 5);
1022        Painter::new(&mut b).set_str_rect_wrap(
1023            Rect::new(2, 1, 4, 3),
1024            "ab\ncd",
1025            WrapMode::Truncate,
1026            Style::default(),
1027        );
1028        assert_eq!(cell_at(&b, 2, 1).content(), "a");
1029        assert_eq!(cell_at(&b, 3, 1).content(), "b");
1030        // Newline returns x to rect.left() = 2, not to 0.
1031        assert_eq!(cell_at(&b, 2, 2).content(), "c");
1032        assert_eq!(cell_at(&b, 3, 2).content(), "d");
1033        assert_eq!(cell_at(&b, 0, 2).content(), " ");
1034    }
1035
1036    #[test]
1037    fn with_resets_style_and_link() {
1038        let mut b = buf(10, 1);
1039        // First call: paint with bold + a link.
1040        Painter::new(&mut b).set_str_wrap(
1041            (0, 0),
1042            "a",
1043            WrapMode::Truncate,
1044            Style::default().bold().link("https://x", ""),
1045        );
1046        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1047        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
1048        // Second call with `_with` and an empty style must reset.
1049        Painter::new(&mut b).set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
1050        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1051        assert!(cell_at(&b, 1, 0).style.link.is_none());
1052    }
1053
1054    #[test]
1055    fn calls_start_from_their_own_base() {
1056        let mut b = buf(10, 1);
1057        let mut p = Painter::new(&mut b);
1058        // Inline SGR bolds within the first call only.
1059        p.set_str_wrap((0, 0), "\x1b[1ma", WrapMode::Truncate, Style::default());
1060        // The next call starts fresh from its base: no bold carries over.
1061        p.set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
1062        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1063        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1064    }
1065
1066    #[test]
1067    fn base_applies_and_inline_reset_returns_to_base() {
1068        let mut b = buf(10, 1);
1069        let base = Style::default().fg(Color::Red);
1070        // "a" gets the base red; the inline bold adds to "b"; the inline reset
1071        // clears only the inline state, so "c" falls back to the base red
1072        // rather than to a fully default style.
1073        Painter::new(&mut b).set_str_wrap((0, 0), "a\x1b[1mb\x1b[0mc", WrapMode::Truncate, base);
1074        let red = Some(Color::Red);
1075        assert_eq!(cell_at(&b, 0, 0).style.fg, red);
1076        assert!(!cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1077        assert_eq!(cell_at(&b, 1, 0).style.fg, red);
1078        assert!(cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1079        assert_eq!(cell_at(&b, 2, 0).style.fg, red);
1080        assert!(!cell_at(&b, 2, 0).style.attrs.contains(AttrFlags::BOLD));
1081    }
1082
1083    #[test]
1084    fn position_and_rect_match_when_rect_covers_bounds() {
1085        let mut a = buf(5, 2);
1086        let mut b = buf(5, 2);
1087        let e1 =
1088            Painter::new(&mut a).set_str_wrap((0, 0), "abc", WrapMode::Truncate, Style::default());
1089        let e2 = Painter::new(&mut b).set_str_rect_wrap(
1090            Rect::new(0, 0, 5, 2),
1091            "abc",
1092            WrapMode::Truncate,
1093            Style::default(),
1094        );
1095        assert_eq!(e1, e2);
1096        assert_eq!(cell_at(&a, 2, 0).content(), cell_at(&b, 2, 0).content());
1097    }
1098
1099    fn row(b: &TextBuffer, y: u16) -> String {
1100        (0..b.width())
1101            .map(|x| cell_at(b, x, y).content().to_string())
1102            .collect()
1103    }
1104
1105    #[test]
1106    fn truncate_tail_not_shown_when_text_fits() {
1107        let mut b = buf(5, 1);
1108        let end = Painter::new(&mut b).set_str_truncate(
1109            (0, 0),
1110            "abc",
1111            Style::default(),
1112            "…",
1113            Style::default(),
1114        );
1115        // "abc" fits in 5 columns, so no tail is stamped.
1116        assert_eq!(row(&b, 0), "abc  ");
1117        assert_eq!(end, Position::new(3, 0));
1118    }
1119
1120    #[test]
1121    fn truncate_single_cell_tail_on_overflow() {
1122        let mut b = buf(5, 1);
1123        let end = Painter::new(&mut b).set_str_truncate(
1124            (0, 0),
1125            "abcdefgh",
1126            Style::default(),
1127            "…",
1128            Style::default(),
1129        );
1130        // 5 columns: 4 text columns + the 1-wide tail at the right edge.
1131        assert_eq!(row(&b, 0), "abcd…");
1132        assert_eq!(end, Position::new(5, 0));
1133    }
1134
1135    #[test]
1136    fn truncate_multi_cell_tail_reserves_its_width() {
1137        let mut b = buf(8, 1);
1138        Painter::new(&mut b).set_str_truncate(
1139            (0, 0),
1140            "abcdefghij",
1141            Style::default(),
1142            " more",
1143            Style::default(),
1144        );
1145        // 8 columns: 3 text columns + the 5-wide " more" tail.
1146        assert_eq!(row(&b, 0), "abc more");
1147    }
1148
1149    #[test]
1150    fn truncate_tail_carries_its_style() {
1151        let mut b = buf(5, 1);
1152        Painter::new(&mut b).set_str_truncate(
1153            (0, 0),
1154            "abcdefgh",
1155            Style::default(),
1156            "…",
1157            Style::default().fg(Color::Red),
1158        );
1159        // The tail cell gets the supplied base style.
1160        assert_eq!(cell_at(&b, 4, 0).style.fg, Some(Color::Red));
1161    }
1162
1163    #[test]
1164    fn truncate_methods_keep_text_and_tail_styles_separate() {
1165        fn paint(
1166            target: &mut impl TextSurface,
1167            rect: bool,
1168            text: &str,
1169            style: &Style,
1170            tail: &str,
1171            tail_style: &Style,
1172        ) -> Position {
1173            if rect {
1174                target.set_str_rect_truncate(Rect::new(1, 1, 5, 2), text, style, tail, tail_style)
1175            } else {
1176                target.set_str_truncate((0, 0), text, style, tail, tail_style)
1177            }
1178        }
1179
1180        let style = Style::new().fg(Color::Green).bold();
1181        let tail_style = Style::new().fg(Color::Red).italic();
1182        for painter in [false, true] {
1183            for rect in [false, true] {
1184                for (text, tail, expected) in [
1185                    ("abc", ".", "abc  "),
1186                    ("abcde", ".", "abcde"),
1187                    ("abcdefgh", ".", "abcd."),
1188                    ("abcdefgh", "", "abcde"),
1189                    ("abcdefgh", " more!", "abcde"),
1190                ] {
1191                    let mut b = if rect { buf(7, 4) } else { buf(5, 2) };
1192                    let text = format!("{text}\n{text}");
1193                    let end = if painter {
1194                        paint(
1195                            &mut Painter::new(&mut b),
1196                            rect,
1197                            &text,
1198                            &style,
1199                            tail,
1200                            &tail_style,
1201                        )
1202                    } else {
1203                        paint(&mut b, rect, &text, &style, tail, &tail_style)
1204                    };
1205                    let offset = u16::from(rect);
1206                    assert_eq!(
1207                        end,
1208                        Position::new(offset + expected.trim_end().len() as u16, offset + 1)
1209                    );
1210                    for y in 0..b.height() {
1211                        for x in 0..b.width() {
1212                            let expected_cell = if (offset..offset + 2).contains(&y)
1213                                && (offset..offset + 5).contains(&x)
1214                            {
1215                                let ch = expected.as_bytes()[usize::from(x - offset)] as char;
1216                                match ch {
1217                                    ' ' => Cell::BLANK,
1218                                    '.' => Cell::new(".", 1).style(&tail_style),
1219                                    _ => Cell::new(ch.to_string(), 1).style(&style),
1220                                }
1221                            } else {
1222                                Cell::BLANK
1223                            };
1224                            assert_eq!(cell_at(&b, x, y), expected_cell);
1225                        }
1226                    }
1227                }
1228            }
1229        }
1230    }
1231
1232    #[test]
1233    fn truncate_inline_styles_start_from_independent_base_styles() {
1234        for rect in [false, true] {
1235            let mut b = buf(5, 1);
1236            let text_style = Style::new().fg(Color::Green);
1237            let tail_style = Style::new().fg(Color::Red);
1238            let mut painter = Painter::new(&mut b);
1239            if rect {
1240                painter.set_str_rect_truncate(
1241                    Rect::new(0, 0, 5, 1),
1242                    "\x1b[1mabcdefgh",
1243                    &text_style,
1244                    "\x1b[3m.",
1245                    &tail_style,
1246                );
1247            } else {
1248                painter.set_str_truncate(
1249                    (0, 0),
1250                    "\x1b[1mabcdefgh",
1251                    &text_style,
1252                    "\x1b[3m.",
1253                    &tail_style,
1254                );
1255            }
1256            for x in 0..4 {
1257                assert_eq!(cell_at(&b, x, 0).style, text_style.clone().bold());
1258            }
1259            assert_eq!(cell_at(&b, 4, 0).style, tail_style.italic());
1260        }
1261    }
1262
1263    #[test]
1264    fn truncate_tail_inline_escapes_apply() {
1265        let mut b = buf(5, 1);
1266        Painter::new(&mut b).set_str_truncate(
1267            (0, 0),
1268            "abcdefgh",
1269            Style::default(),
1270            "\x1b[1m…",
1271            Style::default(),
1272        );
1273        // The tail's inline SGR bolds it even though the base style is empty.
1274        assert!(cell_at(&b, 4, 0).style.attrs.contains(AttrFlags::BOLD));
1275    }
1276
1277    #[test]
1278    fn truncate_wide_tail_too_big_hard_truncates() {
1279        let mut b = buf(3, 1);
1280        let end = Painter::new(&mut b).set_str_truncate(
1281            (0, 0),
1282            "abcdef",
1283            Style::default(),
1284            " more",
1285            Style::default(),
1286        );
1287        // " more" is 5 wide but the clip is only 3, so it is dropped and the
1288        // text is hard-truncated with no tail.
1289        assert_eq!(row(&b, 0), "abc");
1290        assert_eq!(end, Position::new(3, 0));
1291    }
1292
1293    /// The module diagram shows a cluster laid across four columns. Measure
1294    /// the cluster it names so the picture stays answerable to the code.
1295    #[test]
1296    fn a_cluster_measuring_four_claims_four_columns() {
1297        let mut b = buf(6, 1).with_width_mode(WidthMode::Wc);
1298        // Thumbs-up plus a skin tone. `Wc` sums the code points, and each of
1299        // the two owns two columns.
1300        Painter::new(&mut b).set_str((0, 0), "\u{1f44d}\u{1f3fd}", Style::default());
1301
1302        assert_eq!(cell_at(&b, 0, 0).content(), "\u{1f44d}\u{1f3fd}");
1303        assert_eq!(cell_at(&b, 0, 0).width(), 4);
1304        for x in 1..4 {
1305            assert!(
1306                cell_at(&b, x, 0).is_continuation(),
1307                "column {x} belongs to the cluster"
1308            );
1309        }
1310        assert!(cell_at(&b, 4, 0).is_blank(), "the cluster stops at four");
1311    }
1312
1313    #[test]
1314    fn truncate_tail_overwrites_split_wide_cell() {
1315        // A wide cluster sits where the tail's left edge lands; stamping the
1316        // tail must blank the dangling wide primary.
1317        let mut b = buf(5, 1);
1318        Painter::new(&mut b).set_str_truncate(
1319            (0, 0),
1320            "ab中def",
1321            Style::default(),
1322            "…",
1323            Style::default(),
1324        );
1325        // "ab" + wide "中" fills columns 0..4; the tail overwrites column 4,
1326        // which is the continuation of "中", so the wide primary at 3 must be
1327        // blanked rather than left dangling.
1328        assert_eq!(cell_at(&b, 4, 0).content(), "…");
1329        assert!(!cell_at(&b, 3, 0).is_wide());
1330    }
1331}