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//! Non-zero-width grapheme clusters are written as one-cell or two-cell
33//! [`Cell`](crate::cell::Cell) values. Two-cell clusters occupy a primary wide
34//! cell plus the continuation cell maintained by the buffer layer. Zero-width
35//! clusters are appended to the previous pending cluster before it is flushed.
36//!
37//! ```text
38//! input clusters      pending cell       surface cells
39//! ┌────┬──────┐       ┌────────────┐         ┌────┬────┬────┐
40//! │ e  │ ◌́    │ ───▶  │ "e\u{301}" │ ─────▶  │ é  │    │    │
41//! └────┴──────┘       └────────────┘         └────┴────┴────┘
42//!
43//! ┌────┐              ┌─────────┐        ┌────┬────┬────┐
44//! │ 中 │ ─────────▶   │ width 2 │ ────▶  │ 中 │ ▶  │    │
45//! └────┘              └─────────┘        └────┴────┴────┘
46//! ```
47//!
48//! Painting is clipped to either the target bounds or the intersection of a
49//! supplied rectangle with those bounds. [`WrapMode`] applies only when a
50//! non-zero-width cluster would cross the right edge.
51
52use crate::ansi::hyperlink::parse_hyperlink;
53use crate::ansi::params::Params;
54use crate::ansi::text::{Token, string_width, tokenize};
55use crate::buffer::{Bounded, Surface, SurfaceMut};
56use crate::cell::Cell;
57use crate::layout::{Position, Rect};
58use crate::style::{Style, read_style};
59
60use super::{TextSurface, WidthMode, WrapMode};
61
62/// Paint styled strings into a [`TextSurface`].
63///
64/// The painter snapshots its target's [`WidthMode`] and `eaw_wide` policy at
65/// construction, both fixed for the painter's lifetime. It holds no style of
66/// its own: each paint call starts with an empty pen over the base style it is
67/// given, parses the input's inline SGR and OSC 8 sequences into that pen, and
68/// writes each cell as the pen inherited onto the base. Text is written into
69/// the borrowed target surface; dropping a painter has no side effects.
70pub struct Painter<'s, S: TextSurface + ?Sized> {
71    target: &'s mut S,
72    /// Width measurement policy, snapshotted from the target at construction.
73    mode: WidthMode,
74    /// Whether East Asian Ambiguous characters are treated as wide,
75    /// snapshotted from the target at construction.
76    eaw_wide: bool,
77}
78
79impl<'s, S: TextSurface + ?Sized> Painter<'s, S> {
80    /// Create a new painter over `target`.
81    ///
82    /// # Parameters
83    ///
84    /// * `target` — mutable surface receiving painted cells.
85    ///
86    /// # Returns
87    ///
88    /// A painter bound to `target`.
89    ///
90    /// # Errors and panics
91    ///
92    /// This constructor does not fail or intentionally panic.
93    pub fn new(target: &'s mut S) -> Self {
94        let mode = target.width_mode();
95        let eaw_wide = target.eaw_wide();
96        Self {
97            target,
98            mode,
99            eaw_wide,
100        }
101    }
102
103    /// Paint `s` with [`WrapMode::Truncate`], stamping `tail` on overflow.
104    ///
105    /// Falls back to a plain hard truncate when the tail is empty or cannot
106    /// fit within `clip`.
107    fn paint_truncate(
108        &mut self,
109        start: Position,
110        clip: Rect,
111        s: &str,
112        tail_text: &str,
113        tail_style: Style,
114        style: Style,
115    ) -> Position {
116        if clip.is_empty() {
117            return start;
118        }
119        let tail_w = string_width(tail_text.as_bytes(), self.mode, self.eaw_wide) as u16;
120        let tail = if tail_w == 0 || tail_w > clip.width {
121            None
122        } else {
123            Some(Tail {
124                text: tail_text,
125                style: &tail_style,
126                width: tail_w,
127            })
128        };
129        self.paint_inner(start, clip, s, WrapMode::Truncate, tail, style)
130    }
131
132    /// Stamp `tail` over the trailing `tail.width` columns of row `y`, ending
133    /// at `clip`'s right edge, painted with the tail's starting style.
134    fn paint_tail(&mut self, tail: Tail<'_>, clip: Rect, y: u16) {
135        let tail_x = clip.right().saturating_sub(tail.width);
136        let sub = Rect::new(tail_x, y, tail.width, 1).intersection(clip);
137        self.paint_inner(
138            Position::new(tail_x, y),
139            sub,
140            tail.text,
141            WrapMode::Truncate,
142            None,
143            tail.style.clone(),
144        );
145    }
146
147    fn paint(
148        &mut self,
149        start: Position,
150        clip: Rect,
151        s: &str,
152        wrap: WrapMode,
153        style: Style,
154    ) -> Position {
155        self.paint_inner(start, clip, s, wrap, None, style)
156    }
157
158    fn paint_inner(
159        &mut self,
160        start: Position,
161        clip: Rect,
162        s: &str,
163        wrap: WrapMode,
164        tail: Option<Tail<'_>>,
165        base: Style,
166    ) -> Position {
167        if clip.is_empty() {
168            return start;
169        }
170        // `y` only ever advances, so a start below the clip can never paint.
171        if start.y >= clip.bottom() {
172            return start;
173        }
174        let mut x = start.x;
175        let mut y = start.y;
176        // `pen` accumulates the inline SGR/OSC 8 state, starting empty; an
177        // inline reset clears it, so the cells after a reset fall back to
178        // `base`. `pending` is the cell currently being built: its origin,
179        // text, and width. A trailing zero-width grapheme (a combining mark)
180        // joins it instead of starting a new cell, so the cell is held until
181        // the next non-zero-width token finalizes it.
182        let mut pen = Style::default();
183        let mut pending: Option<(u16, u16, String, u8)> = None;
184        // Truncation is per row: once a row overflows, clusters are dropped
185        // until `\n` or `\r` puts the cursor back inside the clip. Escapes
186        // still run, so the pen carries over to the next row.
187        let mut truncated = false;
188
189        for tok in tokenize(s.as_bytes(), self.mode, self.eaw_wide) {
190            // A zero-width grapheme appends to the pending cell without
191            // finalizing it. Everything else finalizes the pending cell first,
192            // writing it with the current style: the pen inherited onto base.
193            if !matches!(tok, Token::Text { width: 0, .. })
194                && let Some((px, py, content, w)) = pending.take()
195                && clip.contains(Position::new(px, py))
196            {
197                let cell = if w == 2 {
198                    Cell::wide(&content)
199                } else {
200                    Cell::narrow(&content)
201                };
202                self.target
203                    .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
204            }
205
206            match tok {
207                // SAFETY (all `from_utf8_unchecked`): the tokenizer cuts on
208                // grapheme-cluster boundaries of a valid `&str`, so each slice
209                // is valid UTF-8. Checked under `debug_assert!` for the same
210                // reason as `ansi::wrap::bs` - the invariant is the tokenizer's
211                // to keep, and when it stopped keeping it this was UB.
212                Token::Text { text, width: 0 } => {
213                    if let Some((_, _, ref mut content, _)) = pending {
214                        debug_assert!(std::str::from_utf8(text).is_ok());
215                        content.push_str(unsafe { std::str::from_utf8_unchecked(text) });
216                    }
217                }
218                Token::Text { text, width } => {
219                    debug_assert!(std::str::from_utf8(text).is_ok());
220                    if truncated {
221                        continue;
222                    }
223                    let g = unsafe { std::str::from_utf8_unchecked(text) };
224                    let cw = width as u8;
225                    if x + cw as u16 > clip.right() {
226                        match wrap {
227                            WrapMode::Truncate => {
228                                if let Some(tail) = tail {
229                                    self.paint_tail(tail, clip, y);
230                                    x = clip.right();
231                                }
232                                truncated = true;
233                                continue;
234                            }
235                            WrapMode::Wrap => {
236                                y = y.saturating_add(1);
237                                x = clip.left();
238                                if y >= clip.bottom() {
239                                    return Position::new(x, y);
240                                }
241                                if x + cw as u16 > clip.right() {
242                                    return Position::new(x, y);
243                                }
244                            }
245                        }
246                    }
247                    pending = Some((x, y, g.to_string(), cw));
248                    x += cw as u16;
249                }
250                Token::Escape(seq) => {
251                    if seq.last() == Some(&b'm')
252                        && let Some(body) = csi_body(seq)
253                    {
254                        read_style(Params::from_raw(body), &mut pen);
255                    } else if let Some(body) = osc_body(seq)
256                        && let Some((params, url)) = parse_hyperlink(body)
257                    {
258                        pen = pen.link(url, params);
259                    }
260                }
261                Token::Control(0x0A) => {
262                    y = y.saturating_add(1);
263                    x = clip.left();
264                    truncated = false;
265                    if y >= clip.bottom() {
266                        return Position::new(x, y);
267                    }
268                }
269                Token::Control(0x0D) => {
270                    x = clip.left();
271                    truncated = false;
272                }
273                Token::Control(_) => {}
274            }
275        }
276
277        // Finalize the last cell.
278        if let Some((px, py, content, w)) = pending.take()
279            && clip.contains(Position::new(px, py))
280        {
281            let cell = if w == 2 {
282                Cell::wide(&content)
283            } else {
284                Cell::narrow(&content)
285            };
286            self.target
287                .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
288        }
289        Position::new(x, y)
290    }
291}
292
293impl<'s, S: TextSurface + ?Sized> Bounded for Painter<'s, S> {
294    fn bounds(&self) -> Rect {
295        self.target.bounds()
296    }
297}
298
299impl<'s, S: TextSurface + ?Sized> Surface for Painter<'s, S> {
300    fn cell(&self, pos: Position) -> Option<&Cell> {
301        self.target.cell(pos)
302    }
303}
304
305impl<'s, S: TextSurface + ?Sized> SurfaceMut for Painter<'s, S> {
306    fn set_cell(&mut self, pos: Position, cell: &Cell) {
307        self.target.set_cell(pos, cell);
308    }
309
310    fn cell_mut(&mut self, pos: Position) -> Option<&mut Cell> {
311        self.target.cell_mut(pos)
312    }
313
314    fn insert_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
315        self.target.insert_lines(y, count, bounds_bottom, fill);
316    }
317
318    fn delete_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
319        self.target.delete_lines(y, count, bounds_bottom, fill);
320    }
321
322    fn insert_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
323        self.target.insert_cells(pos, count, bounds_right, fill);
324    }
325
326    fn delete_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
327        self.target.delete_cells(pos, count, bounds_right, fill);
328    }
329}
330
331/// A [`Painter`] is itself a [`TextSurface`] whose `set_str` family recognizes
332/// inline SGR and OSC 8 hyperlink sequences, updating the running
333/// the running style as the input is parsed. This is the escape-aware
334/// counterpart to the literal painting of the default [`TextSurface`] methods.
335impl<'s, S: TextSurface + ?Sized> TextSurface for Painter<'s, S> {
336    fn width_mode(&self) -> WidthMode {
337        self.mode
338    }
339
340    fn eaw_wide(&self) -> bool {
341        self.eaw_wide
342    }
343
344    /// Measure `s`, skipping recognized inline SGR and OSC 8 escape
345    /// sequences so they contribute no width. This is the escape-aware
346    /// counterpart to the literal default
347    /// [`str_width`](crate::text::TextSurface::str_width).
348    fn str_width(&self, s: &str) -> u16 {
349        string_width(s.as_bytes(), self.mode, self.eaw_wide).min(u16::MAX as usize) as u16
350    }
351
352    /// Paint `s` starting at `pos`, clipped to the target bounds.
353    ///
354    /// The painter's running [`Style`] takes precedence and inherits any unset
355    /// fields from `style`, so the running style carries across calls and
356    /// `style` only fills in what it has not set. Inline SGR and OSC 8 sequences
357    /// then update the running style as the input is processed. Newline advances
358    /// to the next row at the bounds' left edge; carriage return returns to that
359    /// left edge on the current row. Right-edge behavior is
360    /// [`WrapMode::Truncate`].
361    ///
362    /// # Parameters
363    ///
364    /// * `pos` — starting cell position.
365    /// * `s` — UTF-8 input string.
366    /// * `style` — base style the running style inherits unset fields from.
367    ///
368    /// # Returns
369    ///
370    /// The cursor position immediately after the last written cell, or where
371    /// painting stopped.
372    ///
373    /// # Errors and panics
374    ///
375    /// This method does not return errors and does not intentionally panic.
376    fn set_str(&mut self, pos: impl Into<Position>, s: &str, style: impl Into<Style>) -> Position {
377        let clip = self.target.bounds();
378        self.paint(pos.into(), clip, s, WrapMode::default(), style.into())
379    }
380
381    /// Paint `s` starting at `pos` with explicit wrapping behavior.
382    ///
383    /// The target bounds are the clipping rectangle. [`WrapMode::Truncate`]
384    /// drops the rest of the row at the right edge and resumes on the next
385    /// row; [`WrapMode::Wrap`] continues on the next row
386    /// at the bounds' left edge until the bottom edge is reached.
387    ///
388    /// # Parameters
389    ///
390    /// * `pos` — starting cell position.
391    /// * `s` — UTF-8 input string.
392    /// * `wrap` — right-edge behavior for non-zero-width clusters.
393    /// * `style` — initial style for this call.
394    ///
395    /// # Returns
396    ///
397    /// The cursor position immediately after the last written cell, or where
398    /// painting stopped.
399    ///
400    /// # Errors and panics
401    ///
402    /// This method does not return errors and does not intentionally panic.
403    fn set_str_wrap(
404        &mut self,
405        pos: impl Into<Position>,
406        s: &str,
407        wrap: WrapMode,
408        style: impl Into<Style>,
409    ) -> Position {
410        let clip = self.target.bounds();
411        self.paint(pos.into(), clip, s, wrap, style.into())
412    }
413
414    /// Paint `s` into `rect`, clipped to `rect ∩ target.bounds()`.
415    ///
416    /// Painting starts at `rect`'s top-left. Newline and carriage return use
417    /// `rect`'s left edge as the return column. Right-edge behavior is
418    /// [`WrapMode::Truncate`].
419    ///
420    /// # Parameters
421    ///
422    /// * `rect` — origin and clipping rectangle.
423    /// * `s` — UTF-8 input string.
424    /// * `style` — initial style for this call.
425    ///
426    /// # Returns
427    ///
428    /// The cursor position immediately after the last written cell, or where
429    /// painting stopped.
430    ///
431    /// # Errors and panics
432    ///
433    /// This method does not return errors and does not intentionally panic.
434    fn set_str_rect(
435        &mut self,
436        rect: impl Into<Rect>,
437        s: &str,
438        style: impl Into<Style>,
439    ) -> Position {
440        let rect = rect.into();
441        let clip = rect.intersection(self.target.bounds());
442        self.paint(rect.position(), clip, s, WrapMode::default(), style.into())
443    }
444
445    /// Paint `s` into `rect` with explicit wrapping behavior.
446    ///
447    /// The clipping rectangle is `rect ∩ target.bounds()`. [`WrapMode::Wrap`]
448    /// flows down inside `rect`; [`WrapMode::Truncate`] stops at `rect`'s
449    /// right edge.
450    ///
451    /// # Parameters
452    ///
453    /// * `rect` — origin and clipping rectangle.
454    /// * `s` — UTF-8 input string.
455    /// * `wrap` — right-edge behavior for non-zero-width clusters.
456    /// * `style` — initial style for this call.
457    ///
458    /// # Returns
459    ///
460    /// The cursor position immediately after the last written cell, or where
461    /// painting stopped.
462    ///
463    /// # Errors and panics
464    ///
465    /// This method does not return errors and does not intentionally panic.
466    fn set_str_rect_wrap(
467        &mut self,
468        rect: impl Into<Rect>,
469        s: &str,
470        wrap: WrapMode,
471        style: impl Into<Style>,
472    ) -> Position {
473        let rect = rect.into();
474        let clip = rect.intersection(self.target.bounds());
475        self.paint(rect.position(), clip, s, wrap, style.into())
476    }
477
478    /// Paint `s` starting at `pos`, truncating with a `tail` indicator.
479    ///
480    /// Text is painted across the target bounds. When a non-zero-width cluster
481    /// would cross the right edge, the rest of that row is dropped and `tail`
482    /// is stamped over its trailing columns so it ends exactly at the right
483    /// edge. Painting resumes on the next row if the text continues past a
484    /// newline, so a multi-line `s` can stamp one tail per overflowing row.
485    /// The tail appears only on rows that actually overflow; a row that fits
486    /// is left untouched.
487    ///
488    /// `tail` is painted with `tail_style` as its starting style and may carry
489    /// its own inline escape sequences, so it can be a single glyph (`"…"`), a
490    /// word (`" more"`), or a multi-style span. If the tail is wider than the
491    /// available space, it is dropped and the text is hard-truncated instead.
492    ///
493    /// # Parameters
494    ///
495    /// * `pos` — starting cell position.
496    /// * `s` — UTF-8 string to paint.
497    /// * `tail` — truncation indicator, painted when `s` overflows.
498    /// * `tail_style` — starting style for the tail.
499    ///
500    /// # Returns
501    ///
502    /// The cursor position immediately after the last written cell, or where
503    /// painting stopped.
504    ///
505    /// # Errors and panics
506    ///
507    /// This method does not return errors and does not intentionally panic.
508    fn set_str_truncate(
509        &mut self,
510        pos: impl Into<Position>,
511        s: &str,
512        tail: &str,
513        tail_style: impl Into<Style>,
514    ) -> Position {
515        let clip = self.target.bounds();
516        self.paint_truncate(
517            pos.into(),
518            clip,
519            s,
520            tail,
521            tail_style.into(),
522            Style::default(),
523        )
524    }
525
526    /// Paint `s` inside `rect`, truncating with a `tail` indicator.
527    ///
528    /// This is the rectangular form of
529    /// [`set_str_truncate`](Self::set_str_truncate): the clip rectangle is
530    /// `rect ∩ target.bounds()`, and a tail is stamped at `rect`'s right
531    /// edge on each row that overflows it.
532    ///
533    /// # Parameters
534    ///
535    /// * `rect` — clipping rectangle and starting origin.
536    /// * `s` — UTF-8 string to paint.
537    /// * `tail` — truncation indicator, painted when `s` overflows.
538    /// * `tail_style` — starting style for the tail.
539    ///
540    /// # Returns
541    ///
542    /// The cursor position immediately after the last written cell, or where
543    /// painting stopped.
544    ///
545    /// # Errors and panics
546    ///
547    /// This method does not return errors and does not intentionally panic.
548    fn set_str_rect_truncate(
549        &mut self,
550        rect: impl Into<Rect>,
551        s: &str,
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            tail,
562            tail_style.into(),
563            Style::default(),
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((0, 0), "abcdef\nghijkl", "…", Style::default());
846        assert_eq!(cell_at(&b, 3, 0).content(), "…");
847        assert_eq!(cell_at(&b, 0, 1).content(), "g");
848        assert_eq!(cell_at(&b, 3, 1).content(), "…");
849    }
850
851    #[test]
852    fn literal_truncate_resumes_on_next_row() {
853        let mut b = buf(3, 2);
854        b.set_str((0, 0), "abcdef\nxy", Style::default());
855        assert_eq!(cell_at(&b, 2, 0).content(), "c");
856        assert_eq!(cell_at(&b, 0, 1).content(), "x");
857        assert_eq!(cell_at(&b, 1, 1).content(), "y");
858    }
859
860    #[test]
861    fn cr_clears_truncation_for_the_row() {
862        let mut b = buf(3, 1);
863        Painter::new(&mut b).set_str_wrap(
864            (0, 0),
865            "abcdef\rXY",
866            WrapMode::Truncate,
867            Style::default(),
868        );
869        assert_eq!(cell_at(&b, 0, 0).content(), "X");
870        assert_eq!(cell_at(&b, 1, 0).content(), "Y");
871        assert_eq!(cell_at(&b, 2, 0).content(), "c");
872    }
873
874    #[test]
875    fn overflow_drops_rest_of_row_without_backfill() {
876        let mut b = buf(3, 1);
877        let end = Painter::new(&mut b).set_str_wrap(
878            (0, 0),
879            "ab中c",
880            WrapMode::Truncate,
881            Style::default(),
882        );
883        assert_eq!(cell_at(&b, 0, 0).content(), "a");
884        assert_eq!(cell_at(&b, 1, 0).content(), "b");
885        // "中" needs two columns and only one is left; "c" must not slot into
886        // the gap ahead of it.
887        assert_eq!(cell_at(&b, 2, 0).content(), " ");
888        assert_eq!(end, Position::new(2, 0));
889    }
890
891    #[test]
892    fn literal_crlf_breaks_the_line() {
893        // Extended grapheme segmentation joins CR LF into one zero-width
894        // cluster, so it has to be matched explicitly to break the line.
895        let mut b = buf(3, 2);
896        b.set_str((0, 0), "abcdef\r\nxy", Style::default());
897        assert_eq!(cell_at(&b, 2, 0).content(), "c");
898        assert_eq!(cell_at(&b, 0, 1).content(), "x");
899        assert_eq!(cell_at(&b, 1, 1).content(), "y");
900    }
901
902    #[test]
903    fn crlf_breaks_the_line() {
904        let mut b = buf(3, 2);
905        Painter::new(&mut b).set_str_wrap(
906            (0, 0),
907            "abcdef\r\nxy",
908            WrapMode::Truncate,
909            Style::default(),
910        );
911        assert_eq!(cell_at(&b, 2, 0).content(), "c");
912        assert_eq!(cell_at(&b, 0, 1).content(), "x");
913        assert_eq!(cell_at(&b, 1, 1).content(), "y");
914    }
915
916    #[test]
917    fn escapes_still_apply_across_a_truncated_row() {
918        // The pen keeps advancing through the dropped part of the row, so a
919        // style opened there lands on the next row.
920        let mut b = buf(2, 2);
921        Painter::new(&mut b).set_str_wrap(
922            (0, 0),
923            "ab\x1b[1mcd\x1b]8;;https://x\x1b\\\nz",
924            WrapMode::Truncate,
925            Style::default(),
926        );
927        assert!(!cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
928        let z = cell_at(&b, 0, 1);
929        assert_eq!(z.content(), "z");
930        assert!(z.style.attrs.contains(AttrFlags::BOLD));
931        assert_eq!(link_of(&z.style), Some(("https://x", "")));
932    }
933
934    #[test]
935    fn start_below_clip_paints_nothing() {
936        let mut b = buf(3, 2);
937        let end = Painter::new(&mut b).set_str_wrap(
938            (0, 5),
939            "abcdef\nghi",
940            WrapMode::Truncate,
941            Style::default(),
942        );
943        assert_eq!(end, Position::new(0, 5));
944        for y in 0..2 {
945            for x in 0..3 {
946                assert_eq!(cell_at(&b, x, y).content(), " ");
947            }
948        }
949    }
950
951    #[test]
952    fn literal_start_below_clip_paints_nothing() {
953        let mut b = buf(3, 2);
954        let end = b.set_str((0, 5), "abcdef\nghi", Style::default());
955        assert_eq!(end, Position::new(0, 5));
956        assert_eq!(cell_at(&b, 0, 0).content(), " ");
957        assert_eq!(cell_at(&b, 0, 1).content(), " ");
958    }
959
960    #[test]
961    fn wrap_breaks_on_crlf() {
962        // The CRLF fix is shared with WrapMode::Wrap: a joined cluster has to
963        // break the line there too, not read as zero-width filler.
964        let mut b = buf(4, 3);
965        Painter::new(&mut b).set_str_wrap((0, 0), "ab\r\ncd", WrapMode::Wrap, Style::default());
966        assert_eq!(cell_at(&b, 0, 0).content(), "a");
967        assert_eq!(cell_at(&b, 1, 0).content(), "b");
968        assert_eq!(cell_at(&b, 0, 1).content(), "c");
969        assert_eq!(cell_at(&b, 1, 1).content(), "d");
970    }
971
972    #[test]
973    fn literal_wrap_breaks_on_crlf() {
974        let mut b = buf(4, 3);
975        b.set_str_wrap((0, 0), "ab\r\ncd", WrapMode::Wrap, Style::default());
976        assert_eq!(cell_at(&b, 0, 0).content(), "a");
977        assert_eq!(cell_at(&b, 1, 0).content(), "b");
978        assert_eq!(cell_at(&b, 0, 1).content(), "c");
979        assert_eq!(cell_at(&b, 1, 1).content(), "d");
980    }
981
982    #[test]
983    fn wrap_at_right_edge() {
984        let mut b = buf(3, 3);
985        let end =
986            Painter::new(&mut b).set_str_wrap((0, 0), "abcdef", WrapMode::Wrap, Style::default());
987        assert_eq!(end, Position::new(3, 1));
988        assert_eq!(cell_at(&b, 0, 0).content(), "a");
989        assert_eq!(cell_at(&b, 2, 0).content(), "c");
990        assert_eq!(cell_at(&b, 0, 1).content(), "d");
991        assert_eq!(cell_at(&b, 2, 1).content(), "f");
992    }
993
994    #[test]
995    fn rect_clip_and_origin() {
996        let mut b = buf(10, 5);
997        let end = Painter::new(&mut b).set_str_rect_wrap(
998            Rect::new(2, 1, 3, 2),
999            "abcdef",
1000            WrapMode::Wrap,
1001            Style::default(),
1002        );
1003        assert_eq!(end, Position::new(5, 2));
1004        assert_eq!(cell_at(&b, 2, 1).content(), "a");
1005        assert_eq!(cell_at(&b, 4, 1).content(), "c");
1006        assert_eq!(cell_at(&b, 2, 2).content(), "d");
1007        assert_eq!(cell_at(&b, 4, 2).content(), "f");
1008        // Outside the rect must remain blank.
1009        assert_eq!(cell_at(&b, 0, 0).content(), " ");
1010        assert_eq!(cell_at(&b, 5, 1).content(), " ");
1011    }
1012
1013    #[test]
1014    fn rect_newline_uses_rect_left() {
1015        let mut b = buf(10, 5);
1016        Painter::new(&mut b).set_str_rect_wrap(
1017            Rect::new(2, 1, 4, 3),
1018            "ab\ncd",
1019            WrapMode::Truncate,
1020            Style::default(),
1021        );
1022        assert_eq!(cell_at(&b, 2, 1).content(), "a");
1023        assert_eq!(cell_at(&b, 3, 1).content(), "b");
1024        // Newline returns x to rect.left() = 2, not to 0.
1025        assert_eq!(cell_at(&b, 2, 2).content(), "c");
1026        assert_eq!(cell_at(&b, 3, 2).content(), "d");
1027        assert_eq!(cell_at(&b, 0, 2).content(), " ");
1028    }
1029
1030    #[test]
1031    fn with_resets_style_and_link() {
1032        let mut b = buf(10, 1);
1033        // First call: paint with bold + a link.
1034        Painter::new(&mut b).set_str_wrap(
1035            (0, 0),
1036            "a",
1037            WrapMode::Truncate,
1038            Style::default().bold().link("https://x", ""),
1039        );
1040        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1041        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
1042        // Second call with `_with` and an empty style must reset.
1043        Painter::new(&mut b).set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
1044        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1045        assert!(cell_at(&b, 1, 0).style.link.is_none());
1046    }
1047
1048    #[test]
1049    fn calls_start_from_their_own_base() {
1050        let mut b = buf(10, 1);
1051        let mut p = Painter::new(&mut b);
1052        // Inline SGR bolds within the first call only.
1053        p.set_str_wrap((0, 0), "\x1b[1ma", WrapMode::Truncate, Style::default());
1054        // The next call starts fresh from its base: no bold carries over.
1055        p.set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
1056        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1057        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1058    }
1059
1060    #[test]
1061    fn base_applies_and_inline_reset_returns_to_base() {
1062        let mut b = buf(10, 1);
1063        let base = Style::default().fg(Color::Red);
1064        // "a" gets the base red; the inline bold adds to "b"; the inline reset
1065        // clears only the inline state, so "c" falls back to the base red
1066        // rather than to a fully default style.
1067        Painter::new(&mut b).set_str_wrap((0, 0), "a\x1b[1mb\x1b[0mc", WrapMode::Truncate, base);
1068        let red = Some(Color::Red);
1069        assert_eq!(cell_at(&b, 0, 0).style.fg, red);
1070        assert!(!cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
1071        assert_eq!(cell_at(&b, 1, 0).style.fg, red);
1072        assert!(cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
1073        assert_eq!(cell_at(&b, 2, 0).style.fg, red);
1074        assert!(!cell_at(&b, 2, 0).style.attrs.contains(AttrFlags::BOLD));
1075    }
1076
1077    #[test]
1078    fn position_and_rect_match_when_rect_covers_bounds() {
1079        let mut a = buf(5, 2);
1080        let mut b = buf(5, 2);
1081        let e1 =
1082            Painter::new(&mut a).set_str_wrap((0, 0), "abc", WrapMode::Truncate, Style::default());
1083        let e2 = Painter::new(&mut b).set_str_rect_wrap(
1084            Rect::new(0, 0, 5, 2),
1085            "abc",
1086            WrapMode::Truncate,
1087            Style::default(),
1088        );
1089        assert_eq!(e1, e2);
1090        assert_eq!(cell_at(&a, 2, 0).content(), cell_at(&b, 2, 0).content());
1091    }
1092
1093    fn row(b: &TextBuffer, y: u16) -> String {
1094        (0..b.width())
1095            .map(|x| cell_at(b, x, y).content().to_string())
1096            .collect()
1097    }
1098
1099    #[test]
1100    fn truncate_tail_not_shown_when_text_fits() {
1101        let mut b = buf(5, 1);
1102        let end = Painter::new(&mut b).set_str_truncate((0, 0), "abc", "…", Style::default());
1103        // "abc" fits in 5 columns, so no tail is stamped.
1104        assert_eq!(row(&b, 0), "abc  ");
1105        assert_eq!(end, Position::new(3, 0));
1106    }
1107
1108    #[test]
1109    fn truncate_single_cell_tail_on_overflow() {
1110        let mut b = buf(5, 1);
1111        let end = Painter::new(&mut b).set_str_truncate((0, 0), "abcdefgh", "…", Style::default());
1112        // 5 columns: 4 text columns + the 1-wide tail at the right edge.
1113        assert_eq!(row(&b, 0), "abcd…");
1114        assert_eq!(end, Position::new(5, 0));
1115    }
1116
1117    #[test]
1118    fn truncate_multi_cell_tail_reserves_its_width() {
1119        let mut b = buf(8, 1);
1120        Painter::new(&mut b).set_str_truncate((0, 0), "abcdefghij", " more", Style::default());
1121        // 8 columns: 3 text columns + the 5-wide " more" tail.
1122        assert_eq!(row(&b, 0), "abc more");
1123    }
1124
1125    #[test]
1126    fn truncate_tail_carries_its_style() {
1127        let mut b = buf(5, 1);
1128        Painter::new(&mut b).set_str_truncate(
1129            (0, 0),
1130            "abcdefgh",
1131            "…",
1132            Style::default().fg(Color::Red),
1133        );
1134        // The tail cell gets the supplied base style.
1135        assert_eq!(cell_at(&b, 4, 0).style.fg, Some(Color::Red));
1136    }
1137
1138    #[test]
1139    fn truncate_tail_inline_escapes_apply() {
1140        let mut b = buf(5, 1);
1141        Painter::new(&mut b).set_str_truncate((0, 0), "abcdefgh", "\x1b[1m…", Style::default());
1142        // The tail's inline SGR bolds it even though the base style is empty.
1143        assert!(cell_at(&b, 4, 0).style.attrs.contains(AttrFlags::BOLD));
1144    }
1145
1146    #[test]
1147    fn truncate_wide_tail_too_big_hard_truncates() {
1148        let mut b = buf(3, 1);
1149        let end =
1150            Painter::new(&mut b).set_str_truncate((0, 0), "abcdef", " more", Style::default());
1151        // " more" is 5 wide but the clip is only 3, so it is dropped and the
1152        // text is hard-truncated with no tail.
1153        assert_eq!(row(&b, 0), "abc");
1154        assert_eq!(end, Position::new(3, 0));
1155    }
1156
1157    #[test]
1158    fn truncate_tail_overwrites_split_wide_cell() {
1159        // A wide cluster sits where the tail's left edge lands; stamping the
1160        // tail must blank the dangling wide primary.
1161        let mut b = buf(5, 1);
1162        Painter::new(&mut b).set_str_truncate((0, 0), "ab中def", "…", Style::default());
1163        // "ab" + wide "中" fills columns 0..4; the tail overwrites column 4,
1164        // which is the continuation of "中", so the wide primary at 3 must be
1165        // blanked rather than left dangling.
1166        assert_eq!(cell_at(&b, 4, 0).content(), "…");
1167        assert!(!cell_at(&b, 3, 0).is_wide());
1168    }
1169}