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    /// Optional row-fill background. When `Some`, materialization at
14    /// composition (`Frame::hstack`) or at the terminal boundary
15    /// (`VisualFrame::from_frame`) will paint trailing columns of the
16    /// containing slot with this color. Deferring materialization prevents
17    /// premature trailing-space rows from producing phantom wrapped rows when
18    /// wrapped again at a smaller width.
19    fill: Option<Color>,
20}
21
22impl Line {
23    pub fn new(s: impl Into<String>) -> Self {
24        let text = s.into();
25        if text.is_empty() {
26            return Self::default();
27        }
28
29        Self { spans: vec![Span::new(text)], fill: None }
30    }
31
32    pub fn styled(text: impl Into<String>, color: Color) -> Self {
33        Self::with_style(text, Style::fg(color))
34    }
35
36    pub fn with_style(text: impl Into<String>, style: Style) -> Self {
37        let text = text.into();
38        if text.is_empty() {
39            return Self::default();
40        }
41
42        Self { spans: vec![Span::with_style(text, style)], fill: None }
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() && self.fill.is_none()
51    }
52
53    /// Returns this row's fill background, if any.
54    pub fn fill(&self) -> Option<Color> {
55        self.fill
56    }
57
58    /// Builder: mark this row as filling its containing width with `color`.
59    pub fn with_fill(mut self, color: Color) -> Self {
60        self.fill = Some(color);
61        self
62    }
63
64    /// Set or clear this row's fill background. Pass `Some(color)` to mark
65    /// the row for fill, or `None` to drop any existing fill metadata.
66    pub fn set_fill(&mut self, fill: Option<Color>) {
67        self.fill = fill;
68    }
69
70    /// The background color this row's trailing space *would* be filled with.
71    ///
72    /// Prefers explicit fill metadata, otherwise the first background color
73    /// found among the row's spans, otherwise `None`. Used by composition
74    /// layers (e.g., `Frame::fit` with `with_fill`) to decide what background
75    /// to extend.
76    pub fn infer_fill_color(&self) -> Option<Color> {
77        self.fill.or_else(|| self.spans.iter().find_map(|s| s.style().bg))
78    }
79
80    pub fn prepend(mut self, text: impl Into<String>) -> Self {
81        let text = text.into();
82
83        if text.is_empty() {
84            return self;
85        }
86
87        // If a fill style is set, or the *leading* span has a bg, prepended
88        // text picks that style up so the indent is visually contiguous with
89        // the row's eventual fill. Looking only at the first span avoids
90        // bleeding a later span's bg (e.g. a diff_added_bg content span)
91        // backwards across a no-bg gutter into the prepended indent.
92        // Otherwise, merge into the leading default-style span when possible
93        // to keep span counts low.
94        let bg_color = self.fill.or_else(|| self.spans.first().and_then(|s| s.style().bg));
95
96        if let Some(bg) = bg_color {
97            self.spans.insert(0, Span::with_style(text, Style::default().bg_color(bg)));
98        } else if let Some(first) = self.spans.first_mut()
99            && first.style == Style::default()
100        {
101            first.text.insert_str(0, &text);
102        } else {
103            self.spans.insert(0, Span::with_style(text, Style::default()));
104        }
105
106        self
107    }
108
109    pub fn push_text(&mut self, text: impl Into<String>) {
110        self.push_span(Span::new(text));
111    }
112
113    pub fn push_styled(&mut self, text: impl Into<String>, color: Color) {
114        self.push_with_style(text, Style::fg(color));
115    }
116
117    pub fn push_with_style(&mut self, text: impl Into<String>, style: Style) {
118        self.push_span(Span::with_style(text, style));
119    }
120
121    pub fn push_span(&mut self, span: Span) {
122        if span.text.is_empty() {
123            return;
124        }
125
126        if let Some(last) = self.spans.last_mut()
127            && last.style == span.style
128        {
129            last.text.push_str(&span.text);
130            return;
131        }
132        self.spans.push(span);
133    }
134
135    pub fn append_line(&mut self, other: &Line) {
136        for span in &other.spans {
137            self.push_span(span.clone());
138        }
139    }
140
141    pub fn extend_bg_to_width(&mut self, target_width: usize) {
142        let current_width = UnicodeWidthStr::width(self.plain_text().as_str());
143        let pad = target_width.saturating_sub(current_width);
144        if pad == 0 {
145            self.fill = None;
146            return;
147        }
148
149        let pad_style = self.infer_fill_color().map_or_else(Style::default, |bg| Style::default().bg_color(bg));
150        self.fill = None;
151        self.push_with_style(format!("{:pad$}", "", pad = pad), pad_style);
152    }
153
154    pub fn to_ansi_string(&self) -> String {
155        if self.spans.is_empty() {
156            return String::new();
157        }
158
159        let mut out = String::new();
160        let mut active_style = Style::default();
161
162        for span in &self.spans {
163            if span.style != active_style {
164                emit_style_transition(&mut out, active_style, span.style);
165                active_style = span.style;
166            }
167            out.push_str(&span.text);
168        }
169
170        if active_style != Style::default() {
171            emit_style_transition(&mut out, active_style, Style::default());
172        }
173
174        out
175    }
176
177    /// Display width in terminal columns (accounts for unicode widths).
178    pub fn display_width(&self) -> usize {
179        soft_wrap::display_width_line(self)
180    }
181
182    /// Soft-wrap this line to fit within `width` columns.
183    pub fn soft_wrap(&self, width: u16) -> Vec<Line> {
184        soft_wrap::soft_wrap_line(self, width)
185    }
186
187    #[allow(dead_code)]
188    pub fn plain_text(&self) -> String {
189        let mut text = String::new();
190        for span in &self.spans {
191            text.push_str(&span.text);
192        }
193        text
194    }
195}
196
197impl std::fmt::Display for Line {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        for span in &self.spans {
200            f.write_str(&span.text)?;
201        }
202        Ok(())
203    }
204}
205
206fn push_fg_sgr(out: &mut String, color: Option<Color>) {
207    let fg = color.unwrap_or(Color::Reset);
208    let _ = write!(out, "{}", SetForegroundColor(fg));
209}
210
211fn push_bg_sgr(out: &mut String, color: Option<Color>) {
212    let bg = color.unwrap_or(Color::Reset);
213    let _ = write!(out, "{}", SetBackgroundColor(bg));
214}
215
216fn push_attr_sgr(out: &mut String, attr: Attribute) {
217    let _ = write!(out, "{}", SetAttribute(attr));
218}
219
220fn emit_style_transition(out: &mut String, from: Style, to: Style) {
221    // Check if any boolean attribute turned OFF — requires a full reset
222    let needs_reset = (from.bold && !to.bold)
223        || (from.italic && !to.italic)
224        || (from.underline && !to.underline)
225        || (from.dim && !to.dim)
226        || (from.strikethrough && !to.strikethrough);
227
228    if needs_reset {
229        push_attr_sgr(out, Attribute::Reset);
230        // After reset, re-emit all active attributes and colors on `to`
231        if to.bold {
232            push_attr_sgr(out, Attribute::Bold);
233        }
234        if to.italic {
235            push_attr_sgr(out, Attribute::Italic);
236        }
237        if to.underline {
238            push_attr_sgr(out, Attribute::Underlined);
239        }
240        if to.dim {
241            push_attr_sgr(out, Attribute::Dim);
242        }
243        if to.strikethrough {
244            push_attr_sgr(out, Attribute::CrossedOut);
245        }
246        push_fg_sgr(out, to.fg);
247        push_bg_sgr(out, to.bg);
248        return;
249    }
250
251    // Only turning attributes ON — emit incrementally
252    if !from.bold && to.bold {
253        push_attr_sgr(out, Attribute::Bold);
254    }
255    if !from.italic && to.italic {
256        push_attr_sgr(out, Attribute::Italic);
257    }
258    if !from.underline && to.underline {
259        push_attr_sgr(out, Attribute::Underlined);
260    }
261    if !from.dim && to.dim {
262        push_attr_sgr(out, Attribute::Dim);
263    }
264    if !from.strikethrough && to.strikethrough {
265        push_attr_sgr(out, Attribute::CrossedOut);
266    }
267    if from.fg != to.fg {
268        push_fg_sgr(out, to.fg);
269    }
270    if from.bg != to.bg {
271        push_bg_sgr(out, to.bg);
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn prepend_merges_into_default_style_span() {
281        let line = Line::new("hello").prepend("  ");
282        assert_eq!(line.plain_text(), "  hello");
283        assert_eq!(line.spans().len(), 1, "should merge into the existing span");
284    }
285
286    #[test]
287    fn prepend_carries_bg_from_styled_span() {
288        let line = Line::with_style("hello", Style::default().bg_color(Color::Blue));
289        let prepended = line.prepend("  ");
290        assert_eq!(prepended.plain_text(), "  hello");
291        assert_eq!(prepended.spans().len(), 2);
292        assert_eq!(prepended.spans()[0].style().bg, Some(Color::Blue), "prepended span should inherit the bg color");
293    }
294
295    #[test]
296    fn prepend_empty_is_noop() {
297        let line = Line::new("hello").prepend("");
298        assert_eq!(line.plain_text(), "hello");
299    }
300
301    #[test]
302    fn with_fill_sets_fill_metadata_without_changing_spans() {
303        let line = Line::new("hello").with_fill(Color::Red);
304        assert_eq!(line.plain_text(), "hello");
305        assert_eq!(line.fill(), Some(Color::Red));
306    }
307
308    #[test]
309    fn fill_defaults_to_none() {
310        let line = Line::new("hello");
311        assert_eq!(line.fill(), None);
312    }
313
314    #[test]
315    fn extend_bg_to_width_consumes_fill_and_uses_its_color_for_padding() {
316        let mut line = Line::new("hi").with_fill(Color::Magenta);
317        line.extend_bg_to_width(5);
318        assert_eq!(line.plain_text(), "hi   ");
319        assert_eq!(line.fill(), None);
320        let pad_span = line.spans().last().unwrap();
321        assert_eq!(pad_span.style().bg, Some(Color::Magenta));
322    }
323
324    #[test]
325    fn extend_bg_to_width_clears_fill_when_already_at_target_width() {
326        let mut line = Line::new("hello").with_fill(Color::Red);
327        line.extend_bg_to_width(5);
328        assert_eq!(line.plain_text(), "hello");
329        assert_eq!(line.fill(), None, "fill should be cleared even when no padding was needed");
330    }
331
332    #[test]
333    fn extend_bg_to_width_falls_back_to_span_bg_when_no_fill_set() {
334        let mut line = Line::with_style("hi", Style::default().bg_color(Color::Blue));
335        line.extend_bg_to_width(5);
336        let pad_span = line.spans().last().unwrap();
337        assert_eq!(pad_span.style().bg, Some(Color::Blue));
338    }
339
340    #[test]
341    fn prepend_carries_fill_color_when_no_span_bg_present() {
342        let line = Line::new("hi").with_fill(Color::Green).prepend("..");
343        assert_eq!(line.plain_text(), "..hi");
344        // Prepend should not produce a default-style span; it should pick up the
345        // fill color so the indent is visually contiguous with the row's fill.
346        assert_eq!(line.spans()[0].style().bg, Some(Color::Green));
347    }
348
349    #[test]
350    fn prepend_does_not_inherit_bg_from_non_leading_span() {
351        // A line built like a split-diff row: a no-bg gutter followed by
352        // colored content. Prepending an indent must NOT inherit the
353        // content span's bg, otherwise the indent visibly leaks the diff
354        // color out the left edge of the line.
355        let mut line = Line::default();
356        line.push_text("     ");
357        line.push_with_style("old code", Style::default().bg_color(Color::Red));
358        let prepended = line.prepend("  ");
359
360        assert_eq!(prepended.plain_text(), "       old code");
361        assert_eq!(prepended.spans()[0].style().bg, None, "prepended indent should not pick up bg from a later span");
362    }
363
364    #[test]
365    fn builder_style_supports_bold_and_color() {
366        let mut line = Line::default();
367        line.push_with_style("hot", Style::default().bold().color(Color::Red));
368
369        let ansi = line.to_ansi_string();
370        let mut bold = String::new();
371        let mut red = String::new();
372        let mut reset_attr = String::new();
373        push_attr_sgr(&mut bold, Attribute::Bold);
374        push_fg_sgr(&mut red, Some(Color::Red));
375        push_attr_sgr(&mut reset_attr, Attribute::Reset);
376
377        assert!(ansi.contains(&bold));
378        assert!(ansi.contains(&red));
379        assert!(ansi.contains("hot"));
380        // When bold turns off, a full Reset is emitted
381        assert!(ansi.contains(&reset_attr));
382    }
383}