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 /// Rows each item occupies in the last render: 1 for a classic
74 /// one-row-per-item list, or the uniform card height for an
75 /// `item_specs` (card) list. The renderer writes it; mouse
76 /// handlers read it to convert the row-denominated `visible_rows`
77 /// into a per-item scroll window (so wheel/scrollbar bounds are
78 /// right for card lists, and an un-scrollable list still lets the
79 /// wheel bubble to an enclosing scrollable pane).
80 item_height: u32,
81 /// True once the user has scrolled the list by mouse (wheel or
82 /// scrollbar) without moving the selection. While set, the
83 /// renderer respects `scroll_offset` as-is instead of snapping
84 /// it back to keep `selected_index` in view — so a mouse scroll
85 /// can push the selected card off-screen. Cleared whenever the
86 /// selection itself moves (keyboard nav, click, or a plugin
87 /// `SetSelectedIndex`), which re-arms scroll-follows-selection.
88 user_scrolled: bool,
89 },
90 /// `Text` instance state: host-owned `TextEdit` (value + cursor
91 /// row/col + selection anchor + multiline flag), plus a viewport
92 /// scroll offset that's only meaningful for multi-line
93 /// (`rows > 1`) variants — the row index of the first visible
94 /// line. Single-line text widgets always render from value
95 /// byte 0 and rely on render-time head-truncate scrolling, so
96 /// they leave `scroll` at `0`.
97 ///
98 /// Becomes authoritative once the widget mounts; the spec's
99 /// `value` / `cursor_byte` are *initial-only* (used at first
100 /// render and ignored thereafter). This guarantees correctness
101 /// under concurrent keystrokes — the plugin's spec round-trip
102 /// can't race against multiple in-flight `WidgetCommand`
103 /// mutations because the host doesn't read from the spec for
104 /// value at all once instance state exists.
105 ///
106 /// Switching from a naive `(String, u32)` to `TextEdit` is what
107 /// gives the widget framework selection support, word
108 /// navigation, and clipboard ops "for free" — every keybinding
109 /// the legacy Settings UI accepted via `TextEdit` now applies
110 /// to widget-backed text inputs too.
111 Text {
112 editor: TextEdit,
113 scroll: u32,
114 /// Completion popup candidates the plugin most recently
115 /// pushed via `WidgetMutation::SetCompletions`. Empty =
116 /// popup closed. The list is stored host-side rather
117 /// than read from each `WidgetSpec` so the host can
118 /// keep painting the popup across renders that don't
119 /// re-push it, and so `Up`/`Down` selection survives a
120 /// spec refresh.
121 completions: Vec<fresh_core::api::CompletionItem>,
122 /// Host-managed selection cursor into `completions`.
123 /// Reset to 0 every time `SetCompletions` runs with a
124 /// non-empty list; clamped on every render in case the
125 /// list shrank.
126 completion_selected_index: usize,
127 /// Index of the first candidate row currently painted.
128 /// Up/Down adjusts this implicitly (the renderer auto-
129 /// scrolls to keep selection in view); the mouse wheel
130 /// scrolls it directly without moving the selection.
131 completion_scroll_offset: u32,
132 },
133 /// `Tree` instance state: host-owned scroll offset, selected
134 /// index, and the set of expanded item keys. All three become
135 /// authoritative after first render — the spec's
136 /// `selected_index` / `expanded_keys` are seed values only.
137 /// `expanded_keys` is a `HashSet` because expansion is
138 /// set-membership semantically (a key is either expanded or
139 /// not); ordering doesn't matter and we hit-test on contains.
140 Tree {
141 scroll_offset: u32,
142 selected_index: i32,
143 expanded_keys: HashSet<String>,
144 },
145}
146
147/// Per-panel state retained between renders. The reconciler will use
148/// the previous spec to compute the minimum mutation when a future
149/// `UpdateWidgetPanel` arrives.
150#[derive(Debug, Clone)]
151pub struct WidgetPanelState {
152 /// The virtual buffer this panel renders into.
153 pub buffer_id: BufferId,
154 /// The currently-mounted spec.
155 pub spec: WidgetSpec,
156 /// Click rectangles for the rendered output, in declaration
157 /// order. Hit-test scans linearly — the small N (one per
158 /// interactive widget per panel) doesn't justify a spatial
159 /// index.
160 pub hits: Vec<HitArea>,
161 /// Widget instance state by widget `key`. Survives re-renders —
162 /// see `WidgetInstanceState` for what's stored.
163 pub instance_states: HashMap<String, WidgetInstanceState>,
164 /// Currently-focused widget key within this panel. Empty when
165 /// the panel has no focusable widgets, or before the first
166 /// render. Maintained by the renderer (clamps to a valid
167 /// tabbable key on every render) and by `widget_focus_advance`
168 /// (cycles through tabbables on Tab / Shift+Tab).
169 pub focus_key: String,
170 /// Tabbable widget keys collected from the most recent render,
171 /// in declaration order. The Tab-cycle command finds the
172 /// current `focus_key`'s position in this list and advances by
173 /// the requested delta (with wraparound).
174 pub tabbable: Vec<String>,
175}
176
177/// Global registry of mounted widget panels.
178#[derive(Debug, Default)]
179pub struct WidgetRegistry {
180 panels: HashMap<PanelId, WidgetPanelState>,
181}
182
183impl WidgetRegistry {
184 pub fn new() -> Self {
185 Self::default()
186 }
187
188 /// Mount or replace a panel. Returns the previous state if the
189 /// panel was already mounted (the dispatcher may use this to
190 /// detect re-mounts on the same id).
191 ///
192 /// The wide parameter list is the price of `WidgetPanelState`
193 /// being public — every field is plainly named at the call
194 /// site rather than buried inside an opaque builder. The
195 /// dispatcher always populates them all from one `RenderOutput`,
196 /// so the apparent verbosity stays at the boundary.
197 #[allow(clippy::too_many_arguments)]
198 pub fn mount(
199 &mut self,
200 panel_id: PanelId,
201 buffer_id: BufferId,
202 spec: WidgetSpec,
203 hits: Vec<HitArea>,
204 instance_states: HashMap<String, WidgetInstanceState>,
205 focus_key: String,
206 tabbable: Vec<String>,
207 ) -> Option<WidgetPanelState> {
208 self.panels.insert(
209 panel_id,
210 WidgetPanelState {
211 buffer_id,
212 spec,
213 hits,
214 instance_states,
215 focus_key,
216 tabbable,
217 },
218 )
219 }
220
221 /// Replace the spec and rendered metadata on an already-mounted
222 /// panel. Returns `Ok(buffer_id)` to render into, or `Err(())`
223 /// if no panel exists for that id (caller should drop the
224 /// update — the plugin re-emitted after unmount). The unit
225 /// error is sufficient: there's exactly one failure mode and
226 /// no payload to attach.
227 #[allow(clippy::result_unit_err)]
228 #[allow(clippy::too_many_arguments)]
229 pub fn update(
230 &mut self,
231 panel_id: PanelId,
232 spec: WidgetSpec,
233 hits: Vec<HitArea>,
234 instance_states: HashMap<String, WidgetInstanceState>,
235 focus_key: String,
236 tabbable: Vec<String>,
237 ) -> Result<BufferId, ()> {
238 match self.panels.get_mut(&panel_id) {
239 Some(state) => {
240 state.spec = spec;
241 state.hits = hits;
242 state.instance_states = instance_states;
243 state.focus_key = focus_key;
244 state.tabbable = tabbable;
245 Ok(state.buffer_id)
246 }
247 None => Err(()),
248 }
249 }
250
251 /// Read-only access to the instance state for a panel — used by
252 /// the dispatcher to thread previous scroll offsets / cursor
253 /// positions into the next render so they persist.
254 pub fn instance_states(
255 &self,
256 panel_id: PanelId,
257 ) -> Option<&HashMap<String, WidgetInstanceState>> {
258 self.panels.get(&panel_id).map(|s| &s.instance_states)
259 }
260
261 /// Read-only access to the previous render's focus key.
262 pub fn focus_key(&self, panel_id: PanelId) -> Option<&str> {
263 self.panels.get(&panel_id).map(|s| s.focus_key.as_str())
264 }
265
266 /// Set the focus key directly (used by `widget_focus_advance`
267 /// and click-driven focus moves). Updates the in-place state;
268 /// the next render reads it via `focus_key()`.
269 pub fn set_focus_key(&mut self, panel_id: PanelId, key: String) {
270 if let Some(state) = self.panels.get_mut(&panel_id) {
271 state.focus_key = key;
272 }
273 }
274
275 /// Host-driven scroll of a `List` widget (e.g. a scrollbar drag).
276 /// Sets the list's `scroll_offset` and, when the list has a live
277 /// selection, clamps `selected_index` into the new visible window
278 /// `[scroll, scroll + visible)` so the next render's
279 /// ensure-selected-visible doesn't snap the thumb back.
280 ///
281 /// Returns the post-clamp `selected_index` when the list has a
282 /// selection that moved (so the caller can notify the plugin to
283 /// keep its own selection mirror + preview in sync), else `None`.
284 pub fn set_list_scroll(
285 &mut self,
286 panel_id: PanelId,
287 list_key: &str,
288 scroll_offset: u32,
289 visible: u32,
290 ) -> Option<i32> {
291 let _ = visible;
292 let state = self.panels.get_mut(&panel_id)?;
293 let WidgetInstanceState::List {
294 scroll_offset: so,
295 user_scrolled,
296 ..
297 } = state.instance_states.get_mut(list_key)?
298 else {
299 return None;
300 };
301 // Mouse scroll moves the *view* only — the selection stays put
302 // (and may scroll out of view). `user_scrolled` tells the
303 // renderer not to snap the offset back to the selection. Never
304 // returns a moved selection, so no `select`/live-switch fires.
305 *so = scroll_offset;
306 *user_scrolled = true;
307 None
308 }
309
310 /// Update side-effects (hits, instance_states, focus_key, tabbable)
311 /// without taking ownership of the spec. Used by `rerender_widget_panel`
312 /// after an in-place spec mutation: the spec in the registry is already
313 /// current (mutation helpers like `append_tree_nodes_in_spec` mutate it
314 /// in place), so cloning it back through `update()` just to write the
315 /// same value would waste a 5 000-node deep clone for every IPC.
316 pub fn update_side_effects(
317 &mut self,
318 panel_id: PanelId,
319 hits: Vec<HitArea>,
320 instance_states: HashMap<String, WidgetInstanceState>,
321 focus_key: String,
322 tabbable: Vec<String>,
323 ) -> Result<BufferId, ()> {
324 match self.panels.get_mut(&panel_id) {
325 Some(state) => {
326 state.hits = hits;
327 state.instance_states = instance_states;
328 state.focus_key = focus_key;
329 state.tabbable = tabbable;
330 Ok(state.buffer_id)
331 }
332 None => Err(()),
333 }
334 }
335
336 /// Borrow the current spec + return the buffer id. Companion to
337 /// `update_side_effects` — render with the borrow and then write
338 /// back only the side-effects, avoiding the deep clone of the spec
339 /// that `buffer_and_spec()` does.
340 pub fn buffer_and_spec_ref(&self, panel_id: PanelId) -> Option<(BufferId, &WidgetSpec)> {
341 self.panels.get(&panel_id).map(|s| (s.buffer_id, &s.spec))
342 }
343
344 /// Find the buffer and current spec for a panel — used by the
345 /// dispatcher to re-render after a focus advance / activate
346 /// command without the plugin needing to send an UpdateWidgetPanel.
347 pub fn buffer_and_spec(&self, panel_id: PanelId) -> Option<(BufferId, WidgetSpec)> {
348 self.panels
349 .get(&panel_id)
350 .map(|s| (s.buffer_id, s.spec.clone()))
351 }
352
353 /// Tear down a panel. Returns the buffer_id the panel was
354 /// rendering into, so the caller can clear the buffer if it
355 /// owns it.
356 pub fn unmount(&mut self, panel_id: PanelId) -> Option<BufferId> {
357 self.panels.remove(&panel_id).map(|s| s.buffer_id)
358 }
359
360 /// Read-only access to a panel's current state.
361 pub fn get(&self, panel_id: PanelId) -> Option<&WidgetPanelState> {
362 self.panels.get(&panel_id)
363 }
364
365 /// Mutable access — used by `WidgetCommand` handlers that
366 /// update widget instance state (e.g. TextInput value/cursor)
367 /// directly without round-tripping through the plugin.
368 pub fn get_mut(&mut self, panel_id: PanelId) -> Option<&mut WidgetPanelState> {
369 self.panels.get_mut(&panel_id)
370 }
371
372 /// All currently-mounted panel ids — useful for theme-change
373 /// re-render passes (every panel re-renders against the new
374 /// theme without plugin involvement).
375 pub fn panel_ids(&self) -> Vec<PanelId> {
376 self.panels.keys().copied().collect()
377 }
378
379 /// Panels rendering into `buffer_id`. Used by mouse-wheel
380 /// routing to find which widget panel sits under the pointer.
381 pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelId> {
382 self.panels
383 .iter()
384 .filter(|(_, s)| s.buffer_id == buffer_id)
385 .map(|(pid, _)| *pid)
386 .collect()
387 }
388
389 /// Hit-test the given buffer-local position against every
390 /// currently-mounted panel rendering into `buffer_id`. Returns
391 /// the matching panel id and a clone of the hit area on a hit,
392 /// `None` otherwise.
393 ///
394 /// Linear scan: panel count is typically 1 per buffer; per-panel
395 /// hit count is small (one per interactive widget). A spatial
396 /// index would be over-engineering at this scale.
397 pub fn hit_test(
398 &self,
399 buffer_id: BufferId,
400 row: u32,
401 col_byte: u32,
402 ) -> Option<(PanelId, HitArea)> {
403 for (pid, state) in &self.panels {
404 if state.buffer_id != buffer_id {
405 continue;
406 }
407 for hit in &state.hits {
408 if hit.buffer_row == row
409 && (col_byte as usize) >= hit.byte_start
410 && (col_byte as usize) < hit.byte_end
411 {
412 return Some((*pid, hit.clone()));
413 }
414 }
415 }
416 None
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use serde_json::json;
424
425 fn empty_spec() -> WidgetSpec {
426 WidgetSpec::Col {
427 children: vec![],
428 key: None,
429 }
430 }
431
432 fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
433 HitArea {
434 widget_key: key.into(),
435 widget_kind: "button",
436 buffer_row: row,
437 byte_start,
438 byte_end,
439 payload: json!({}),
440 event_type: "activate",
441 }
442 }
443
444 #[test]
445 fn hit_test_finds_widget_inside_range() {
446 let mut reg = WidgetRegistry::new();
447 reg.mount(
448 42,
449 BufferId(7),
450 empty_spec(),
451 vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
452 HashMap::new(),
453 String::new(),
454 Vec::new(),
455 );
456 let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
457 assert_eq!(hit.0, 42);
458 assert_eq!(hit.1.widget_key, "b");
459 }
460
461 #[test]
462 fn hit_test_returns_none_when_outside_range() {
463 let mut reg = WidgetRegistry::new();
464 reg.mount(
465 1,
466 BufferId(0),
467 empty_spec(),
468 vec![make_hit(0, 0, 5, "a")],
469 HashMap::new(),
470 String::new(),
471 Vec::new(),
472 );
473 assert!(
474 reg.hit_test(BufferId(0), 0, 5).is_none(),
475 "byte_end is exclusive"
476 );
477 assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
478 assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
479 assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
480 }
481
482 fn mount_with_list(reg: &mut WidgetRegistry, scroll: u32, sel: i32) {
483 let mut states = HashMap::new();
484 states.insert(
485 "lst".to_string(),
486 WidgetInstanceState::List {
487 scroll_offset: scroll,
488 selected_index: sel,
489 item_height: 1,
490 user_scrolled: false,
491 },
492 );
493 reg.mount(
494 7,
495 BufferId(0),
496 empty_spec(),
497 Vec::new(),
498 states,
499 String::new(),
500 Vec::new(),
501 );
502 }
503
504 fn list_state(reg: &WidgetRegistry) -> (u32, i32) {
505 match reg.instance_states(7).unwrap().get("lst").unwrap() {
506 WidgetInstanceState::List {
507 scroll_offset,
508 selected_index,
509 ..
510 } => (*scroll_offset, *selected_index),
511 _ => panic!("not a list"),
512 }
513 }
514
515 #[test]
516 fn set_list_scroll_moves_view_only_not_selection() {
517 // Mouse scroll moves the *view* and never the selection — even
518 // when the selection (row 2) ends up above the dragged-to window
519 // [10, 18). No move is reported, so no `select`/live-switch
520 // fires; the selection is allowed to leave the visible range.
521 let mut reg = WidgetRegistry::new();
522 mount_with_list(&mut reg, 0, 2);
523 let moved = reg.set_list_scroll(7, "lst", 10, 8);
524 assert_eq!(moved, None);
525 assert_eq!(list_state(®), (10, 2));
526 }
527
528 #[test]
529 fn set_list_scroll_leaves_in_view_selection_untouched() {
530 // Selection already inside the new window — offset updates,
531 // selection stays, and no move is reported.
532 let mut reg = WidgetRegistry::new();
533 mount_with_list(&mut reg, 0, 12);
534 let moved = reg.set_list_scroll(7, "lst", 10, 8); // window [10,18)
535 assert_eq!(moved, None);
536 assert_eq!(list_state(®), (10, 12));
537 }
538
539 #[test]
540 fn set_list_scroll_ignores_selectionless_list() {
541 // A display-only list (selected_index < 0) just scrolls; no
542 // selection clamp, no reported move.
543 let mut reg = WidgetRegistry::new();
544 mount_with_list(&mut reg, 0, -1);
545 let moved = reg.set_list_scroll(7, "lst", 5, 8);
546 assert_eq!(moved, None);
547 assert_eq!(list_state(®), (5, -1));
548 }
549
550 #[test]
551 fn unmount_clears_hits() {
552 let mut reg = WidgetRegistry::new();
553 reg.mount(
554 5,
555 BufferId(2),
556 empty_spec(),
557 vec![make_hit(0, 0, 3, "x")],
558 HashMap::new(),
559 String::new(),
560 Vec::new(),
561 );
562 assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
563 reg.unmount(5);
564 assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
565 }
566
567 #[test]
568 fn update_replaces_hits() {
569 let mut reg = WidgetRegistry::new();
570 reg.mount(
571 5,
572 BufferId(2),
573 empty_spec(),
574 vec![make_hit(0, 0, 3, "old")],
575 HashMap::new(),
576 String::new(),
577 Vec::new(),
578 );
579 reg.update(
580 5,
581 empty_spec(),
582 vec![make_hit(1, 4, 9, "new")],
583 HashMap::new(),
584 String::new(),
585 Vec::new(),
586 )
587 .expect("mounted");
588 // Old hit gone; new hit visible.
589 assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
590 let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
591 assert_eq!(hit.1.widget_key, "new");
592 }
593}