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}