Skip to main content

hjkl_engine_tui/
lib.rs

1//! Ratatui adapter surface for [`hjkl_engine`].
2//!
3//! Provides:
4//! - [`style_to_ratatui`] — convert an engine-native [`hjkl_engine::types::Style`]
5//!   to a [`ratatui::style::Style`].
6//! - [`style_from_ratatui`] — the inverse conversion (lossy for ratatui colors
7//!   the engine doesn't model — flattens to nearest RGB).
8//! - [`EditorRatatuiExt`] — extension trait on [`hjkl_engine::Editor`] that
9//!   exposes `intern_ratatui_style`, `install_ratatui_syntax_spans`, and
10//!   `ratatui_style_table`. Extracted from `hjkl-engine`'s `ratatui` feature
11//!   gate as part of #162 phase 2.
12//! - [`KeyEvent`] — re-export of [`crossterm::event::KeyEvent`] for
13//!   downstream convenience.
14//! - [`crossterm_to_input`] — convert a crossterm `KeyEvent` to the
15//!   engine-agnostic [`hjkl_engine::Input`] type. Moved from `hjkl-engine`'s
16//!   `crossterm` feature gate as part of #162 phase 3.
17
18use crossterm::event::{KeyCode, KeyModifiers};
19use hjkl_engine::{
20    Editor, Input, Key,
21    types::{Attrs, Color, Host, Style},
22};
23use ratatui::style::{Color as RColor, Modifier as RMod, Style as RStyle};
24
25/// Re-export of [`crossterm::event::KeyEvent`] for downstream convenience.
26pub use crossterm::event::KeyEvent;
27
28/// Convert a crossterm [`KeyEvent`] to the engine-agnostic [`hjkl_engine::Input`].
29///
30/// Keys the engine FSM does not model (`KeyCode::F(_)`, `KeyCode::Insert`, and
31/// any other unrecognised variant) map to [`hjkl_engine::Key::Null`]; callers
32/// should early-return or discard such inputs. Moved from `hjkl-engine`'s
33/// `crossterm` feature gate as part of #162 phase 3.
34pub fn crossterm_to_input(key: KeyEvent) -> Input {
35    let k = match key.code {
36        KeyCode::Char(c) => Key::Char(c),
37        KeyCode::Backspace => Key::Backspace,
38        KeyCode::Delete => Key::Delete,
39        KeyCode::Enter => Key::Enter,
40        KeyCode::Left => Key::Left,
41        KeyCode::Right => Key::Right,
42        KeyCode::Up => Key::Up,
43        KeyCode::Down => Key::Down,
44        KeyCode::Home => Key::Home,
45        KeyCode::End => Key::End,
46        KeyCode::Tab => Key::Tab,
47        KeyCode::Esc => Key::Esc,
48        _ => Key::Null,
49    };
50    Input {
51        key: k,
52        ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
53        alt: key.modifiers.contains(KeyModifiers::ALT),
54        shift: key.modifiers.contains(KeyModifiers::SHIFT),
55    }
56}
57
58/// Convert an engine-native [`Style`] to a [`ratatui::style::Style`].
59///
60/// Lossless within the styles each library represents.
61pub fn style_to_ratatui(s: Style) -> RStyle {
62    let mut out = RStyle::default();
63    if let Some(c) = s.fg {
64        out = out.fg(RColor::Rgb(c.0, c.1, c.2));
65    }
66    if let Some(c) = s.bg {
67        out = out.bg(RColor::Rgb(c.0, c.1, c.2));
68    }
69    let mut m = RMod::empty();
70    if s.attrs.contains(Attrs::BOLD) {
71        m |= RMod::BOLD;
72    }
73    if s.attrs.contains(Attrs::ITALIC) {
74        m |= RMod::ITALIC;
75    }
76    if s.attrs.contains(Attrs::UNDERLINE) {
77        m |= RMod::UNDERLINED;
78    }
79    if s.attrs.contains(Attrs::REVERSE) {
80        m |= RMod::REVERSED;
81    }
82    if s.attrs.contains(Attrs::DIM) {
83        m |= RMod::DIM;
84    }
85    if s.attrs.contains(Attrs::STRIKE) {
86        m |= RMod::CROSSED_OUT;
87    }
88    out.add_modifier(m)
89}
90
91/// Convert a [`ratatui::style::Style`] to an engine-native [`Style`].
92///
93/// Lossy for ratatui colors the engine doesn't model (Indexed, named ANSI) —
94/// flattens to nearest RGB.
95pub fn style_from_ratatui(s: RStyle) -> Style {
96    fn c(rc: RColor) -> Color {
97        match rc {
98            RColor::Rgb(r, g, b) => Color(r, g, b),
99            RColor::Black => Color(0, 0, 0),
100            RColor::Red => Color(205, 49, 49),
101            RColor::Green => Color(13, 188, 121),
102            RColor::Yellow => Color(229, 229, 16),
103            RColor::Blue => Color(36, 114, 200),
104            RColor::Magenta => Color(188, 63, 188),
105            RColor::Cyan => Color(17, 168, 205),
106            RColor::Gray => Color(229, 229, 229),
107            RColor::DarkGray => Color(102, 102, 102),
108            RColor::LightRed => Color(241, 76, 76),
109            RColor::LightGreen => Color(35, 209, 139),
110            RColor::LightYellow => Color(245, 245, 67),
111            RColor::LightBlue => Color(59, 142, 234),
112            RColor::LightMagenta => Color(214, 112, 214),
113            RColor::LightCyan => Color(41, 184, 219),
114            RColor::White => Color(255, 255, 255),
115            _ => Color(0, 0, 0),
116        }
117    }
118    let mut attrs = Attrs::empty();
119    if s.add_modifier.contains(RMod::BOLD) {
120        attrs |= Attrs::BOLD;
121    }
122    if s.add_modifier.contains(RMod::ITALIC) {
123        attrs |= Attrs::ITALIC;
124    }
125    if s.add_modifier.contains(RMod::UNDERLINED) {
126        attrs |= Attrs::UNDERLINE;
127    }
128    if s.add_modifier.contains(RMod::REVERSED) {
129        attrs |= Attrs::REVERSE;
130    }
131    if s.add_modifier.contains(RMod::DIM) {
132        attrs |= Attrs::DIM;
133    }
134    if s.add_modifier.contains(RMod::CROSSED_OUT) {
135        attrs |= Attrs::STRIKE;
136    }
137    Style {
138        fg: s.fg.map(c),
139        bg: s.bg.map(c),
140        attrs,
141    }
142}
143
144/// Extension trait that adds ratatui-flavoured style methods to
145/// [`hjkl_engine::Editor`].
146///
147/// Bring into scope with `use hjkl_engine_tui::EditorRatatuiExt;` then call
148/// the methods directly on any `Editor<B, H>` value.
149pub trait EditorRatatuiExt {
150    /// Intern a [`ratatui::style::Style`] and return the opaque id used in
151    /// `hjkl_buffer::Span::style`. Converts via [`style_from_ratatui`] then
152    /// delegates to `Editor::intern_style`.
153    fn intern_ratatui_style(&mut self, style: RStyle) -> u32;
154
155    /// Install styled syntax spans given as ratatui styles. Converts each
156    /// `ratatui::style::Style` to engine-native via [`style_from_ratatui`]
157    /// then delegates to `Editor::install_syntax_spans`. Drops zero-width
158    /// runs and clamps `end` to the line's char length.
159    fn install_ratatui_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, RStyle)>>);
160
161    /// Patch only `rows` of the installed spans (ratatui-typed input).
162    /// Mirrors [`hjkl_engine::Editor::patch_syntax_spans_range`] for
163    /// callers that have ratatui styles.
164    fn patch_ratatui_syntax_spans_range(
165        &mut self,
166        rows: std::ops::Range<usize>,
167        spans: &[Vec<(usize, usize, RStyle)>],
168    );
169
170    /// Allocate and return the style table converted to ratatui styles.
171    /// Convenience for render paths that need a `Vec<ratatui::style::Style>`.
172    /// Allocates on every call — prefer a per-draw local binding.
173    fn ratatui_style_table(&self) -> Vec<RStyle>;
174}
175
176impl<H: Host> EditorRatatuiExt for Editor<hjkl_buffer::Buffer, H> {
177    fn intern_ratatui_style(&mut self, style: RStyle) -> u32 {
178        self.intern_style(style_from_ratatui(style))
179    }
180
181    fn install_ratatui_syntax_spans(&mut self, spans: Vec<Vec<(usize, usize, RStyle)>>) {
182        let engine_spans: Vec<Vec<(usize, usize, Style)>> = spans
183            .into_iter()
184            .map(|row_spans| {
185                row_spans
186                    .into_iter()
187                    .map(|(start, end, style)| (start, end, style_from_ratatui(style)))
188                    .collect()
189            })
190            .collect();
191        self.install_syntax_spans(engine_spans);
192    }
193
194    fn patch_ratatui_syntax_spans_range(
195        &mut self,
196        rows: std::ops::Range<usize>,
197        spans: &[Vec<(usize, usize, RStyle)>],
198    ) {
199        let engine_spans: Vec<Vec<(usize, usize, Style)>> = spans
200            .iter()
201            .map(|row_spans| {
202                row_spans
203                    .iter()
204                    .map(|(start, end, style)| (*start, *end, style_from_ratatui(*style)))
205                    .collect()
206            })
207            .collect();
208        self.patch_syntax_spans_range(rows, &engine_spans);
209    }
210
211    fn ratatui_style_table(&self) -> Vec<RStyle> {
212        self.style_table()
213            .iter()
214            .copied()
215            .map(style_to_ratatui)
216            .collect()
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use hjkl_engine::{Editor, types::DefaultHost};
224
225    fn fresh_editor(content: &str) -> Editor {
226        let mut e = Editor::new(
227            hjkl_buffer::Buffer::new(),
228            DefaultHost::new(),
229            hjkl_engine::types::Options::default(),
230        );
231        e.set_content(content);
232        e
233    }
234
235    #[test]
236    fn intern_ratatui_style_dedups_repeated_styles() {
237        use ratatui::style::{Color, Style};
238        let mut e = fresh_editor("");
239        let red = Style::default().fg(Color::Red);
240        let blue = Style::default().fg(Color::Blue);
241        let id_r1 = e.intern_ratatui_style(red);
242        let id_r2 = e.intern_ratatui_style(red);
243        let id_b = e.intern_ratatui_style(blue);
244        assert_eq!(id_r1, id_r2);
245        assert_ne!(id_r1, id_b);
246        assert_eq!(e.style_table().len(), 2);
247    }
248
249    #[test]
250    fn install_ratatui_syntax_spans_translates_styled_spans() {
251        use hjkl_engine::types::Color as EColor;
252        use ratatui::style::{Color, Style};
253        let mut e = fresh_editor("SELECT foo");
254        e.install_ratatui_syntax_spans(vec![vec![(0, 6, Style::default().fg(Color::Red))]]);
255        let by_row = e.buffer_spans();
256        assert_eq!(by_row.len(), 1);
257        assert_eq!(by_row[0].len(), 1);
258        assert_eq!(by_row[0][0].start_byte, 0);
259        assert_eq!(by_row[0][0].end_byte, 6);
260        let id = by_row[0][0].style;
261        // Named colors are flattened to RGB at the ratatui→engine boundary
262        // (see `style_from_ratatui`). Color::Red maps to (205, 49, 49).
263        assert_eq!(e.style_table()[id as usize].fg, Some(EColor(205, 49, 49)));
264    }
265
266    #[test]
267    fn install_ratatui_syntax_spans_clamps_sentinel_end() {
268        use ratatui::style::{Color, Style};
269        let mut e = fresh_editor("hello");
270        e.install_ratatui_syntax_spans(vec![vec![(
271            0,
272            usize::MAX,
273            Style::default().fg(Color::Blue),
274        )]]);
275        let by_row = e.buffer_spans();
276        assert_eq!(by_row[0][0].end_byte, 5);
277    }
278
279    #[test]
280    fn install_ratatui_syntax_spans_drops_zero_width() {
281        use ratatui::style::{Color, Style};
282        let mut e = fresh_editor("abc");
283        e.install_ratatui_syntax_spans(vec![vec![(2, 2, Style::default().fg(Color::Red))]]);
284        assert!(e.buffer_spans()[0].is_empty());
285    }
286}