Skip to main content

tau_term_screen/
style.rs

1//! Styled text types for terminal rendering.
2//!
3//! Content is represented as sequences of [`Span`]s, each pairing a
4//! plain-text string with a [`Style`]. Display width is always
5//! computable from the text alone — no ANSI escape codes are stored
6//! in the data model.
7
8use std::sync::Arc;
9
10pub use crossterm::style::Color;
11/// Maximum OSC 8 target length accepted by Tau's terminal renderer.
12pub use tau_proto::MAX_HYPERLINK_TARGET_BYTES;
13use unicode_segmentation::UnicodeSegmentation;
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16use crate::TwoLineElision;
17
18/// Display width of a string in terminal columns, measured by grapheme cluster.
19///
20/// The measurement uses the same control-character policy as cell conversion:
21/// line breaks have no inline width, tabs render as one space, and other
22/// control graphemes render as a visible replacement cell.
23pub fn display_width(text: &str) -> usize {
24    UnicodeSegmentation::graphemes(text, true)
25        .map(screen_grapheme_width)
26        .sum()
27}
28
29/// Returns a string that fits within `max_width` terminal columns, appending an
30/// ellipsis when truncation is needed.
31pub fn truncate_to_width(text: &str, max_width: usize) -> String {
32    if max_width == 0 {
33        return String::new();
34    }
35    if display_width(text) <= max_width {
36        return text.to_owned();
37    }
38    if max_width == 1 {
39        return "…".to_owned();
40    }
41
42    let mut out = String::new();
43    let mut width = 0;
44    let prefix_width = max_width - 1;
45    for grapheme in UnicodeSegmentation::graphemes(text, true) {
46        let grapheme_width = screen_grapheme_width(grapheme);
47        if prefix_width < width + grapheme_width {
48            break;
49        }
50        width += grapheme_width;
51        out.push_str(grapheme);
52    }
53    out.push('…');
54    out
55}
56
57/// Returns the previous grapheme-cluster boundary before `pos`.
58pub fn previous_grapheme_boundary(text: &str, pos: usize) -> usize {
59    let pos = pos.min(text.len());
60    UnicodeSegmentation::grapheme_indices(text, true)
61        .map(|(idx, _)| idx)
62        .take_while(|idx| *idx < pos)
63        .last()
64        .unwrap_or(0)
65}
66
67/// Returns the next grapheme-cluster boundary after `pos`.
68pub fn next_grapheme_boundary(text: &str, pos: usize) -> usize {
69    if text.len() <= pos {
70        return text.len();
71    }
72    for (idx, grapheme) in UnicodeSegmentation::grapheme_indices(text, true) {
73        let end = idx + grapheme.len();
74        if pos < end {
75            return end;
76        }
77    }
78    text.len()
79}
80
81pub(crate) fn is_line_break_grapheme(grapheme: &str) -> bool {
82    matches!(grapheme, "\n" | "\r\n" | "\r")
83}
84
85pub(crate) fn screen_grapheme_width(grapheme: &str) -> usize {
86    if is_line_break_grapheme(grapheme) {
87        0
88    } else if grapheme == "\t" || grapheme.chars().any(char::is_control) {
89        1
90    } else {
91        UnicodeWidthStr::width(grapheme)
92    }
93}
94
95pub(crate) fn push_grapheme_cells(
96    cells: &mut Vec<Cell>,
97    grapheme: &str,
98    style: Style,
99    hyperlink: Option<&Arc<str>>,
100) {
101    if grapheme == "\t" {
102        cells.push(Cell::new(' ', style).with_hyperlink(hyperlink.cloned()));
103        return;
104    }
105    if grapheme.chars().any(char::is_control) {
106        cells.push(Cell::new('�', style).with_hyperlink(hyperlink.cloned()));
107        return;
108    }
109    let grapheme_width = screen_grapheme_width(grapheme);
110    for (idx, ch) in grapheme.chars().enumerate() {
111        let width = if idx == 0 { grapheme_width } else { 0 };
112        cells.push(
113            Cell::new(ch, style)
114                .with_width(width)
115                .with_hyperlink(hyperlink.cloned()),
116        );
117    }
118}
119
120pub(crate) fn visit_styled_graphemes(
121    spans: &[Span],
122    mut f: impl FnMut(&str, Style, Option<&Arc<str>>),
123) {
124    let mut text = String::new();
125    let mut char_styles = Vec::new();
126    for span in spans {
127        for ch in span.text.chars() {
128            char_styles.push((text.len(), span.style, span.hyperlink.as_ref()));
129            text.push(ch);
130        }
131    }
132
133    let mut style_idx = 0;
134    for (byte, grapheme) in UnicodeSegmentation::grapheme_indices(text.as_str(), true) {
135        while style_idx + 1 < char_styles.len() && char_styles[style_idx + 1].0 <= byte {
136            style_idx += 1;
137        }
138        let style = char_styles
139            .get(style_idx)
140            .map(|(_, style, _)| *style)
141            .unwrap_or_default();
142        let hyperlink = char_styles.get(style_idx).and_then(|(_, _, link)| *link);
143        f(grapheme, style, hyperlink);
144    }
145}
146
147/// Visual attributes for a single character cell.
148#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
149pub struct Style {
150    /// Optional foreground color applied to rendered cells.
151    pub fg: Option<Color>,
152    /// Optional background color applied to rendered cells.
153    pub bg: Option<Color>,
154    /// Whether cells should be emitted with bold text.
155    pub bold: bool,
156    /// Whether cells should be emitted with underline.
157    pub underline: bool,
158    /// Whether cells should be emitted with italic text.
159    pub italic: bool,
160    /// Whether cells should be emitted with strikethrough text.
161    pub strikethrough: bool,
162}
163
164impl Style {
165    /// Returns this style with a foreground color set.
166    pub fn fg(mut self, color: Color) -> Self {
167        self.fg = Some(color);
168        self
169    }
170
171    /// Returns this style with a background color set.
172    pub fn bg(mut self, color: Color) -> Self {
173        self.bg = Some(color);
174        self
175    }
176
177    /// Returns this style with bold text enabled.
178    pub fn bold(mut self) -> Self {
179        self.bold = true;
180        self
181    }
182
183    /// Returns this style with underline enabled.
184    pub fn underline(mut self) -> Self {
185        self.underline = true;
186        self
187    }
188
189    /// Returns this style with italic text enabled.
190    pub fn italic(mut self) -> Self {
191        self.italic = true;
192        self
193    }
194
195    /// Returns this style with strikethrough text enabled.
196    pub fn strikethrough(mut self) -> Self {
197        self.strikethrough = true;
198        self
199    }
200}
201
202/// A terminal cell: one character, its visual style, and display width.
203#[derive(Clone, PartialEq, Eq, Debug)]
204pub struct Cell {
205    /// Character emitted for this cell.
206    pub ch: char,
207    /// Visual style applied while emitting this cell.
208    pub style: Style,
209    /// Display width in terminal columns.
210    pub width: usize,
211    /// Sanitized OSC 8 target active for this cell.
212    pub hyperlink: Option<Arc<str>>,
213}
214
215impl Cell {
216    pub(crate) fn sanitized_char(ch: char) -> char {
217        if ch == '\t' {
218            ' '
219        } else if ch.is_control() {
220            '�'
221        } else {
222            ch
223        }
224    }
225
226    /// Creates a styled terminal cell, sanitizing control characters.
227    pub fn new(ch: char, style: Style) -> Self {
228        let ch = Self::sanitized_char(ch);
229        Self {
230            ch,
231            style,
232            width: ch.width().unwrap_or(0),
233            hyperlink: None,
234        }
235    }
236
237    /// Creates an unstyled terminal cell, sanitizing control characters.
238    pub fn plain(ch: char) -> Self {
239        let ch = Self::sanitized_char(ch);
240        Self {
241            ch,
242            style: Style::default(),
243            width: ch.width().unwrap_or(0),
244            hyperlink: None,
245        }
246    }
247
248    pub(crate) fn normalized(&self) -> Self {
249        let ch = Self::sanitized_char(self.ch);
250        if ch == self.ch {
251            self.clone()
252        } else {
253            Self {
254                ch,
255                style: self.style,
256                width: ch.width().unwrap_or(0),
257                hyperlink: self.hyperlink.clone(),
258            }
259        }
260    }
261
262    /// Returns a copy of this cell with an explicit display width.
263    pub fn with_width(mut self, width: usize) -> Self {
264        self.width = width;
265        self
266    }
267
268    /// Associates this cell with an OSC 8 hyperlink target.
269    pub fn with_hyperlink(mut self, hyperlink: Option<Arc<str>>) -> Self {
270        self.hyperlink = hyperlink.filter(|target| sanitize_hyperlink_target(target).is_some());
271        self
272    }
273
274    /// Display width in terminal columns (1 for ASCII, 2 for wide
275    /// chars like emoji/CJK, 0 for zero-width combiners).
276    pub fn col_width(&self) -> usize {
277        self.width
278    }
279}
280
281/// A run of text with a uniform style.
282///
283/// Spans are concatenated before grapheme segmentation so clusters may cross a
284/// span boundary. Keep style boundaries on grapheme-cluster boundaries when
285/// predictable per-cluster styling matters.
286#[derive(Clone, Debug, PartialEq, Eq)]
287pub struct Span {
288    /// Plain text belonging to this span.
289    pub text: String,
290    /// Style associated with text in this span.
291    ///
292    /// If a rendered grapheme cluster crosses span boundaries, [`StyledText`]
293    /// uses the style at the cluster's first scalar value.
294    pub style: Style,
295    /// Sanitized OSC 8 target for this span, when present.
296    pub hyperlink: Option<Arc<str>>,
297}
298
299impl Span {
300    /// Creates a span with explicit text and style.
301    pub fn new(text: impl Into<String>, style: Style) -> Self {
302        Self {
303            text: text.into(),
304            style,
305            hyperlink: None,
306        }
307    }
308
309    /// Creates an unstyled span.
310    pub fn plain(text: impl Into<String>) -> Self {
311        Self {
312            text: text.into(),
313            style: Style::default(),
314            hyperlink: None,
315        }
316    }
317
318    /// Returns this span with an OSC 8 target when the target is safe.
319    pub fn hyperlink(mut self, target: impl AsRef<str>) -> Self {
320        self.hyperlink = sanitize_hyperlink_target(target.as_ref()).map(Arc::from);
321        self
322    }
323}
324
325/// A sequence of styled spans representing rich text.
326///
327/// Can be constructed from plain `&str` / `String` (unstyled),
328/// a single [`Span`], or a `Vec<Span>`.
329///
330/// Layout concatenates all spans before grapheme segmentation. Splitting a
331/// grapheme cluster across spans is supported for width and wrapping, but the
332/// cluster uses the style active at its first scalar value.
333#[derive(Clone, Debug, Default, PartialEq, Eq)]
334pub struct StyledText {
335    spans: Vec<Span>,
336}
337
338impl StyledText {
339    /// Creates an empty styled text sequence.
340    pub fn new() -> Self {
341        Self::default()
342    }
343
344    /// Appends a styled span to this text sequence.
345    pub fn push(&mut self, span: Span) {
346        self.spans.push(span);
347    }
348
349    /// Returns the spans that make up this text sequence.
350    pub fn spans(&self) -> &[Span] {
351        &self.spans
352    }
353
354    /// Returns mutable access to the spans in this text sequence.
355    pub fn spans_mut(&mut self) -> &mut [Span] {
356        &mut self.spans
357    }
358
359    /// Total display width in terminal columns.
360    ///
361    /// Wide characters and emoji grapheme clusters count as terminal columns,
362    /// not Unicode scalar values.
363    pub fn char_count(&self) -> usize {
364        let mut text = String::new();
365        for span in &self.spans {
366            text.push_str(&span.text);
367        }
368        display_width(&text)
369    }
370
371    /// Returns `true` if there is no text content.
372    pub fn is_empty(&self) -> bool {
373        self.spans.iter().all(|s| s.text.is_empty())
374    }
375
376    /// Converts to a flat sequence of [`Cell`]s (newlines excluded).
377    pub fn to_cells(&self) -> Vec<Cell> {
378        let mut cells = Vec::new();
379        visit_styled_graphemes(&self.spans, |grapheme, style, hyperlink| {
380            if !is_line_break_grapheme(grapheme) {
381                push_grapheme_cells(&mut cells, grapheme, style, hyperlink);
382            }
383        });
384        cells
385    }
386}
387
388/// Rejects targets that could terminate OSC 8 or inject terminal controls.
389pub fn sanitize_hyperlink_target(target: &str) -> Option<&str> {
390    (!target.is_empty()
391        && target.len() <= MAX_HYPERLINK_TARGET_BYTES
392        && !target.chars().any(char::is_control))
393    .then_some(target)
394}
395
396impl From<&str> for StyledText {
397    fn from(s: &str) -> Self {
398        Self {
399            spans: vec![Span::plain(s)],
400        }
401    }
402}
403
404impl From<String> for StyledText {
405    fn from(s: String) -> Self {
406        Self {
407            spans: vec![Span::plain(s)],
408        }
409    }
410}
411
412impl From<Span> for StyledText {
413    fn from(span: Span) -> Self {
414        Self { spans: vec![span] }
415    }
416}
417
418impl From<Vec<Span>> for StyledText {
419    fn from(spans: Vec<Span>) -> Self {
420        Self { spans }
421    }
422}
423
424/// Opaque numeric identifier for a [`StyledBlock`].
425#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
426pub struct BlockId(
427    /// Stable numeric identity assigned by the higher-level block owner.
428    pub u64,
429);
430
431/// Horizontal alignment within a block.
432#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
433pub enum Align {
434    /// Place content at the left edge of the content area.
435    #[default]
436    Left,
437    /// Center content within the content area.
438    Center,
439}
440
441/// Mutually exclusive content layout selected for one [`StyledBlock`].
442#[derive(Clone, Debug, PartialEq, Eq)]
443pub(crate) enum BlockLayout {
444    /// Wrap [`StyledBlock::content`] and optionally attach right content.
445    Ordinary,
446    /// Render one adaptive priority line with its owned detail body.
447    Priority {
448        /// Adaptive single-row header.
449        line: crate::PriorityLine,
450        /// Ordinary detail rows owned by the header.
451        body: StyledText,
452    },
453    /// Render a bounded width-adaptive leading/trailing excerpt.
454    TwoLineElision(TwoLineElision),
455}
456
457/// A unit of layout: styled content with background, alignment, and margins.
458///
459/// When rendered, the block's content is wrapped at the available
460/// terminal-column width (after subtracting margins), aligned within that
461/// space, and the block's background color fills remaining content-area cells.
462#[derive(Clone, Debug, PartialEq, Eq)]
463pub struct StyledBlock {
464    /// Primary content rendered when ordinary layout is selected.
465    pub content: StyledText,
466    /// Optional right-aligned adornment for ordinary single-row blocks.
467    ///
468    /// `layout_block` renders this only when [`Self::align`] is
469    /// [`Align::Left`], primary content lays out to one row, and both sides
470    /// fit with separator padding.
471    pub right_content: StyledText,
472    /// Mutually exclusive ordinary, priority-line, or two-line-elision layout.
473    pub(crate) layout: BlockLayout,
474    /// Optional background color for the content area and its padding.
475    pub bg: Option<Color>,
476    /// Horizontal alignment applied to primary content.
477    pub align: Align,
478    /// Requested transparent left margin width in terminal columns.
479    ///
480    /// `layout_block` may clamp this so each row has at least one content
481    /// column.
482    pub margin_left: u16,
483    /// Requested transparent right margin width in terminal columns.
484    ///
485    /// `layout_block` may clamp this so each row has at least one content
486    /// column.
487    pub margin_right: u16,
488}
489
490impl StyledBlock {
491    /// Creates a left-aligned block with no margins or background.
492    pub fn new(content: impl Into<StyledText>) -> Self {
493        Self {
494            content: content.into(),
495            right_content: StyledText::new(),
496            layout: BlockLayout::Ordinary,
497            bg: None,
498            align: Align::Left,
499            margin_left: 0,
500            margin_right: 0,
501        }
502    }
503
504    /// Returns `true` when the selected layout's effective primary content is
505    /// empty.
506    ///
507    /// A priority line supersedes ordinary primary content. Right content
508    /// remains an adornment and is excluded from this primary-content
509    /// predicate.
510    #[must_use]
511    pub fn is_empty(&self) -> bool {
512        match &self.layout {
513            BlockLayout::Ordinary => self.content.is_empty(),
514            BlockLayout::Priority { line, .. } => line.is_empty(),
515            BlockLayout::TwoLineElision(elision) => elision.is_empty(),
516        }
517    }
518
519    /// Returns this block with a content-area background color.
520    pub fn bg(mut self, color: Color) -> Self {
521        self.bg = Some(color);
522        self
523    }
524
525    /// Returns this block with the requested content alignment.
526    pub fn align(mut self, align: Align) -> Self {
527        self.align = align;
528        self
529    }
530
531    /// Returns this block with right-side adornment content.
532    ///
533    /// The adornment is rendered only for left-aligned, single-row primary
534    /// content when both sides fit with separator padding.
535    pub fn right_content(mut self, content: impl Into<StyledText>) -> Self {
536        self.right_content = content.into();
537        self
538    }
539
540    /// Returns this block with width-adaptive two-row excerpt layout.
541    pub fn two_line_elision(mut self, elision: TwoLineElision) -> Self {
542        self.layout = BlockLayout::TwoLineElision(elision);
543        self
544    }
545
546    /// Returns this block with priority-based single-line content.
547    pub fn priority_line(mut self, line: crate::PriorityLine) -> Self {
548        self.layout = BlockLayout::Priority {
549            line,
550            body: StyledText::new(),
551        };
552        self
553    }
554
555    /// Returns this block with ordinary body content below its priority line.
556    ///
557    /// The body has no effect unless [`Self::priority_line`] is present, and
558    /// it remains hidden whenever that line's essential band fails closed.
559    pub fn priority_line_body(mut self, body: impl Into<StyledText>) -> Self {
560        if let BlockLayout::Priority {
561            body: current_body, ..
562        } = &mut self.layout
563        {
564            *current_body = body.into();
565        }
566        self
567    }
568
569    /// Returns priority-line content when this block uses priority layout.
570    pub fn priority_line_content(&self) -> Option<&crate::PriorityLine> {
571        match &self.layout {
572            BlockLayout::Priority { line, .. } => Some(line),
573            BlockLayout::Ordinary | BlockLayout::TwoLineElision(_) => None,
574        }
575    }
576
577    /// Returns the body owned by this block's priority layout, if selected.
578    pub fn priority_line_body_content(&self) -> Option<&StyledText> {
579        match &self.layout {
580            BlockLayout::Priority { body, .. } => Some(body),
581            BlockLayout::Ordinary | BlockLayout::TwoLineElision(_) => None,
582        }
583    }
584
585    /// Returns this block with a requested transparent left margin.
586    ///
587    /// The margin may be clamped during layout.
588    pub fn margin_left(mut self, n: u16) -> Self {
589        self.margin_left = n;
590        self
591    }
592
593    /// Returns this block with a requested transparent right margin.
594    ///
595    /// The margin may be clamped during layout.
596    pub fn margin_right(mut self, n: u16) -> Self {
597        self.margin_right = n;
598        self
599    }
600
601    /// Returns this block with requested transparent left and right margins.
602    ///
603    /// The margins may be clamped during layout.
604    pub fn margins(mut self, left: u16, right: u16) -> Self {
605        self.margin_left = left;
606        self.margin_right = right;
607        self
608    }
609}
610
611impl From<&str> for StyledBlock {
612    fn from(s: &str) -> Self {
613        Self::new(s)
614    }
615}
616
617impl From<String> for StyledBlock {
618    fn from(s: String) -> Self {
619        Self::new(s)
620    }
621}
622
623impl From<StyledText> for StyledBlock {
624    fn from(text: StyledText) -> Self {
625        Self::new(text)
626    }
627}