Skip to main content

lattice_core/ui/
popup.rs

1//! Popup overlay primitives.
2//!
3//! Today the only popup surface is the help-buffer overlay
4//! (DESIGN.md §5.11). Even so, "where the popup sits on screen"
5//! is a renderer concern -- not a help-content concern -- so the
6//! placement enum lives here and is reused by any future popup
7//! kind (inline diagnostic box, completion docs, signature side-
8//! panel) without dragging in help-buffer machinery.
9
10/// Where the popup overlay anchors on screen.
11///
12/// Cursor-anchored popups (hover, signature help, diagnostic-at-
13/// cursor) sit adjacent to the symbol that triggered them so the
14/// user's eye doesn't have to leave the cursor to read them.
15/// Centred popups are command-launched and unrelated to where the
16/// cursor happens to be (`:lsp-status`, `:describe-*`, `:apropos`,
17/// `:help`, `:keymap`, `:options`, `:ls`, `:lsp-log`, ...) so
18/// anchoring next to the cursor would produce a visually arbitrary
19/// placement. Centring puts them where the eye lands by default.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum PopupPlacement {
22    /// Anchor adjacent to the document cursor (above / below
23    /// depending on screen room).
24    CursorAnchored,
25    /// Centre over the buffer area.
26    #[default]
27    Centered,
28    /// Full width of the active pane, anchored to its bottom
29    /// edge. Which-key's placement.
30    ///
31    /// Bottom-anchored full-width is what emacs `which-key` and
32    /// `which-key.nvim` both do, and per the UX-convention rule that
33    /// muscle memory is the default worth keeping. It is also the only
34    /// placement where the column count is predictable, which is what
35    /// lets the grid be laid out ahead of the renderer.
36    ///
37    /// Height is content + border, hard-capped at half the pane so the
38    /// hint can never swallow the buffer it is describing.
39    ///
40    /// Slice: WK.5.
41    MinibufferBand,
42}
43
44/// Whether opening a popup moves focus into it. Names the distinction
45/// the code already makes between the two overlay entry points, so any
46/// popup kind (help, ACP permission menu, LSP code-action list) picks a
47/// focus mode without dragging in help-buffer machinery.
48///
49/// See `docs/dev/architecture/popup-api.md` §4.1.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum PopupFocus {
52    /// Focus moves into the popup buffer: it becomes the active buffer,
53    /// its major mode receives keys, and modal resets to Normal (State
54    /// B). `:help`, `:describe-*`, `:apropos`, the permission menu.
55    #[default]
56    Steal,
57    /// The popup floats over the active buffer, which keeps focus and
58    /// the caret; modal and active-buffer are untouched (State A).
59    /// Hover, signature help.
60    Passive,
61}
62
63/// Outer size (border-inclusive) of a help / hover popup overlay.
64///
65/// Centred popups are reading surfaces (`:help`, `:options`,
66/// `:describe-*`, `:apropos`, `:customize`); they want enough
67/// room to lay out paragraphs + tables comfortably without
68/// covering the whole screen. The buffer below stays partly
69/// visible so the user keeps spatial context. Caps:
70///
71/// - Width: `min(buffer_width - 4, 120)`, floor 30. The 120-cell
72///   ceiling preserves a comfortable line length for reading
73///   markdown -- wider lines are harder to scan, so even on a
74///   200-cell terminal the popup stops at 120.
75/// - Height: `buffer_height * 3 / 4`, floor 5, no upper ceiling.
76///   Three-quarters leaves a strip of the underlying buffer
77///   visible at top and bottom while giving a large help doc all
78///   the vertical room the screen allows; anything longer than the
79///   box scrolls within the popup.
80///
81/// Cursor-anchored popups are tooltips (hover, signature help);
82/// they sit adjacent to the cursor and want to *not* dominate
83/// the screen. Caps stay tight: width 30..=80, height 5..=20.
84///
85/// `line_count` is the popup's content row count
86/// (excluding borders). The returned `(width, height)` is
87/// border-inclusive (`height = inner + 2`); subtract 2 to get
88/// the inner viewport for motion / scroll.
89pub fn popup_outer_size(
90    buffer_width: u16,
91    buffer_height: u16,
92    line_count: u16,
93    placement: PopupPlacement,
94) -> (u16, u16) {
95    let line_count = line_count.max(1);
96    let (max_h, max_w) = match placement {
97        PopupPlacement::Centered => {
98            // Height is a true three-quarters of the viewport (floor 5, no upper
99            // ceiling): a large help doc should use all the vertical room the
100            // screen allows, while ¾ still leaves a strip of buffer above and
101            // below for context. The old 40-row cap made the popup feel cramped
102            // on tall / high-resolution displays — a 70-row screen stopped at 40
103            // and scrolled the rest. The `.max(5)` keeps `max_h >= 5` so the
104            // `clamp(5, max_h)` below never inverts on a tiny terminal.
105            let max_h = ((buffer_height as u32 * 3 / 4).max(5)) as u16;
106            let max_w = (buffer_width.saturating_sub(4)).clamp(30, 120);
107            (max_h, max_w)
108        }
109        PopupPlacement::CursorAnchored => {
110            let max_h = (buffer_height / 2).clamp(5, 20);
111            let max_w = (buffer_width.saturating_sub(4)).clamp(30, 80);
112            (max_h, max_w)
113        }
114        // WK.5: full pane width, and never more than half its height.
115        // The half-pane cap is the hard one: a hint that covers the code
116        // it describes has defeated itself, and a prefix with a hundred
117        // continuations would otherwise ask for exactly that.
118        PopupPlacement::MinibufferBand => {
119            let max_h = (buffer_height / 2).max(1);
120            (max_h, buffer_width.max(1))
121        }
122    };
123    // A pane-bottom popup sizes to its content and has no floor: a
124    // two-row hint is two rows. The 5-row floor is for reading surfaces,
125    // where a box smaller than that reads as broken rather than terse.
126    let height = if matches!(placement, PopupPlacement::MinibufferBand) {
127        (line_count.saturating_add(2)).min(max_h)
128    } else {
129        (line_count.saturating_add(2)).clamp(5, max_h)
130    };
131    (max_w, height)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn pane_bottom_is_full_width_and_sizes_to_its_content() {
140        // 8 content rows in a 100x40 pane → full width, 10 rows.
141        let (w, h) = popup_outer_size(100, 40, 8, PopupPlacement::MinibufferBand);
142        assert_eq!(w, 100, "full pane width — the grid was laid out to it");
143        assert_eq!(h, 10, "content + border, no 5-row floor padding it out");
144    }
145
146    #[test]
147    fn pane_bottom_never_takes_more_than_half_the_pane() {
148        // 100 rows of continuations in a 40-row pane.
149        let (_w, h) = popup_outer_size(100, 40, 100, PopupPlacement::MinibufferBand);
150        assert_eq!(
151            h, 20,
152            "a hint that covers the code it describes has defeated itself"
153        );
154    }
155
156    #[test]
157    fn a_two_row_pane_bottom_popup_stays_two_rows() {
158        let (_w, h) = popup_outer_size(100, 40, 1, PopupPlacement::MinibufferBand);
159        assert_eq!(
160            h, 3,
161            "one row plus border — the 5-row floor is for reading surfaces, \
162             where a smaller box reads as broken rather than terse"
163        );
164    }
165
166    #[test]
167    fn centered_popup_uses_three_quarters_height_and_120_width_cap() {
168        // 200 wide, 60 tall, 50 lines of content. Centered.
169        let (w, h) = popup_outer_size(200, 60, 50, PopupPlacement::Centered);
170        // Width caps at 120 (not buffer_width - 4 = 196).
171        assert_eq!(w, 120);
172        // Height is 3/4 of 60 = 45 — content (50+2) exceeds it, so it fills the
173        // three-quarters. No 40-row ceiling anymore.
174        assert_eq!(h, 45);
175    }
176
177    #[test]
178    fn centered_popup_has_no_40_row_ceiling_on_tall_screens() {
179        // A tall / high-resolution viewport: 3/4 of 100 = 75, and a long help
180        // doc uses all of it rather than stopping at the old 40-row cap.
181        let (_w, h) = popup_outer_size(200, 100, 200, PopupPlacement::Centered);
182        assert_eq!(h, 75, "large docs fill three-quarters of a tall screen");
183    }
184
185    #[test]
186    fn centered_popup_fits_short_content_to_content_height() {
187        let (_w, h) = popup_outer_size(200, 60, 8, PopupPlacement::Centered);
188        // 8 lines + 2 borders = 10, well under both caps.
189        assert_eq!(h, 10);
190    }
191
192    #[test]
193    fn centered_popup_floor_at_five_rows() {
194        // Empty content still gets 5-row floor (3 inner).
195        let (_w, h) = popup_outer_size(200, 60, 0, PopupPlacement::Centered);
196        assert_eq!(h, 5);
197    }
198
199    #[test]
200    fn cursor_anchored_keeps_tooltip_caps_unchanged() {
201        // Same buffer, same content as the centered case.
202        let (w, h) = popup_outer_size(200, 60, 50, PopupPlacement::CursorAnchored);
203        // Width caps at 80, height at 20 -- tooltip ergonomics.
204        assert_eq!(w, 80);
205        assert_eq!(h, 20);
206    }
207
208    #[test]
209    fn small_buffer_shrinks_centered_height_proportionally() {
210        // 30-row buffer: 3/4 = 22.5 → 22. Content exceeds it, so it fills the
211        // three-quarters.
212        let (_w, h) = popup_outer_size(100, 30, 50, PopupPlacement::Centered);
213        assert_eq!(h, 22);
214    }
215
216    #[test]
217    fn narrow_buffer_shrinks_centered_width() {
218        // 50-cell buffer: width = min(50-4, 120) = 46.
219        let (w, _h) = popup_outer_size(50, 60, 50, PopupPlacement::Centered);
220        assert_eq!(w, 46);
221    }
222}