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.
165///
166/// The count is returned whole. A caller that stores it in a narrower type
167/// has to bound it there, where the bound can be applied to the real width
168/// rather than to a wrapped one.
169fn str_cell_width(s: &str) -> usize {
170    use unicode_width::UnicodeWidthStr;
171    if s.len() == 1 {
172        1
173    } else {
174        let width = s.width();
175        let extra = s
176            .chars()
177            .filter(|c| {
178                matches!(
179                    *c,
180                    HALFWIDTH_KATAKANA_VOICED_SOUND_MARK
181                        | HALFWIDTH_KATAKANA_SEMI_VOICED_SOUND_MARK
182                )
183            })
184            .count();
185        width + extra
186    }
187}
188
189/// Convert a concrete buffer cell into the uncurses cell staged in the buffer.
190///
191/// The cell is credited with the number of columns its symbol measures, so a
192/// cluster wider than two columns keeps every column it takes. A symbol that
193/// measures nothing still occupies the one column ratatui gave it. The source
194/// cell's foreground, background, underline color, and modifiers are converted
195/// through [`to_uncurses_style`].
196pub(crate) fn cell_from_ratatui(rc: &ratatui::buffer::Cell) -> CzCell {
197    let style = RtStyle {
198        fg: Some(rc.fg),
199        bg: Some(rc.bg),
200        underline_color: Some(rc.underline_color),
201        add_modifier: rc.modifier,
202        sub_modifier: Modifier::empty(),
203    };
204    let style = to_uncurses_style(style);
205    let symbol = rc.symbol();
206    // A ratatui cell carries whatever symbol the caller stored, and a cell
207    // here is credited with the columns that symbol measures. Clamping the
208    // count would let the two grids disagree about where the next column
209    // starts. The floor of one keeps a zero-width symbol occupying the
210    // column ratatui gave it.
211    let width = str_cell_width(symbol).clamp(1, u8::MAX as usize) as u8;
212    let cell = CzCell::new(symbol, width);
213    cell.style(style)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::cell_from_ratatui;
219
220    fn width_of(symbol: &str) -> u8 {
221        let mut rc = ratatui::buffer::Cell::default();
222        rc.set_symbol(symbol);
223        cell_from_ratatui(&rc).width()
224    }
225
226    #[test]
227    fn a_converted_cell_keeps_the_columns_its_symbol_measures() {
228        assert_eq!(width_of("a"), 1);
229        assert_eq!(width_of("世"), 2);
230        // ratatui lets a caller store any string in a cell. Capping the
231        // count would leave this grid crediting the symbol with fewer
232        // columns than the terminal advances, and every later column on the
233        // row would disagree.
234        assert_eq!(width_of("abc"), 3);
235    }
236
237    #[test]
238    fn a_symbol_too_wide_for_a_cell_keeps_the_widest_count_one_can_hold() {
239        // A cell records its width in a `u8`, so a symbol measuring more
240        // columns than that takes the largest count the type holds. Reading
241        // the width into a narrower type first would wrap it: 65,536
242        // columns would come back as none at all, and the cap meant to
243        // catch that would read it as a single column instead.
244        let huge = "\u{4e16}".repeat(32_768);
245        assert_eq!(super::str_cell_width(&huge), 65_536);
246        assert_eq!(width_of(&huge), u8::MAX);
247    }
248
249    #[test]
250    fn a_symbol_that_measures_nothing_still_holds_its_column() {
251        // ratatui gave the symbol a cell, so it keeps one here too.
252        assert_eq!(width_of("\u{200b}"), 1);
253    }
254}