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#[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 pub fn display_width(&self) -> usize {
150 soft_wrap::display_width_line(self)
151 }
152
153 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 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 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 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 assert!(ansi.contains(&reset_attr));
294 }
295}