Skip to main content

urushi_adapter_ratatui/
style.rs

1//! Conversion of logical text styles to Ratatui styles.
2
3use ::ratatui::style::{Color as RatatuiColor, Modifier as RatatuiModifier, Style as InnerStyle};
4
5use urushi::{Color, TerminalTextStyle, TextAttribute, TextAttributes, TextStyle};
6
7/// One logical [`TextStyle`] as a Ratatui style.
8///
9/// This is the whole style boundary of the adapter. Geometry never reaches it:
10/// a [`ResolvedView`](urushi::ResolvedView) carries only `TextStyle` per
11/// grapheme, and a block's border colors arrive as the border graphemes' own
12/// text style, so the adapter never distinguishes a border from its content.
13/// OSC 8 hyperlinks are intentionally discarded: Ratatui's cell style has no
14/// field that can retain a link target or its parameters.
15///
16/// The wrapper exists because `Style` and `TextStyle` are both foreign to this
17/// crate, so `impl From<&TextStyle> for ratatui::Style` is not allowed here.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct RatatuiStyle {
20    content: InnerStyle,
21}
22
23impl RatatuiStyle {
24    pub const fn into_inner(self) -> InnerStyle {
25        self.content
26    }
27}
28
29impl From<&TextStyle> for RatatuiStyle {
30    fn from(value: &TextStyle) -> Self {
31        let terminal = TerminalTextStyle::from(value).style();
32        let mut style = InnerStyle::new();
33        if let Some(color) = ratatui_color(terminal.foreground) {
34            style = style.fg(color);
35        }
36        if let Some(color) = ratatui_color(terminal.background) {
37            style = style.bg(color);
38        }
39        // Ratatui has no underline shape, and its underline color lives behind
40        // a feature that pulls in a backend this adapter does not depend on, so
41        // every underline degrades to the plain `UNDERLINED` modifier. The
42        // degradation is deterministic: two styles differing only in underline
43        // shape or color reach Ratatui as the same style.
44        if terminal.underline.is_some() {
45            style = style.add_modifier(RatatuiModifier::UNDERLINED);
46        }
47        Self {
48            content: style.add_modifier(ratatui_attributes(terminal.attributes)),
49        }
50    }
51}
52
53fn ratatui_attributes(attributes: TextAttributes) -> RatatuiModifier {
54    let mut converted = RatatuiModifier::empty();
55    for attribute in attributes {
56        converted = converted.union(match attribute {
57            TextAttribute::Bold => RatatuiModifier::BOLD,
58            TextAttribute::Dim => RatatuiModifier::DIM,
59            TextAttribute::Italic => RatatuiModifier::ITALIC,
60            TextAttribute::SlowBlink => RatatuiModifier::SLOW_BLINK,
61            TextAttribute::RapidBlink => RatatuiModifier::RAPID_BLINK,
62            TextAttribute::Reversed => RatatuiModifier::REVERSED,
63            TextAttribute::Hidden => RatatuiModifier::HIDDEN,
64            TextAttribute::CrossedOut => RatatuiModifier::CROSSED_OUT,
65            TextAttribute::Fraktur
66            | TextAttribute::Framed
67            | TextAttribute::Encircled
68            | TextAttribute::Overlined => RatatuiModifier::empty(),
69        });
70    }
71    converted
72}
73
74impl From<RatatuiStyle> for InnerStyle {
75    fn from(value: RatatuiStyle) -> Self {
76        value.into_inner()
77    }
78}
79
80const fn ratatui_color(color: Option<Color>) -> Option<RatatuiColor> {
81    match color {
82        None => None,
83        Some(Color::Ansi(0)) => Some(RatatuiColor::Black),
84        Some(Color::Ansi(1)) => Some(RatatuiColor::Red),
85        Some(Color::Ansi(2)) => Some(RatatuiColor::Green),
86        Some(Color::Ansi(3)) => Some(RatatuiColor::Yellow),
87        Some(Color::Ansi(4)) => Some(RatatuiColor::Blue),
88        Some(Color::Ansi(5)) => Some(RatatuiColor::Magenta),
89        Some(Color::Ansi(6)) => Some(RatatuiColor::Cyan),
90        Some(Color::Ansi(7)) => Some(RatatuiColor::Gray),
91        Some(Color::Ansi(8)) => Some(RatatuiColor::DarkGray),
92        Some(Color::Ansi(9)) => Some(RatatuiColor::LightRed),
93        Some(Color::Ansi(10)) => Some(RatatuiColor::LightGreen),
94        Some(Color::Ansi(11)) => Some(RatatuiColor::LightYellow),
95        Some(Color::Ansi(12)) => Some(RatatuiColor::LightBlue),
96        Some(Color::Ansi(13)) => Some(RatatuiColor::LightMagenta),
97        Some(Color::Ansi(14)) => Some(RatatuiColor::LightCyan),
98        Some(Color::Ansi(15)) => Some(RatatuiColor::White),
99        Some(Color::Ansi(index) | Color::Ansi256(index)) => Some(RatatuiColor::Indexed(index)),
100        Some(Color::Rgb(red, green, blue)) => Some(RatatuiColor::Rgb(red, green, blue)),
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use urushi::{Color, TextAttribute, UnderlineStyle};
107
108    use super::*;
109
110    #[test]
111    fn converts_active_attributes() {
112        let converted = RatatuiStyle::from(
113            &TextStyle::new()
114                .add_attributes(TextAttribute::Bold | TextAttribute::Italic)
115                .remove_attribute(TextAttribute::Italic),
116        )
117        .into_inner();
118
119        assert_eq!(converted.add_modifier, RatatuiModifier::BOLD);
120        assert!(converted.sub_modifier.is_empty());
121    }
122
123    #[test]
124    fn every_underline_degrades_to_the_plain_underlined_modifier() {
125        // Ratatui has no underline shape, so the five shapes and any underline
126        // color arrive as one modifier. Fixing the degradation here is what
127        // keeps it from silently becoming something else.
128        let styles = [
129            TextStyle::new().underlined(),
130            TextStyle::new().underline_style(UnderlineStyle::Double),
131            TextStyle::new().underline_style(UnderlineStyle::Curly),
132            TextStyle::new().underline_style(UnderlineStyle::Dotted),
133            TextStyle::new().underline_style(UnderlineStyle::Dashed),
134            TextStyle::new().underline_color(Color::RED),
135        ];
136
137        for style in styles {
138            let converted = RatatuiStyle::from(&style).into_inner();
139
140            assert_eq!(converted.add_modifier, RatatuiModifier::UNDERLINED);
141            assert_eq!(converted.fg, None);
142        }
143
144        assert!(
145            RatatuiStyle::from(&TextStyle::new())
146                .into_inner()
147                .add_modifier
148                .is_empty()
149        );
150    }
151
152    #[test]
153    fn hyperlink_is_intentionally_lost_at_the_cell_style_boundary() {
154        assert_eq!(
155            RatatuiStyle::from(&TextStyle::new().hyperlink("https://example.com")),
156            RatatuiStyle::from(&TextStyle::new())
157        );
158    }
159}