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        let mut x = start.x;
171        let mut y = start.y;
172        // `pen` accumulates the inline SGR/OSC 8 state, starting empty; an
173        // inline reset clears it, so the cells after a reset fall back to
174        // `base`. `pending` is the cell currently being built: its origin,
175        // text, and width. A trailing zero-width grapheme (a combining mark)
176        // joins it instead of starting a new cell, so the cell is held until
177        // the next non-zero-width token finalizes it.
178        let mut pen = Style::default();
179        let mut pending: Option<(u16, u16, String, u8)> = None;
180
181        for tok in tokenize(s.as_bytes(), self.mode, self.eaw_wide) {
182            // A zero-width grapheme appends to the pending cell without
183            // finalizing it. Everything else finalizes the pending cell first,
184            // writing it with the current style: the pen inherited onto base.
185            if !matches!(tok, Token::Text { width: 0, .. })
186                && let Some((px, py, content, w)) = pending.take()
187                && clip.contains(Position::new(px, py))
188            {
189                let cell = if w == 2 {
190                    Cell::wide(&content)
191                } else {
192                    Cell::narrow(&content)
193                };
194                self.target
195                    .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
196            }
197
198            match tok {
199                // SAFETY (all `from_utf8_unchecked`): the tokenizer cuts on
200                // grapheme-cluster boundaries of a valid `&str`, so each slice
201                // is valid UTF-8.
202                Token::Text { text, width: 0 } => {
203                    if let Some((_, _, ref mut content, _)) = pending {
204                        content.push_str(unsafe { std::str::from_utf8_unchecked(text) });
205                    }
206                }
207                Token::Text { text, width } => {
208                    let g = unsafe { std::str::from_utf8_unchecked(text) };
209                    let cw = width as u8;
210                    if x + cw as u16 > clip.right() {
211                        match wrap {
212                            WrapMode::Truncate => {
213                                if let Some(tail) = tail {
214                                    self.paint_tail(tail, clip, y);
215                                    return Position::new(clip.right(), y);
216                                }
217                                return Position::new(x, y);
218                            }
219                            WrapMode::Wrap => {
220                                y = y.saturating_add(1);
221                                x = clip.left();
222                                if y >= clip.bottom() {
223                                    return Position::new(x, y);
224                                }
225                                if x + cw as u16 > clip.right() {
226                                    return Position::new(x, y);
227                                }
228                            }
229                        }
230                    }
231                    pending = Some((x, y, g.to_string(), cw));
232                    x += cw as u16;
233                }
234                Token::Escape(seq) => {
235                    if seq.last() == Some(&b'm')
236                        && let Some(body) = csi_body(seq)
237                    {
238                        read_style(Params::from_raw(body), &mut pen);
239                    } else if let Some(body) = osc_body(seq)
240                        && let Some((params, url)) = parse_hyperlink(body)
241                    {
242                        pen = pen.link(url, params);
243                    }
244                }
245                Token::Control(0x0A) => {
246                    y = y.saturating_add(1);
247                    x = clip.left();
248                    if y >= clip.bottom() {
249                        return Position::new(x, y);
250                    }
251                }
252                Token::Control(0x0D) => {
253                    x = clip.left();
254                }
255                Token::Control(_) => {}
256            }
257        }
258
259        // Finalize the last cell.
260        if let Some((px, py, content, w)) = pending.take()
261            && clip.contains(Position::new(px, py))
262        {
263            let cell = if w == 2 {
264                Cell::wide(&content)
265            } else {
266                Cell::narrow(&content)
267            };
268            self.target
269                .set_cell(Position::new(px, py), &cell.style(pen.inherit(&base)));
270        }
271        Position::new(x, y)
272    }
273}
274
275impl<'s, S: TextSurface + ?Sized> Bounded for Painter<'s, S> {
276    fn bounds(&self) -> Rect {
277        self.target.bounds()
278    }
279}
280
281impl<'s, S: TextSurface + ?Sized> Surface for Painter<'s, S> {
282    fn cell(&self, pos: Position) -> Option<&Cell> {
283        self.target.cell(pos)
284    }
285}
286
287impl<'s, S: TextSurface + ?Sized> SurfaceMut for Painter<'s, S> {
288    fn set_cell(&mut self, pos: Position, cell: &Cell) {
289        self.target.set_cell(pos, cell);
290    }
291
292    fn cell_mut(&mut self, pos: Position) -> Option<&mut Cell> {
293        self.target.cell_mut(pos)
294    }
295
296    fn insert_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
297        self.target.insert_lines(y, count, bounds_bottom, fill);
298    }
299
300    fn delete_lines(&mut self, y: u16, count: u16, bounds_bottom: u16, fill: &Cell) {
301        self.target.delete_lines(y, count, bounds_bottom, fill);
302    }
303
304    fn insert_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
305        self.target.insert_cells(pos, count, bounds_right, fill);
306    }
307
308    fn delete_cells(&mut self, pos: Position, count: u16, bounds_right: u16, fill: &Cell) {
309        self.target.delete_cells(pos, count, bounds_right, fill);
310    }
311}
312
313/// A [`Painter`] is itself a [`TextSurface`] whose `set_str` family recognizes
314/// inline SGR and OSC 8 hyperlink sequences, updating the running
315/// the running style as the input is parsed. This is the escape-aware
316/// counterpart to the literal painting of the default [`TextSurface`] methods.
317impl<'s, S: TextSurface + ?Sized> TextSurface for Painter<'s, S> {
318    fn width_mode(&self) -> WidthMode {
319        self.mode
320    }
321
322    fn eaw_wide(&self) -> bool {
323        self.eaw_wide
324    }
325
326    /// Measure `s`, skipping recognized inline SGR and OSC 8 escape
327    /// sequences so they contribute no width. This is the escape-aware
328    /// counterpart to the literal default
329    /// [`str_width`](crate::text::TextSurface::str_width).
330    fn str_width(&self, s: &str) -> u16 {
331        string_width(s.as_bytes(), self.mode, self.eaw_wide).min(u16::MAX as usize) as u16
332    }
333
334    /// Paint `s` starting at `pos`, clipped to the target bounds.
335    ///
336    /// The painter's running [`Style`] takes precedence and inherits any unset
337    /// fields from `style`, so the running style carries across calls and
338    /// `style` only fills in what it has not set. Inline SGR and OSC 8 sequences
339    /// then update the running style as the input is processed. Newline advances
340    /// to the next row at the bounds' left edge; carriage return returns to that
341    /// left edge on the current row. Right-edge behavior is
342    /// [`WrapMode::Truncate`].
343    ///
344    /// # Parameters
345    ///
346    /// * `pos` — starting cell position.
347    /// * `s` — UTF-8 input string.
348    /// * `style` — base style the running style inherits unset fields from.
349    ///
350    /// # Returns
351    ///
352    /// The cursor position immediately after the last written cell, or where
353    /// painting stopped.
354    ///
355    /// # Errors and panics
356    ///
357    /// This method does not return errors and does not intentionally panic.
358    fn set_str(&mut self, pos: impl Into<Position>, s: &str, style: impl Into<Style>) -> Position {
359        let clip = self.target.bounds();
360        self.paint(pos.into(), clip, s, WrapMode::default(), style.into())
361    }
362
363    /// Paint `s` starting at `pos` with explicit wrapping behavior.
364    ///
365    /// The target bounds are the clipping rectangle. [`WrapMode::Truncate`]
366    /// stops at the right edge; [`WrapMode::Wrap`] continues on the next row
367    /// at the bounds' left edge until the bottom edge is reached.
368    ///
369    /// # Parameters
370    ///
371    /// * `pos` — starting cell position.
372    /// * `s` — UTF-8 input string.
373    /// * `wrap` — right-edge behavior for non-zero-width clusters.
374    /// * `style` — initial style for this call.
375    ///
376    /// # Returns
377    ///
378    /// The cursor position immediately after the last written cell, or where
379    /// painting stopped.
380    ///
381    /// # Errors and panics
382    ///
383    /// This method does not return errors and does not intentionally panic.
384    fn set_str_wrap(
385        &mut self,
386        pos: impl Into<Position>,
387        s: &str,
388        wrap: WrapMode,
389        style: impl Into<Style>,
390    ) -> Position {
391        let clip = self.target.bounds();
392        self.paint(pos.into(), clip, s, wrap, style.into())
393    }
394
395    /// Paint `s` into `rect`, clipped to `rect ∩ target.bounds()`.
396    ///
397    /// Painting starts at `rect`'s top-left. Newline and carriage return use
398    /// `rect`'s left edge as the return column. Right-edge behavior is
399    /// [`WrapMode::Truncate`].
400    ///
401    /// # Parameters
402    ///
403    /// * `rect` — origin and clipping rectangle.
404    /// * `s` — UTF-8 input string.
405    /// * `style` — initial style for this call.
406    ///
407    /// # Returns
408    ///
409    /// The cursor position immediately after the last written cell, or where
410    /// painting stopped.
411    ///
412    /// # Errors and panics
413    ///
414    /// This method does not return errors and does not intentionally panic.
415    fn set_str_rect(
416        &mut self,
417        rect: impl Into<Rect>,
418        s: &str,
419        style: impl Into<Style>,
420    ) -> Position {
421        let rect = rect.into();
422        let clip = rect.intersection(self.target.bounds());
423        self.paint(rect.position(), clip, s, WrapMode::default(), style.into())
424    }
425
426    /// Paint `s` into `rect` with explicit wrapping behavior.
427    ///
428    /// The clipping rectangle is `rect ∩ target.bounds()`. [`WrapMode::Wrap`]
429    /// flows down inside `rect`; [`WrapMode::Truncate`] stops at `rect`'s
430    /// right edge.
431    ///
432    /// # Parameters
433    ///
434    /// * `rect` — origin and clipping rectangle.
435    /// * `s` — UTF-8 input string.
436    /// * `wrap` — right-edge behavior for non-zero-width clusters.
437    /// * `style` — initial style for this call.
438    ///
439    /// # Returns
440    ///
441    /// The cursor position immediately after the last written cell, or where
442    /// painting stopped.
443    ///
444    /// # Errors and panics
445    ///
446    /// This method does not return errors and does not intentionally panic.
447    fn set_str_rect_wrap(
448        &mut self,
449        rect: impl Into<Rect>,
450        s: &str,
451        wrap: WrapMode,
452        style: impl Into<Style>,
453    ) -> Position {
454        let rect = rect.into();
455        let clip = rect.intersection(self.target.bounds());
456        self.paint(rect.position(), clip, s, wrap, style.into())
457    }
458
459    /// Paint `s` starting at `pos`, truncating with a `tail` indicator.
460    ///
461    /// Text is painted across the target bounds. When a non-zero-width cluster
462    /// would cross the right edge, painting stops and `tail` is stamped over
463    /// the trailing columns so it ends exactly at the right edge. The tail
464    /// appears only when the text actually overflows; text that fits is left
465    /// untouched.
466    ///
467    /// `tail` is painted with `tail_style` as its starting style and may carry
468    /// its own inline escape sequences, so it can be a single glyph (`"…"`), a
469    /// word (`" more"`), or a multi-style span. If the tail is wider than the
470    /// available space, it is dropped and the text is hard-truncated instead.
471    ///
472    /// # Parameters
473    ///
474    /// * `pos` — starting cell position.
475    /// * `s` — UTF-8 string to paint.
476    /// * `tail` — truncation indicator, painted when `s` overflows.
477    /// * `tail_style` — starting style for the tail.
478    ///
479    /// # Returns
480    ///
481    /// The cursor position immediately after the last written cell, or where
482    /// painting stopped.
483    ///
484    /// # Errors and panics
485    ///
486    /// This method does not return errors and does not intentionally panic.
487    fn set_str_truncate(
488        &mut self,
489        pos: impl Into<Position>,
490        s: &str,
491        tail: &str,
492        tail_style: impl Into<Style>,
493    ) -> Position {
494        let clip = self.target.bounds();
495        self.paint_truncate(
496            pos.into(),
497            clip,
498            s,
499            tail,
500            tail_style.into(),
501            Style::default(),
502        )
503    }
504
505    /// Paint `s` inside `rect`, truncating with a `tail` indicator.
506    ///
507    /// This is the rectangular form of
508    /// [`set_str_truncate`](Self::set_str_truncate): the clip rectangle is
509    /// `rect ∩ target.bounds()`, and the tail is stamped at `rect`'s right
510    /// edge when the text overflows it.
511    ///
512    /// # Parameters
513    ///
514    /// * `rect` — clipping rectangle and starting origin.
515    /// * `s` — UTF-8 string to paint.
516    /// * `tail` — truncation indicator, painted when `s` overflows.
517    /// * `tail_style` — starting style for the tail.
518    ///
519    /// # Returns
520    ///
521    /// The cursor position immediately after the last written cell, or where
522    /// painting stopped.
523    ///
524    /// # Errors and panics
525    ///
526    /// This method does not return errors and does not intentionally panic.
527    fn set_str_rect_truncate(
528        &mut self,
529        rect: impl Into<Rect>,
530        s: &str,
531        tail: &str,
532        tail_style: impl Into<Style>,
533    ) -> Position {
534        let rect = rect.into();
535        let clip = rect.intersection(self.target.bounds());
536        self.paint_truncate(
537            rect.position(),
538            clip,
539            s,
540            tail,
541            tail_style.into(),
542            Style::default(),
543        )
544    }
545}
546
547/// A truncation tail: borrowed indicator text, its starting style, and its
548/// measured cell width. `Copy` so the overflow branch can hand it to
549/// [`Painter::paint_tail`] without moving out of the `Option`.
550#[derive(Clone, Copy)]
551struct Tail<'a> {
552    text: &'a str,
553    style: &'a Style,
554    width: u16,
555}
556
557/// Return the body of a CSI sequence (between introducer and final byte).
558///
559/// Recognises both `\x1b[ … <final>` (7-bit) and `\x9b … <final>` (8-bit)
560/// forms where `<final>` is in `0x40..=0x7e`. Returns `None` for any
561/// other escape or for an incomplete sequence missing its final byte.
562fn csi_body(seq: &[u8]) -> Option<&[u8]> {
563    let body_start = if seq.len() >= 2 && seq[0] == 0x1b && seq[1] == b'[' {
564        2
565    } else if !seq.is_empty() && seq[0] == 0x9b {
566        1
567    } else {
568        return None;
569    };
570    let last = *seq.last()?;
571    if !(0x40..=0x7e).contains(&last) || seq.len() <= body_start {
572        return None;
573    }
574    Some(&seq[body_start..seq.len() - 1])
575}
576
577/// Return the body of an OSC sequence (between introducer and string
578/// terminator). Recognises `\x1b] … (BEL | ESC \\ | 0x9c)?` (7-bit) and
579/// `\x9d … (BEL | 0x9c | ESC \\)?` (8-bit) forms. An incomplete sequence
580/// missing its terminator still returns its content; a non-OSC sequence
581/// returns `None`.
582fn osc_body(seq: &[u8]) -> Option<&[u8]> {
583    let body_start = if seq.len() >= 2 && seq[0] == 0x1b && seq[1] == b']' {
584        2
585    } else if !seq.is_empty() && seq[0] == 0x9d {
586        1
587    } else {
588        return None;
589    };
590    if seq.len() <= body_start {
591        return Some(&[]);
592    }
593    let end = if seq.ends_with(b"\x1b\\") {
594        seq.len() - 2
595    } else if matches!(seq.last(), Some(0x07 | 0x9c)) {
596        seq.len() - 1
597    } else {
598        seq.len()
599    };
600    if end < body_start {
601        return Some(&[]);
602    }
603    Some(&seq[body_start..end])
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::buffer::{Surface, TextBuffer};
610    use crate::color::Color;
611    use crate::style::AttrFlags;
612
613    fn buf(width: u16, height: u16) -> TextBuffer {
614        TextBuffer::new(width, height)
615    }
616
617    fn cell_at(b: &TextBuffer, x: u16, y: u16) -> Cell {
618        b.cell(Position::new(x, y)).cloned().unwrap()
619    }
620
621    fn link_of(s: &crate::style::Style) -> Option<(&str, &str)> {
622        s.link
623            .as_deref()
624            .map(|l| (l.url.as_str(), l.params.as_str()))
625    }
626
627    #[test]
628    fn plain_text() {
629        let mut b = buf(10, 1);
630        let end =
631            Painter::new(&mut b).set_str_wrap((0, 0), "abc", WrapMode::Truncate, Style::default());
632        assert_eq!(end, Position::new(3, 0));
633        assert_eq!(cell_at(&b, 0, 0).content(), "a");
634        assert_eq!(cell_at(&b, 2, 0).content(), "c");
635    }
636
637    #[test]
638    fn sgr_updates_style_mid_stream() {
639        let mut b = buf(10, 1);
640        let mut p = Painter::new(&mut b);
641        let end = p.set_str_wrap(
642            (0, 0),
643            "a\x1b[1mb\x1b[mc",
644            WrapMode::Truncate,
645            Style::default(),
646        );
647        assert_eq!(end, Position::new(3, 0));
648        let c0 = cell_at(&b, 0, 0);
649        let c1 = cell_at(&b, 1, 0);
650        let c2 = cell_at(&b, 2, 0);
651        assert!(!c0.style.attrs.contains(AttrFlags::BOLD));
652        assert!(c1.style.attrs.contains(AttrFlags::BOLD));
653        assert!(!c2.style.attrs.contains(AttrFlags::BOLD));
654    }
655
656    #[test]
657    fn sgr_color() {
658        let mut b = buf(5, 1);
659        Painter::new(&mut b).set_str_wrap(
660            (0, 0),
661            "\x1b[31mr",
662            WrapMode::Truncate,
663            Style::default(),
664        );
665        assert_eq!(cell_at(&b, 0, 0).style.fg, Some(Color::Red));
666    }
667
668    #[test]
669    fn osc8_toggles_link() {
670        let mut b = buf(10, 1);
671        Painter::new(&mut b).set_str_wrap(
672            (0, 0),
673            "\x1b]8;;https://x\x1b\\a\x1b]8;;\x1b\\b",
674            WrapMode::Truncate,
675            Style::default(),
676        );
677        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
678        assert!(cell_at(&b, 1, 0).style.link.is_none());
679    }
680
681    #[test]
682    fn osc8_malformed_ignored() {
683        // Missing the second `;` -> not a valid OSC 8; should not affect
684        // the currently active link.
685        let mut b = buf(10, 1);
686        let mut p = Painter::new(&mut b);
687        p.set_str_wrap(
688            (0, 0),
689            "\x1b]8;;https://x\x1b\\a\x1b]8;garbage\x1b\\b",
690            WrapMode::Truncate,
691            Style::default(),
692        );
693        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
694        assert_eq!(link_of(&cell_at(&b, 1, 0).style), Some(("https://x", "")));
695    }
696
697    #[test]
698    fn newline_advances_row() {
699        let mut b = buf(5, 3);
700        let end = Painter::new(&mut b).set_str_wrap(
701            (0, 0),
702            "ab\ncd",
703            WrapMode::Truncate,
704            Style::default(),
705        );
706        assert_eq!(cell_at(&b, 0, 0).content(), "a");
707        assert_eq!(cell_at(&b, 1, 0).content(), "b");
708        assert_eq!(cell_at(&b, 0, 1).content(), "c");
709        assert_eq!(cell_at(&b, 1, 1).content(), "d");
710        assert_eq!(end, Position::new(2, 1));
711    }
712
713    #[test]
714    fn cr_returns_to_left() {
715        let mut b = buf(5, 1);
716        Painter::new(&mut b).set_str_wrap((0, 0), "abc\rXY", WrapMode::Truncate, Style::default());
717        // 'X' overwrites 'a', 'Y' overwrites 'b', 'c' remains.
718        assert_eq!(cell_at(&b, 0, 0).content(), "X");
719        assert_eq!(cell_at(&b, 1, 0).content(), "Y");
720        assert_eq!(cell_at(&b, 2, 0).content(), "c");
721    }
722
723    #[test]
724    fn newline_past_bottom_returns() {
725        let mut b = buf(5, 2);
726        let end = Painter::new(&mut b).set_str_wrap(
727            (0, 0),
728            "a\nb\nc",
729            WrapMode::Truncate,
730            Style::default(),
731        );
732        assert_eq!(end, Position::new(0, 2));
733        assert_eq!(cell_at(&b, 0, 0).content(), "a");
734        assert_eq!(cell_at(&b, 0, 1).content(), "b");
735        // Row 2 is out of bounds; "c" never lands.
736    }
737
738    #[test]
739    fn truncate_at_right_edge() {
740        let mut b = buf(3, 1);
741        let end = Painter::new(&mut b).set_str_wrap(
742            (0, 0),
743            "abcdef",
744            WrapMode::Truncate,
745            Style::default(),
746        );
747        assert_eq!(end, Position::new(3, 0));
748        assert_eq!(cell_at(&b, 0, 0).content(), "a");
749        assert_eq!(cell_at(&b, 2, 0).content(), "c");
750    }
751
752    #[test]
753    fn wrap_at_right_edge() {
754        let mut b = buf(3, 3);
755        let end =
756            Painter::new(&mut b).set_str_wrap((0, 0), "abcdef", WrapMode::Wrap, Style::default());
757        assert_eq!(end, Position::new(3, 1));
758        assert_eq!(cell_at(&b, 0, 0).content(), "a");
759        assert_eq!(cell_at(&b, 2, 0).content(), "c");
760        assert_eq!(cell_at(&b, 0, 1).content(), "d");
761        assert_eq!(cell_at(&b, 2, 1).content(), "f");
762    }
763
764    #[test]
765    fn rect_clip_and_origin() {
766        let mut b = buf(10, 5);
767        let end = Painter::new(&mut b).set_str_rect_wrap(
768            Rect::new(2, 1, 3, 2),
769            "abcdef",
770            WrapMode::Wrap,
771            Style::default(),
772        );
773        assert_eq!(end, Position::new(5, 2));
774        assert_eq!(cell_at(&b, 2, 1).content(), "a");
775        assert_eq!(cell_at(&b, 4, 1).content(), "c");
776        assert_eq!(cell_at(&b, 2, 2).content(), "d");
777        assert_eq!(cell_at(&b, 4, 2).content(), "f");
778        // Outside the rect must remain blank.
779        assert_eq!(cell_at(&b, 0, 0).content(), " ");
780        assert_eq!(cell_at(&b, 5, 1).content(), " ");
781    }
782
783    #[test]
784    fn rect_newline_uses_rect_left() {
785        let mut b = buf(10, 5);
786        Painter::new(&mut b).set_str_rect_wrap(
787            Rect::new(2, 1, 4, 3),
788            "ab\ncd",
789            WrapMode::Truncate,
790            Style::default(),
791        );
792        assert_eq!(cell_at(&b, 2, 1).content(), "a");
793        assert_eq!(cell_at(&b, 3, 1).content(), "b");
794        // Newline returns x to rect.left() = 2, not to 0.
795        assert_eq!(cell_at(&b, 2, 2).content(), "c");
796        assert_eq!(cell_at(&b, 3, 2).content(), "d");
797        assert_eq!(cell_at(&b, 0, 2).content(), " ");
798    }
799
800    #[test]
801    fn with_resets_style_and_link() {
802        let mut b = buf(10, 1);
803        // First call: paint with bold + a link.
804        Painter::new(&mut b).set_str_wrap(
805            (0, 0),
806            "a",
807            WrapMode::Truncate,
808            Style::default().bold().link("https://x", ""),
809        );
810        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
811        assert_eq!(link_of(&cell_at(&b, 0, 0).style), Some(("https://x", "")));
812        // Second call with `_with` and an empty style must reset.
813        Painter::new(&mut b).set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
814        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
815        assert!(cell_at(&b, 1, 0).style.link.is_none());
816    }
817
818    #[test]
819    fn calls_start_from_their_own_base() {
820        let mut b = buf(10, 1);
821        let mut p = Painter::new(&mut b);
822        // Inline SGR bolds within the first call only.
823        p.set_str_wrap((0, 0), "\x1b[1ma", WrapMode::Truncate, Style::default());
824        // The next call starts fresh from its base: no bold carries over.
825        p.set_str_wrap((1, 0), "b", WrapMode::Truncate, Style::default());
826        assert!(cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
827        assert!(!cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
828    }
829
830    #[test]
831    fn base_applies_and_inline_reset_returns_to_base() {
832        let mut b = buf(10, 1);
833        let base = Style::default().fg(Color::Red);
834        // "a" gets the base red; the inline bold adds to "b"; the inline reset
835        // clears only the inline state, so "c" falls back to the base red
836        // rather than to a fully default style.
837        Painter::new(&mut b).set_str_wrap((0, 0), "a\x1b[1mb\x1b[0mc", WrapMode::Truncate, base);
838        let red = Some(Color::Red);
839        assert_eq!(cell_at(&b, 0, 0).style.fg, red);
840        assert!(!cell_at(&b, 0, 0).style.attrs.contains(AttrFlags::BOLD));
841        assert_eq!(cell_at(&b, 1, 0).style.fg, red);
842        assert!(cell_at(&b, 1, 0).style.attrs.contains(AttrFlags::BOLD));
843        assert_eq!(cell_at(&b, 2, 0).style.fg, red);
844        assert!(!cell_at(&b, 2, 0).style.attrs.contains(AttrFlags::BOLD));
845    }
846
847    #[test]
848    fn position_and_rect_match_when_rect_covers_bounds() {
849        let mut a = buf(5, 2);
850        let mut b = buf(5, 2);
851        let e1 =
852            Painter::new(&mut a).set_str_wrap((0, 0), "abc", WrapMode::Truncate, Style::default());
853        let e2 = Painter::new(&mut b).set_str_rect_wrap(
854            Rect::new(0, 0, 5, 2),
855            "abc",
856            WrapMode::Truncate,
857            Style::default(),
858        );
859        assert_eq!(e1, e2);
860        assert_eq!(cell_at(&a, 2, 0).content(), cell_at(&b, 2, 0).content());
861    }
862
863    fn row(b: &TextBuffer, y: u16) -> String {
864        (0..b.width())
865            .map(|x| cell_at(b, x, y).content().to_string())
866            .collect()
867    }
868
869    #[test]
870    fn truncate_tail_not_shown_when_text_fits() {
871        let mut b = buf(5, 1);
872        let end = Painter::new(&mut b).set_str_truncate((0, 0), "abc", "…", Style::default());
873        // "abc" fits in 5 columns, so no tail is stamped.
874        assert_eq!(row(&b, 0), "abc  ");
875        assert_eq!(end, Position::new(3, 0));
876    }
877
878    #[test]
879    fn truncate_single_cell_tail_on_overflow() {
880        let mut b = buf(5, 1);
881        let end = Painter::new(&mut b).set_str_truncate((0, 0), "abcdefgh", "…", Style::default());
882        // 5 columns: 4 text columns + the 1-wide tail at the right edge.
883        assert_eq!(row(&b, 0), "abcd…");
884        assert_eq!(end, Position::new(5, 0));
885    }
886
887    #[test]
888    fn truncate_multi_cell_tail_reserves_its_width() {
889        let mut b = buf(8, 1);
890        Painter::new(&mut b).set_str_truncate((0, 0), "abcdefghij", " more", Style::default());
891        // 8 columns: 3 text columns + the 5-wide " more" tail.
892        assert_eq!(row(&b, 0), "abc more");
893    }
894
895    #[test]
896    fn truncate_tail_carries_its_style() {
897        let mut b = buf(5, 1);
898        Painter::new(&mut b).set_str_truncate(
899            (0, 0),
900            "abcdefgh",
901            "…",
902            Style::default().fg(Color::Red),
903        );
904        // The tail cell gets the supplied base style.
905        assert_eq!(cell_at(&b, 4, 0).style.fg, Some(Color::Red));
906    }
907
908    #[test]
909    fn truncate_tail_inline_escapes_apply() {
910        let mut b = buf(5, 1);
911        Painter::new(&mut b).set_str_truncate((0, 0), "abcdefgh", "\x1b[1m…", Style::default());
912        // The tail's inline SGR bolds it even though the base style is empty.
913        assert!(cell_at(&b, 4, 0).style.attrs.contains(AttrFlags::BOLD));
914    }
915
916    #[test]
917    fn truncate_wide_tail_too_big_hard_truncates() {
918        let mut b = buf(3, 1);
919        let end =
920            Painter::new(&mut b).set_str_truncate((0, 0), "abcdef", " more", Style::default());
921        // " more" is 5 wide but the clip is only 3, so it is dropped and the
922        // text is hard-truncated with no tail.
923        assert_eq!(row(&b, 0), "abc");
924        assert_eq!(end, Position::new(3, 0));
925    }
926
927    #[test]
928    fn truncate_tail_overwrites_split_wide_cell() {
929        // A wide cluster sits where the tail's left edge lands; stamping the
930        // tail must blank the dangling wide primary.
931        let mut b = buf(5, 1);
932        Painter::new(&mut b).set_str_truncate((0, 0), "ab中def", "…", Style::default());
933        // "ab" + wide "中" fills columns 0..4; the tail overwrites column 4,
934        // which is the continuation of "中", so the wide primary at 3 must be
935        // blanked rather than left dangling.
936        assert_eq!(cell_at(&b, 4, 0).content(), "…");
937        assert!(!cell_at(&b, 3, 0).is_wide());
938    }
939}