Skip to main content

tui/rendering/
line.rs

1use crossterm::style::{Attribute, Color, SetAttribute, SetBackgroundColor, SetForegroundColor};
2use std::fmt::Write as _;
3use unicode_width::UnicodeWidthStr;
4
5use super::soft_wrap;
6use super::span::Span;
7use super::style::Style;
8
9/// A single line of styled terminal output, composed of [`Span`]s.
10///
11/// ANSI escape codes are emitted only when [`to_ansi_string`](Line::to_ansi_string)
12/// is called, keeping the data model free of formatting concerns.
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct Line {
15    spans: Vec<Span>,
16}
17
18impl Line {
19    pub fn new(s: impl Into<String>) -> Self {
20        let text = s.into();
21        if text.is_empty() {
22            return Self::default();
23        }
24
25        Self {
26            spans: vec![Span::new(text)],
27        }
28    }
29
30    pub fn styled(text: impl Into<String>, color: Color) -> Self {
31        Self::with_style(text, Style::fg(color))
32    }
33
34    pub fn with_style(text: impl Into<String>, style: Style) -> Self {
35        let text = text.into();
36        if text.is_empty() {
37            return Self::default();
38        }
39
40        Self {
41            spans: vec![Span::with_style(text, style)],
42        }
43    }
44
45    pub fn spans(&self) -> &[Span] {
46        &self.spans
47    }
48
49    pub fn is_empty(&self) -> bool {
50        self.spans.is_empty()
51    }
52
53    pub fn prepend(mut self, text: impl Into<String>) -> Self {
54        let text = text.into();
55
56        if text.is_empty() {
57            return self;
58        } else if let Some(first) = self.spans.first_mut()
59            && first.style == Style::default()
60        {
61            first.text.insert_str(0, &text);
62        } else {
63            let bg_style = self
64                .spans
65                .iter()
66                .find_map(|s| s.style().bg)
67                .map(|bg| Style::default().bg_color(bg))
68                .unwrap_or_default();
69            self.spans.insert(0, Span::with_style(text, bg_style));
70        }
71
72        self
73    }
74
75    pub fn push_text(&mut self, text: impl Into<String>) {
76        self.push_span(Span::new(text));
77    }
78
79    pub fn push_styled(&mut self, text: impl Into<String>, color: Color) {
80        self.push_with_style(text, Style::fg(color));
81    }
82
83    pub fn push_with_style(&mut self, text: impl Into<String>, style: Style) {
84        self.push_span(Span::with_style(text, style));
85    }
86
87    pub fn push_span(&mut self, span: Span) {
88        if span.text.is_empty() {
89            return;
90        }
91
92        if let Some(last) = self.spans.last_mut()
93            && last.style == span.style
94        {
95            last.text.push_str(&span.text);
96            return;
97        }
98        self.spans.push(span);
99    }
100
101    pub fn append_line(&mut self, other: &Line) {
102        for span in &other.spans {
103            self.push_span(span.clone());
104        }
105    }
106
107    pub fn extend_bg_to_width(&mut self, target_width: usize) {
108        let current_width = UnicodeWidthStr::width(self.plain_text().as_str());
109        let pad = target_width.saturating_sub(current_width);
110        if pad == 0 {
111            return;
112        }
113
114        let bg = self.spans.iter().find_map(|span| span.style().bg);
115        if let Some(bg) = bg {
116            self.push_with_style(
117                format!("{:pad$}", "", pad = pad),
118                Style::default().bg_color(bg),
119            );
120        } else {
121            self.push_text(format!("{:pad$}", "", pad = pad));
122        }
123    }
124
125    pub fn to_ansi_string(&self) -> String {
126        if self.spans.is_empty() {
127            return String::new();
128        }
129
130        let mut out = String::new();
131        let mut active_style = Style::default();
132
133        for span in &self.spans {
134            if span.style != active_style {
135                emit_style_transition(&mut out, active_style, span.style);
136                active_style = span.style;
137            }
138            out.push_str(&span.text);
139        }
140
141        if active_style != Style::default() {
142            emit_style_transition(&mut out, active_style, Style::default());
143        }
144
145        out
146    }
147
148    /// Display width in terminal columns (accounts for unicode widths).
149    pub fn display_width(&self) -> usize {
150        soft_wrap::display_width_line(self)
151    }
152
153    /// Soft-wrap this line to fit within `width` columns.
154    pub fn soft_wrap(&self, width: u16) -> Vec<Line> {
155        soft_wrap::soft_wrap_line(self, width)
156    }
157
158    #[allow(dead_code)]
159    pub fn plain_text(&self) -> String {
160        let mut text = String::new();
161        for span in &self.spans {
162            text.push_str(&span.text);
163        }
164        text
165    }
166}
167
168impl std::fmt::Display for Line {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        for span in &self.spans {
171            f.write_str(&span.text)?;
172        }
173        Ok(())
174    }
175}
176
177fn push_fg_sgr(out: &mut String, color: Option<Color>) {
178    let fg = color.unwrap_or(Color::Reset);
179    let _ = write!(out, "{}", SetForegroundColor(fg));
180}
181
182fn push_bg_sgr(out: &mut String, color: Option<Color>) {
183    let bg = color.unwrap_or(Color::Reset);
184    let _ = write!(out, "{}", SetBackgroundColor(bg));
185}
186
187fn push_attr_sgr(out: &mut String, attr: Attribute) {
188    let _ = write!(out, "{}", SetAttribute(attr));
189}
190
191fn emit_style_transition(out: &mut String, from: Style, to: Style) {
192    // Check if any boolean attribute turned OFF — requires a full reset
193    let needs_reset = (from.bold && !to.bold)
194        || (from.italic && !to.italic)
195        || (from.underline && !to.underline)
196        || (from.dim && !to.dim)
197        || (from.strikethrough && !to.strikethrough);
198
199    if needs_reset {
200        push_attr_sgr(out, Attribute::Reset);
201        // After reset, re-emit all active attributes and colors on `to`
202        if to.bold {
203            push_attr_sgr(out, Attribute::Bold);
204        }
205        if to.italic {
206            push_attr_sgr(out, Attribute::Italic);
207        }
208        if to.underline {
209            push_attr_sgr(out, Attribute::Underlined);
210        }
211        if to.dim {
212            push_attr_sgr(out, Attribute::Dim);
213        }
214        if to.strikethrough {
215            push_attr_sgr(out, Attribute::CrossedOut);
216        }
217        push_fg_sgr(out, to.fg);
218        push_bg_sgr(out, to.bg);
219        return;
220    }
221
222    // Only turning attributes ON — emit incrementally
223    if !from.bold && to.bold {
224        push_attr_sgr(out, Attribute::Bold);
225    }
226    if !from.italic && to.italic {
227        push_attr_sgr(out, Attribute::Italic);
228    }
229    if !from.underline && to.underline {
230        push_attr_sgr(out, Attribute::Underlined);
231    }
232    if !from.dim && to.dim {
233        push_attr_sgr(out, Attribute::Dim);
234    }
235    if !from.strikethrough && to.strikethrough {
236        push_attr_sgr(out, Attribute::CrossedOut);
237    }
238    if from.fg != to.fg {
239        push_fg_sgr(out, to.fg);
240    }
241    if from.bg != to.bg {
242        push_bg_sgr(out, to.bg);
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn prepend_merges_into_default_style_span() {
252        let line = Line::new("hello").prepend("  ");
253        assert_eq!(line.plain_text(), "  hello");
254        assert_eq!(line.spans().len(), 1, "should merge into the existing span");
255    }
256
257    #[test]
258    fn prepend_carries_bg_from_styled_span() {
259        let line = Line::with_style("hello", Style::default().bg_color(Color::Blue));
260        let prepended = line.prepend("  ");
261        assert_eq!(prepended.plain_text(), "  hello");
262        assert_eq!(prepended.spans().len(), 2);
263        assert_eq!(
264            prepended.spans()[0].style().bg,
265            Some(Color::Blue),
266            "prepended span should inherit the bg color"
267        );
268    }
269
270    #[test]
271    fn prepend_empty_is_noop() {
272        let line = Line::new("hello").prepend("");
273        assert_eq!(line.plain_text(), "hello");
274    }
275
276    #[test]
277    fn builder_style_supports_bold_and_color() {
278        let mut line = Line::default();
279        line.push_with_style("hot", Style::default().bold().color(Color::Red));
280
281        let ansi = line.to_ansi_string();
282        let mut bold = String::new();
283        let mut red = String::new();
284        let mut reset_attr = String::new();
285        push_attr_sgr(&mut bold, Attribute::Bold);
286        push_fg_sgr(&mut red, Some(Color::Red));
287        push_attr_sgr(&mut reset_attr, Attribute::Reset);
288
289        assert!(ansi.contains(&bold));
290        assert!(ansi.contains(&red));
291        assert!(ansi.contains("hot"));
292        // When bold turns off, a full Reset is emitted
293        assert!(ansi.contains(&reset_attr));
294    }
295}