Skip to main content

lattice_ui_tui/app/
popup.rs

1//! Popup overlay lifecycle.
2//!
3//! A popup is a rectangular UI surface drawn over the buffer
4//! area. Inside the popup a buffer renders the same way it would
5//! inside any other window / split / tab; the popup itself is
6//! content-agnostic and provides no key bindings of its own. Mode-
7//! specific behaviour (help-mode binding `q` / `<Esc>` to close,
8//! diagnostic-detail-mode binding `<CR>` to follow the link, etc.)
9//! comes from the buffer's major mode, exactly as it would in a
10//! split. The popup is purely a renderer + lifecycle concern.
11//!
12//! ## Surface
13//!
14//! - [`App::open_popup`] — display a popup with `buffer` as its
15//!   content; the caller passes the [`PopupPlacement`] explicitly
16//!   so cursor-anchored vs centred is a decision *at the call
17//!   site*, not a hidden default. Hover / signature help pass
18//!   [`PopupPlacement::CursorAnchored`]; everything else
19//!   (`:lsp-status`, `:describe-*`, `:apropos`, `:help`,
20//!   `:keymap`, `:options`, `:ls`, `:lsp-log`, ...) passes
21//!   [`PopupPlacement::Centered`].
22//! - [`App::set_popup_placement`] — update placement while the
23//!   popup is still open (e.g. promoting an anchored hover into a
24//!   centred reference view on focus).
25//! - [`App::dismiss_popup`] — close the popup; restores any focus
26//!   state captured when the user focused into it. Idempotent.
27//! - [`App::popup_placement`] — read-side accessor for the
28//!   renderer; returns `None` when no popup is open.
29//!
30//! ## Why placement on `App`, not on `HelpBuffer`
31//!
32//! The popup is a generic surface; the buffer inside is incidental.
33//! Storing placement on the buffer would conflate "what to show"
34//! with "where to put it" -- two pieces of state that change
35//! independently. A future file-preview popup wouldn't suddenly
36//! gain a `placement` field on `Buffer`; the popup gains the
37//! field, exactly as it does today.
38
39use crate::help::HelpContent;
40use lattice_host::popup::{HelpMetadata, PopupPlacement};
41
42use super::App;
43
44/// One frame of in-popup navigation history. Captured by
45/// [`App::snapshot_current_popup`] before [`App::swap_popup_content`]
46/// overwrites the buffer; popped by [`App::pop_popup_back`] when the
47/// user presses `<C-o>` from inside the popup. Carries everything
48/// needed to fully restore the prior view: title, rope, cursor,
49/// scroll, placement, and the link / anchor / highlight metadata
50/// that backs the renderer + follow-link reader.
51// Moved to lattice_host::popup::PopupSnapshot; keep local alias if needed.
52pub use lattice_host::popup::PopupSnapshot;
53
54impl App {
55    /// Open a popup with `content` as its body at the requested
56    /// `placement`. The popup focuses in: subsequent vim-grammar
57    /// motions and ex-commands operate on the popup's content
58    /// (mode-specific bindings come from the buffer's major
59    /// mode). Captures pre-popup focus state so [`Self::dismiss_popup`]
60    /// restores the user cleanly to the prior buffer / cursor /
61    /// scroll.
62    ///
63    /// `content` is a [`HelpContent`] = (slim `HelpBuffer`, parsed
64    /// `HelpMetadata`). The buffer becomes `App.editor.popup_buffer` (the
65    /// popup hot-path slot); the metadata is seeded into
66    /// `App.editor.buffer_locals[buffer.id]` via [`Self::seed_help_metadata_locals`]
67    /// so the renderer + link-follow / anchor-jump readers route
68    /// uniformly through buffer_locals (M.3.2.c.5).
69    pub(crate) fn open_popup(&mut self, content: HelpContent, placement: PopupPlacement) {
70        // Slice 3c.final.E.3: route through `mutate_editor_with`.
71        // The closure-tail publish from `mutate_editor_with` replaces
72        // the prior 3c.atomic.E manual `publish_render_state()`.
73        let signals = self.mutate_editor_with(move |e| e.open_popup(content, placement));
74        for s in signals {
75            self.handle_renderer_signal(s);
76        }
77    }
78
79    /// Open `content` as a *floating* popup over the active
80    /// document (M.4 follow-up). Distinct from
81    /// [`Self::open_popup`]: focus stays on the doc -- cursor
82    /// motion in the doc auto-dismisses (the State A semantics
83    /// `do_open_hover` codified). Activates `markdown-mode` as
84    /// the major and `hover-mode` as the minor; the latter is
85    /// what the dispatch's auto-dismiss check
86    /// (`popup_has_hover_mode`) keys on.
87    ///
88    /// Used by hover (`K`), signature help, and any future
89    /// cursor-anchored quick-info popup that wants the
90    /// "popup floats; doc keeps focus" shape.
91    pub(crate) fn open_floating_popup(&mut self, content: HelpContent, placement: PopupPlacement) {
92        // Slice 3c.final.E.3: route through `mutate_editor_with`.
93        let signals = self.mutate_editor_with(move |e| e.open_floating_popup(content, placement));
94        for s in signals {
95            self.handle_renderer_signal(s);
96        }
97    }
98
99    /// M.4 (b): clear out a popup buffer's registry / mode /
100    /// option-cache state. Called by [`Self::open_popup`] before
101    /// adopting a new popup (so back-to-back popups don't
102    /// accumulate stale entries) and by [`Self::dismiss_popup`]
103    /// when the popup closes. No-op when no popup is set.
104    pub(super) fn dismiss_stale_popup_registry(&mut self) {
105        // Slice 3c.final.E.3: route through `mutate_editor`.
106        self.mutate_editor(|e| e.dismiss_stale_popup_registry());
107    }
108
109    /// M.4 (b): resolve the popup's `HelpBuffer` through the
110    /// unified registry. The field stores only the `BufferId`; the
111    /// actual buffer lives in `app.editor.buffers` with
112    /// `BufferFlags { listed: false, hidden: true }`. Returns a
113    /// cloned snapshot (the rope is cheap-to-clone); `None` when no
114    /// popup is open or the registry entry has been torn down.
115    pub fn popup_help(&self) -> Option<crate::help::HelpBuffer> {
116        // Slice 3c.final.B (group 3) note: the published
117        // `rs.popup.help` substate IS populated and the GPUI peer
118        // reads it directly. This TUI-side wrapper keeps the
119        // legacy `editor.popup_help()` path because the test
120        // suite + several out-of-dispatch help-popup setup paths
121        // mutate the popup buffer without republishing
122        // `RenderState`. Migrating the wrapper to read from
123        // `rs.popup.help` requires adding `publish_render_state()`
124        // calls at every popup-mutation site (open, scroll,
125        // dismiss, follow-link, …) — deferred to a follow-up
126        // (3c.final.B.3b) so the slice stays bounded.
127        self.read_editor(move |e| e.popup_help())
128    }
129
130    // Slice 3c.final.E.5i: `with_popup_help_mut` moved to
131    // `#[cfg(test)] impl App` below — the `impl FnOnce(&mut
132    // HelpBuffer) -> R` arg is fundamentally incompatible with
133    // the `Send + 'static` closure bound of `mutate_editor` (the
134    // user-supplied `f` carries no Send bound, and adding one
135    // would propagate `Send` requirements onto every test
136    // fixture's local-borrow closure). Only callers are in
137    // app.rs's `mod tests` block (popup_help_mut_*); production
138    // code mutates popup buffers through the host directly.
139
140    /// M.3.2.c.5: mirror parsed help metadata into the buffer-locals
141    /// map for `buffer_id`. Idempotent (replace-on-collision). The
142    /// active pane's buffer_id and `buffer.id` may differ (in-pane
143    /// help registry uses the registry id; the popup uses the
144    /// buffer's construction id) -- callers seed under both as
145    /// needed.
146    /// 5.5.G.7: body migrated to
147    /// [`lattice_host::dispatch::Editor::seed_help_metadata_locals`].
148    pub(crate) fn seed_help_metadata_locals(
149        &mut self,
150        buffer_id: crate::buffers::BufferId,
151        metadata: HelpMetadata,
152    ) {
153        // Slice 3c.final.E.3: route through `mutate_editor`.
154        self.mutate_editor(move |e| e.seed_help_metadata_locals(buffer_id, metadata));
155    }
156
157    // Slice 3c.final.E.swap: `popup_help_links` and
158    // `popup_help_anchors` return borrowed slices off
159    // `editor.buffer_locals`; their only callers are in
160    // `#[cfg(test)] mod tests` blocks. Moved to
161    // the `#[cfg(test)] impl App` block at the bottom of this
162    // file. `active_popup_placement` same — only `display.rs` +
163    // `popup.rs` tests use it.
164
165    /// Update the popup's placement in place. No-op when no popup
166    /// is currently open. Used by callers that want to flip
167    /// between cursor-anchored and centred mid-popup (e.g. hover
168    /// promoted to a focused reference view).
169    ///
170    /// `#[allow(dead_code)]`: the method is a designed-in API
171    /// surface (referenced from the module-level docs above) but
172    /// no production call site lives at HEAD; current call sites
173    /// pass the placement at popup open. Removing it now would
174    /// re-add the test surface when the first promote-popup flow
175    /// lands. Tests below exercise it.
176    #[allow(dead_code)]
177    pub(crate) fn set_popup_placement(&mut self, placement: PopupPlacement) {
178        if self.popup().buffer_id.is_some() {
179            self.mutate_editor(move |e| e.popup_placement = placement);
180        }
181    }
182
183    /// Snapshot the current popup's content + cursor + metadata so
184    /// it can be restored later by `<C-o>`. Returns `None` if no
185    /// popup is open or the registry entry has been torn down.
186    pub(super) fn snapshot_current_popup(&self) -> Option<PopupSnapshot> {
187        // Phase 5.8.AE: body migrated.
188        self.read_editor(move |e| e.snapshot_current_popup())
189    }
190
191    /// Swap `content` into the existing popup buffer in place.
192    /// Phase 5.8.AE: body migrated.
193    pub(super) fn swap_popup_content(&mut self, content: HelpContent, placement: PopupPlacement) {
194        // Slice 3c.final.E.3: route through `mutate_editor`.
195        self.mutate_editor(move |e| e.swap_popup_content(content, placement));
196    }
197
198    // 5.5.H: `pop_popup_back` App-side delegate retired (zero
199    // callers; host copy at
200    // [`lattice_host::dispatch::Editor::pop_popup_back`]).
201
202    // Read-side accessor for the renderer: the active popup's
203    // placement, or `None` when no popup is open.
204    //
205    // Renamed from `popup_placement()` in Phase 5.B.10
206    // because the migrated `Editor::popup_placement` field
207    // shadowed the method via auto-deref; the rename keeps
208    // the method's intent (Option-returning gated accessor)
209    // distinct from the raw field.
210    // Slice 3c.final.E.swap: `active_popup_placement` moved to
211    // `#[cfg(test)] impl App` below — only test callers.
212
213    /// Close the popup. Drops the popup's content slot, resets
214    /// placement to default, and restores any focus state that
215    /// was captured at open. Idempotent: closing when no popup
216    /// is open is a no-op.
217    pub(crate) fn dismiss_popup(&mut self) {
218        // Slice 3c.final.E.3: route through `mutate_editor`.
219        self.mutate_editor(|e| e.dismiss_popup());
220    }
221}
222
223// Slice 3c.final.E.5i — test-fixture surface for popup-help reads.
224// PU.1a: `with_popup_help_mut` retired — help content is an
225// actor-backed Document, so tests mutate `editor.cursor` /
226// `editor.popup_cursor` (view state) directly, not the storage.
227#[cfg(test)]
228impl App {
229    pub fn popup_help_links(&self) -> Option<&[crate::help::HelpLink]> {
230        let id = self.popup().buffer_id?;
231        self.editor
232            .buffer_locals
233            .get(&id)
234            .and_then(|l| l.get::<crate::modes::HelpLinks>())
235            .map(|h| h.0.as_slice())
236    }
237
238    pub fn popup_help_anchors(&self) -> Option<&[crate::help::HelpAnchor]> {
239        let id = self.popup().buffer_id?;
240        self.editor
241            .buffer_locals
242            .get(&id)
243            .and_then(|l| l.get::<crate::modes::HelpAnchors>())
244            .map(|h| h.0.as_slice())
245    }
246
247    pub fn active_popup_placement(&self) -> Option<PopupPlacement> {
248        self.editor.popup_buffer.map(|_| self.popup().placement)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::app::test_helpers::app_with;
256    use crate::help::HelpContent;
257
258    #[test]
259    fn lsp_status_popup_is_centered() {
260        let mut a = app_with("hello", 10);
261        a.do_lsp_status();
262        assert_eq!(a.active_popup_placement(), Some(PopupPlacement::Centered));
263    }
264
265    #[test]
266    fn hover_popup_is_cursor_anchored() {
267        let mut a = app_with("hello", 10);
268        a.do_open_hover("hover body");
269        assert_eq!(
270            a.active_popup_placement(),
271            Some(PopupPlacement::CursorAnchored)
272        );
273    }
274
275    #[test]
276    fn hover_popup_activates_hover_mode_minor() {
277        // M.4: do_open_hover activates `hover-mode` as a minor on
278        // the popup buffer. Future hover-only behaviour gates on
279        // this mode being active rather than the popup's state
280        // shape (`prev_pane_for_popup.is_none()`).
281        let mut a = app_with("hello", 10);
282        a.do_open_hover("hover body");
283        let buffer_id = a.editor.popup_buffer.expect("popup open");
284        let modes = a
285            .editor
286            .active_modes
287            .get(&buffer_id)
288            .expect("popup has modes");
289        assert!(
290            modes.minors().contains(&crate::modes::HoverMode::mode_id()),
291            "hover popup should activate hover-mode minor; got {:?}",
292            modes.minors()
293        );
294    }
295
296    #[test]
297    fn open_popup_with_explicit_placement_overrides_default() {
298        let mut a = app_with("hello", 10);
299        let buf = HelpContent::from_lines("test", vec!["body".into()]);
300        a.open_popup(buf, PopupPlacement::CursorAnchored);
301        assert_eq!(
302            a.active_popup_placement(),
303            Some(PopupPlacement::CursorAnchored)
304        );
305    }
306
307    #[test]
308    fn set_popup_placement_updates_open_popup() {
309        let mut a = app_with("hello", 10);
310        a.do_lsp_status();
311        a.set_popup_placement(PopupPlacement::CursorAnchored);
312        assert_eq!(
313            a.active_popup_placement(),
314            Some(PopupPlacement::CursorAnchored)
315        );
316    }
317
318    #[test]
319    fn set_popup_placement_is_noop_when_closed() {
320        let mut a = app_with("hello", 10);
321        a.set_popup_placement(PopupPlacement::CursorAnchored);
322        // No popup open: placement read returns None regardless.
323        assert_eq!(a.active_popup_placement(), None);
324    }
325
326    #[test]
327    fn popup_registers_buffer_with_unlisted_hidden_flags() {
328        // M.4 (b): popup buffers participate in `app.editor.buffers` like
329        // every other buffer, with `listed: false` (skipped by `:bn`
330        // / `:bp` / `:ls`) and `hidden: true` (informational; popups
331        // don't have windows of their own).
332        let mut a = app_with("hello", 10);
333        a.do_lsp_status();
334        let id = a.editor.popup_buffer.expect("popup open");
335        let (flags, is_help) = a
336            .editor
337            .buffers
338            .with_entry(id, |entry| {
339                (
340                    entry.flags,
341                    entry.kind() == crate::buffers::BufferKind::Help,
342                )
343            })
344            .expect("popup registered");
345        assert!(!flags.listed);
346        assert!(flags.hidden);
347        assert!(is_help);
348        // `:ls` / `:bn` cycling skips it.
349        assert!(!a.editor.buffers.listed_ids_sorted().contains(&id));
350    }
351
352    #[test]
353    fn dismiss_popup_removes_buffer_from_registry() {
354        // M.4 (b): closing a popup tears down its registry / mode /
355        // option-cache state. Otherwise back-to-back popups
356        // accumulate stale entries indefinitely.
357        let mut a = app_with("hello", 10);
358        a.do_lsp_status();
359        let id = a.editor.popup_buffer.expect("popup open");
360        assert!(a.editor.buffers.contains(id));
361        a.dismiss_popup();
362        assert!(!a.editor.buffers.contains(id));
363        assert!(a.editor.active_modes.get(&id).is_none());
364        assert!(a.editor.buffer_locals.get(&id).is_none());
365    }
366
367    #[test]
368    fn back_to_back_popups_reuse_the_same_buffer() {
369        // Opening a second popup while one is already open swaps
370        // the content in place rather than allocating a fresh
371        // buffer. Jump-list / marks / search state keyed by the
372        // popup id stay coherent across in-popup navigation; the
373        // registry never holds more than one popup at a time.
374        let mut a = app_with("hello", 10);
375        a.do_lsp_status();
376        let first_id = a.editor.popup_buffer.expect("first popup open");
377        a.do_lsp_status();
378        let second_id = a.editor.popup_buffer.expect("second popup open");
379        assert_eq!(
380            first_id, second_id,
381            "popup id should be reused on in-Help reopen"
382        );
383        assert!(
384            a.editor.buffers.contains(first_id),
385            "popup buffer survives the swap"
386        );
387        // The prior frame is recorded on the back-stack so `<C-o>`
388        // can restore it.
389        assert_eq!(a.editor.popup_back_stack.len(), 1);
390    }
391
392    #[test]
393    fn dismiss_popup_clears_placement() {
394        let mut a = app_with("hello", 10);
395        a.do_open_hover("hover body");
396        assert_eq!(
397            a.active_popup_placement(),
398            Some(PopupPlacement::CursorAnchored)
399        );
400        a.dismiss_popup();
401        assert_eq!(a.active_popup_placement(), None);
402        assert_eq!(a.editor.popup_placement, PopupPlacement::default());
403    }
404
405    #[test]
406    fn opening_centered_popup_after_hover_resets_placement() {
407        let mut a = app_with("hello", 10);
408        a.do_open_hover("hover body");
409        // Subsequent command-launched popup must override the
410        // sticky CursorAnchored placement from the prior hover.
411        a.dismiss_popup();
412        a.do_lsp_status();
413        assert_eq!(a.active_popup_placement(), Some(PopupPlacement::Centered));
414    }
415}