Skip to main content

lattice_ui_tui/app/
display.rs

1//! Buffer-display dispatch (DESIGN.md ยง5.9).
2//!
3//! Every command that produces a help-style buffer
4//! (`:lsp-status`, `:help foo`, `:diagnostics`, hover, picker
5//! accept, ...) routes through [`App::display_buffer`]. The
6//! caller passes a [`BufferDisplayCategory`] expressing *intent*
7//! ("this is an LSP log", "this is a hover popup"); the App
8//! resolves the category to a concrete [`BufferDisplay`] -- via
9//! [`default_display`] today, with user-supplied overrides
10//! layered on in a follow-up -- and dispatches to the matching
11//! surface.
12//!
13//! Three surfaces in v1:
14//! - [`BufferDisplay::Popup`] -> [`App::open_popup`] (overlay).
15//! - [`BufferDisplay::ActivePane`] ->
16//!   [`App::open_help_in_pane`] (registry-tracked, swap active
17//!   pane).
18//! - [`BufferDisplay::Split`] -> [`App::open_help_in_split`]
19//!   (split active pane, focus the new sibling).
20//!
21//! A future GPUI / web renderer adds variants for tabs / OS
22//! windows / inline panels without changing call sites: each
23//! command keeps emitting its category, and the resolver +
24//! dispatcher are the only places that learn new variants.
25
26use lattice_core::ui::display::{BufferDisplay, BufferDisplayCategory};
27use lattice_core::ui::pane::SplitOrientation;
28
29use crate::buffers::BufferId;
30use crate::help::HelpContent;
31
32use super::App;
33
34impl App {
35    /// Display `content` under the given `category`. Resolves the
36    /// category to a [`BufferDisplay`] (built-in default for now;
37    /// user overrides land in a follow-up) and dispatches.
38    ///
39    /// Returns the registered [`BufferId`] for `ActivePane` /
40    /// `Split` displays so callers can wire follow-up state
41    /// (e.g. live-tail subscriptions key off this id). Returns
42    /// `None` for `Popup` displays -- the popup buffer lives in
43    /// the registry too, but most callers don't need the id back.
44    pub(crate) fn display_buffer(
45        &mut self,
46        content: HelpContent,
47        category: BufferDisplayCategory,
48    ) -> Option<BufferId> {
49        // Phase 5.8.AE: body migrated.
50        let (id, signals) = self.mutate_editor_with(move |e| e.display_buffer(content, category));
51        for s in signals {
52            self.handle_renderer_signal(s);
53        }
54        id
55    }
56
57    /// Resolve a [`BufferDisplayCategory`] to a concrete
58    /// [`BufferDisplay`]. Reads the per-category typed option
59    /// (`:set <category>.display = ...`) and falls back to
60    /// `default_display` when the option resolves to
61    /// `BufferDisplayPreference::Default` (the implicit value
62    /// when the user hasn't set it explicitly).
63    ///
64    /// Reads route through the config registry's typed-keyed
65    /// `get_typed::<D>()` -- O(1) hash lookup + an `Arc::clone`.
66    pub fn resolve_display(&self, category: BufferDisplayCategory) -> BufferDisplay {
67        // Phase 5.8.AD.6: body migrated.
68        self.read_editor(move |e| e.resolve_display(category))
69    }
70
71    /// Apply the [`BufferDisplayCategory::PickerResult`] preference
72    /// to the active pane *before* a picker jump / buffer-switch
73    /// runs. `ActivePane` (default) is a no-op; `Split` performs
74    /// the split + focus shift so the subsequent file-edit /
75    /// activation lands in a new sibling pane. `Popup` doesn't
76    /// translate cleanly to a file buffer (popups hold help-style
77    /// content); we fall through to `ActivePane` and surface no
78    /// error -- the user-set override applies to *normal* buffer
79    /// outputs only.
80    pub(crate) fn prepare_pane_for_picker_result(&mut self) {
81        // Phase 5.8.AD.6: body migrated.
82        self.mutate_editor_with(move |e| e.prepare_pane_for_picker_result());
83    }
84
85    /// Open `content` in a fresh split alongside the active pane.
86    /// Mirrors vim's `:help` (horizontal) and `:vert help`
87    /// (vertical) -- the new pane gains focus, the original
88    /// stays put with its content.
89    ///
90    /// The help buffer is registered in `app.editor.buffers` (same shape
91    /// as [`Self::open_help_in_pane`]) and adopted by the new
92    /// pane through the activation path so mode state, help
93    /// metadata locals, and the popup hot-path slot all converge
94    /// on the registered id.
95    pub(crate) fn open_help_in_split(
96        &mut self,
97        content: HelpContent,
98        orientation: SplitOrientation,
99    ) -> BufferId {
100        // Phase 5.8.AE: body migrated.
101        let (id, signals) =
102            self.mutate_editor_with(move |e| e.open_help_in_split(content, orientation));
103        for s in signals {
104            self.handle_renderer_signal(s);
105        }
106        id
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::app::test_helpers::app_with;
114    use crate::buffers::BufferKind;
115    use crate::popup::PopupPlacement;
116
117    #[test]
118    fn lsp_status_category_routes_to_centered_popup() {
119        let mut a = app_with("hi", 5);
120        let content = HelpContent::from_lines("status", vec!["server: rust-analyzer".into()]);
121        a.display_buffer(content, BufferDisplayCategory::LspStatus);
122        assert_eq!(a.active_popup_placement(), Some(PopupPlacement::Centered));
123        assert!(a.editor.popup_buffer.is_some());
124    }
125
126    #[test]
127    fn lsp_log_category_routes_to_active_pane() {
128        let mut a = app_with("hi", 5);
129        let content = HelpContent::from_lines("lsp-log", vec!["...".into()]);
130        let id = a
131            .display_buffer(content, BufferDisplayCategory::LspLog)
132            .expect("active-pane returns an id");
133        // Active-pane adoption: pane swaps to the help buffer and
134        // active_buffer flips to Help. (`popup_buffer` is set as a
135        // hot-path mirror by `open_help_in_pane`; that's expected
136        // -- the popup *slot* is reused; what matters here is the
137        // pane state.)
138        assert_eq!(a.editor.active_buffer, BufferKind::Help);
139        assert_eq!(a.editor.pane_tree.active().buffer_id, id);
140        assert_eq!(a.editor.pane_tree.active().buffer, BufferKind::Help);
141    }
142
143    #[test]
144    fn hover_category_routes_to_cursor_anchored_popup() {
145        let mut a = app_with("hi", 5);
146        let content = HelpContent::from_lines("hover", vec!["doc string".into()]);
147        a.display_buffer(content, BufferDisplayCategory::Hover);
148        assert_eq!(
149            a.active_popup_placement(),
150            Some(PopupPlacement::CursorAnchored)
151        );
152    }
153
154    #[test]
155    fn set_category_display_overrides_resolved_value() {
156        // M.4 follow-up: a typed-option override
157        // (`:set hover.display = popup-cursor`) flips
158        // `App::resolve_display(Hover)` from the built-in
159        // floating-cursor default to the user-chosen
160        // popup-cursor variant. Mechanism: the
161        // `BufferDisplayPreference` enum-typed option resolves
162        // to a non-`Default` variant; `pref.resolve(category)`
163        // returns the override.
164        let mut a = app_with("hi", 5);
165        // Default (Hover) is FloatingPopup(CursorAnchored).
166        assert_eq!(
167            a.resolve_display(BufferDisplayCategory::Hover),
168            BufferDisplay::FLOATING_CURSOR
169        );
170        // Override via the typed-options surface.
171        a.do_set("hover.display=popup-cursor");
172        assert_eq!(
173            a.resolve_display(BufferDisplayCategory::Hover),
174            BufferDisplay::POPUP_CURSOR
175        );
176        // Resetting to default round-trips back.
177        a.do_set("hover.display=default");
178        assert_eq!(
179            a.resolve_display(BufferDisplayCategory::Hover),
180            BufferDisplay::FLOATING_CURSOR
181        );
182    }
183
184    #[test]
185    fn picker_result_active_pane_default_is_noop() {
186        // Default for PickerResult is ActivePane, so calling
187        // `prepare_pane_for_picker_result` should not split.
188        let mut a = app_with("hi", 5);
189        let initial = a.editor.pane_tree.len();
190        a.prepare_pane_for_picker_result();
191        assert_eq!(a.editor.pane_tree.len(), initial);
192    }
193
194    #[test]
195    fn split_horizontal_creates_second_pane_focused_on_help() {
196        // Smoke test for the split path that wasn't reachable from
197        // any command before this slice. Routes through the
198        // resolver via a synthesized override (we don't expose a
199        // category whose default is Split yet).
200        let mut a = app_with("hi", 5);
201        let initial_pane_count = a.editor.pane_tree.len();
202        let content = HelpContent::from_lines("help-split", vec!["x".into()]);
203        let id = a.open_help_in_split(content, SplitOrientation::Horizontal);
204        assert_eq!(a.editor.pane_tree.len(), initial_pane_count + 1);
205        // The active pane after the split holds the help buffer.
206        assert_eq!(a.editor.pane_tree.active().buffer_id, id);
207        assert_eq!(a.editor.active_buffer, BufferKind::Help);
208    }
209}