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 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}