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    },
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    /// Host-driven scroll of a `List` widget (e.g. a scrollbar drag).
260    /// Sets the list's `scroll_offset` and, when the list has a live
261    /// selection, clamps `selected_index` into the new visible window
262    /// `[scroll, scroll + visible)` so the next render's
263    /// ensure-selected-visible doesn't snap the thumb back.
264    ///
265    /// Returns the post-clamp `selected_index` when the list has a
266    /// selection that moved (so the caller can notify the plugin to
267    /// keep its own selection mirror + preview in sync), else `None`.
268    pub fn set_list_scroll(
269        &mut self,
270        panel_id: PanelId,
271        list_key: &str,
272        scroll_offset: u32,
273        visible: u32,
274    ) -> Option<i32> {
275        let state = self.panels.get_mut(&panel_id)?;
276        let WidgetInstanceState::List {
277            scroll_offset: so,
278            selected_index,
279        } = state.instance_states.get_mut(list_key)?
280        else {
281            return None;
282        };
283        *so = scroll_offset;
284        if *selected_index < 0 || visible == 0 {
285            return None;
286        }
287        let prev = *selected_index;
288        let lo = scroll_offset as i32;
289        let hi = (scroll_offset + visible).saturating_sub(1) as i32;
290        if *selected_index < lo {
291            *selected_index = lo;
292        } else if *selected_index > hi {
293            *selected_index = hi;
294        }
295        if *selected_index != prev {
296            Some(*selected_index)
297        } else {
298            None
299        }
300    }
301
302    /// Update side-effects (hits, instance_states, focus_key, tabbable)
303    /// without taking ownership of the spec. Used by `rerender_widget_panel`
304    /// after an in-place spec mutation: the spec in the registry is already
305    /// current (mutation helpers like `append_tree_nodes_in_spec` mutate it
306    /// in place), so cloning it back through `update()` just to write the
307    /// same value would waste a 5 000-node deep clone for every IPC.
308    pub fn update_side_effects(
309        &mut self,
310        panel_id: PanelId,
311        hits: Vec<HitArea>,
312        instance_states: HashMap<String, WidgetInstanceState>,
313        focus_key: String,
314        tabbable: Vec<String>,
315    ) -> Result<BufferId, ()> {
316        match self.panels.get_mut(&panel_id) {
317            Some(state) => {
318                state.hits = hits;
319                state.instance_states = instance_states;
320                state.focus_key = focus_key;
321                state.tabbable = tabbable;
322                Ok(state.buffer_id)
323            }
324            None => Err(()),
325        }
326    }
327
328    /// Borrow the current spec + return the buffer id. Companion to
329    /// `update_side_effects` — render with the borrow and then write
330    /// back only the side-effects, avoiding the deep clone of the spec
331    /// that `buffer_and_spec()` does.
332    pub fn buffer_and_spec_ref(&self, panel_id: PanelId) -> Option<(BufferId, &WidgetSpec)> {
333        self.panels.get(&panel_id).map(|s| (s.buffer_id, &s.spec))
334    }
335
336    /// Find the buffer and current spec for a panel — used by the
337    /// dispatcher to re-render after a focus advance / activate
338    /// command without the plugin needing to send an UpdateWidgetPanel.
339    pub fn buffer_and_spec(&self, panel_id: PanelId) -> Option<(BufferId, WidgetSpec)> {
340        self.panels
341            .get(&panel_id)
342            .map(|s| (s.buffer_id, s.spec.clone()))
343    }
344
345    /// Tear down a panel. Returns the buffer_id the panel was
346    /// rendering into, so the caller can clear the buffer if it
347    /// owns it.
348    pub fn unmount(&mut self, panel_id: PanelId) -> Option<BufferId> {
349        self.panels.remove(&panel_id).map(|s| s.buffer_id)
350    }
351
352    /// Read-only access to a panel's current state.
353    pub fn get(&self, panel_id: PanelId) -> Option<&WidgetPanelState> {
354        self.panels.get(&panel_id)
355    }
356
357    /// Mutable access — used by `WidgetCommand` handlers that
358    /// update widget instance state (e.g. TextInput value/cursor)
359    /// directly without round-tripping through the plugin.
360    pub fn get_mut(&mut self, panel_id: PanelId) -> Option<&mut WidgetPanelState> {
361        self.panels.get_mut(&panel_id)
362    }
363
364    /// All currently-mounted panel ids — useful for theme-change
365    /// re-render passes (every panel re-renders against the new
366    /// theme without plugin involvement).
367    pub fn panel_ids(&self) -> Vec<PanelId> {
368        self.panels.keys().copied().collect()
369    }
370
371    /// Panels rendering into `buffer_id`. Used by mouse-wheel
372    /// routing to find which widget panel sits under the pointer.
373    pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelId> {
374        self.panels
375            .iter()
376            .filter(|(_, s)| s.buffer_id == buffer_id)
377            .map(|(pid, _)| *pid)
378            .collect()
379    }
380
381    /// Hit-test the given buffer-local position against every
382    /// currently-mounted panel rendering into `buffer_id`. Returns
383    /// the matching panel id and a clone of the hit area on a hit,
384    /// `None` otherwise.
385    ///
386    /// Linear scan: panel count is typically 1 per buffer; per-panel
387    /// hit count is small (one per interactive widget). A spatial
388    /// index would be over-engineering at this scale.
389    pub fn hit_test(
390        &self,
391        buffer_id: BufferId,
392        row: u32,
393        col_byte: u32,
394    ) -> Option<(PanelId, HitArea)> {
395        for (pid, state) in &self.panels {
396            if state.buffer_id != buffer_id {
397                continue;
398            }
399            for hit in &state.hits {
400                if hit.buffer_row == row
401                    && (col_byte as usize) >= hit.byte_start
402                    && (col_byte as usize) < hit.byte_end
403                {
404                    return Some((*pid, hit.clone()));
405                }
406            }
407        }
408        None
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use serde_json::json;
416
417    fn empty_spec() -> WidgetSpec {
418        WidgetSpec::Col {
419            children: vec![],
420            key: None,
421        }
422    }
423
424    fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
425        HitArea {
426            widget_key: key.into(),
427            widget_kind: "button",
428            buffer_row: row,
429            byte_start,
430            byte_end,
431            payload: json!({}),
432            event_type: "activate",
433        }
434    }
435
436    #[test]
437    fn hit_test_finds_widget_inside_range() {
438        let mut reg = WidgetRegistry::new();
439        reg.mount(
440            42,
441            BufferId(7),
442            empty_spec(),
443            vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
444            HashMap::new(),
445            String::new(),
446            Vec::new(),
447        );
448        let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
449        assert_eq!(hit.0, 42);
450        assert_eq!(hit.1.widget_key, "b");
451    }
452
453    #[test]
454    fn hit_test_returns_none_when_outside_range() {
455        let mut reg = WidgetRegistry::new();
456        reg.mount(
457            1,
458            BufferId(0),
459            empty_spec(),
460            vec![make_hit(0, 0, 5, "a")],
461            HashMap::new(),
462            String::new(),
463            Vec::new(),
464        );
465        assert!(
466            reg.hit_test(BufferId(0), 0, 5).is_none(),
467            "byte_end is exclusive"
468        );
469        assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
470        assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
471        assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
472    }
473
474    fn mount_with_list(reg: &mut WidgetRegistry, scroll: u32, sel: i32) {
475        let mut states = HashMap::new();
476        states.insert(
477            "lst".to_string(),
478            WidgetInstanceState::List {
479                scroll_offset: scroll,
480                selected_index: sel,
481            },
482        );
483        reg.mount(
484            7,
485            BufferId(0),
486            empty_spec(),
487            Vec::new(),
488            states,
489            String::new(),
490            Vec::new(),
491        );
492    }
493
494    fn list_state(reg: &WidgetRegistry) -> (u32, i32) {
495        match reg.instance_states(7).unwrap().get("lst").unwrap() {
496            WidgetInstanceState::List {
497                scroll_offset,
498                selected_index,
499            } => (*scroll_offset, *selected_index),
500            _ => panic!("not a list"),
501        }
502    }
503
504    #[test]
505    fn set_list_scroll_sets_offset_and_clamps_selection_into_view() {
506        // Selection (row 2) is above the dragged-to window [10, 18);
507        // it should clamp up to the new top (10) and report the move.
508        let mut reg = WidgetRegistry::new();
509        mount_with_list(&mut reg, 0, 2);
510        let moved = reg.set_list_scroll(7, "lst", 10, 8);
511        assert_eq!(moved, Some(10));
512        assert_eq!(list_state(&reg), (10, 10));
513    }
514
515    #[test]
516    fn set_list_scroll_leaves_in_view_selection_untouched() {
517        // Selection already inside the new window — offset updates,
518        // selection stays, and no move is reported (no plugin sync
519        // needed).
520        let mut reg = WidgetRegistry::new();
521        mount_with_list(&mut reg, 0, 12);
522        let moved = reg.set_list_scroll(7, "lst", 10, 8); // window [10,18)
523        assert_eq!(moved, None);
524        assert_eq!(list_state(&reg), (10, 12));
525    }
526
527    #[test]
528    fn set_list_scroll_ignores_selectionless_list() {
529        // A display-only list (selected_index < 0) just scrolls; no
530        // selection clamp, no reported move.
531        let mut reg = WidgetRegistry::new();
532        mount_with_list(&mut reg, 0, -1);
533        let moved = reg.set_list_scroll(7, "lst", 5, 8);
534        assert_eq!(moved, None);
535        assert_eq!(list_state(&reg), (5, -1));
536    }
537
538    #[test]
539    fn unmount_clears_hits() {
540        let mut reg = WidgetRegistry::new();
541        reg.mount(
542            5,
543            BufferId(2),
544            empty_spec(),
545            vec![make_hit(0, 0, 3, "x")],
546            HashMap::new(),
547            String::new(),
548            Vec::new(),
549        );
550        assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
551        reg.unmount(5);
552        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
553    }
554
555    #[test]
556    fn update_replaces_hits() {
557        let mut reg = WidgetRegistry::new();
558        reg.mount(
559            5,
560            BufferId(2),
561            empty_spec(),
562            vec![make_hit(0, 0, 3, "old")],
563            HashMap::new(),
564            String::new(),
565            Vec::new(),
566        );
567        reg.update(
568            5,
569            empty_spec(),
570            vec![make_hit(1, 4, 9, "new")],
571            HashMap::new(),
572            String::new(),
573            Vec::new(),
574        )
575        .expect("mounted");
576        // Old hit gone; new hit visible.
577        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
578        let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
579        assert_eq!(hit.1.widget_key, "new");
580    }
581}