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 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 pub fn fill(&self) -> Option<Color> {
55 self.fill
56 }
57
58 pub fn with_fill(mut self, color: Color) -> Self {
60 self.fill = Some(color);
61 self
62 }
63
64 pub fn set_fill(&mut self, fill: Option<Color>) {
67 self.fill = fill;
68 }
69
70 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 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 pub fn display_width(&self) -> usize {
179 soft_wrap::display_width_line(self)
180 }
181
182 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 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 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 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 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 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 assert!(ansi.contains(&reset_attr));
382 }
383}