Skip to main content

uncurses/text/
surface.rs

1//! [`TextSurface`] — text drawing as an extension of any mutable surface.
2//!
3//! [`SurfaceMut`](crate::buffer::SurfaceMut) reads and writes cells; it does
4//! not decide how many cells a string occupies. `TextSurface` adds that policy
5//! layer. Implementors provide a [width mode](TextSurface::width_mode) and
6//! [East-Asian Ambiguous policy](TextSurface::eaw_wide); the default `set_str`
7//! family segments the input into grapheme clusters and paints each with the
8//! given style.
9//!
10//! The default methods paint text **literally**: inline SGR (`CSI … m`) and
11//! OSC 8 hyperlink sequences are not interpreted, so they are segmented and
12//! drawn like any other text. Reach for [`Painter`](super::Painter) — itself a
13//! `TextSurface` — when a string should be parsed for inline style and
14//! hyperlink escapes. Newline and carriage return still move the paint cursor
15//! within the active clip rectangle.
16//!
17//! Use this trait when writing widgets, layout helpers, or tests that should
18//! accept any text-capable destination:
19//!
20//! ```rust,ignore
21//! fn label(surface: &mut impl TextSurface, at: Position, text: &str, style: Style) {
22//!     surface.set_str(at, text, style);
23//! }
24//! ```
25
26use crate::buffer::SurfaceMut;
27use crate::cell::Cell;
28use crate::layout::{Position, Rect, overruns};
29use crate::style::Style;
30
31use super::{WidthMode, WrapMode, grapheme_cells};
32
33/// A [`SurfaceMut`] with a text-measurement policy and string-painting helpers.
34///
35/// Implement this for surface types that can accept styled text. The only
36/// required decisions are [`width_mode`](Self::width_mode), which controls how
37/// grapheme clusters are measured, and [`eaw_wide`](Self::eaw_wide), which
38/// controls East-Asian Ambiguous code points.
39///
40/// The default `set_str` family paints text literally: each grapheme cluster is
41/// drawn with the given style and inline SGR / OSC 8 escapes are not
42/// interpreted. Use [`Painter`](super::Painter), which is itself a
43/// `TextSurface`, to parse inline style and hyperlink escapes. Newline and
44/// carriage return move the paint cursor within the active clip rectangle.
45pub trait TextSurface: SurfaceMut {
46    /// Return the width-measurement mode used when shaping strings.
47    ///
48    /// [`WidthMode::Wc`] sums the code points of each grapheme cluster;
49    /// [`WidthMode::Grapheme`] measures the whole cluster. The selected mode is
50    /// used by the `set_str` family and [`str_width`](Self::str_width).
51    ///
52    /// This method is pure policy lookup. It should not inspect or mutate the
53    /// surface contents.
54    fn width_mode(&self) -> WidthMode;
55
56    /// Return the East-Asian Ambiguous width policy for this surface.
57    ///
58    /// When `true`, code points whose East-Asian-Width property is
59    /// `Ambiguous` are measured as two cells. When `false`, they are measured
60    /// as one. The flag is passed to [`char_width`](super::char_width) and
61    /// [`grapheme_width`](super::grapheme_width) by all text operations.
62    fn eaw_wide(&self) -> bool;
63
64    /// Paint `s` at `pos`, clipped to the surface bounds.
65    ///
66    /// Every cell painted gets `style`. This default implementation paints
67    /// *literally*: escape sequences in `s` are drawn as visible characters,
68    /// not interpreted. For SGR/OSC 8-aware painting that turns inline escapes
69    /// into styling, wrap the surface in a [`Painter`](super::Painter). Newline
70    /// moves to the next row at the surface's left edge; carriage return moves
71    /// to that left edge on the current row. If a non-zero-width grapheme
72    /// cluster would cross the right edge, the rest of that row is dropped and
73    /// painting resumes on the next row.
74    ///
75    /// # Parameters
76    ///
77    /// * `pos` — starting cell position.
78    /// * `s` — UTF-8 string to paint. Escape sequences are drawn literally.
79    /// * `style` — style applied to every cell painted in this call.
80    ///
81    /// # Returns
82    ///
83    /// The cursor position immediately after the last written cell, or the
84    /// position where painting stopped. The returned position may be outside
85    /// the surface when input reaches the bottom edge.
86    ///
87    /// # Errors and panics
88    ///
89    /// This method does not return errors and does not intentionally panic.
90    ///
91    /// # Usage note
92    ///
93    /// Use [`set_str_wrap`](Self::set_str_wrap) when text should continue on
94    /// following rows instead of truncating at the right edge.
95    fn set_str(&mut self, pos: impl Into<Position>, s: &str, style: impl Into<Style>) -> Position {
96        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
97        let clip = self.bounds();
98        paint_literal(
99            self,
100            pos.into(),
101            clip,
102            s,
103            WrapMode::Truncate,
104            mode,
105            eaw,
106            &style.into(),
107        )
108    }
109
110    /// Paint `s` at `pos` with an explicit right-edge behavior.
111    ///
112    /// This is the same operation as [`set_str`](Self::set_str), except
113    /// `wrap` decides what happens when a non-zero-width grapheme cluster would
114    /// cross the surface's right edge: [`WrapMode::Truncate`] stops, and
115    /// [`WrapMode::Wrap`] continues at the left edge of the next row until the
116    /// bottom edge is reached.
117    ///
118    /// # Parameters
119    ///
120    /// * `pos` — starting cell position.
121    /// * `s` — UTF-8 string to paint.
122    /// * `wrap` — wrapping policy at the right edge.
123    /// * `style` — initial style for painted cells.
124    ///
125    /// # Returns
126    ///
127    /// The cursor position immediately after the last written cell, or where
128    /// painting stopped.
129    ///
130    /// # Errors and panics
131    ///
132    /// This method does not return errors and does not intentionally panic.
133    fn set_str_wrap(
134        &mut self,
135        pos: impl Into<Position>,
136        s: &str,
137        wrap: WrapMode,
138        style: impl Into<Style>,
139    ) -> Position {
140        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
141        let clip = self.bounds();
142        paint_literal(self, pos.into(), clip, s, wrap, mode, eaw, &style.into())
143    }
144
145    /// Paint `s` inside `rect`, clipped to the surface bounds.
146    ///
147    /// Painting starts at `rect`'s top-left corner and is clipped to
148    /// `rect ∩ self.bounds()`. Newline and carriage return use `rect`'s left
149    /// edge as the return column. If a non-zero-width grapheme cluster would
150    /// cross `rect`'s right edge, the rest of that row is dropped and painting
151    /// resumes on the next row.
152    ///
153    /// # Parameters
154    ///
155    /// * `rect` — clipping rectangle and starting origin.
156    /// * `s` — UTF-8 string to paint.
157    /// * `style` — initial style for painted cells.
158    ///
159    /// # Returns
160    ///
161    /// The cursor position immediately after the last written cell, or where
162    /// painting stopped.
163    ///
164    /// # Errors and panics
165    ///
166    /// This method does not return errors and does not intentionally panic.
167    fn set_str_rect(
168        &mut self,
169        rect: impl Into<Rect>,
170        s: &str,
171        style: impl Into<Style>,
172    ) -> Position {
173        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
174        let rect = rect.into();
175        let clip = rect.intersection(self.bounds());
176        paint_literal(
177            self,
178            rect.position(),
179            clip,
180            s,
181            WrapMode::Truncate,
182            mode,
183            eaw,
184            &style.into(),
185        )
186    }
187
188    /// Paint `s` inside `rect` with an explicit right-edge behavior.
189    ///
190    /// This is the rectangular form of [`set_str_wrap`](Self::set_str_wrap).
191    /// [`WrapMode::Wrap`] flows to the next row at `rect`'s left edge;
192    /// [`WrapMode::Truncate`] stops at `rect`'s right edge.
193    ///
194    /// # Parameters
195    ///
196    /// * `rect` — clipping rectangle and starting origin.
197    /// * `s` — UTF-8 string to paint.
198    /// * `wrap` — wrapping policy at the rectangle's right edge.
199    /// * `style` — initial style for painted cells.
200    ///
201    /// # Returns
202    ///
203    /// The cursor position immediately after the last written cell, or where
204    /// painting stopped.
205    ///
206    /// # Errors and panics
207    ///
208    /// This method does not return errors and does not intentionally panic.
209    fn set_str_rect_wrap(
210        &mut self,
211        rect: impl Into<Rect>,
212        s: &str,
213        wrap: WrapMode,
214        style: impl Into<Style>,
215    ) -> Position {
216        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
217        let rect = rect.into();
218        let clip = rect.intersection(self.bounds());
219        paint_literal(
220            self,
221            rect.position(),
222            clip,
223            s,
224            wrap,
225            mode,
226            eaw,
227            &style.into(),
228        )
229    }
230
231    /// Paint `s` at `pos`, truncating with a `tail` indicator on overflow.
232    ///
233    /// When a cluster would cross the surface's right edge, the rest of that
234    /// row is dropped and `tail` is stamped over its trailing columns, ending
235    /// at the right edge. Painting resumes on the next row if `s` continues
236    /// past a newline, so a multi-line `s` can stamp one tail per overflowing
237    /// row. The tail appears only on rows that actually overflow. `tail` is
238    /// painted literally with `tail_style`; `s` uses `s_style`. Use
239    /// [`Painter`](super::Painter) to interpret inline escapes in either
240    /// string. A tail wider than the surface is dropped in favor of a hard
241    /// truncate.
242    ///
243    /// # Parameters
244    ///
245    /// * `pos` — starting cell position.
246    /// * `s` — UTF-8 string to paint.
247    /// * `s_style` — style for the main text.
248    /// * `tail` — truncation indicator, painted when `s` overflows.
249    /// * `tail_style` — starting style for the tail.
250    ///
251    /// # Returns
252    ///
253    /// The cursor position immediately after the last written cell, or where
254    /// painting stopped.
255    ///
256    /// # Errors and panics
257    ///
258    /// This method does not return errors and does not intentionally panic.
259    fn set_str_truncate(
260        &mut self,
261        pos: impl Into<Position>,
262        s: &str,
263        s_style: impl Into<Style>,
264        tail: &str,
265        tail_style: impl Into<Style>,
266    ) -> Position {
267        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
268        let clip = self.bounds();
269        paint_literal_truncate(
270            self,
271            pos.into(),
272            clip,
273            s,
274            &s_style.into(),
275            tail,
276            &tail_style.into(),
277            mode,
278            eaw,
279        )
280    }
281
282    /// Paint `s` inside `rect`, truncating with a `tail` indicator on overflow.
283    ///
284    /// This is the rectangular form of [`set_str_truncate`](Self::set_str_truncate):
285    /// the clip rectangle is `rect ∩ self.bounds()`, and a tail is stamped at
286    /// `rect`'s right edge on each row that overflows it.
287    ///
288    /// # Parameters
289    ///
290    /// * `rect` — clipping rectangle and starting origin.
291    /// * `s` — UTF-8 string to paint.
292    /// * `s_style` — style for the main text.
293    /// * `tail` — truncation indicator, painted when `s` overflows.
294    /// * `tail_style` — starting style for the tail.
295    ///
296    /// # Returns
297    ///
298    /// The cursor position immediately after the last written cell, or where
299    /// painting stopped.
300    ///
301    /// # Errors and panics
302    ///
303    /// This method does not return errors and does not intentionally panic.
304    fn set_str_rect_truncate(
305        &mut self,
306        rect: impl Into<Rect>,
307        s: &str,
308        s_style: impl Into<Style>,
309        tail: &str,
310        tail_style: impl Into<Style>,
311    ) -> Position {
312        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
313        let rect = rect.into();
314        let clip = rect.intersection(self.bounds());
315        paint_literal_truncate(
316            self,
317            rect.position(),
318            clip,
319            s,
320            &s_style.into(),
321            tail,
322            &tail_style.into(),
323            mode,
324            eaw,
325        )
326    }
327
328    /// Measure the display width of `s` in terminal columns.
329    ///
330    /// The measurement segments `s` into grapheme clusters under this
331    /// surface's [`width_mode`](Self::width_mode) and
332    /// [`eaw_wide`](Self::eaw_wide) policy and sums their widths. Like the
333    /// default `set_str` family, this does **not** interpret inline escape
334    /// sequences: an SGR or OSC 8 sequence in `s` is measured as the width of
335    /// its visible bytes. Use [`Painter`](super::Painter), whose `str_width`
336    /// skips recognized escapes, to measure escape-bearing text.
337    ///
338    /// # Parameters
339    ///
340    /// * `s` — string to measure.
341    ///
342    /// # Returns
343    ///
344    /// The display width in cells, saturated at `u16::MAX`.
345    ///
346    /// # Errors and panics
347    ///
348    /// This method does not fail or intentionally panic.
349    fn str_width(&self, s: &str) -> u16 {
350        self.grapheme_cells(s)
351            .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)))
352    }
353
354    /// Measure one extended grapheme cluster in cells under this surface's
355    /// width mode and East-Asian Ambiguous policy.
356    ///
357    /// # Parameters
358    ///
359    /// * `g` — a single grapheme cluster.
360    ///
361    /// # Returns
362    ///
363    /// The cluster width in cells, normally `0`, `1`, or `2`.
364    ///
365    /// # Errors and panics
366    ///
367    /// This method does not fail or intentionally panic.
368    fn grapheme_width(&self, g: &str) -> u8 {
369        self.width_mode().grapheme_width(g, self.eaw_wide())
370    }
371
372    /// Iterate `s` as `(cluster, width)` pairs under this surface's width mode
373    /// and East-Asian Ambiguous policy.
374    ///
375    /// # Parameters
376    ///
377    /// * `s` — UTF-8 string to segment and measure.
378    ///
379    /// # Returns
380    ///
381    /// An iterator yielding borrowed cluster slices and their cell widths.
382    ///
383    /// # Errors and panics
384    ///
385    /// This method does not fail or intentionally panic.
386    fn grapheme_cells<'a>(&self, s: &'a str) -> impl Iterator<Item = (&'a str, u8)> {
387        grapheme_cells(s, self.width_mode(), self.eaw_wide())
388    }
389}
390
391/// A literal truncation tail: indicator text, its starting style, and its
392/// measured cell width.
393struct LiteralTail<'a> {
394    text: &'a str,
395    style: &'a Style,
396    width: u16,
397}
398
399/// Paint `s` literally, clipped to `clip`, with `wrap` behavior at the right
400/// edge. Each grapheme cluster is drawn with `style`; inline escapes are not
401/// interpreted (they are segmented and drawn like any other text). Newline and
402/// carriage return reposition within `clip`.
403#[allow(clippy::too_many_arguments)]
404fn paint_literal<S: SurfaceMut + ?Sized>(
405    target: &mut S,
406    start: Position,
407    clip: Rect,
408    s: &str,
409    wrap: WrapMode,
410    mode: WidthMode,
411    eaw_wide: bool,
412    style: &Style,
413) -> Position {
414    paint_literal_inner(target, start, clip, s, wrap, mode, eaw_wide, style, None)
415}
416
417/// Paint `s` literally with [`WrapMode::Truncate`], stamping `tail` on overflow.
418///
419/// The main text is drawn with `s_style`; the tail is drawn with
420/// `tail_style`. The tail is dropped (hard truncate) when it is empty or wider
421/// than the clip.
422#[allow(clippy::too_many_arguments)]
423fn paint_literal_truncate<S: SurfaceMut + ?Sized>(
424    target: &mut S,
425    start: Position,
426    clip: Rect,
427    s: &str,
428    s_style: &Style,
429    tail_text: &str,
430    tail_style: &Style,
431    mode: WidthMode,
432    eaw_wide: bool,
433) -> Position {
434    if clip.is_empty() {
435        return start;
436    }
437    let tail_w = grapheme_cells(tail_text, mode, eaw_wide)
438        .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)));
439    let tail = if tail_w == 0 || tail_w > clip.width {
440        None
441    } else {
442        Some(LiteralTail {
443            text: tail_text,
444            style: tail_style,
445            width: tail_w,
446        })
447    };
448    paint_literal_inner(
449        target,
450        start,
451        clip,
452        s,
453        WrapMode::Truncate,
454        mode,
455        eaw_wide,
456        s_style,
457        tail,
458    )
459}
460
461#[allow(clippy::too_many_arguments)]
462fn paint_literal_inner<S: SurfaceMut + ?Sized>(
463    target: &mut S,
464    start: Position,
465    clip: Rect,
466    s: &str,
467    wrap: WrapMode,
468    mode: WidthMode,
469    eaw_wide: bool,
470    style: &Style,
471    tail: Option<LiteralTail<'_>>,
472) -> Position {
473    if clip.is_empty() {
474        return start;
475    }
476    // `y` only ever advances, so a start below the clip can never paint.
477    if start.y >= clip.bottom() {
478        return start;
479    }
480    let mut x = start.x;
481    let mut y = start.y;
482    // Truncation is per row: once a row overflows, clusters are dropped until
483    // `\n` or `\r` puts the cursor back inside the clip.
484    let mut truncated = false;
485
486    for (cluster, w) in grapheme_cells(s, mode, eaw_wide) {
487        // Extended grapheme segmentation joins CR LF into a single cluster, so
488        // it has to be matched alongside a lone `\n` or it reads as zero-width
489        // filler and never breaks the line.
490        if cluster == "\n" || cluster == "\r\n" {
491            y = y.saturating_add(1);
492            x = clip.left();
493            truncated = false;
494            if y >= clip.bottom() {
495                return Position::new(x, y);
496            }
497            continue;
498        }
499        if cluster == "\r" {
500            x = clip.left();
501            truncated = false;
502            continue;
503        }
504        if truncated || w == 0 {
505            continue;
506        }
507        let cw = w;
508        let w = u16::from(w);
509        if overruns(x, w, clip.right()) {
510            match wrap {
511                WrapMode::Truncate => {
512                    if let Some(t) = &tail {
513                        stamp_literal_tail(target, t, clip, y, mode, eaw_wide);
514                        x = clip.right();
515                    }
516                    truncated = true;
517                    continue;
518                }
519                WrapMode::Wrap => {
520                    y = y.saturating_add(1);
521                    x = clip.left();
522                    if y >= clip.bottom() {
523                        return Position::new(x, y);
524                    }
525                    if overruns(x, w, clip.right()) {
526                        return Position::new(x, y);
527                    }
528                }
529            }
530        }
531        if clip.contains(Position::new(x, y)) {
532            let cell = Cell::new(cluster, cw);
533            target.set_cell(Position::new(x, y), &cell.style(style.clone()));
534        }
535        x += w;
536    }
537    Position::new(x, y)
538}
539
540/// Stamp `tail` over the trailing `tail.width` columns of row `y`, ending at
541/// `clip`'s right edge, painted literally with the tail's style.
542fn stamp_literal_tail<S: SurfaceMut + ?Sized>(
543    target: &mut S,
544    tail: &LiteralTail<'_>,
545    clip: Rect,
546    y: u16,
547    mode: WidthMode,
548    eaw_wide: bool,
549) {
550    let tail_x = clip.right().saturating_sub(tail.width);
551    let sub = Rect::new(tail_x, y, tail.width, 1).intersection(clip);
552    paint_literal_inner(
553        target,
554        Position::new(tail_x, y),
555        sub,
556        tail.text,
557        WrapMode::Truncate,
558        mode,
559        eaw_wide,
560        tail.style,
561        None,
562    );
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::buffer::TextBuffer;
569    use crate::style::Style;
570
571    /// `right()` is exclusive and saturates at `u16::MAX`, so a row that ends
572    /// at the top of the address space can leave `x` equal to it. Adding the
573    /// next cluster's width there overflowed and panicked.
574    #[test]
575    fn a_row_ending_at_the_last_addressable_column_truncates_instead_of_panicking() {
576        let mut buf = TextBuffer::new(u16::MAX, 1);
577        let s: String = "a".repeat(usize::from(u16::MAX) + 8);
578        buf.set_str((0, 0), &s, Style::default());
579
580        // A cluster that claims many columns has a wider window in which to
581        // overflow, so check it lands on the same truncating path.
582        let family = "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}";
583        let mut wide = TextBuffer::new(u16::MAX, 1);
584        wide.set_str(
585            (0, 0),
586            &"a".repeat(usize::from(u16::MAX) - 2),
587            Style::default(),
588        );
589        wide.set_str((u16::MAX - 2, 0), family, Style::default());
590    }
591}