Skip to main content

oino_ui/
text.rs

1//! ratatui text model (ยง4.2): [`Span`], [`Line`], [`Text`] and [`Alignment`].
2//!
3//! Every type accepts the shorthand forms from Appendix A: a bare string, an array
4//! (of spans / lines), or the full object. A string splits on `\n` when rendered.
5
6use serde::{Deserialize, Serialize};
7
8use crate::style::{Style, Stylize};
9
10/// Text alignment.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Alignment {
13    /// Left.
14    #[default]
15    Left,
16    /// Center.
17    Center,
18    /// Right.
19    Right,
20}
21
22/// ratatui `Span`: a string or `{content, style?, link?}`.
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum Span {
26    /// Shorthand: plain string.
27    Str(String),
28    /// Full form.
29    Full {
30        /// Span text.
31        content: String,
32        /// Span style.
33        #[serde(default, skip_serializing_if = "Option::is_none")]
34        style: Option<Style>,
35        /// Hyperlink target (GUI `<a>` / OSC 8).
36        #[serde(default, skip_serializing_if = "Option::is_none")]
37        link: Option<String>,
38    },
39}
40
41impl Default for Span {
42    fn default() -> Self {
43        Self::Str(String::new())
44    }
45}
46
47impl Span {
48    /// Builds a styled span.
49    #[must_use]
50    pub fn from_string(content: impl Into<String>) -> Self {
51        Self::Full {
52            content: content.into(),
53            style: None,
54            link: None,
55        }
56    }
57
58    /// Styled span with a hyperlink target.
59    #[must_use]
60    pub fn linked(content: impl Into<String>, link: impl Into<String>) -> Self {
61        Self::Full {
62            content: content.into(),
63            style: None,
64            link: Some(link.into()),
65        }
66    }
67
68    /// Sets a hyperlink target, keeping the current style.
69    #[must_use]
70    pub fn with_link(self, link: impl Into<String>) -> Self {
71        match self {
72            Self::Str(content) => Self::Full {
73                content,
74                style: None,
75                link: Some(link.into()),
76            },
77            Self::Full {
78                content,
79                style,
80                link: _,
81            } => Self::Full {
82                content,
83                style,
84                link: Some(link.into()),
85            },
86        }
87    }
88
89    /// The span's text.
90    #[must_use]
91    pub fn content(&self) -> &str {
92        match self {
93            Self::Str(content) | Self::Full { content, .. } => content,
94        }
95    }
96
97    /// The span's style, if any.
98    #[must_use]
99    pub fn style(&self) -> Option<&Style> {
100        match self {
101            Self::Str(_) => None,
102            Self::Full { style, .. } => style.as_ref(),
103        }
104    }
105
106    /// The span's link target, if any.
107    #[must_use]
108    pub fn link(&self) -> Option<&str> {
109        match self {
110            Self::Str(_) => None,
111            Self::Full { link, .. } => link.as_deref(),
112        }
113    }
114
115    /// Effective style: the stored one or the default.
116    #[must_use]
117    pub fn effective_style(&self) -> Style {
118        self.style().cloned().unwrap_or_default()
119    }
120}
121
122impl From<&str> for Span {
123    fn from(s: &str) -> Self {
124        Self::Str(s.to_owned())
125    }
126}
127
128impl From<String> for Span {
129    fn from(s: String) -> Self {
130        Self::Str(s)
131    }
132}
133
134impl Stylize<Span> for Span {
135    fn style(self, style: Style) -> Span {
136        match self {
137            Self::Str(content) => Self::Full {
138                content,
139                style: Some(style),
140                link: None,
141            },
142            Self::Full {
143                content,
144                style: existing,
145                link,
146            } => Self::Full {
147                content,
148                style: Some(existing.unwrap_or_default().patch(style)),
149                link,
150            },
151        }
152    }
153}
154
155impl Stylize<Span> for &str {
156    fn style(self, style: Style) -> Span {
157        Span::Full {
158            content: self.to_owned(),
159            style: Some(style),
160            link: None,
161        }
162    }
163}
164
165impl Stylize<Span> for String {
166    fn style(self, style: Style) -> Span {
167        Span::Full {
168            content: self,
169            style: Some(style),
170            link: None,
171        }
172    }
173}
174
175/// ratatui `Line`: a string, an array of spans, or `{spans, style?, alignment?}`.
176#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
177#[serde(untagged)]
178pub enum Line {
179    /// Shorthand: plain string.
180    Str(String),
181    /// Shorthand: array of spans.
182    Spans(Vec<Span>),
183    /// Full form.
184    Full {
185        /// Line spans.
186        spans: Vec<Span>,
187        /// Line style.
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        style: Option<Style>,
190        /// Line alignment.
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        alignment: Option<Alignment>,
193    },
194}
195
196impl Default for Line {
197    fn default() -> Self {
198        Self::Str(String::new())
199    }
200}
201
202impl Line {
203    /// Full-form line from spans.
204    #[must_use]
205    pub fn from_spans(spans: Vec<Span>) -> Self {
206        Self::Full {
207            spans,
208            style: None,
209            alignment: None,
210        }
211    }
212
213    /// Flattens the line to plain text.
214    #[must_use]
215    pub fn to_text(&self) -> String {
216        let spans: &[Span] = match self {
217            Self::Str(_) => return String::new(),
218            Self::Spans(spans) | Self::Full { spans, .. } => spans,
219        };
220        let mut out = String::new();
221        for span in spans {
222            out.push_str(span.content());
223        }
224        out
225    }
226
227    /// A single-span line holding `text` (empty for shorthand string lines).
228    #[must_use]
229    pub fn as_span(&self) -> Option<&Span> {
230        match self {
231            Self::Str(_) => None,
232            Self::Spans(spans) | Self::Full { spans, .. } => spans.first(),
233        }
234    }
235
236    /// The line's spans (a shorthand string becomes one span).
237    #[must_use]
238    pub fn spans(&self) -> Vec<Span> {
239        match self {
240            Self::Str(text) => vec![Span::Str(text.clone())],
241            Self::Spans(spans) | Self::Full { spans, .. } => spans.clone(),
242        }
243    }
244
245    /// The line's style, if any.
246    #[must_use]
247    pub fn style(&self) -> Option<&Style> {
248        match self {
249            Self::Str(_) | Self::Spans(_) => None,
250            Self::Full { style, .. } => style.as_ref(),
251        }
252    }
253
254    /// The line's alignment, if any.
255    #[must_use]
256    pub fn alignment(&self) -> Option<Alignment> {
257        match self {
258            Self::Str(_) | Self::Spans(_) => None,
259            Self::Full { alignment, .. } => *alignment,
260        }
261    }
262}
263
264impl From<&str> for Line {
265    fn from(s: &str) -> Self {
266        Self::Str(s.to_owned())
267    }
268}
269
270impl From<String> for Line {
271    fn from(s: String) -> Self {
272        Self::Str(s)
273    }
274}
275
276impl From<Span> for Line {
277    fn from(span: Span) -> Self {
278        Self::Spans(vec![span])
279    }
280}
281
282impl From<Vec<Span>> for Line {
283    fn from(spans: Vec<Span>) -> Self {
284        Self::Spans(spans)
285    }
286}
287
288impl Stylize<Line> for Line {
289    fn style(self, style: Style) -> Line {
290        let spans = match self {
291            Self::Str(text) => vec![Span::Str(text)],
292            Self::Spans(spans) => spans,
293            Self::Full {
294                spans,
295                style: existing,
296                alignment,
297            } => {
298                return Self::Full {
299                    spans,
300                    style: Some(existing.unwrap_or_default().patch(style)),
301                    alignment,
302                };
303            }
304        };
305        Self::Full {
306            spans,
307            style: Some(style),
308            alignment: None,
309        }
310    }
311}
312
313impl Stylize<Line> for Vec<Span> {
314    fn style(self, style: Style) -> Line {
315        Line::Full {
316            spans: self,
317            style: Some(style),
318            alignment: None,
319        }
320    }
321}
322
323/// ratatui `Text`: a string, an array of lines, or `{lines, style?, alignment?}`.
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
325#[serde(untagged)]
326pub enum Text {
327    /// Shorthand: plain string (splits on `\n`).
328    Str(String),
329    /// Shorthand: array of lines.
330    Lines(Vec<Line>),
331    /// Full form.
332    Full {
333        /// Text lines.
334        lines: Vec<Line>,
335        /// Text style.
336        #[serde(default, skip_serializing_if = "Option::is_none")]
337        style: Option<Style>,
338        /// Text alignment.
339        #[serde(default, skip_serializing_if = "Option::is_none")]
340        alignment: Option<Alignment>,
341    },
342}
343
344impl Default for Text {
345    fn default() -> Self {
346        Self::Str(String::new())
347    }
348}
349
350impl Text {
351    /// Full-form text from lines.
352    #[must_use]
353    pub fn from_lines(lines: Vec<Line>) -> Self {
354        Self::Full {
355            lines,
356            style: None,
357            alignment: None,
358        }
359    }
360
361    /// The text's lines (a shorthand string splits on `\n`).
362    #[must_use]
363    pub fn lines(&self) -> Vec<Line> {
364        match self {
365            Self::Str(text) => text
366                .split('\n')
367                .map(|line| Line::Str(line.to_owned()))
368                .collect(),
369            Self::Lines(lines) | Self::Full { lines, .. } => lines.clone(),
370        }
371    }
372
373    /// The text's style, if any.
374    #[must_use]
375    pub fn style(&self) -> Option<&Style> {
376        match self {
377            Self::Str(_) | Self::Lines(_) => None,
378            Self::Full { style, .. } => style.as_ref(),
379        }
380    }
381
382    /// The text's alignment, if any.
383    #[must_use]
384    pub fn alignment(&self) -> Option<Alignment> {
385        match self {
386            Self::Str(_) | Self::Lines(_) => None,
387            Self::Full { alignment, .. } => *alignment,
388        }
389    }
390
391    /// Flattens to plain text joined by `\n`.
392    #[must_use]
393    pub fn to_text(&self) -> String {
394        match self {
395            Self::Str(text) => text.clone(),
396            Self::Lines(lines) | Self::Full { lines, .. } => lines
397                .iter()
398                .map(|line| match line {
399                    Line::Str(text) => text.clone(),
400                    _ => line.to_text(),
401                })
402                .collect::<Vec<_>>()
403                .join("\n"),
404        }
405    }
406}
407
408impl From<&str> for Text {
409    fn from(s: &str) -> Self {
410        Self::Str(s.to_owned())
411    }
412}
413
414impl From<String> for Text {
415    fn from(s: String) -> Self {
416        Self::Str(s)
417    }
418}
419
420impl From<Line> for Text {
421    fn from(line: Line) -> Self {
422        Self::Lines(vec![line])
423    }
424}
425
426impl From<Span> for Text {
427    fn from(span: Span) -> Self {
428        Self::Lines(vec![Line::Spans(vec![span])])
429    }
430}
431
432impl From<Vec<Line>> for Text {
433    fn from(lines: Vec<Line>) -> Self {
434        Self::Lines(lines)
435    }
436}
437
438impl Stylize<Text> for Text {
439    fn style(self, style: Style) -> Text {
440        let lines = match self {
441            Self::Str(text) => text
442                .split('\n')
443                .map(|line| Line::Str(line.to_owned()))
444                .collect(),
445            Self::Lines(lines) => lines,
446            Self::Full {
447                lines,
448                style: existing,
449                alignment,
450            } => {
451                return Self::Full {
452                    lines,
453                    style: Some(existing.unwrap_or_default().patch(style)),
454                    alignment,
455                };
456            }
457        };
458        Self::Full {
459            lines,
460            style: Some(style),
461            alignment: None,
462        }
463    }
464}
465
466impl Stylize<Text> for Vec<Line> {
467    fn style(self, style: Style) -> Text {
468        Text::Full {
469            lines: self,
470            style: Some(style),
471            alignment: None,
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    #![allow(
479        clippy::unwrap_used,
480        clippy::expect_used,
481        clippy::panic,
482        reason = "tests assert by unwrapping"
483    )]
484    use super::*;
485    use crate::style::Modifier;
486
487    #[test]
488    fn shorthand_forms_parse() {
489        let span: Span = serde_json::from_str("\"hi\"").unwrap_or_default();
490        assert_eq!(span.content(), "hi");
491        let line: Line = serde_json::from_str("[\"a\",\"b\"]").unwrap_or_default();
492        assert_eq!(line.to_text(), "ab");
493        let text: Text = serde_json::from_str("\"x\\ny\"").unwrap_or_default();
494        assert_eq!(text.lines().len(), 2);
495    }
496
497    #[test]
498    fn stylize_chain_matches_ratatui_shape() {
499        let span = "Board ".bold().fg(crate::style::Color::Accent);
500        assert_eq!(span.content(), "Board ");
501        assert!(span.effective_style().add_modifier.contains(Modifier::BOLD));
502    }
503}