Skip to main content

buffr_modal/
edit_mode.rs

1//! Edit-mode session — wires `hjkl_engine::Editor` to a [`BuffrHost`]
2//! against an in-memory text buffer.
3//!
4//! In production this is fed by CEF V8 bindings: text-field focus
5//! events seed [`EditSession`] from the field's value, keystrokes get
6//! forwarded as [`KeyEvent`]s, and the host's tick loop drains
7//! [`EditSession::take_content_change`] to push DOM updates back via
8//! the JS bridge. The in-memory shape here is identical to that path
9//! sans CEF, which keeps the integration testable without a browser.
10
11use crate::host::BuffrHost;
12use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13use hjkl_engine::{
14    Editor, KeybindingMode, Modifiers, PlannedInput, SpecialKey, VimMode, types::Options,
15};
16use std::sync::Arc;
17
18/// Convert a crossterm [`KeyEvent`] into the engine's [`PlannedInput`].
19///
20/// Uses the crossterm-free `feed_input` path so the engine and buffr
21/// can carry different crossterm major versions without a type mismatch.
22fn key_event_to_planned(key: KeyEvent) -> PlannedInput {
23    let mods = Modifiers {
24        ctrl: key.modifiers.contains(KeyModifiers::CONTROL),
25        shift: key.modifiers.contains(KeyModifiers::SHIFT),
26        alt: key.modifiers.contains(KeyModifiers::ALT),
27        super_: key.modifiers.contains(KeyModifiers::SUPER),
28    };
29    match key.code {
30        KeyCode::Char(c) => PlannedInput::Char(c, mods),
31        KeyCode::Esc => PlannedInput::Key(SpecialKey::Esc, mods),
32        KeyCode::Enter => PlannedInput::Key(SpecialKey::Enter, mods),
33        KeyCode::Backspace => PlannedInput::Key(SpecialKey::Backspace, mods),
34        KeyCode::Tab => PlannedInput::Key(SpecialKey::Tab, mods),
35        KeyCode::BackTab => PlannedInput::Key(SpecialKey::BackTab, mods),
36        KeyCode::Up => PlannedInput::Key(SpecialKey::Up, mods),
37        KeyCode::Down => PlannedInput::Key(SpecialKey::Down, mods),
38        KeyCode::Left => PlannedInput::Key(SpecialKey::Left, mods),
39        KeyCode::Right => PlannedInput::Key(SpecialKey::Right, mods),
40        KeyCode::Home => PlannedInput::Key(SpecialKey::Home, mods),
41        KeyCode::End => PlannedInput::Key(SpecialKey::End, mods),
42        KeyCode::PageUp => PlannedInput::Key(SpecialKey::PageUp, mods),
43        KeyCode::PageDown => PlannedInput::Key(SpecialKey::PageDown, mods),
44        KeyCode::Insert => PlannedInput::Key(SpecialKey::Insert, mods),
45        KeyCode::Delete => PlannedInput::Key(SpecialKey::Delete, mods),
46        KeyCode::F(n) => PlannedInput::Key(SpecialKey::F(n), mods),
47        // Anything else the engine can't model — treat as consumed no-op
48        // by wrapping a Null-equivalent char that the FSM ignores.
49        _ => PlannedInput::Key(SpecialKey::Insert, mods),
50    }
51}
52
53/// One active edit-mode session bound to a single text field.
54///
55/// Owns the engine [`Editor`] generic over [`BuffrHost`]. The host
56/// (clipboard / time / intent fan-out) lives inside the editor as of
57/// hjkl 0.1.0 — `Editor<hjkl_buffer::Buffer, BuffrHost>`. Pull-model:
58/// per render frame the host calls [`EditSession::take_content_change`]
59/// and forwards any new content to the DOM.
60pub struct EditSession {
61    editor: Editor<hjkl_buffer::Buffer, BuffrHost>,
62}
63
64impl EditSession {
65    /// Boot the session with the field's current value.
66    pub fn new(initial: &str) -> Self {
67        let mut editor = Editor::new(
68            hjkl_buffer::Buffer::new(),
69            BuffrHost::new(),
70            Options::default(),
71        );
72        // 0.1.0: keybinding mode is a post-construction public field on
73        // Editor. Vim is the default already, but set explicitly so the
74        // intent stays visible at the call site.
75        editor.keybinding_mode = KeybindingMode::Vim;
76        editor.set_content(initial);
77        Self { editor }
78    }
79
80    /// Feed one keystroke. Returns `true` when the keystroke was
81    /// consumed by the engine; `false` means the caller should let
82    /// the page see it (`<Esc>` in normal mode, etc.).
83    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
84        self.editor.feed_input(key_event_to_planned(key))
85    }
86
87    /// Feed a [`hjkl_engine::PlannedInput`] directly. Bypasses the
88    /// crossterm conversion layer — used by the winit event path which
89    /// constructs `PlannedInput` from winit's `KeyEvent` directly so
90    /// the two crates never need a shared `crossterm` version.
91    pub fn feed_planned(&mut self, input: hjkl_engine::PlannedInput) -> bool {
92        self.editor.feed_input(input)
93    }
94
95    /// Convenience: type a literal character with no modifiers. Used
96    /// by tests and by the JS bridge when forwarding plain printable
97    /// keys.
98    pub fn type_char(&mut self, ch: char) -> bool {
99        self.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
100    }
101
102    /// Convenience: send a special key (Esc, Enter, etc.) with no
103    /// modifiers.
104    pub fn press(&mut self, code: KeyCode) -> bool {
105        self.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
106    }
107
108    /// Type a string in insert mode. Caller must already have entered
109    /// insert mode (`type_char('i')`); panics if asked to type while
110    /// not in insert.
111    pub fn type_str(&mut self, s: &str) {
112        debug_assert_eq!(self.editor.vim_mode(), VimMode::Insert);
113        for ch in s.chars() {
114            self.type_char(ch);
115        }
116    }
117
118    /// Pull-model change drain. Returns the new content if anything
119    /// changed since the last call; `None` if nothing did. Host
120    /// forwards `Some` to the DOM.
121    pub fn take_content_change(&mut self) -> Option<Arc<String>> {
122        self.editor.take_content_change()
123    }
124
125    /// Current full content. Useful for first-frame rendering.
126    pub fn content(&self) -> String {
127        self.editor.content()
128    }
129
130    /// Mode for the status-line summary.
131    pub fn vim_mode(&self) -> VimMode {
132        self.editor.vim_mode()
133    }
134
135    /// Drain queued clipboard writes the engine has accumulated.
136    /// Host's tick loop dispatches each to CEF.
137    pub fn drain_clipboard_outbox(&mut self) -> Vec<String> {
138        self.editor.host_mut().drain_clipboard_outbox()
139    }
140
141    /// Drain queued intents (`RequestAutocomplete`, `SwitchBuffer`,
142    /// etc.). Host fans each out to its CEF / browser-action layer.
143    pub fn drain_intents(&mut self) -> Vec<crate::host::BuffrEditIntent> {
144        self.editor.host_mut().drain_intents()
145    }
146
147    /// Mutable access to the host. Production callers reach for this
148    /// to refresh the clipboard cache on focus events / OSC52 reply.
149    pub fn host_mut(&mut self) -> &mut BuffrHost {
150        self.editor.host_mut()
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn empty_session_starts_in_normal_mode() {
160        let s = EditSession::new("");
161        assert_eq!(s.vim_mode(), VimMode::Normal);
162        // Editor::content() always terminates with a trailing newline.
163        assert!(matches!(s.content().as_str(), "" | "\n"));
164    }
165
166    #[test]
167    fn type_hello_in_insert_then_esc() {
168        let mut s = EditSession::new("");
169        // `i` enters insert mode.
170        s.type_char('i');
171        assert_eq!(s.vim_mode(), VimMode::Insert);
172        s.type_str("hello");
173        s.press(KeyCode::Esc);
174        assert_eq!(s.vim_mode(), VimMode::Normal);
175        assert!(s.content().starts_with("hello"));
176    }
177
178    #[test]
179    fn take_content_change_drains_after_first_call() {
180        let mut s = EditSession::new("foo");
181        // First call returns Some — the initial set_content marked dirty.
182        assert!(s.take_content_change().is_some());
183        // Subsequent call sees no change.
184        assert!(s.take_content_change().is_none());
185        // Mutate and confirm the dirty edge re-triggers.
186        s.type_char('i');
187        s.type_char('X');
188        s.press(KeyCode::Esc);
189        let after = s.take_content_change();
190        assert!(after.is_some());
191        assert!(after.unwrap().contains('X'));
192    }
193
194    #[test]
195    fn dd_clears_only_line() {
196        let mut s = EditSession::new("hello world");
197        // Two `d` strokes in normal mode delete the line.
198        s.type_char('d');
199        s.type_char('d');
200        // After dd on a one-line buffer, content becomes empty (or just \n).
201        let content = s.content();
202        assert!(
203            content.is_empty() || content == "\n",
204            "expected empty or \\n, got {content:?}"
205        );
206    }
207
208    #[test]
209    fn esc_from_normal_stays_normal() {
210        // Page-mode would forward an Esc-in-normal back to JS for
211        // page-level handling. Here we just confirm the engine
212        // doesn't panic and stays in Normal.
213        let mut s = EditSession::new("hello");
214        s.press(KeyCode::Esc);
215        s.press(KeyCode::Esc);
216        s.press(KeyCode::Esc);
217        assert_eq!(s.vim_mode(), VimMode::Normal);
218    }
219
220    #[test]
221    fn feed_planned_round_trip() {
222        // Stage 2: feed_planned bypasses crossterm; same FSM result.
223        // Type `i`, `H`, `i`, Esc → content starts with "Hi".
224        use hjkl_engine::{Modifiers, PlannedInput, SpecialKey};
225        let empty_mods = Modifiers::default();
226        let mut s = EditSession::new("");
227        // `i` → enter insert mode
228        s.feed_planned(PlannedInput::Char('i', empty_mods));
229        assert_eq!(s.vim_mode(), VimMode::Insert);
230        s.feed_planned(PlannedInput::Char('H', empty_mods));
231        s.feed_planned(PlannedInput::Char('i', empty_mods));
232        s.feed_planned(PlannedInput::Key(SpecialKey::Esc, empty_mods));
233        assert_eq!(s.vim_mode(), VimMode::Normal);
234        assert!(
235            s.content().starts_with("Hi"),
236            "expected content to start with 'Hi', got {:?}",
237            s.content()
238        );
239    }
240
241    #[test]
242    fn yank_then_paste_via_clipboard() {
243        // `yy` yanks a line into the unnamed register; `p` pastes it
244        // below. Confirms the engine's register pipeline works without
245        // the host clipboard plumbing — internal-only.
246        let mut s = EditSession::new("alpha");
247        s.type_char('y');
248        s.type_char('y');
249        s.type_char('p');
250        let content = s.content();
251        // Paste produces "alpha\nalpha" (or similar with a trailing newline).
252        assert!(
253            content.matches("alpha").count() >= 2,
254            "expected two 'alpha' lines, got {content:?}"
255        );
256    }
257}