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