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}