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}