fresh/widgets/registry.rs
1//! Panel registry — maps plugin-allocated `panel_id` to mounted spec
2//! and hit-area data for click routing.
3//!
4//! The registry is the source of truth for "which panels exist, what
5//! spec are they currently rendering, and which buffer rows belong
6//! to which widget." It does *not* own the virtual buffer the
7//! rendered output goes into — the plugin still owns the virtual
8//! buffer and passes its `BufferId` at mount time.
9
10use fresh_core::api::WidgetSpec;
11use fresh_core::BufferId;
12use std::collections::{HashMap, HashSet};
13
14/// Plugin-allocated panel identifier. Unique within a plugin; the
15/// editor does not interpret the value.
16pub type PanelId = u64;
17
18/// One clickable rectangle within a rendered widget panel.
19///
20/// The renderer produces one `HitArea` per interactive widget node
21/// (`Toggle`, `Button` in v1). Layout containers (`Row`, `Col`,
22/// `Spacer`, `HintBar`, `Raw`) emit no hit areas of their own; their
23/// children's hit areas bubble up with row/byte offsets adjusted to
24/// reflect the final on-screen position.
25///
26/// Hit-test is `(buffer_row, buffer_col_byte) ∈ rectangle`; the byte
27/// range is in UTF-8 bytes within the row's text, matching the
28/// coordinate space `mouse_click` already delivers
29/// (`HookArgs::MouseClick::buffer_col`).
30#[derive(Debug, Clone)]
31pub struct HitArea {
32 /// Stable widget key from the spec, or empty when the spec did
33 /// not assign one.
34 pub widget_key: String,
35 /// Widget kind discriminator: `"toggle"` or `"button"`.
36 pub widget_kind: &'static str,
37 /// 0-indexed row within the rendered virtual buffer.
38 pub buffer_row: u32,
39 /// First UTF-8 byte (inclusive) within the row's text.
40 pub byte_start: usize,
41 /// Last UTF-8 byte (exclusive) within the row's text.
42 pub byte_end: usize,
43 /// Event payload to deliver with the `widget_event` hook.
44 /// For `"toggle"`: `{ "checked": <new value> }`. For
45 /// `"button"`: `{}`.
46 pub payload: serde_json::Value,
47 /// Event type to deliver with the `widget_event` hook
48 /// (`"toggle"` or `"activate"`).
49 pub event_type: &'static str,
50}
51
52/// Widget instance state retained across spec updates, keyed by
53/// the widget's stable `key`. This is the "Spec/instance separation"
54/// described in §6 of the design doc — a plugin can rebuild its
55/// `WidgetSpec` from scratch on every model change without losing
56/// scroll offset, cursor position, expanded keys, or focus, because
57/// stateful widgets look up their instance state by `key`.
58#[derive(Debug, Clone, Default)]
59pub enum WidgetInstanceState {
60 /// Empty/placeholder — never persisted, used as a default.
61 #[default]
62 None,
63 /// `List` instance state: host-owned scroll offset *and*
64 /// selected index. `selected_index` becomes authoritative
65 /// after first render — same correctness reasoning as
66 /// `TextInput`'s host-owned value (host can mutate it via
67 /// `WidgetCommand::SelectMove` without racing the plugin's
68 /// spec round-trip).
69 List {
70 scroll_offset: u32,
71 selected_index: i32,
72 },
73 /// `Text` instance state: host-owned value + cursor byte
74 /// offset, plus a viewport scroll offset that's only meaningful
75 /// for multi-line (`rows > 1`) variants — the row index of the
76 /// first visible line. Single-line text widgets always render
77 /// from value byte 0 and rely on render-time head-truncate
78 /// scrolling, so they leave `scroll` at `0`.
79 ///
80 /// Becomes authoritative once the widget mounts; the spec's
81 /// `value` / `cursor_byte` are *initial-only* (used at first
82 /// render and ignored thereafter). This guarantees correctness
83 /// under concurrent keystrokes — the plugin's spec round-trip
84 /// can't race against multiple in-flight `WidgetCommand`
85 /// mutations because the host doesn't read from the spec for
86 /// value at all once instance state exists.
87 Text {
88 value: String,
89 cursor_byte: u32,
90 scroll: u32,
91 },
92 /// `Tree` instance state: host-owned scroll offset, selected
93 /// index, and the set of expanded item keys. All three become
94 /// authoritative after first render — the spec's
95 /// `selected_index` / `expanded_keys` are seed values only.
96 /// `expanded_keys` is a `HashSet` because expansion is
97 /// set-membership semantically (a key is either expanded or
98 /// not); ordering doesn't matter and we hit-test on contains.
99 Tree {
100 scroll_offset: u32,
101 selected_index: i32,
102 expanded_keys: HashSet<String>,
103 },
104}
105
106/// Per-panel state retained between renders. The reconciler will use
107/// the previous spec to compute the minimum mutation when a future
108/// `UpdateWidgetPanel` arrives.
109#[derive(Debug, Clone)]
110pub struct WidgetPanelState {
111 /// The virtual buffer this panel renders into.
112 pub buffer_id: BufferId,
113 /// The currently-mounted spec.
114 pub spec: WidgetSpec,
115 /// Click rectangles for the rendered output, in declaration
116 /// order. Hit-test scans linearly — the small N (one per
117 /// interactive widget per panel) doesn't justify a spatial
118 /// index.
119 pub hits: Vec<HitArea>,
120 /// Widget instance state by widget `key`. Survives re-renders —
121 /// see `WidgetInstanceState` for what's stored.
122 pub instance_states: HashMap<String, WidgetInstanceState>,
123 /// Currently-focused widget key within this panel. Empty when
124 /// the panel has no focusable widgets, or before the first
125 /// render. Maintained by the renderer (clamps to a valid
126 /// tabbable key on every render) and by `widget_focus_advance`
127 /// (cycles through tabbables on Tab / Shift+Tab).
128 pub focus_key: String,
129 /// Tabbable widget keys collected from the most recent render,
130 /// in declaration order. The Tab-cycle command finds the
131 /// current `focus_key`'s position in this list and advances by
132 /// the requested delta (with wraparound).
133 pub tabbable: Vec<String>,
134}
135
136/// Global registry of mounted widget panels.
137#[derive(Debug, Default)]
138pub struct WidgetRegistry {
139 panels: HashMap<PanelId, WidgetPanelState>,
140}
141
142impl WidgetRegistry {
143 pub fn new() -> Self {
144 Self::default()
145 }
146
147 /// Mount or replace a panel. Returns the previous state if the
148 /// panel was already mounted (the dispatcher may use this to
149 /// detect re-mounts on the same id).
150 ///
151 /// The wide parameter list is the price of `WidgetPanelState`
152 /// being public — every field is plainly named at the call
153 /// site rather than buried inside an opaque builder. The
154 /// dispatcher always populates them all from one `RenderOutput`,
155 /// so the apparent verbosity stays at the boundary.
156 #[allow(clippy::too_many_arguments)]
157 pub fn mount(
158 &mut self,
159 panel_id: PanelId,
160 buffer_id: BufferId,
161 spec: WidgetSpec,
162 hits: Vec<HitArea>,
163 instance_states: HashMap<String, WidgetInstanceState>,
164 focus_key: String,
165 tabbable: Vec<String>,
166 ) -> Option<WidgetPanelState> {
167 self.panels.insert(
168 panel_id,
169 WidgetPanelState {
170 buffer_id,
171 spec,
172 hits,
173 instance_states,
174 focus_key,
175 tabbable,
176 },
177 )
178 }
179
180 /// Replace the spec and rendered metadata on an already-mounted
181 /// panel. Returns `Ok(buffer_id)` to render into, or `Err(())`
182 /// if no panel exists for that id (caller should drop the
183 /// update — the plugin re-emitted after unmount). The unit
184 /// error is sufficient: there's exactly one failure mode and
185 /// no payload to attach.
186 #[allow(clippy::result_unit_err)]
187 #[allow(clippy::too_many_arguments)]
188 pub fn update(
189 &mut self,
190 panel_id: PanelId,
191 spec: WidgetSpec,
192 hits: Vec<HitArea>,
193 instance_states: HashMap<String, WidgetInstanceState>,
194 focus_key: String,
195 tabbable: Vec<String>,
196 ) -> Result<BufferId, ()> {
197 match self.panels.get_mut(&panel_id) {
198 Some(state) => {
199 state.spec = spec;
200 state.hits = hits;
201 state.instance_states = instance_states;
202 state.focus_key = focus_key;
203 state.tabbable = tabbable;
204 Ok(state.buffer_id)
205 }
206 None => Err(()),
207 }
208 }
209
210 /// Read-only access to the instance state for a panel — used by
211 /// the dispatcher to thread previous scroll offsets / cursor
212 /// positions into the next render so they persist.
213 pub fn instance_states(
214 &self,
215 panel_id: PanelId,
216 ) -> Option<&HashMap<String, WidgetInstanceState>> {
217 self.panels.get(&panel_id).map(|s| &s.instance_states)
218 }
219
220 /// Read-only access to the previous render's focus key.
221 pub fn focus_key(&self, panel_id: PanelId) -> Option<&str> {
222 self.panels.get(&panel_id).map(|s| s.focus_key.as_str())
223 }
224
225 /// Set the focus key directly (used by `widget_focus_advance`
226 /// and click-driven focus moves). Updates the in-place state;
227 /// the next render reads it via `focus_key()`.
228 pub fn set_focus_key(&mut self, panel_id: PanelId, key: String) {
229 if let Some(state) = self.panels.get_mut(&panel_id) {
230 state.focus_key = key;
231 }
232 }
233
234 /// Find the buffer and current spec for a panel — used by the
235 /// dispatcher to re-render after a focus advance / activate
236 /// command without the plugin needing to send an UpdateWidgetPanel.
237 pub fn buffer_and_spec(&self, panel_id: PanelId) -> Option<(BufferId, WidgetSpec)> {
238 self.panels
239 .get(&panel_id)
240 .map(|s| (s.buffer_id, s.spec.clone()))
241 }
242
243 /// Tear down a panel. Returns the buffer_id the panel was
244 /// rendering into, so the caller can clear the buffer if it
245 /// owns it.
246 pub fn unmount(&mut self, panel_id: PanelId) -> Option<BufferId> {
247 self.panels.remove(&panel_id).map(|s| s.buffer_id)
248 }
249
250 /// Read-only access to a panel's current state.
251 pub fn get(&self, panel_id: PanelId) -> Option<&WidgetPanelState> {
252 self.panels.get(&panel_id)
253 }
254
255 /// Mutable access — used by `WidgetCommand` handlers that
256 /// update widget instance state (e.g. TextInput value/cursor)
257 /// directly without round-tripping through the plugin.
258 pub fn get_mut(&mut self, panel_id: PanelId) -> Option<&mut WidgetPanelState> {
259 self.panels.get_mut(&panel_id)
260 }
261
262 /// All currently-mounted panel ids — useful for theme-change
263 /// re-render passes (every panel re-renders against the new
264 /// theme without plugin involvement).
265 pub fn panel_ids(&self) -> Vec<PanelId> {
266 self.panels.keys().copied().collect()
267 }
268
269 /// Panels rendering into `buffer_id`. Used by mouse-wheel
270 /// routing to find which widget panel sits under the pointer.
271 pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelId> {
272 self.panels
273 .iter()
274 .filter(|(_, s)| s.buffer_id == buffer_id)
275 .map(|(pid, _)| *pid)
276 .collect()
277 }
278
279 /// Hit-test the given buffer-local position against every
280 /// currently-mounted panel rendering into `buffer_id`. Returns
281 /// the matching panel id and a clone of the hit area on a hit,
282 /// `None` otherwise.
283 ///
284 /// Linear scan: panel count is typically 1 per buffer; per-panel
285 /// hit count is small (one per interactive widget). A spatial
286 /// index would be over-engineering at this scale.
287 pub fn hit_test(
288 &self,
289 buffer_id: BufferId,
290 row: u32,
291 col_byte: u32,
292 ) -> Option<(PanelId, HitArea)> {
293 for (pid, state) in &self.panels {
294 if state.buffer_id != buffer_id {
295 continue;
296 }
297 for hit in &state.hits {
298 if hit.buffer_row == row
299 && (col_byte as usize) >= hit.byte_start
300 && (col_byte as usize) < hit.byte_end
301 {
302 return Some((*pid, hit.clone()));
303 }
304 }
305 }
306 None
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use serde_json::json;
314
315 fn empty_spec() -> WidgetSpec {
316 WidgetSpec::Col {
317 children: vec![],
318 key: None,
319 }
320 }
321
322 fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
323 HitArea {
324 widget_key: key.into(),
325 widget_kind: "button",
326 buffer_row: row,
327 byte_start,
328 byte_end,
329 payload: json!({}),
330 event_type: "activate",
331 }
332 }
333
334 #[test]
335 fn hit_test_finds_widget_inside_range() {
336 let mut reg = WidgetRegistry::new();
337 reg.mount(
338 42,
339 BufferId(7),
340 empty_spec(),
341 vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
342 HashMap::new(),
343 String::new(),
344 Vec::new(),
345 );
346 let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
347 assert_eq!(hit.0, 42);
348 assert_eq!(hit.1.widget_key, "b");
349 }
350
351 #[test]
352 fn hit_test_returns_none_when_outside_range() {
353 let mut reg = WidgetRegistry::new();
354 reg.mount(
355 1,
356 BufferId(0),
357 empty_spec(),
358 vec![make_hit(0, 0, 5, "a")],
359 HashMap::new(),
360 String::new(),
361 Vec::new(),
362 );
363 assert!(
364 reg.hit_test(BufferId(0), 0, 5).is_none(),
365 "byte_end is exclusive"
366 );
367 assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
368 assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
369 assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
370 }
371
372 #[test]
373 fn unmount_clears_hits() {
374 let mut reg = WidgetRegistry::new();
375 reg.mount(
376 5,
377 BufferId(2),
378 empty_spec(),
379 vec![make_hit(0, 0, 3, "x")],
380 HashMap::new(),
381 String::new(),
382 Vec::new(),
383 );
384 assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
385 reg.unmount(5);
386 assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
387 }
388
389 #[test]
390 fn update_replaces_hits() {
391 let mut reg = WidgetRegistry::new();
392 reg.mount(
393 5,
394 BufferId(2),
395 empty_spec(),
396 vec![make_hit(0, 0, 3, "old")],
397 HashMap::new(),
398 String::new(),
399 Vec::new(),
400 );
401 reg.update(
402 5,
403 empty_spec(),
404 vec![make_hit(1, 4, 9, "new")],
405 HashMap::new(),
406 String::new(),
407 Vec::new(),
408 )
409 .expect("mounted");
410 // Old hit gone; new hit visible.
411 assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
412 let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
413 assert_eq!(hit.1.widget_key, "new");
414 }
415}