Skip to main content

fresh/widgets/
registry.rs

1//! Panel registry — maps a panel's composite identity (owning plugin,
2//! plugin-local `panel_id`) to mounted spec and hit-area data for click
3//! routing.
4//!
5//! The registry is the source of truth for "which panels exist, what
6//! spec are they currently rendering, and which buffer rows belong
7//! to which widget." It does *not* own the virtual buffer the
8//! rendered output goes into — the plugin still owns the virtual
9//! buffer and passes its `BufferId` at mount time.
10
11use crate::primitives::text_edit::TextEdit;
12use fresh_core::api::WidgetSpec;
13use fresh_core::BufferId;
14use std::collections::{HashMap, HashSet};
15
16/// Plugin-allocated panel identifier. Unique within a plugin; the
17/// editor does not interpret the value.
18pub type PanelId = u64;
19
20/// Composite panel identity: panel ids are plugin-local, so the
21/// registry key is (owning plugin, id). The owner is recorded host-side
22/// at mount time from the calling plugin's identity — never trusted
23/// from the JS side.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub struct PanelKey {
26    /// Name of the plugin that mounted the panel.
27    pub plugin: String,
28    /// The plugin-local panel id.
29    pub id: PanelId,
30}
31
32impl PanelKey {
33    pub fn new(plugin: impl Into<String>, id: PanelId) -> Self {
34        Self {
35            plugin: plugin.into(),
36            id,
37        }
38    }
39}
40
41impl std::fmt::Display for PanelKey {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(f, "{}:{}", self.plugin, self.id)
44    }
45}
46
47/// One clickable rectangle within a rendered widget panel.
48///
49/// The renderer produces one `HitArea` per interactive widget node
50/// (`Toggle`, `Button` in v1). Layout containers (`Row`, `Col`,
51/// `Spacer`, `HintBar`, `Raw`) emit no hit areas of their own; their
52/// children's hit areas bubble up with row/byte offsets adjusted to
53/// reflect the final on-screen position.
54///
55/// Hit-test is `(buffer_row, buffer_col_byte) ∈ rectangle`; the byte
56/// range is in UTF-8 bytes within the row's text, matching the
57/// coordinate space `mouse_click` already delivers
58/// (`HookArgs::MouseClick::buffer_col`).
59#[derive(Debug, Clone)]
60pub struct HitArea {
61    /// Stable widget key from the spec, or empty when the spec did
62    /// not assign one.
63    pub widget_key: String,
64    /// Widget kind discriminator: `"toggle"` or `"button"`.
65    pub widget_kind: &'static str,
66    /// 0-indexed row within the rendered virtual buffer.
67    pub buffer_row: u32,
68    /// First UTF-8 byte (inclusive) within the row's text.
69    pub byte_start: usize,
70    /// Last UTF-8 byte (exclusive) within the row's text.
71    pub byte_end: usize,
72    /// Event payload to deliver with the `widget_event` hook.
73    /// For `"toggle"`: `{ "checked": <new value> }`. For
74    /// `"button"`: `{}`.
75    pub payload: serde_json::Value,
76    /// Event type to deliver with the `widget_event` hook
77    /// (`"toggle"` or `"activate"`).
78    pub event_type: &'static str,
79}
80
81/// Widget instance state retained across spec updates, keyed by
82/// the widget's stable `key`. This is the "Spec/instance separation"
83/// described in §6 of the design doc — a plugin can rebuild its
84/// `WidgetSpec` from scratch on every model change without losing
85/// scroll offset, cursor position, expanded keys, or focus, because
86/// stateful widgets look up their instance state by `key`.
87#[derive(Debug, Clone, Default)]
88pub enum WidgetInstanceState {
89    /// Empty/placeholder — never persisted, used as a default.
90    #[default]
91    None,
92    /// `List` instance state: host-owned scroll offset *and*
93    /// selected index. `selected_index` becomes authoritative
94    /// after first render — same correctness reasoning as
95    /// `TextInput`'s host-owned value (host can mutate it via
96    /// `WidgetCommand::SelectMove` without racing the plugin's
97    /// spec round-trip).
98    List {
99        scroll_offset: u32,
100        selected_index: i32,
101        /// Rows each item occupies in the last render: 1 for a classic
102        /// one-row-per-item list, or the uniform card height for an
103        /// `item_specs` (card) list. The renderer writes it; mouse
104        /// handlers read it to convert the row-denominated `visible_rows`
105        /// into a per-item scroll window (so wheel/scrollbar bounds are
106        /// right for card lists, and an un-scrollable list still lets the
107        /// wheel bubble to an enclosing scrollable pane).
108        item_height: u32,
109        /// True once the user has scrolled the list by mouse (wheel or
110        /// scrollbar) without moving the selection. While set, the
111        /// renderer respects `scroll_offset` as-is instead of snapping
112        /// it back to keep `selected_index` in view — so a mouse scroll
113        /// can push the selected card off-screen. Cleared whenever the
114        /// selection itself moves (keyboard nav, click, or a plugin
115        /// `SetSelectedIndex`), which re-arms scroll-follows-selection.
116        user_scrolled: bool,
117    },
118    /// `Text` instance state: host-owned `TextEdit` (value + cursor
119    /// row/col + selection anchor + multiline flag), plus a viewport
120    /// scroll offset that's only meaningful for multi-line
121    /// (`rows > 1`) variants — the row index of the first visible
122    /// line. Single-line text widgets always render from value
123    /// byte 0 and rely on render-time head-truncate scrolling, so
124    /// they leave `scroll` at `0`.
125    ///
126    /// Becomes authoritative once the widget mounts; the spec's
127    /// `value` / `cursor_byte` are *initial-only* (used at first
128    /// render and ignored thereafter). This guarantees correctness
129    /// under concurrent keystrokes — the plugin's spec round-trip
130    /// can't race against multiple in-flight `WidgetCommand`
131    /// mutations because the host doesn't read from the spec for
132    /// value at all once instance state exists.
133    ///
134    /// Switching from a naive `(String, u32)` to `TextEdit` is what
135    /// gives the widget framework selection support, word
136    /// navigation, and clipboard ops "for free" — every keybinding
137    /// the legacy Settings UI accepted via `TextEdit` now applies
138    /// to widget-backed text inputs too.
139    Text {
140        editor: TextEdit,
141        scroll: u32,
142        /// Completion popup candidates the plugin most recently
143        /// pushed via `WidgetMutation::SetCompletions`. Empty =
144        /// popup closed. The list is stored host-side rather
145        /// than read from each `WidgetSpec` so the host can
146        /// keep painting the popup across renders that don't
147        /// re-push it, and so `Up`/`Down` selection survives a
148        /// spec refresh.
149        completions: Vec<fresh_core::api::CompletionItem>,
150        /// Host-managed selection cursor into `completions`.
151        /// Reset to 0 every time `SetCompletions` runs with a
152        /// non-empty list; clamped on every render in case the
153        /// list shrank.
154        completion_selected_index: usize,
155        /// Index of the first candidate row currently painted.
156        /// Up/Down adjusts this implicitly (the renderer auto-
157        /// scrolls to keep selection in view); the mouse wheel
158        /// scrolls it directly without moving the selection.
159        completion_scroll_offset: u32,
160        /// Whether the user has *explicitly* moved into the open
161        /// completion popup (via ↑/↓ or the mouse wheel). Reset to
162        /// `false` every time the popup (re)opens from typing, so a
163        /// freshly-surfaced dropdown isn't "entered": Tab and Enter
164        /// then act on the *form* (advance / submit) instead of
165        /// accepting a candidate, and the popup paints no highlighted
166        /// row. The first ↓ flips it true — the dropdown is now
167        /// navigable, the selected row highlights, and Enter accepts.
168        completion_navigated: bool,
169    },
170    /// `Tree` instance state: host-owned scroll offset, selected
171    /// index, and the set of expanded item keys. All three become
172    /// authoritative after first render — the spec's
173    /// `selected_index` / `expanded_keys` are seed values only.
174    /// `expanded_keys` is a `HashSet` because expansion is
175    /// set-membership semantically (a key is either expanded or
176    /// not); ordering doesn't matter and we hit-test on contains.
177    Tree {
178        scroll_offset: u32,
179        selected_index: i32,
180        expanded_keys: HashSet<String>,
181    },
182}
183
184/// Per-panel state retained between renders. The reconciler will use
185/// the previous spec to compute the minimum mutation when a future
186/// `UpdateWidgetPanel` arrives.
187#[derive(Debug, Clone)]
188pub struct WidgetPanelState {
189    /// The virtual buffer this panel renders into.
190    pub buffer_id: BufferId,
191    /// The currently-mounted spec.
192    pub spec: WidgetSpec,
193    /// Click rectangles for the rendered output, in declaration
194    /// order. Hit-test scans linearly — the small N (one per
195    /// interactive widget per panel) doesn't justify a spatial
196    /// index.
197    pub hits: Vec<HitArea>,
198    /// Widget instance state by widget `key`. Survives re-renders —
199    /// see `WidgetInstanceState` for what's stored.
200    pub instance_states: HashMap<String, WidgetInstanceState>,
201    /// Currently-focused widget key within this panel. Empty when
202    /// the panel has no focusable widgets, or before the first
203    /// render. Maintained by the renderer (clamps to a valid
204    /// tabbable key on every render) and by `widget_focus_advance`
205    /// (cycles through tabbables on Tab / Shift+Tab).
206    pub focus_key: String,
207    /// Tabbable widget keys collected from the most recent render,
208    /// in declaration order. The Tab-cycle command finds the
209    /// current `focus_key`'s position in this list and advances by
210    /// the requested delta (with wraparound).
211    pub tabbable: Vec<String>,
212}
213
214/// Global registry of mounted widget panels, keyed by composite
215/// (plugin, panel id) identity — two plugins reusing the same local id
216/// coexist without evicting each other.
217#[derive(Debug, Default)]
218pub struct WidgetRegistry {
219    panels: HashMap<PanelKey, WidgetPanelState>,
220}
221
222impl WidgetRegistry {
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    /// Mount or replace a panel. Returns the previous state if the
228    /// panel was already mounted (the dispatcher may use this to
229    /// detect re-mounts on the same id).
230    ///
231    /// The wide parameter list is the price of `WidgetPanelState`
232    /// being public — every field is plainly named at the call
233    /// site rather than buried inside an opaque builder. The
234    /// dispatcher always populates them all from one `RenderOutput`,
235    /// so the apparent verbosity stays at the boundary.
236    #[allow(clippy::too_many_arguments)]
237    pub fn mount(
238        &mut self,
239        panel_key: PanelKey,
240        buffer_id: BufferId,
241        spec: WidgetSpec,
242        hits: Vec<HitArea>,
243        instance_states: HashMap<String, WidgetInstanceState>,
244        focus_key: String,
245        tabbable: Vec<String>,
246    ) -> Option<WidgetPanelState> {
247        self.panels.insert(
248            panel_key,
249            WidgetPanelState {
250                buffer_id,
251                spec,
252                hits,
253                instance_states,
254                focus_key,
255                tabbable,
256            },
257        )
258    }
259
260    /// Replace the spec and rendered metadata on an already-mounted
261    /// panel. Returns `Ok(buffer_id)` to render into, or `Err(())`
262    /// if no panel exists for that id (caller should drop the
263    /// update — the plugin re-emitted after unmount). The unit
264    /// error is sufficient: there's exactly one failure mode and
265    /// no payload to attach.
266    #[allow(clippy::result_unit_err)]
267    #[allow(clippy::too_many_arguments)]
268    pub fn update(
269        &mut self,
270        panel_key: &PanelKey,
271        spec: WidgetSpec,
272        hits: Vec<HitArea>,
273        instance_states: HashMap<String, WidgetInstanceState>,
274        focus_key: String,
275        tabbable: Vec<String>,
276    ) -> Result<BufferId, ()> {
277        match self.panels.get_mut(panel_key) {
278            Some(state) => {
279                state.spec = spec;
280                state.hits = hits;
281                state.instance_states = instance_states;
282                state.focus_key = focus_key;
283                state.tabbable = tabbable;
284                Ok(state.buffer_id)
285            }
286            None => Err(()),
287        }
288    }
289
290    /// Read-only access to the instance state for a panel — used by
291    /// the dispatcher to thread previous scroll offsets / cursor
292    /// positions into the next render so they persist.
293    pub fn instance_states(
294        &self,
295        panel_key: &PanelKey,
296    ) -> Option<&HashMap<String, WidgetInstanceState>> {
297        self.panels.get(panel_key).map(|s| &s.instance_states)
298    }
299
300    /// Read-only access to the previous render's focus key.
301    pub fn focus_key(&self, panel_key: &PanelKey) -> Option<&str> {
302        self.panels.get(panel_key).map(|s| s.focus_key.as_str())
303    }
304
305    /// Set the focus key directly (used by `widget_focus_advance`
306    /// and click-driven focus moves). Updates the in-place state;
307    /// the next render reads it via `focus_key()`.
308    pub fn set_focus_key(&mut self, panel_key: &PanelKey, key: String) {
309        if let Some(state) = self.panels.get_mut(panel_key) {
310            state.focus_key = key;
311        }
312    }
313
314    /// Host-driven scroll of a `List` widget (e.g. a scrollbar drag).
315    /// Sets the list's `scroll_offset` and, when the list has a live
316    /// selection, clamps `selected_index` into the new visible window
317    /// `[scroll, scroll + visible)` so the next render's
318    /// ensure-selected-visible doesn't snap the thumb back.
319    ///
320    /// Returns the post-clamp `selected_index` when the list has a
321    /// selection that moved (so the caller can notify the plugin to
322    /// keep its own selection mirror + preview in sync), else `None`.
323    pub fn set_list_scroll(
324        &mut self,
325        panel_key: &PanelKey,
326        list_key: &str,
327        scroll_offset: u32,
328        visible: u32,
329    ) -> Option<i32> {
330        let _ = visible;
331        let state = self.panels.get_mut(panel_key)?;
332        let WidgetInstanceState::List {
333            scroll_offset: so,
334            user_scrolled,
335            ..
336        } = state.instance_states.get_mut(list_key)?
337        else {
338            return None;
339        };
340        // Mouse scroll moves the *view* only — the selection stays put
341        // (and may scroll out of view). `user_scrolled` tells the
342        // renderer not to snap the offset back to the selection. Never
343        // returns a moved selection, so no `select`/live-switch fires.
344        *so = scroll_offset;
345        *user_scrolled = true;
346        None
347    }
348
349    /// Update side-effects (hits, instance_states, focus_key, tabbable)
350    /// without taking ownership of the spec. Used by `rerender_widget_panel`
351    /// after an in-place spec mutation: the spec in the registry is already
352    /// current (mutation helpers like `append_tree_nodes_in_spec` mutate it
353    /// in place), so cloning it back through `update()` just to write the
354    /// same value would waste a 5 000-node deep clone for every IPC.
355    pub fn update_side_effects(
356        &mut self,
357        panel_key: &PanelKey,
358        hits: Vec<HitArea>,
359        instance_states: HashMap<String, WidgetInstanceState>,
360        focus_key: String,
361        tabbable: Vec<String>,
362    ) -> Result<BufferId, ()> {
363        match self.panels.get_mut(panel_key) {
364            Some(state) => {
365                state.hits = hits;
366                state.instance_states = instance_states;
367                state.focus_key = focus_key;
368                state.tabbable = tabbable;
369                Ok(state.buffer_id)
370            }
371            None => Err(()),
372        }
373    }
374
375    /// Borrow the current spec + return the buffer id. Companion to
376    /// `update_side_effects` — render with the borrow and then write
377    /// back only the side-effects, avoiding the deep clone of the spec
378    /// that `buffer_and_spec()` does.
379    pub fn buffer_and_spec_ref(&self, panel_key: &PanelKey) -> Option<(BufferId, &WidgetSpec)> {
380        self.panels.get(panel_key).map(|s| (s.buffer_id, &s.spec))
381    }
382
383    /// Find the buffer and current spec for a panel — used by the
384    /// dispatcher to re-render after a focus advance / activate
385    /// command without the plugin needing to send an UpdateWidgetPanel.
386    pub fn buffer_and_spec(&self, panel_key: &PanelKey) -> Option<(BufferId, WidgetSpec)> {
387        self.panels
388            .get(panel_key)
389            .map(|s| (s.buffer_id, s.spec.clone()))
390    }
391
392    /// Tear down a panel. Returns the buffer_id the panel was
393    /// rendering into, so the caller can clear the buffer if it
394    /// owns it.
395    pub fn unmount(&mut self, panel_key: &PanelKey) -> Option<BufferId> {
396        self.panels.remove(panel_key).map(|s| s.buffer_id)
397    }
398
399    /// Read-only access to a panel's current state.
400    pub fn get(&self, panel_key: &PanelKey) -> Option<&WidgetPanelState> {
401        self.panels.get(panel_key)
402    }
403
404    /// Mutable access — used by `WidgetCommand` handlers that
405    /// update widget instance state (e.g. TextInput value/cursor)
406    /// directly without round-tripping through the plugin.
407    pub fn get_mut(&mut self, panel_key: &PanelKey) -> Option<&mut WidgetPanelState> {
408        self.panels.get_mut(panel_key)
409    }
410
411    /// All currently-mounted panel keys — useful for theme-change
412    /// re-render passes (every panel re-renders against the new
413    /// theme without plugin involvement).
414    pub fn panel_keys(&self) -> Vec<PanelKey> {
415        self.panels.keys().cloned().collect()
416    }
417
418    /// Panels rendering into `buffer_id`. Used by mouse-wheel
419    /// routing to find which widget panel sits under the pointer.
420    pub fn panels_for_buffer(&self, buffer_id: BufferId) -> Vec<PanelKey> {
421        self.panels
422            .iter()
423            .filter(|(_, s)| s.buffer_id == buffer_id)
424            .map(|(key, _)| key.clone())
425            .collect()
426    }
427
428    /// Hit-test the given buffer-local position against every
429    /// currently-mounted panel rendering into `buffer_id`. Returns
430    /// the matching panel id and a clone of the hit area on a hit,
431    /// `None` otherwise.
432    ///
433    /// Linear scan: panel count is typically 1 per buffer; per-panel
434    /// hit count is small (one per interactive widget). A spatial
435    /// index would be over-engineering at this scale.
436    pub fn hit_test(
437        &self,
438        buffer_id: BufferId,
439        row: u32,
440        col_byte: u32,
441    ) -> Option<(PanelKey, HitArea)> {
442        for (key, state) in &self.panels {
443            if state.buffer_id != buffer_id {
444                continue;
445            }
446            for hit in &state.hits {
447                if hit.buffer_row == row
448                    && (col_byte as usize) >= hit.byte_start
449                    && (col_byte as usize) < hit.byte_end
450                {
451                    return Some((key.clone(), hit.clone()));
452                }
453            }
454        }
455        None
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use serde_json::json;
463
464    fn pk(id: PanelId) -> PanelKey {
465        PanelKey::new("test-plugin", id)
466    }
467
468    fn empty_spec() -> WidgetSpec {
469        WidgetSpec::Col {
470            children: vec![],
471            key: None,
472        }
473    }
474
475    fn make_hit(row: u32, byte_start: usize, byte_end: usize, key: &str) -> HitArea {
476        HitArea {
477            widget_key: key.into(),
478            widget_kind: "button",
479            buffer_row: row,
480            byte_start,
481            byte_end,
482            payload: json!({}),
483            event_type: "activate",
484        }
485    }
486
487    #[test]
488    fn hit_test_finds_widget_inside_range() {
489        let mut reg = WidgetRegistry::new();
490        reg.mount(
491            pk(42),
492            BufferId(7),
493            empty_spec(),
494            vec![make_hit(0, 0, 5, "a"), make_hit(0, 7, 12, "b")],
495            HashMap::new(),
496            String::new(),
497            Vec::new(),
498        );
499        let hit = reg.hit_test(BufferId(7), 0, 8).expect("inside b");
500        assert_eq!(hit.0, pk(42));
501        assert_eq!(hit.1.widget_key, "b");
502    }
503
504    #[test]
505    fn hit_test_returns_none_when_outside_range() {
506        let mut reg = WidgetRegistry::new();
507        reg.mount(
508            pk(1),
509            BufferId(0),
510            empty_spec(),
511            vec![make_hit(0, 0, 5, "a")],
512            HashMap::new(),
513            String::new(),
514            Vec::new(),
515        );
516        assert!(
517            reg.hit_test(BufferId(0), 0, 5).is_none(),
518            "byte_end is exclusive"
519        );
520        assert!(reg.hit_test(BufferId(0), 0, 100).is_none());
521        assert!(reg.hit_test(BufferId(0), 1, 0).is_none(), "wrong row");
522        assert!(reg.hit_test(BufferId(99), 0, 0).is_none(), "wrong buffer");
523    }
524
525    fn mount_with_list(reg: &mut WidgetRegistry, scroll: u32, sel: i32) {
526        let mut states = HashMap::new();
527        states.insert(
528            "lst".to_string(),
529            WidgetInstanceState::List {
530                scroll_offset: scroll,
531                selected_index: sel,
532                item_height: 1,
533                user_scrolled: false,
534            },
535        );
536        reg.mount(
537            pk(7),
538            BufferId(0),
539            empty_spec(),
540            Vec::new(),
541            states,
542            String::new(),
543            Vec::new(),
544        );
545    }
546
547    fn list_state(reg: &WidgetRegistry) -> (u32, i32) {
548        match reg.instance_states(&pk(7)).unwrap().get("lst").unwrap() {
549            WidgetInstanceState::List {
550                scroll_offset,
551                selected_index,
552                ..
553            } => (*scroll_offset, *selected_index),
554            _ => panic!("not a list"),
555        }
556    }
557
558    #[test]
559    fn set_list_scroll_moves_view_only_not_selection() {
560        // Mouse scroll moves the *view* and never the selection — even
561        // when the selection (row 2) ends up above the dragged-to window
562        // [10, 18). No move is reported, so no `select`/live-switch
563        // fires; the selection is allowed to leave the visible range.
564        let mut reg = WidgetRegistry::new();
565        mount_with_list(&mut reg, 0, 2);
566        let moved = reg.set_list_scroll(&pk(7), "lst", 10, 8);
567        assert_eq!(moved, None);
568        assert_eq!(list_state(&reg), (10, 2));
569    }
570
571    #[test]
572    fn set_list_scroll_leaves_in_view_selection_untouched() {
573        // Selection already inside the new window — offset updates,
574        // selection stays, and no move is reported.
575        let mut reg = WidgetRegistry::new();
576        mount_with_list(&mut reg, 0, 12);
577        let moved = reg.set_list_scroll(&pk(7), "lst", 10, 8); // window [10,18)
578        assert_eq!(moved, None);
579        assert_eq!(list_state(&reg), (10, 12));
580    }
581
582    #[test]
583    fn set_list_scroll_ignores_selectionless_list() {
584        // A display-only list (selected_index < 0) just scrolls; no
585        // selection clamp, no reported move.
586        let mut reg = WidgetRegistry::new();
587        mount_with_list(&mut reg, 0, -1);
588        let moved = reg.set_list_scroll(&pk(7), "lst", 5, 8);
589        assert_eq!(moved, None);
590        assert_eq!(list_state(&reg), (5, -1));
591    }
592
593    #[test]
594    fn same_local_id_from_two_plugins_coexists() {
595        // Panel ids are plugin-local: a second plugin mounting the same
596        // local id must NOT evict the first plugin's panel, and the
597        // hit-test must resolve each buffer's hit to its owning plugin.
598        let mut reg = WidgetRegistry::new();
599        reg.mount(
600            PanelKey::new("alpha", 1),
601            BufferId(10),
602            empty_spec(),
603            vec![make_hit(0, 0, 5, "a-btn")],
604            HashMap::new(),
605            String::new(),
606            Vec::new(),
607        );
608        let evicted = reg.mount(
609            PanelKey::new("beta", 1),
610            BufferId(20),
611            empty_spec(),
612            vec![make_hit(0, 0, 5, "b-btn")],
613            HashMap::new(),
614            String::new(),
615            Vec::new(),
616        );
617        assert!(evicted.is_none(), "beta:1 must not evict alpha:1");
618
619        let (key_a, hit_a) = reg.hit_test(BufferId(10), 0, 2).expect("alpha hit");
620        assert_eq!(key_a, PanelKey::new("alpha", 1));
621        assert_eq!(hit_a.widget_key, "a-btn");
622        let (key_b, hit_b) = reg.hit_test(BufferId(20), 0, 2).expect("beta hit");
623        assert_eq!(key_b, PanelKey::new("beta", 1));
624        assert_eq!(hit_b.widget_key, "b-btn");
625
626        // Unmounting one plugin's panel leaves the other untouched.
627        reg.unmount(&PanelKey::new("beta", 1));
628        assert!(reg.hit_test(BufferId(20), 0, 2).is_none());
629        assert!(reg.hit_test(BufferId(10), 0, 2).is_some());
630    }
631
632    #[test]
633    fn unmount_clears_hits() {
634        let mut reg = WidgetRegistry::new();
635        reg.mount(
636            pk(5),
637            BufferId(2),
638            empty_spec(),
639            vec![make_hit(0, 0, 3, "x")],
640            HashMap::new(),
641            String::new(),
642            Vec::new(),
643        );
644        assert!(reg.hit_test(BufferId(2), 0, 1).is_some());
645        reg.unmount(&pk(5));
646        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
647    }
648
649    #[test]
650    fn update_replaces_hits() {
651        let mut reg = WidgetRegistry::new();
652        reg.mount(
653            pk(5),
654            BufferId(2),
655            empty_spec(),
656            vec![make_hit(0, 0, 3, "old")],
657            HashMap::new(),
658            String::new(),
659            Vec::new(),
660        );
661        reg.update(
662            &pk(5),
663            empty_spec(),
664            vec![make_hit(1, 4, 9, "new")],
665            HashMap::new(),
666            String::new(),
667            Vec::new(),
668        )
669        .expect("mounted");
670        // Old hit gone; new hit visible.
671        assert!(reg.hit_test(BufferId(2), 0, 1).is_none());
672        let hit = reg.hit_test(BufferId(2), 1, 5).unwrap();
673        assert_eq!(hit.1.widget_key, "new");
674    }
675}