Skip to main content

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}