Skip to main content

uncurses_ratatui/
convert.rs

1//! Conversion helpers from widget-library buffer data to uncurses cells.
2//!
3//! ## Color and style mapping
4//!
5//! Public helpers convert individual colors and styles into uncurses values.
6//! `Reset` colors become `None`, named colors become the closest ANSI basic
7//! color, indexed colors keep their palette index, and RGB colors keep their
8//! three components. Style conversion copies supported additive modifiers into
9//! [`AttrFlags`](uncurses::style::AttrFlags), maps underline to
10//! [`UnderlineStyle::Single`], and ignores subtractive modifiers because a
11//! concrete buffer cell already carries its effective style.
12//!
13//! ## Cell conversion
14//!
15//! Backend drawing uses the private cell converter to combine symbol width and
16//! style conversion before writing into the screen buffer.
17//!
18//! ```text
19//! ratatui Cell
20//! ├─ symbol ───────────────► width check ───► narrow / wide uncurses Cell
21//! └─ fg/bg/underline/style ─► style mapper ─► uncurses Style
22//!                                            └► styled Cell
23//! ```
24
25use ratatui::style::{Color as RtColor, Modifier, Style as RtStyle};
26use uncurses::cell::Cell as CzCell;
27use uncurses::color::Color as CzColor;
28use uncurses::style::{AttrFlags, Style as CzStyle, UnderlineStyle};
29
30/// Convert a widget-library color into an uncurses color.
31///
32/// ## Parameters
33///
34/// * `c` - the source color to encode.
35///
36/// ## Returns
37///
38/// `None` for [`RtColor::Reset`], because reset means "no explicit color" in
39/// the target style. All other variants return `Some`: named colors map to
40/// named [`CzColor`] values, [`RtColor::Indexed`] keeps the same palette index,
41/// and [`RtColor::Rgb`] keeps the same red, green, and blue channels.
42///
43/// ## Errors
44///
45/// This conversion is infallible.
46///
47/// ## Panics
48///
49/// Does not panic.
50///
51/// ## Usage note
52///
53/// This helper does not downsample colors for terminal capability profiles;
54/// that happens later in the uncurses renderer.
55pub fn to_uncurses_color(c: RtColor) -> Option<CzColor> {
56    Some(match c {
57        RtColor::Reset => return None,
58        RtColor::Black => CzColor::Black,
59        RtColor::Red => CzColor::Red,
60        RtColor::Green => CzColor::Green,
61        RtColor::Yellow => CzColor::Yellow,
62        RtColor::Blue => CzColor::Blue,
63        RtColor::Magenta => CzColor::Magenta,
64        RtColor::Cyan => CzColor::Cyan,
65        RtColor::Gray => CzColor::White,
66        RtColor::DarkGray => CzColor::BrightBlack,
67        RtColor::LightRed => CzColor::BrightRed,
68        RtColor::LightGreen => CzColor::BrightGreen,
69        RtColor::LightYellow => CzColor::BrightYellow,
70        RtColor::LightBlue => CzColor::BrightBlue,
71        RtColor::LightMagenta => CzColor::BrightMagenta,
72        RtColor::LightCyan => CzColor::BrightCyan,
73        RtColor::White => CzColor::BrightWhite,
74        RtColor::Rgb(r, g, b) => CzColor::Rgb(r, g, b),
75        RtColor::Indexed(i) => CzColor::Indexed(i),
76    })
77}
78
79fn to_attrs(m: Modifier) -> AttrFlags {
80    let mut a = AttrFlags::empty();
81    if m.contains(Modifier::BOLD) {
82        a |= AttrFlags::BOLD;
83    }
84    if m.contains(Modifier::DIM) {
85        a |= AttrFlags::FAINT;
86    }
87    if m.contains(Modifier::ITALIC) {
88        a |= AttrFlags::ITALIC;
89    }
90    if m.contains(Modifier::SLOW_BLINK) {
91        a |= AttrFlags::SLOW_BLINK;
92    }
93    if m.contains(Modifier::RAPID_BLINK) {
94        a |= AttrFlags::RAPID_BLINK;
95    }
96    if m.contains(Modifier::REVERSED) {
97        a |= AttrFlags::REVERSE;
98    }
99    if m.contains(Modifier::HIDDEN) {
100        a |= AttrFlags::CONCEAL;
101    }
102    if m.contains(Modifier::CROSSED_OUT) {
103        a |= AttrFlags::STRIKETHROUGH;
104    }
105    a
106}
107
108/// Convert a widget-library style into an uncurses style.
109///
110/// ## Parameters
111///
112/// * `s` - the source style. The conversion reads `fg`, `bg`,
113///   `underline_color`, and `add_modifier`.
114///
115/// ## Returns
116///
117/// A [`CzStyle`] with foreground, background, and underline colors converted by
118/// [`to_uncurses_color`]. Additive modifiers map as follows: bold, dim, italic,
119/// slow blink, rapid blink, reversed, hidden, and crossed-out become the
120/// corresponding uncurses attribute flags. Underline becomes
121/// [`UnderlineStyle::Single`] when [`Modifier::UNDERLINED`] is present, and
122/// [`UnderlineStyle::None`] otherwise.
123///
124/// ## Errors
125///
126/// This conversion is infallible.
127///
128/// ## Panics
129///
130/// Does not panic.
131///
132/// ## Usage note
133///
134/// `sub_modifier` is ignored. The backend converts concrete buffer cells, whose
135/// style has already been resolved by the widget library.
136pub fn to_uncurses_style(s: RtStyle) -> CzStyle {
137    let attrs = to_attrs(s.add_modifier);
138    let underline = if s.add_modifier.contains(Modifier::UNDERLINED) {
139        UnderlineStyle::Single
140    } else {
141        UnderlineStyle::None
142    };
143    let mut style = CzStyle::default().underline_style(underline);
144    if let Some(fg) = s.fg.and_then(to_uncurses_color) {
145        style = style.fg(fg);
146    }
147    if let Some(bg) = s.bg.and_then(to_uncurses_color) {
148        style = style.bg(bg);
149    }
150    if let Some(uc) = s.underline_color.and_then(to_uncurses_color) {
151        style = style.underline_color(uc);
152    }
153    style.attrs(attrs)
154}
155
156/// Halfwidth Katakana Voiced Sound Mark (dakuten).
157const HALFWIDTH_KATAKANA_VOICED_SOUND_MARK: char = '\u{FF9E}';
158/// Halfwidth Katakana Semi-Voiced Sound Mark (handakuten).
159const HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK: char = '\u{FF9F}';
160
161/// Display width of `s` in terminal cells, matching the widget library's cell
162/// width for strings. Includes a fast path for single-byte ASCII and a `+1`
163/// compensation for each halfwidth dakuten/handakuten that `unicode-width`
164/// reports as zero.
165fn str_cell_width(s: &str) -> u16 {
166    use unicode_width::UnicodeWidthStr;
167    if s.len() == 1 {
168        1
169    } else {
170        let width = s.width() as u16;
171        let extra = s
172            .chars()
173            .filter(|c| {
174                matches!(
175                    *c,
176                    HALFWIDTH_KATAKANA_VOICED_SOUND_MARK
177                        | HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK
178                )
179            })
180            .count() as u16;
181        width.saturating_add(extra)
182    }
183}
184
185/// Convert a concrete buffer cell into the uncurses cell staged in the buffer.
186///
187/// The symbol is classified as wide when its terminal-cell width is at least
188/// two; otherwise it is stored as a narrow cell. The source cell's foreground,
189/// background, underline color, and modifiers are converted through
190/// [`to_uncurses_style`].
191pub(crate) fn cell_from_ratatui(rc: &ratatui::buffer::Cell) -> CzCell {
192    let style = RtStyle {
193        fg: Some(rc.fg),
194        bg: Some(rc.bg),
195        underline_color: Some(rc.underline_color),
196        add_modifier: rc.modifier,
197        sub_modifier: Modifier::empty(),
198    };
199    let style = to_uncurses_style(style);
200    let symbol = rc.symbol();
201    let cell = if str_cell_width(symbol) >= 2 {
202        CzCell::wide(symbol)
203    } else {
204        CzCell::narrow(symbol)
205    };
206    cell.style(style)
207}