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}