Skip to main content

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(&reg), (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(&reg), (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(&reg), (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}