Skip to main content

buffr_modal/
host.rs

1//! `BuffrHost` — the host adapter that wires `hjkl_engine::Editor` to
2//! the buffr browser shell.
3//!
4//! Implements [`hjkl_engine::Host`] with `type Intent = BuffrEditIntent`.
5//! Inherent helpers (`set_clipboard_cache`, `drain_clipboard_outbox`,
6//! `drain_intents`) sit alongside the trait methods so the host's tick
7//! loop can flush queued operations on its own cadence — the engine
8//! never blocks on either clipboard or intent fan-out.
9
10use hjkl_buffer::Viewport;
11use hjkl_engine::{CursorShape, Host};
12use std::time::Instant;
13
14/// Buffer identifier in buffr's tab manager. Opaque — host owns the
15/// generation; engine echoes it back in intents.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct BuffrBufferId(pub u64);
18
19/// Intents the engine emits back at the host. Variants align with the
20/// SPEC `Host::Intent` shape buffr will set when `hjkl_engine::Host`
21/// ships.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum BuffrEditIntent {
24    /// Form-field autocomplete trigger (`Ctrl-Space`, `<Tab>` in some
25    /// configs). Host queries the page's form-fill or LSP-equivalent
26    /// service.
27    RequestAutocomplete,
28    /// Switch focus to a different buffer (tab / textarea).
29    SwitchBuffer(BuffrBufferId),
30    /// User typed a key the page should see un-modified (e.g., `<Esc>`
31    /// in a `contenteditable` should bubble to JS handlers).
32    PassThrough,
33}
34
35/// Host adapter consumed by `hjkl_engine::Editor` once edit-mode is
36/// active.
37#[derive(Debug)]
38pub struct BuffrHost {
39    /// Last cursor shape requested by the engine. Drained by the host
40    /// renderer per frame.
41    pub last_cursor_shape: CursorShape,
42    // Other fields intentionally below — keep `last_cursor_shape` first
43    // so debug-printing the host shows the most recently observed
44    // mode-derived state.
45    /// Cached system clipboard value. Refreshed by the host on focus
46    /// events / OSC52 reply / explicit poll. Reads from the engine
47    /// return this slot directly — never block.
48    clipboard_cache: Option<String>,
49    /// Pending writes to the system clipboard. Flushed asynchronously
50    /// by the host's tick loop; engine never awaits.
51    clipboard_outbox: Vec<String>,
52    /// Wall-clock start so timeouts can be expressed as `Duration` from
53    /// editor construction time. Engine itself doesn't read clocks
54    /// directly — it asks the host via `now()`.
55    started: Instant,
56    /// Intent queue drained by the host once per render frame.
57    intents: Vec<BuffrEditIntent>,
58    /// Runtime viewport (rows/cols, scroll offsets) the engine reads
59    /// and writes via `Host::viewport` / `Host::viewport_mut` as of
60    /// hjkl 0.0.34 (Patch C-δ.1 — viewport relocated off `Buffer`).
61    ///
62    /// Sourced from the CEF/winit canvas: when buffr's main loop wires
63    /// edit-mode into the page lifecycle it should call
64    /// [`set_viewport_size`] from the existing `WindowEvent::Resized`
65    /// handler in `apps/buffr/src/main.rs`. Until then the viewport
66    /// stays at the zero-size default — engine scroll math falls back
67    /// to no-op (see `Viewport::new` docs), so this is a safe stub.
68    viewport: Viewport,
69}
70
71impl Default for BuffrHost {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl BuffrHost {
78    pub fn new() -> Self {
79        Self {
80            last_cursor_shape: CursorShape::Block,
81            clipboard_cache: None,
82            clipboard_outbox: Vec::new(),
83            started: Instant::now(),
84            intents: Vec::new(),
85            viewport: Viewport::new(),
86        }
87    }
88
89    /// Publish the current canvas size into the viewport. Called from
90    /// the host's resize event handler (winit `WindowEvent::Resized`
91    /// in `apps/buffr`). `width` / `height` are in **cells**, not
92    /// pixels — buffr will divide pixel dimensions by font metrics
93    /// before calling this once edit-mode rendering lands.
94    ///
95    /// Currently unwired: buffr's main loop drives CEF + softbuffer
96    /// chrome and doesn't yet route resizes into edit-mode. When the
97    /// edit-mode overlay is plumbed, hook this in.
98    pub fn set_viewport_size(&mut self, width: u16, height: u16) {
99        self.viewport.width = width;
100        self.viewport.height = height;
101    }
102
103    /// Update the cached clipboard. Host calls this on focus events or
104    /// when an OSC52 read reply arrives.
105    pub fn set_clipboard_cache(&mut self, text: Option<String>) {
106        self.clipboard_cache = text;
107    }
108
109    /// Drain pending clipboard writes. Host's tick loop calls this and
110    /// dispatches each to the platform clipboard backend.
111    pub fn drain_clipboard_outbox(&mut self) -> Vec<String> {
112        std::mem::take(&mut self.clipboard_outbox)
113    }
114
115    /// Drain queued intents. Host calls this once per render frame.
116    pub fn drain_intents(&mut self) -> Vec<BuffrEditIntent> {
117        std::mem::take(&mut self.intents)
118    }
119}
120
121impl Host for BuffrHost {
122    type Intent = BuffrEditIntent;
123
124    fn write_clipboard(&mut self, text: String) {
125        self.clipboard_outbox.push(text);
126    }
127
128    fn read_clipboard(&mut self) -> Option<String> {
129        self.clipboard_cache.clone()
130    }
131
132    fn now(&self) -> std::time::Duration {
133        self.started.elapsed()
134    }
135
136    fn prompt_search(&mut self) -> Option<String> {
137        // CEF prompt overlay is wired in phase 3 of buffr's roadmap.
138        // Until then, abort the search rather than block on a sync
139        // prompt the host can't service.
140        None
141    }
142
143    fn emit_cursor_shape(&mut self, shape: CursorShape) {
144        self.last_cursor_shape = shape;
145    }
146
147    fn emit_intent(&mut self, intent: Self::Intent) {
148        self.intents.push(intent);
149    }
150
151    fn viewport(&self) -> &Viewport {
152        &self.viewport
153    }
154
155    fn viewport_mut(&mut self) -> &mut Viewport {
156        &mut self.viewport
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn clipboard_outbox_drains() {
166        let mut host = BuffrHost::new();
167        host.write_clipboard("foo".into());
168        host.write_clipboard("bar".into());
169        let drained = host.drain_clipboard_outbox();
170        assert_eq!(drained, vec!["foo".to_string(), "bar".to_string()]);
171        assert!(host.drain_clipboard_outbox().is_empty());
172    }
173
174    #[test]
175    fn read_clipboard_uses_cache() {
176        let mut host = BuffrHost::new();
177        assert_eq!(host.read_clipboard(), None);
178        host.set_clipboard_cache(Some("payload".into()));
179        assert_eq!(host.read_clipboard().as_deref(), Some("payload"));
180    }
181
182    #[test]
183    fn intents_drain() {
184        let mut host = BuffrHost::new();
185        host.emit_intent(BuffrEditIntent::RequestAutocomplete);
186        host.emit_intent(BuffrEditIntent::PassThrough);
187        let drained = host.drain_intents();
188        assert_eq!(drained.len(), 2);
189        assert!(host.drain_intents().is_empty());
190    }
191
192    #[test]
193    fn now_advances() {
194        let host = BuffrHost::new();
195        let t0 = host.now();
196        std::thread::sleep(std::time::Duration::from_millis(1));
197        let t1 = host.now();
198        assert!(t1 > t0);
199    }
200
201    #[test]
202    fn cursor_shape_recorded() {
203        let mut host = BuffrHost::new();
204        assert_eq!(host.last_cursor_shape, CursorShape::Block);
205        host.emit_cursor_shape(CursorShape::Bar);
206        assert_eq!(host.last_cursor_shape, CursorShape::Bar);
207    }
208
209    /// Compile-time check that BuffrHost satisfies the Host trait
210    /// bound — confirms `type Intent = BuffrEditIntent` plus the full
211    /// method set.
212    #[test]
213    fn satisfies_host_trait() {
214        fn assert_host<H: Host>() {}
215        assert_host::<BuffrHost>();
216    }
217
218    #[test]
219    fn viewport_defaults_zero_then_round_trips() {
220        let mut host = BuffrHost::new();
221        // Pre-resize: zero-size viewport, engine scroll math is a no-op.
222        assert_eq!(host.viewport().width, 0);
223        assert_eq!(host.viewport().height, 0);
224
225        // Resize event: host publishes new dimensions.
226        host.set_viewport_size(120, 40);
227        assert_eq!(host.viewport().width, 120);
228        assert_eq!(host.viewport().height, 40);
229
230        // Engine writes via viewport_mut (e.g. set_viewport_top in 0.0.34).
231        host.viewport_mut().top_row = 5;
232        assert_eq!(host.viewport().top_row, 5);
233    }
234}