lattice_core/ui/display.rs
1//! Buffer display preferences (DESIGN.md ยง5.9).
2//!
3//! When an ex-command produces a buffer (`:lsp-status`, `:help foo`,
4//! `:diagnostics`, picker accept, hover, ...) it doesn't open the
5//! buffer directly -- it asks the App to *display* it under a
6//! [`BufferDisplayCategory`]. The App resolves the category to a
7//! concrete [`BufferDisplay`] (built-in default today; user-
8//! overridable via typed options in a follow-up) and dispatches to
9//! the matching surface: popup overlay, active pane replacement,
10//! or a fresh split.
11//!
12//! Decoupling the *what* (the buffer) from the *where* (the
13//! display) means a single user preference -- "I want LSP logs in
14//! a horizontal split, not a popup" -- is one toggle, not a patch
15//! to every command that produces an LSP log buffer.
16//!
17//! The taxonomy is by *intent*, not by command: adding a new
18//! `:describe-symbol` falls under the existing
19//! [`BufferDisplayCategory::HelpDescribe`] knob without a new
20//! category; adding a whole new feature (say `git.log`) is one
21//! new variant.
22
23use crate::ui::pane::SplitOrientation;
24use crate::ui::popup::PopupPlacement;
25
26/// Where to put a buffer the App is about to display.
27///
28/// Renderer-agnostic: a future GPUI / web renderer maps these
29/// variants to its own surfaces. The TUI maps `Popup` to the
30/// existing centred-or-anchored overlay, `FloatingPopup` to the
31/// hover-style overlay (popup floats; the doc keeps focus),
32/// `ActivePane` to the `:lsp-log`-style swap-active-pane path,
33/// and `Split` to a horizontal / vertical split via the pane
34/// tree.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum BufferDisplay {
37 /// Focused overlay -- the popup gains focus, the underlying
38 /// document is paused. Used by `:lsp-status` /
39 /// `:describe-*` / `:apropos` / etc. Carries its own
40 /// placement (cursor-anchored vs centred).
41 Popup(PopupPlacement),
42 /// Floating overlay -- the popup paints on top, but the
43 /// document keeps focus. Cursor motion in the doc
44 /// auto-dismisses (via the `hover-mode` minor contract).
45 /// Used by hover (`K`) and signature help.
46 FloatingPopup(PopupPlacement),
47 /// Replace the active pane's buffer with this one. The
48 /// previous buffer stays in the registry; pane history /
49 /// `<C-^>` (post v1) returns to it.
50 ActivePane,
51 /// Split the active pane (orientation-dependent) and open
52 /// the buffer in the new pane. The new pane gains focus,
53 /// matching vim's `:help` / `:vert help`.
54 Split(SplitOrientation),
55}
56
57impl BufferDisplay {
58 /// Focused popup centred in the frame.
59 pub const POPUP_CENTERED: Self = Self::Popup(PopupPlacement::Centered);
60 /// Focused popup anchored at the cursor.
61 pub const POPUP_CURSOR: Self = Self::Popup(PopupPlacement::CursorAnchored);
62 /// Unfocused hover-style popup anchored at the cursor.
63 pub const FLOATING_CURSOR: Self = Self::FloatingPopup(PopupPlacement::CursorAnchored);
64 /// New pane below the active one (vim `:split`).
65 pub const SPLIT_HORIZONTAL: Self = Self::Split(SplitOrientation::Horizontal);
66 /// New pane beside the active one (vim `:vsplit`).
67 pub const SPLIT_VERTICAL: Self = Self::Split(SplitOrientation::Vertical);
68}
69
70/// Per-feature display preference. Each command that produces a
71/// buffer carries its category; the App resolves the category to
72/// a [`BufferDisplay`] via [`default_display`] (today) or a
73/// user-supplied override (follow-up).
74///
75/// Granularity rationale: per-command is too narrow (six
76/// `:describe-*` variants would need six knobs); per-`BufferKind`
77/// is too coarse (every help-flavoured surface is `Help`, but
78/// hover and `:lsp-log` have wildly different display intents).
79/// Per-category groups commands that share *intent*, which is
80/// what users actually configure.
81///
82/// Multiple-choice surfaces (`:diagnostics`, `:references`,
83/// `:symbol`, `:Files`, `:buffers`) are *picker-shaped by
84/// design* -- the user picks one of N candidates -- and don't
85/// have a category here. Their picker UI is a fixed surface;
86/// where the *selected* result lands is governed by
87/// [`Self::PickerResult`]. The categories below all describe
88/// dedicated single-buffer outputs.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum BufferDisplayCategory {
91 // ---- LSP feature group ----
92 /// `:lsp-status` -- one-shot read of the supervisor state.
93 LspStatus,
94 /// `:lsp-log` / `:lsp-trace-log` -- live-tailed log views
95 /// the user wants to keep around. (`:lsp-server-log` is a
96 /// picker; it routes through [`Self::PickerResult`] once the
97 /// user picks a server, then the chosen log opens under
98 /// `LspLog`.)
99 LspLog,
100 /// `:messages` -- chronological transcript of every echo
101 /// area message (analogue of emacs's `*Messages*`).
102 /// Live-tailed via the `MessagesRing`; opens in the
103 /// active pane by default.
104 Messages,
105
106 // ---- Help feature group ----
107 /// `:help <topic>` -- free-form help docs.
108 HelpTopic,
109 /// `:describe-command/-buffer/-key/-option/-mode/-event` --
110 /// introspection-driven help.
111 HelpDescribe,
112 /// `:apropos <pattern>` -- search command names + docs.
113 HelpApropos,
114 /// `:ls`, `:keymap`, `:marks`, `:registers`, `:options` --
115 /// state-listing help views.
116 HelpList,
117
118 // ---- Cursor-adjacent overlays ----
119 /// `K` -- inline hover / quick-info popup. Auto-dismisses on
120 /// cursor motion.
121 Hover,
122 /// Signature help -- argument-list popup that follows the
123 /// cursor as the user types args.
124 Signature,
125
126 // ---- Picker accept destination ----
127 /// Where the *selected* buffer / location lands after the
128 /// user accepts a picker entry. Used by `:diagnostics` /
129 /// `:references` / `:symbol` / `:Files` / `:buffers` /
130 /// `:lsp-server-log` -- their picker UI is fixed; this knob
131 /// controls the post-accept buffer placement.
132 PickerResult,
133}
134
135crate::labeled_enum! {
136 /// User-facing override for a [`BufferDisplay`]. Each
137 /// [`BufferDisplayCategory`] gets a typed option keyed
138 /// `<category>.display` whose value is one of these variants;
139 /// the App's resolver maps the variant to the matching
140 /// `BufferDisplay`. `Default` means "use the built-in default
141 /// for this category" โ the fallback when the user hasn't
142 /// set the option explicitly.
143 ///
144 /// Flat (non-parametric) shape so the typed-options system
145 /// can store + parse it as one scalar; the parametric
146 /// `BufferDisplay` variants (`Popup(PopupPlacement)`,
147 /// `Split(SplitOrientation)`) are flattened here as
148 /// `PopupCentered` / `PopupCursor` / `SplitHorizontal` /
149 /// `SplitVertical`.
150 ///
151 /// Slice `3c.unify.option-docs-builtin` migrated this enum
152 /// to `labeled_enum!` โ adding a new display preference is
153 /// now one line; `label` / `parse_label` / `doc` / `all` are
154 /// derived automatically.
155 pub enum BufferDisplayPreference {
156 /// The implicit value when no override has been set.
157 #[default]
158 Default = "default"
159 => "Use the category's built-in default",
160 /// Centred focused popup (focus moves into the popup).
161 /// Accepts `popup` as an alias of `popup-centered`.
162 PopupCentered = "popup-centered" | "popup"
163 => "Centred focused popup (focus moves into the popup)",
164 /// Focused popup anchored at the cursor.
165 PopupCursor = "popup-cursor"
166 => "Cursor-anchored focused popup",
167 /// Doc keeps focus, popup auto-dismisses on cursor
168 /// motion (the hover shape). `floating` is the canonical
169 /// short alias.
170 FloatingCursor = "floating-cursor" | "floating"
171 => "Cursor-anchored floating popup (auto-dismisses on cursor motion)",
172 /// Swap the buffer into the active pane. Alias `pane`.
173 ActivePane = "active-pane" | "pane"
174 => "Replace the active pane's buffer",
175 /// Horizontal split (new pane below). Aliases `split`,
176 /// `split-horizontal`.
177 SplitHorizontal = "split-h" | "split" | "split-horizontal"
178 => "Horizontal split alongside the active pane",
179 /// Vertical split (new pane beside). Alias `split-vertical`.
180 SplitVertical = "split-v" | "split-vertical"
181 => "Vertical split alongside the active pane",
182 }
183}
184
185impl BufferDisplayPreference {
186 /// Map to the concrete [`BufferDisplay`] for the given
187 /// `category`. `Default` falls through to
188 /// [`default_display`]; any explicit variant returns its
189 /// fixed shape.
190 ///
191 /// # Examples
192 ///
193 /// ```
194 /// use lattice_core::ui::display::{
195 /// BufferDisplay, BufferDisplayCategory, BufferDisplayPreference,
196 /// };
197 ///
198 /// // No override: hover keeps its built-in floating popup.
199 /// assert_eq!(
200 /// BufferDisplayPreference::Default.resolve(BufferDisplayCategory::Hover),
201 /// BufferDisplay::FLOATING_CURSOR,
202 /// );
203 /// // `:set lsp.log.display=split` โ the alias parses, then resolves.
204 /// let pref = BufferDisplayPreference::parse_label("split")
205 /// .unwrap_or_default();
206 /// assert_eq!(
207 /// pref.resolve(BufferDisplayCategory::LspLog),
208 /// BufferDisplay::SPLIT_HORIZONTAL,
209 /// );
210 /// ```
211 pub fn resolve(self, category: BufferDisplayCategory) -> BufferDisplay {
212 match self {
213 Self::Default => default_display(category),
214 Self::PopupCentered => BufferDisplay::POPUP_CENTERED,
215 Self::PopupCursor => BufferDisplay::POPUP_CURSOR,
216 Self::FloatingCursor => BufferDisplay::FLOATING_CURSOR,
217 Self::ActivePane => BufferDisplay::ActivePane,
218 Self::SplitHorizontal => BufferDisplay::SPLIT_HORIZONTAL,
219 Self::SplitVertical => BufferDisplay::SPLIT_VERTICAL,
220 }
221 }
222}
223
224/// Built-in default for each category. Matches today's
225/// hard-coded behaviour so the dispatch refactor lands without
226/// observable behaviour changes; user overrides layer on top
227/// in a follow-up slice.
228pub const fn default_display(category: BufferDisplayCategory) -> BufferDisplay {
229 use BufferDisplayCategory as C;
230 match category {
231 C::LspStatus => BufferDisplay::POPUP_CENTERED,
232 C::LspLog => BufferDisplay::ActivePane,
233 C::Messages => BufferDisplay::ActivePane,
234 C::HelpTopic => BufferDisplay::POPUP_CENTERED,
235 C::HelpDescribe => BufferDisplay::POPUP_CENTERED,
236 C::HelpApropos => BufferDisplay::POPUP_CENTERED,
237 C::HelpList => BufferDisplay::POPUP_CENTERED,
238 C::Hover => BufferDisplay::FLOATING_CURSOR,
239 C::Signature => BufferDisplay::FLOATING_CURSOR,
240 C::PickerResult => BufferDisplay::ActivePane,
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn defaults_match_legacy_hardcoded_behaviour() {
250 // `:lsp-status` was `open_popup(centered)` -- preserved.
251 assert_eq!(
252 default_display(BufferDisplayCategory::LspStatus),
253 BufferDisplay::POPUP_CENTERED
254 );
255 // `:lsp-log` family was `open_help_in_pane` -- preserved.
256 assert_eq!(
257 default_display(BufferDisplayCategory::LspLog),
258 BufferDisplay::ActivePane
259 );
260 // Hover routes to the floating-popup variant (M.4
261 // follow-up): popup floats, doc keeps focus; the
262 // hover-mode minor's auto-dismiss-on-cursor-motion
263 // contract makes State A semantics observable here.
264 assert_eq!(
265 default_display(BufferDisplayCategory::Hover),
266 BufferDisplay::FLOATING_CURSOR
267 );
268 }
269
270 #[test]
271 fn buffer_display_constants_match_variants() {
272 assert_eq!(
273 BufferDisplay::POPUP_CENTERED,
274 BufferDisplay::Popup(PopupPlacement::Centered)
275 );
276 assert_eq!(
277 BufferDisplay::SPLIT_HORIZONTAL,
278 BufferDisplay::Split(SplitOrientation::Horizontal)
279 );
280 }
281}