Skip to main content

ansiq_core/
text.rs

1use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
2
3use crate::{Alignment, Style, patch_style};
4
5#[derive(Clone, Debug, PartialEq, Eq, Hash)]
6pub struct StyledChunk {
7    pub text: String,
8    pub style: Style,
9}
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub struct StyledLine {
13    pub chunks: Vec<StyledChunk>,
14    pub alignment: Alignment,
15    pub width: u16,
16}
17
18#[derive(Clone)]
19struct StyledToken {
20    text: String,
21    style: Style,
22    is_whitespace: bool,
23    width: u16,
24}
25
26#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
27pub struct Span {
28    pub content: String,
29    pub style: Style,
30}
31
32impl Span {
33    pub fn raw(content: impl Into<String>) -> Self {
34        Self {
35            content: content.into(),
36            style: Style::default(),
37        }
38    }
39
40    pub fn styled(content: impl Into<String>, style: Style) -> Self {
41        Self {
42            content: content.into(),
43            style,
44        }
45    }
46
47    pub fn style(mut self, style: Style) -> Self {
48        self.style = style;
49        self
50    }
51
52    pub fn width(&self) -> usize {
53        UnicodeWidthStr::width(self.content.as_str())
54    }
55}
56
57impl From<&str> for Span {
58    fn from(value: &str) -> Self {
59        Self::raw(value)
60    }
61}
62
63impl From<String> for Span {
64    fn from(value: String) -> Self {
65        Self::raw(value)
66    }
67}
68
69#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
70pub struct Line {
71    pub spans: Vec<Span>,
72    pub alignment: Option<Alignment>,
73}
74
75impl Line {
76    pub fn raw(content: impl Into<String>) -> Self {
77        Self {
78            spans: vec![Span::raw(content)],
79            alignment: None,
80        }
81    }
82
83    pub fn styled(content: impl Into<String>, style: Style) -> Self {
84        Self {
85            spans: vec![Span::styled(content, style)],
86            alignment: None,
87        }
88    }
89
90    pub fn alignment(mut self, alignment: Alignment) -> Self {
91        self.alignment = Some(alignment);
92        self
93    }
94
95    pub fn left_aligned(self) -> Self {
96        self.alignment(Alignment::Left)
97    }
98
99    pub fn centered(self) -> Self {
100        self.alignment(Alignment::Center)
101    }
102
103    pub fn right_aligned(self) -> Self {
104        self.alignment(Alignment::Right)
105    }
106
107    pub fn width(&self) -> usize {
108        self.spans.iter().map(Span::width).sum()
109    }
110
111    pub const fn height(&self) -> usize {
112        1
113    }
114
115    pub fn plain(&self) -> String {
116        self.spans
117            .iter()
118            .map(|span| span.content.as_str())
119            .collect::<String>()
120    }
121}
122
123impl From<&str> for Line {
124    fn from(value: &str) -> Self {
125        Self::raw(value)
126    }
127}
128
129impl From<String> for Line {
130    fn from(value: String) -> Self {
131        Self::raw(value)
132    }
133}
134
135impl From<Span> for Line {
136    fn from(value: Span) -> Self {
137        Self {
138            spans: vec![value],
139            alignment: None,
140        }
141    }
142}
143
144impl From<Vec<Span>> for Line {
145    fn from(value: Vec<Span>) -> Self {
146        Self {
147            spans: value,
148            alignment: None,
149        }
150    }
151}
152
153#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
154pub struct Text {
155    pub lines: Vec<Line>,
156    pub alignment: Option<Alignment>,
157}
158
159impl Text {
160    pub fn raw(content: impl Into<String>) -> Self {
161        Self::from(content.into())
162    }
163
164    pub fn styled(content: impl Into<String>, style: Style) -> Self {
165        Self::from(Line::styled(content, style))
166    }
167
168    pub fn alignment(mut self, alignment: Alignment) -> Self {
169        self.alignment = Some(alignment);
170        self
171    }
172
173    pub fn left_aligned(self) -> Self {
174        self.alignment(Alignment::Left)
175    }
176
177    pub fn centered(self) -> Self {
178        self.alignment(Alignment::Center)
179    }
180
181    pub fn right_aligned(self) -> Self {
182        self.alignment(Alignment::Right)
183    }
184
185    pub fn height(&self) -> usize {
186        self.lines.len().max(1)
187    }
188
189    pub fn width(&self) -> usize {
190        self.lines.iter().map(Line::width).max().unwrap_or(0)
191    }
192
193    pub fn is_empty(&self) -> bool {
194        self.lines.iter().all(|line| line.spans.is_empty())
195    }
196
197    pub fn plain(&self) -> String {
198        self.lines
199            .iter()
200            .map(Line::plain)
201            .collect::<Vec<_>>()
202            .join("\n")
203    }
204}
205
206impl From<&str> for Text {
207    fn from(value: &str) -> Self {
208        Self::from(value.to_string())
209    }
210}
211
212impl From<String> for Text {
213    fn from(value: String) -> Self {
214        let lines = if value.is_empty() {
215            vec![Line::default()]
216        } else {
217            value.split('\n').map(Line::raw).collect()
218        };
219
220        Self {
221            lines,
222            alignment: None,
223        }
224    }
225}
226
227impl From<Span> for Text {
228    fn from(value: Span) -> Self {
229        Self::from(Line::from(value))
230    }
231}
232
233impl From<Line> for Text {
234    fn from(value: Line) -> Self {
235        let alignment = value.alignment;
236        Self {
237            lines: vec![value],
238            alignment,
239        }
240    }
241}
242
243impl From<Vec<Line>> for Text {
244    fn from(value: Vec<Line>) -> Self {
245        let alignment = value.iter().find_map(|line| line.alignment);
246        Self {
247            lines: if value.is_empty() {
248                vec![Line::default()]
249            } else {
250                value
251            },
252            alignment,
253        }
254    }
255}
256
257pub fn display_width(text: &str) -> u16 {
258    text.chars()
259        .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
260        .sum()
261}
262
263pub fn display_width_prefix(text: &str, cursor: usize) -> u16 {
264    text.chars()
265        .take(cursor)
266        .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
267        .sum()
268}
269
270pub fn clip_to_width(content: &str, width: u16) -> String {
271    if width == 0 {
272        return String::new();
273    }
274
275    let mut clipped = String::new();
276    let mut used = 0u16;
277
278    for ch in content.chars() {
279        let char_width = (UnicodeWidthChar::width(ch).unwrap_or(0) as u16).max(1);
280        if used.saturating_add(char_width) > width {
281            break;
282        }
283        clipped.push(ch);
284        used = used.saturating_add(char_width);
285    }
286
287    clipped
288}
289
290pub fn wrap_plain_lines(content: &str, width: u16, trim_leading: bool) -> Vec<String> {
291    if width == 0 {
292        return Vec::new();
293    }
294
295    let mut lines = Vec::new();
296
297    for raw_line in content.split('\n') {
298        if raw_line.is_empty() {
299            lines.push(String::new());
300            continue;
301        }
302
303        let mut current = String::new();
304        let mut current_width = 0u16;
305
306        for ch in raw_line.chars() {
307            let char_width = (UnicodeWidthChar::width(ch).unwrap_or(0) as u16).max(1);
308            if current_width.saturating_add(char_width) > width && !current.is_empty() {
309                lines.push(current);
310                current = String::new();
311                current_width = 0;
312                if trim_leading && ch.is_whitespace() {
313                    continue;
314                }
315            }
316
317            current.push(ch);
318            current_width = current_width.saturating_add(char_width);
319        }
320
321        lines.push(current);
322    }
323
324    if lines.is_empty() {
325        lines.push(String::new());
326    }
327
328    lines
329}
330
331pub fn styled_lines_from_text(
332    text: &Text,
333    base_style: Style,
334    fallback_alignment: Alignment,
335) -> Vec<StyledLine> {
336    let alignment = text.alignment.unwrap_or(fallback_alignment);
337    let mut lines: Vec<StyledLine> = text
338        .lines
339        .iter()
340        .map(|line| {
341            let chunks: Vec<StyledChunk> = line
342                .spans
343                .iter()
344                .map(|span| StyledChunk {
345                    text: span.content.clone(),
346                    style: patch_style(base_style, span.style),
347                })
348                .collect();
349            StyledLine {
350                width: chunks
351                    .iter()
352                    .map(|chunk| UnicodeWidthStr::width(chunk.text.as_str()) as u16)
353                    .sum(),
354                chunks,
355                alignment: line.alignment.unwrap_or(alignment),
356            }
357        })
358        .collect();
359
360    if lines.is_empty() {
361        lines.push(StyledLine {
362            chunks: Vec::new(),
363            alignment,
364            width: 0,
365        });
366    }
367
368    lines
369}
370
371pub fn styled_line_from_line(line: &Line, base_style: Style) -> StyledLine {
372    styled_lines_from_text(&Text::from(line.clone()), base_style, Alignment::Left)
373        .into_iter()
374        .next()
375        .unwrap_or(StyledLine {
376            chunks: Vec::new(),
377            alignment: Alignment::Left,
378            width: 0,
379        })
380}
381
382pub fn styled_line_from_span(span: &Span, base_style: Style) -> StyledLine {
383    styled_line_from_line(&Line::from(span.clone()), base_style)
384}
385
386pub fn wrap_styled_lines(lines: &[StyledLine], width: u16, trim: bool) -> Vec<StyledLine> {
387    if width == 0 {
388        return Vec::new();
389    }
390
391    let mut wrapped = Vec::new();
392
393    for line in lines {
394        let mut current = StyledLine {
395            chunks: Vec::new(),
396            alignment: line.alignment,
397            width: 0,
398        };
399        for token in styled_tokens_from_line(line) {
400            if token.is_whitespace && trim && current.width == 0 {
401                continue;
402            }
403
404            if token.width <= width {
405                if current.width.saturating_add(token.width) > width && current.width > 0 {
406                    wrapped.push(current);
407                    current = StyledLine {
408                        chunks: Vec::new(),
409                        alignment: line.alignment,
410                        width: 0,
411                    };
412                    if token.is_whitespace && trim {
413                        continue;
414                    }
415                }
416
417                append_token(&mut current, &token);
418                continue;
419            }
420
421            let mut token_text = String::new();
422            let mut token_width = 0u16;
423            for ch in token.text.chars() {
424                let char_width = (UnicodeWidthChar::width(ch).unwrap_or(0) as u16).max(1);
425
426                if token.is_whitespace && trim && current.width == 0 {
427                    continue;
428                }
429
430                if current
431                    .width
432                    .saturating_add(token_width)
433                    .saturating_add(char_width)
434                    > width
435                    && (current.width > 0 || token_width > 0)
436                {
437                    if !token_text.is_empty() {
438                        current.width = current.width.saturating_add(token_width);
439                        push_chunk(&mut current.chunks, token_text.clone(), token.style);
440                        token_text.clear();
441                        token_width = 0;
442                    }
443
444                    wrapped.push(current);
445                    current = StyledLine {
446                        chunks: Vec::new(),
447                        alignment: line.alignment,
448                        width: 0,
449                    };
450
451                    if token.is_whitespace && trim {
452                        continue;
453                    }
454                }
455
456                token_text.push(ch);
457                token_width = token_width.saturating_add(char_width);
458            }
459
460            if !token_text.is_empty() {
461                current.width = current.width.saturating_add(token_width);
462                push_chunk(&mut current.chunks, token_text, token.style);
463            }
464        }
465
466        wrapped.push(current);
467    }
468
469    wrapped
470}
471
472fn styled_tokens_from_line(line: &StyledLine) -> Vec<StyledToken> {
473    let mut tokens = Vec::new();
474
475    for chunk in &line.chunks {
476        let mut token = String::new();
477        let mut token_is_whitespace = None;
478        let mut token_width = 0u16;
479
480        for ch in chunk.text.chars() {
481            let is_whitespace = ch.is_whitespace();
482            if token_is_whitespace.is_some() && token_is_whitespace != Some(is_whitespace) {
483                tokens.push(StyledToken {
484                    text: token.clone(),
485                    style: chunk.style,
486                    is_whitespace: token_is_whitespace.unwrap_or(false),
487                    width: token_width,
488                });
489                token.clear();
490                token_width = 0;
491            }
492
493            token_is_whitespace = Some(is_whitespace);
494            token.push(ch);
495            token_width = token_width
496                .saturating_add((UnicodeWidthChar::width(ch).unwrap_or(0) as u16).max(1));
497        }
498
499        if !token.is_empty() {
500            tokens.push(StyledToken {
501                text: token,
502                style: chunk.style,
503                is_whitespace: token_is_whitespace.unwrap_or(false),
504                width: token_width,
505            });
506        }
507    }
508
509    tokens
510}
511
512fn append_token(target: &mut StyledLine, token: &StyledToken) {
513    target.width = target.width.saturating_add(token.width);
514    push_chunk(&mut target.chunks, token.text.clone(), token.style);
515}
516
517fn push_chunk(chunks: &mut Vec<StyledChunk>, text: String, style: Style) {
518    if text.is_empty() {
519        return;
520    }
521
522    if let Some(last) = chunks.last_mut()
523        && last.style == style
524    {
525        last.text.push_str(&text);
526        return;
527    }
528
529    chunks.push(StyledChunk { text, style });
530}