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 pub fn display_width(&self) -> usize {
140 soft_wrap::display_width_line(self)
141 }
142
143 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 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 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 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 assert!(ansi.contains(&reset_attr));
280 }
281}