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};
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`] uses the first code point 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 with `tail_style` and may carry inline escape sequences, so it
239    /// can be a single glyph (`"…"`), a word (`" more"`), or a multi-style
240    /// span. 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    /// * `tail` — truncation indicator, painted when `s` overflows.
248    /// * `tail_style` — starting style for the tail.
249    ///
250    /// # Returns
251    ///
252    /// The cursor position immediately after the last written cell, or where
253    /// painting stopped.
254    ///
255    /// # Errors and panics
256    ///
257    /// This method does not return errors and does not intentionally panic.
258    fn set_str_truncate(
259        &mut self,
260        pos: impl Into<Position>,
261        s: &str,
262        tail: &str,
263        tail_style: impl Into<Style>,
264    ) -> Position {
265        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
266        let clip = self.bounds();
267        paint_literal_truncate(
268            self,
269            pos.into(),
270            clip,
271            s,
272            tail,
273            &tail_style.into(),
274            mode,
275            eaw,
276        )
277    }
278
279    /// Paint `s` inside `rect`, truncating with a `tail` indicator on overflow.
280    ///
281    /// This is the rectangular form of [`set_str_truncate`](Self::set_str_truncate):
282    /// the clip rectangle is `rect ∩ self.bounds()`, and a tail is stamped at
283    /// `rect`'s right edge on each row that overflows it.
284    ///
285    /// # Parameters
286    ///
287    /// * `rect` — clipping rectangle and starting origin.
288    /// * `s` — UTF-8 string to paint.
289    /// * `tail` — truncation indicator, painted when `s` overflows.
290    /// * `tail_style` — starting style for the tail.
291    ///
292    /// # Returns
293    ///
294    /// The cursor position immediately after the last written cell, or where
295    /// painting stopped.
296    ///
297    /// # Errors and panics
298    ///
299    /// This method does not return errors and does not intentionally panic.
300    fn set_str_rect_truncate(
301        &mut self,
302        rect: impl Into<Rect>,
303        s: &str,
304        tail: &str,
305        tail_style: impl Into<Style>,
306    ) -> Position {
307        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
308        let rect = rect.into();
309        let clip = rect.intersection(self.bounds());
310        paint_literal_truncate(
311            self,
312            rect.position(),
313            clip,
314            s,
315            tail,
316            &tail_style.into(),
317            mode,
318            eaw,
319        )
320    }
321
322    /// Measure the display width of `s` in terminal columns.
323    ///
324    /// The measurement segments `s` into grapheme clusters under this
325    /// surface's [`width_mode`](Self::width_mode) and
326    /// [`eaw_wide`](Self::eaw_wide) policy and sums their widths. Like the
327    /// default `set_str` family, this does **not** interpret inline escape
328    /// sequences: an SGR or OSC 8 sequence in `s` is measured as the width of
329    /// its visible bytes. Use [`Painter`](super::Painter), whose `str_width`
330    /// skips recognized escapes, to measure escape-bearing text.
331    ///
332    /// # Parameters
333    ///
334    /// * `s` — string to measure.
335    ///
336    /// # Returns
337    ///
338    /// The display width in cells, saturated at `u16::MAX`.
339    ///
340    /// # Errors and panics
341    ///
342    /// This method does not fail or intentionally panic.
343    fn str_width(&self, s: &str) -> u16 {
344        self.grapheme_cells(s)
345            .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)))
346    }
347
348    /// Measure one extended grapheme cluster in cells under this surface's
349    /// width mode and East-Asian Ambiguous policy.
350    ///
351    /// # Parameters
352    ///
353    /// * `g` — a single grapheme cluster.
354    ///
355    /// # Returns
356    ///
357    /// The cluster width in cells, normally `0`, `1`, or `2`.
358    ///
359    /// # Errors and panics
360    ///
361    /// This method does not fail or intentionally panic.
362    fn grapheme_width(&self, g: &str) -> u8 {
363        self.width_mode().grapheme_width(g, self.eaw_wide())
364    }
365
366    /// Iterate `s` as `(cluster, width)` pairs under this surface's width mode
367    /// and East-Asian Ambiguous policy.
368    ///
369    /// # Parameters
370    ///
371    /// * `s` — UTF-8 string to segment and measure.
372    ///
373    /// # Returns
374    ///
375    /// An iterator yielding borrowed cluster slices and their cell widths.
376    ///
377    /// # Errors and panics
378    ///
379    /// This method does not fail or intentionally panic.
380    fn grapheme_cells<'a>(&self, s: &'a str) -> impl Iterator<Item = (&'a str, u8)> {
381        grapheme_cells(s, self.width_mode(), self.eaw_wide())
382    }
383}
384
385/// A literal truncation tail: indicator text, its starting style, and its
386/// measured cell width.
387struct LiteralTail<'a> {
388    text: &'a str,
389    style: &'a Style,
390    width: u16,
391}
392
393/// Paint `s` literally, clipped to `clip`, with `wrap` behavior at the right
394/// edge. Each grapheme cluster is drawn with `style`; inline escapes are not
395/// interpreted (they are segmented and drawn like any other text). Newline and
396/// carriage return reposition within `clip`.
397#[allow(clippy::too_many_arguments)]
398fn paint_literal<S: SurfaceMut + ?Sized>(
399    target: &mut S,
400    start: Position,
401    clip: Rect,
402    s: &str,
403    wrap: WrapMode,
404    mode: WidthMode,
405    eaw_wide: bool,
406    style: &Style,
407) -> Position {
408    paint_literal_inner(target, start, clip, s, wrap, mode, eaw_wide, style, None)
409}
410
411/// Paint `s` literally with [`WrapMode::Truncate`], stamping `tail` on overflow.
412///
413/// The main text is drawn with [`Style::default()`]; the tail is drawn with
414/// `tail_style`. The tail is dropped (hard truncate) when it is empty or wider
415/// than the clip.
416#[allow(clippy::too_many_arguments)]
417fn paint_literal_truncate<S: SurfaceMut + ?Sized>(
418    target: &mut S,
419    start: Position,
420    clip: Rect,
421    s: &str,
422    tail_text: &str,
423    tail_style: &Style,
424    mode: WidthMode,
425    eaw_wide: bool,
426) -> Position {
427    if clip.is_empty() {
428        return start;
429    }
430    let tail_w = grapheme_cells(tail_text, mode, eaw_wide)
431        .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)));
432    let tail = if tail_w == 0 || tail_w > clip.width {
433        None
434    } else {
435        Some(LiteralTail {
436            text: tail_text,
437            style: tail_style,
438            width: tail_w,
439        })
440    };
441    paint_literal_inner(
442        target,
443        start,
444        clip,
445        s,
446        WrapMode::Truncate,
447        mode,
448        eaw_wide,
449        &Style::default(),
450        tail,
451    )
452}
453
454#[allow(clippy::too_many_arguments)]
455fn paint_literal_inner<S: SurfaceMut + ?Sized>(
456    target: &mut S,
457    start: Position,
458    clip: Rect,
459    s: &str,
460    wrap: WrapMode,
461    mode: WidthMode,
462    eaw_wide: bool,
463    style: &Style,
464    tail: Option<LiteralTail<'_>>,
465) -> Position {
466    if clip.is_empty() {
467        return start;
468    }
469    // `y` only ever advances, so a start below the clip can never paint.
470    if start.y >= clip.bottom() {
471        return start;
472    }
473    let mut x = start.x;
474    let mut y = start.y;
475    // Truncation is per row: once a row overflows, clusters are dropped until
476    // `\n` or `\r` puts the cursor back inside the clip.
477    let mut truncated = false;
478
479    for (cluster, w) in grapheme_cells(s, mode, eaw_wide) {
480        // Extended grapheme segmentation joins CR LF into a single cluster, so
481        // it has to be matched alongside a lone `\n` or it reads as zero-width
482        // filler and never breaks the line.
483        if cluster == "\n" || cluster == "\r\n" {
484            y = y.saturating_add(1);
485            x = clip.left();
486            truncated = false;
487            if y >= clip.bottom() {
488                return Position::new(x, y);
489            }
490            continue;
491        }
492        if cluster == "\r" {
493            x = clip.left();
494            truncated = false;
495            continue;
496        }
497        if truncated || w == 0 {
498            continue;
499        }
500        let w = w as u16;
501        if x + w > clip.right() {
502            match wrap {
503                WrapMode::Truncate => {
504                    if let Some(t) = &tail {
505                        stamp_literal_tail(target, t, clip, y, mode, eaw_wide);
506                        x = clip.right();
507                    }
508                    truncated = true;
509                    continue;
510                }
511                WrapMode::Wrap => {
512                    y = y.saturating_add(1);
513                    x = clip.left();
514                    if y >= clip.bottom() {
515                        return Position::new(x, y);
516                    }
517                    if x + w > clip.right() {
518                        return Position::new(x, y);
519                    }
520                }
521            }
522        }
523        if clip.contains(Position::new(x, y)) {
524            let cell = if w == 2 {
525                Cell::wide(cluster)
526            } else {
527                Cell::narrow(cluster)
528            };
529            target.set_cell(Position::new(x, y), &cell.style(style.clone()));
530        }
531        x += w;
532    }
533    Position::new(x, y)
534}
535
536/// Stamp `tail` over the trailing `tail.width` columns of row `y`, ending at
537/// `clip`'s right edge, painted literally with the tail's style.
538fn stamp_literal_tail<S: SurfaceMut + ?Sized>(
539    target: &mut S,
540    tail: &LiteralTail<'_>,
541    clip: Rect,
542    y: u16,
543    mode: WidthMode,
544    eaw_wide: bool,
545) {
546    let tail_x = clip.right().saturating_sub(tail.width);
547    let sub = Rect::new(tail_x, y, tail.width, 1).intersection(clip);
548    paint_literal_inner(
549        target,
550        Position::new(tail_x, y),
551        sub,
552        tail.text,
553        WrapMode::Truncate,
554        mode,
555        eaw_wide,
556        tail.style,
557        None,
558    );
559}