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}