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}