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