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}