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}