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}