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