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, painting stops.
73    ///
74    /// # Parameters
75    ///
76    /// * `pos` — starting cell position.
77    /// * `s` — UTF-8 string to paint. Escape sequences are drawn literally.
78    /// * `style` — style applied to every cell painted in this call.
79    ///
80    /// # Returns
81    ///
82    /// The cursor position immediately after the last written cell, or the
83    /// position where painting stopped. The returned position may be outside
84    /// the surface when input reaches the bottom edge.
85    ///
86    /// # Errors and panics
87    ///
88    /// This method does not return errors and does not intentionally panic.
89    ///
90    /// # Usage note
91    ///
92    /// Use [`set_str_wrap`](Self::set_str_wrap) when text should continue on
93    /// following rows instead of truncating at the right edge.
94    fn set_str(&mut self, pos: impl Into<Position>, s: &str, style: impl Into<Style>) -> Position {
95        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
96        let clip = self.bounds();
97        paint_literal(
98            self,
99            pos.into(),
100            clip,
101            s,
102            WrapMode::Truncate,
103            mode,
104            eaw,
105            &style.into(),
106        )
107    }
108
109    /// Paint `s` at `pos` with an explicit right-edge behavior.
110    ///
111    /// This is the same operation as [`set_str`](Self::set_str), except
112    /// `wrap` decides what happens when a non-zero-width grapheme cluster would
113    /// cross the surface's right edge: [`WrapMode::Truncate`] stops, and
114    /// [`WrapMode::Wrap`] continues at the left edge of the next row until the
115    /// bottom edge is reached.
116    ///
117    /// # Parameters
118    ///
119    /// * `pos` — starting cell position.
120    /// * `s` — UTF-8 string to paint.
121    /// * `wrap` — wrapping policy at the right edge.
122    /// * `style` — initial style for painted cells.
123    ///
124    /// # Returns
125    ///
126    /// The cursor position immediately after the last written cell, or where
127    /// painting stopped.
128    ///
129    /// # Errors and panics
130    ///
131    /// This method does not return errors and does not intentionally panic.
132    fn set_str_wrap(
133        &mut self,
134        pos: impl Into<Position>,
135        s: &str,
136        wrap: WrapMode,
137        style: impl Into<Style>,
138    ) -> Position {
139        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
140        let clip = self.bounds();
141        paint_literal(self, pos.into(), clip, s, wrap, mode, eaw, &style.into())
142    }
143
144    /// Paint `s` inside `rect`, clipped to the surface bounds.
145    ///
146    /// Painting starts at `rect`'s top-left corner and is clipped to
147    /// `rect ∩ self.bounds()`. Newline and carriage return use `rect`'s left
148    /// edge as the return column. If a non-zero-width grapheme cluster would
149    /// cross `rect`'s right edge, painting stops.
150    ///
151    /// # Parameters
152    ///
153    /// * `rect` — clipping rectangle and starting origin.
154    /// * `s` — UTF-8 string to paint.
155    /// * `style` — initial style for painted cells.
156    ///
157    /// # Returns
158    ///
159    /// The cursor position immediately after the last written cell, or where
160    /// painting stopped.
161    ///
162    /// # Errors and panics
163    ///
164    /// This method does not return errors and does not intentionally panic.
165    fn set_str_rect(
166        &mut self,
167        rect: impl Into<Rect>,
168        s: &str,
169        style: impl Into<Style>,
170    ) -> Position {
171        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
172        let rect = rect.into();
173        let clip = rect.intersection(self.bounds());
174        paint_literal(
175            self,
176            rect.position(),
177            clip,
178            s,
179            WrapMode::Truncate,
180            mode,
181            eaw,
182            &style.into(),
183        )
184    }
185
186    /// Paint `s` inside `rect` with an explicit right-edge behavior.
187    ///
188    /// This is the rectangular form of [`set_str_wrap`](Self::set_str_wrap).
189    /// [`WrapMode::Wrap`] flows to the next row at `rect`'s left edge;
190    /// [`WrapMode::Truncate`] stops at `rect`'s right edge.
191    ///
192    /// # Parameters
193    ///
194    /// * `rect` — clipping rectangle and starting origin.
195    /// * `s` — UTF-8 string to paint.
196    /// * `wrap` — wrapping policy at the rectangle's right edge.
197    /// * `style` — initial style for painted cells.
198    ///
199    /// # Returns
200    ///
201    /// The cursor position immediately after the last written cell, or where
202    /// painting stopped.
203    ///
204    /// # Errors and panics
205    ///
206    /// This method does not return errors and does not intentionally panic.
207    fn set_str_rect_wrap(
208        &mut self,
209        rect: impl Into<Rect>,
210        s: &str,
211        wrap: WrapMode,
212        style: impl Into<Style>,
213    ) -> Position {
214        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
215        let rect = rect.into();
216        let clip = rect.intersection(self.bounds());
217        paint_literal(
218            self,
219            rect.position(),
220            clip,
221            s,
222            wrap,
223            mode,
224            eaw,
225            &style.into(),
226        )
227    }
228
229    /// Paint `s` at `pos`, truncating with a `tail` indicator on overflow.
230    ///
231    /// When a cluster would cross the surface's right edge, painting stops and
232    /// `tail` is stamped over the trailing columns, ending at the right edge.
233    /// The tail appears only when `s` actually overflows. `tail` is painted
234    /// with `tail_style` and may carry inline escape sequences, so it can be a
235    /// single glyph (`"…"`), a word (`" more"`), or a multi-style span. A tail
236    /// wider than the surface is dropped in favor of a hard truncate.
237    ///
238    /// # Parameters
239    ///
240    /// * `pos` — starting cell position.
241    /// * `s` — UTF-8 string to paint.
242    /// * `tail` — truncation indicator, painted when `s` overflows.
243    /// * `tail_style` — starting style for the tail.
244    ///
245    /// # Returns
246    ///
247    /// The cursor position immediately after the last written cell, or where
248    /// painting stopped.
249    ///
250    /// # Errors and panics
251    ///
252    /// This method does not return errors and does not intentionally panic.
253    fn set_str_truncate(
254        &mut self,
255        pos: impl Into<Position>,
256        s: &str,
257        tail: &str,
258        tail_style: impl Into<Style>,
259    ) -> Position {
260        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
261        let clip = self.bounds();
262        paint_literal_truncate(
263            self,
264            pos.into(),
265            clip,
266            s,
267            tail,
268            &tail_style.into(),
269            mode,
270            eaw,
271        )
272    }
273
274    /// Paint `s` inside `rect`, truncating with a `tail` indicator on overflow.
275    ///
276    /// This is the rectangular form of [`set_str_truncate`](Self::set_str_truncate):
277    /// the clip rectangle is `rect ∩ self.bounds()`, and the tail is stamped at
278    /// `rect`'s right edge when the text overflows it.
279    ///
280    /// # Parameters
281    ///
282    /// * `rect` — clipping rectangle and starting origin.
283    /// * `s` — UTF-8 string to paint.
284    /// * `tail` — truncation indicator, painted when `s` overflows.
285    /// * `tail_style` — starting style for the tail.
286    ///
287    /// # Returns
288    ///
289    /// The cursor position immediately after the last written cell, or where
290    /// painting stopped.
291    ///
292    /// # Errors and panics
293    ///
294    /// This method does not return errors and does not intentionally panic.
295    fn set_str_rect_truncate(
296        &mut self,
297        rect: impl Into<Rect>,
298        s: &str,
299        tail: &str,
300        tail_style: impl Into<Style>,
301    ) -> Position {
302        let (mode, eaw) = (self.width_mode(), self.eaw_wide());
303        let rect = rect.into();
304        let clip = rect.intersection(self.bounds());
305        paint_literal_truncate(
306            self,
307            rect.position(),
308            clip,
309            s,
310            tail,
311            &tail_style.into(),
312            mode,
313            eaw,
314        )
315    }
316
317    /// Measure the display width of `s` in terminal columns.
318    ///
319    /// The measurement segments `s` into grapheme clusters under this
320    /// surface's [`width_mode`](Self::width_mode) and
321    /// [`eaw_wide`](Self::eaw_wide) policy and sums their widths. Like the
322    /// default `set_str` family, this does **not** interpret inline escape
323    /// sequences: an SGR or OSC 8 sequence in `s` is measured as the width of
324    /// its visible bytes. Use [`Painter`](super::Painter), whose `str_width`
325    /// skips recognized escapes, to measure escape-bearing text.
326    ///
327    /// # Parameters
328    ///
329    /// * `s` — string to measure.
330    ///
331    /// # Returns
332    ///
333    /// The display width in cells, saturated at `u16::MAX`.
334    ///
335    /// # Errors and panics
336    ///
337    /// This method does not fail or intentionally panic.
338    fn str_width(&self, s: &str) -> u16 {
339        self.grapheme_cells(s)
340            .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)))
341    }
342
343    /// Measure one extended grapheme cluster in cells under this surface's
344    /// width mode and East-Asian Ambiguous policy.
345    ///
346    /// # Parameters
347    ///
348    /// * `g` — a single grapheme cluster.
349    ///
350    /// # Returns
351    ///
352    /// The cluster width in cells, normally `0`, `1`, or `2`.
353    ///
354    /// # Errors and panics
355    ///
356    /// This method does not fail or intentionally panic.
357    fn grapheme_width(&self, g: &str) -> u8 {
358        self.width_mode().grapheme_width(g, self.eaw_wide())
359    }
360
361    /// Iterate `s` as `(cluster, width)` pairs under this surface's width mode
362    /// and East-Asian Ambiguous policy.
363    ///
364    /// # Parameters
365    ///
366    /// * `s` — UTF-8 string to segment and measure.
367    ///
368    /// # Returns
369    ///
370    /// An iterator yielding borrowed cluster slices and their cell widths.
371    ///
372    /// # Errors and panics
373    ///
374    /// This method does not fail or intentionally panic.
375    fn grapheme_cells<'a>(&self, s: &'a str) -> impl Iterator<Item = (&'a str, u8)> {
376        grapheme_cells(s, self.width_mode(), self.eaw_wide())
377    }
378}
379
380/// A literal truncation tail: indicator text, its starting style, and its
381/// measured cell width.
382struct LiteralTail<'a> {
383    text: &'a str,
384    style: &'a Style,
385    width: u16,
386}
387
388/// Paint `s` literally, clipped to `clip`, with `wrap` behavior at the right
389/// edge. Each grapheme cluster is drawn with `style`; inline escapes are not
390/// interpreted (they are segmented and drawn like any other text). Newline and
391/// carriage return reposition within `clip`.
392#[allow(clippy::too_many_arguments)]
393fn paint_literal<S: SurfaceMut + ?Sized>(
394    target: &mut S,
395    start: Position,
396    clip: Rect,
397    s: &str,
398    wrap: WrapMode,
399    mode: WidthMode,
400    eaw_wide: bool,
401    style: &Style,
402) -> Position {
403    paint_literal_inner(target, start, clip, s, wrap, mode, eaw_wide, style, None)
404}
405
406/// Paint `s` literally with [`WrapMode::Truncate`], stamping `tail` on overflow.
407///
408/// The main text is drawn with [`Style::default()`]; the tail is drawn with
409/// `tail_style`. The tail is dropped (hard truncate) when it is empty or wider
410/// than the clip.
411#[allow(clippy::too_many_arguments)]
412fn paint_literal_truncate<S: SurfaceMut + ?Sized>(
413    target: &mut S,
414    start: Position,
415    clip: Rect,
416    s: &str,
417    tail_text: &str,
418    tail_style: &Style,
419    mode: WidthMode,
420    eaw_wide: bool,
421) -> Position {
422    if clip.is_empty() {
423        return start;
424    }
425    let tail_w = grapheme_cells(tail_text, mode, eaw_wide)
426        .fold(0u16, |acc, (_, w)| acc.saturating_add(u16::from(w)));
427    let tail = if tail_w == 0 || tail_w > clip.width {
428        None
429    } else {
430        Some(LiteralTail {
431            text: tail_text,
432            style: tail_style,
433            width: tail_w,
434        })
435    };
436    paint_literal_inner(
437        target,
438        start,
439        clip,
440        s,
441        WrapMode::Truncate,
442        mode,
443        eaw_wide,
444        &Style::default(),
445        tail,
446    )
447}
448
449#[allow(clippy::too_many_arguments)]
450fn paint_literal_inner<S: SurfaceMut + ?Sized>(
451    target: &mut S,
452    start: Position,
453    clip: Rect,
454    s: &str,
455    wrap: WrapMode,
456    mode: WidthMode,
457    eaw_wide: bool,
458    style: &Style,
459    tail: Option<LiteralTail<'_>>,
460) -> Position {
461    if clip.is_empty() {
462        return start;
463    }
464    let mut x = start.x;
465    let mut y = start.y;
466
467    for (cluster, w) in grapheme_cells(s, mode, eaw_wide) {
468        if cluster == "\n" {
469            y = y.saturating_add(1);
470            x = clip.left();
471            if y >= clip.bottom() {
472                return Position::new(x, y);
473            }
474            continue;
475        }
476        if cluster == "\r" {
477            x = clip.left();
478            continue;
479        }
480        if w == 0 {
481            continue;
482        }
483        let w = w as u16;
484        if x + w > clip.right() {
485            match wrap {
486                WrapMode::Truncate => {
487                    if let Some(t) = &tail {
488                        stamp_literal_tail(target, t, clip, y, mode, eaw_wide);
489                        return Position::new(clip.right(), y);
490                    }
491                    return Position::new(x, y);
492                }
493                WrapMode::Wrap => {
494                    y = y.saturating_add(1);
495                    x = clip.left();
496                    if y >= clip.bottom() {
497                        return Position::new(x, y);
498                    }
499                    if x + w > clip.right() {
500                        return Position::new(x, y);
501                    }
502                }
503            }
504        }
505        if clip.contains(Position::new(x, y)) {
506            let cell = if w == 2 {
507                Cell::wide(cluster)
508            } else {
509                Cell::narrow(cluster)
510            };
511            target.set_cell(Position::new(x, y), &cell.style(style.clone()));
512        }
513        x += w;
514    }
515    Position::new(x, y)
516}
517
518/// Stamp `tail` over the trailing `tail.width` columns of row `y`, ending at
519/// `clip`'s right edge, painted literally with the tail's style.
520fn stamp_literal_tail<S: SurfaceMut + ?Sized>(
521    target: &mut S,
522    tail: &LiteralTail<'_>,
523    clip: Rect,
524    y: u16,
525    mode: WidthMode,
526    eaw_wide: bool,
527) {
528    let tail_x = clip.right().saturating_sub(tail.width);
529    let sub = Rect::new(tail_x, y, tail.width, 1).intersection(clip);
530    paint_literal_inner(
531        target,
532        Position::new(tail_x, y),
533        sub,
534        tail.text,
535        WrapMode::Truncate,
536        mode,
537        eaw_wide,
538        tail.style,
539        None,
540    );
541}