lattice_ui_tui/pane_render.rs
1//! Mode-keyed pane render dispatch (M.4 follow-up).
2//!
3//! Replaces the helper-side `match buffer.kind` in the renderer's
4//! `draw_pane_content` /
5//! [`crate::app::App::pane_status_label`] with a [`ModeId`]-keyed
6//! lookup. Each major / minor mode that owns its own render flow
7//! registers a [`PaneRenderProvider`] at boot; the renderer walks
8//! the active buffer's minors (most-specific first) then its major
9//! to find the provider, falling back to the document path when no
10//! provider matches. Plugins (post-1.0) register additional modes
11//! through the same registry.
12//!
13//! Lives in `lattice-ui-tui` rather than `lattice-mode` because the
14//! function signatures take ratatui types (`Frame`, `Rect`) -- the
15//! mode crate stays renderer-agnostic. A future renderer (GPUI,
16//! web) gets its own registry with its own native signatures; the
17//! [`ModeId`] keys are shared so the registration story is uniform
18//! across renderers.
19
20use std::collections::HashMap;
21
22use lattice_core::BufferId;
23use lattice_host::pane_render::ProviderLookup;
24use lattice_mode::ModeId;
25use lattice_runtime::DocumentSnapshot;
26use ratatui::Frame;
27use ratatui::layout::Rect;
28
29use crate::app::App;
30use crate::pane::PaneState;
31
32/// Renderer for one pane. Receives the same arguments as the
33/// dispatcher in `crate::render::draw_pane_content`: the frame,
34/// content rect, app state, the active document snapshot (used by
35/// the document fallback path), the pane state, an `is_active`
36/// flag, and the pane index (for inactive-pane stash lookups).
37pub type PaneRenderFn = fn(&mut Frame, Rect, &App, &DocumentSnapshot, &PaneState, bool, usize);
38
39/// Status-line label for one pane. Returned to the renderer's
40/// `draw_pane_status_line` so it can paint the bottom row.
41pub type PaneStatusFn = fn(&App, &PaneState) -> String;
42
43/// One mode's pane-render contribution. Kept paired so a mode owns
44/// both its content rendering and its status label -- the two are
45/// almost always defined together (a help pane wants `[help]` in
46/// the status; a file-tree pane wants `[tree] /path/to/root`).
47pub struct PaneRenderProvider {
48 /// How to paint the pane's content, or `None` when the mode's
49 /// buffers paint through the **shared document compose path**.
50 ///
51 /// DL.4: `None` is the goal state, not a special case. A mode that
52 /// supplies its own painter re-implements scroll windowing, the
53 /// cursor row and per-row styling — which is how one arithmetic
54 /// slip came to live in four places (CV.5). The file tree became
55 /// `None` when it converged to a `DocumentEntry`; oil follows in
56 /// DL.5.
57 pub render: Option<PaneRenderFn>,
58 pub status: PaneStatusFn,
59}
60
61/// Boot-time registry. Owned by [`App`]; keyed by [`ModeId`].
62#[derive(Default)]
63pub struct PaneRenderRegistry {
64 map: HashMap<ModeId, PaneRenderProvider>,
65}
66
67impl PaneRenderRegistry {
68 pub fn new() -> Self {
69 Self::default()
70 }
71
72 /// Register a provider for `mode`. Replaces any previous
73 /// registration for the same id.
74 pub fn register(&mut self, mode: ModeId, provider: PaneRenderProvider) {
75 self.map.insert(mode, provider);
76 }
77
78 /// Register a mode that owns only its status label and paints
79 /// through the shared document path (DL.4).
80 pub fn register_status_only(&mut self, mode: ModeId, status: PaneStatusFn) {
81 self.map.insert(
82 mode,
83 PaneRenderProvider {
84 render: None,
85 status,
86 },
87 );
88 }
89
90 /// Look up a provider by mode id. `None` if no mode has
91 /// registered for it (callers fall back to their default
92 /// path).
93 pub fn get(&self, mode: ModeId) -> Option<&PaneRenderProvider> {
94 self.map.get(&mode)
95 }
96}
97
98/// Phase 5.6: the host's [`lattice_host::pane_render::resolve_pane_render_mode`]
99/// walks active modes; this impl is the renderer-side probe it
100/// queries to learn which `ModeId` has a TUI-shaped provider
101/// registered. Each renderer ships its own typed registry and its
102/// own `ProviderLookup` impl over it; the walk algorithm itself
103/// stays host-side.
104impl ProviderLookup for PaneRenderRegistry {
105 fn has_provider(&self, mode: ModeId) -> bool {
106 self.map.contains_key(&mode)
107 }
108}
109
110impl App {
111 /// Resolve the [`PaneRenderProvider`] for `buffer_id`. Walks
112 /// active minors in reverse activation order (most-recently
113 /// activated wins -- the same priority the option resolver
114 /// uses) before falling back to the major. Returns `None`
115 /// when nothing is registered, in which case the renderer
116 /// uses its default document path.
117 ///
118 /// Phase 5.6: the walk lives host-side in
119 /// [`lattice_host::pane_render::resolve_pane_render_mode`]; this
120 /// method is a thin renderer-side adapter that hands the host the
121 /// active editor + registry and looks up the matched provider
122 /// from the resulting `ModeId`. The two-step shape (resolve id,
123 /// then `registry.get`) keeps the trait minimal: the host never
124 /// sees the renderer-typed `PaneRenderProvider`.
125 pub fn pane_render_provider(&self, buffer_id: BufferId) -> Option<&PaneRenderProvider> {
126 // Slice 3c.final.X.cleanup: route through published
127 // `ModesRenderState` (B.11) instead of `read_editor`.
128 // Called per pane per frame from the host's split layout
129 // — same cost class as the compose-loop reads B-extension
130 // already lifted. Walk minors (innermost first) before
131 // falling back to major, matching `resolve_pane_render_mode`.
132 let modes = self.modes();
133 let active = modes.map.get(&buffer_id)?;
134 for minor_id in active.minors().iter().rev() {
135 if self.pane_render_registry.has_provider(*minor_id) {
136 return self.pane_render_registry.get(*minor_id);
137 }
138 }
139 active
140 .major()
141 .filter(|id| self.pane_render_registry.has_provider(*id))
142 .and_then(|id| self.pane_render_registry.get(id))
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use crate::app::test_helpers::app_with;
149 use lattice_mode::Mode;
150
151 #[test]
152 fn document_buffer_has_no_provider_falls_through_to_default() {
153 // A plain document buffer's major is `text-mode` (or a
154 // language major). Neither is registered with a pane-render
155 // provider; the renderer must fall through to its default
156 // document path.
157 let a = app_with("hello", 10);
158 let active_id = a.editor.pane_tree.active().buffer_id;
159 assert!(a.pane_render_provider(active_id).is_none());
160 }
161
162 #[test]
163 fn help_minor_provider_wins_over_markdown_major() {
164 // In-pane help buffers run markdown-mode (major) +
165 // help-mode (minor). The dispatch walks minors first
166 // then the major, so the help-mode provider wins -- the
167 // buffer renders as help, not as a plain markdown
168 // document.
169 let mut a = app_with("hi", 5);
170 let help = crate::help::HelpContent::from_lines("test", vec!["line one".to_string()]);
171 let help_id = a.open_help_in_pane(help);
172 let modes = a
173 .editor
174 .active_modes
175 .get(&help_id)
176 .expect("in-pane help has modes");
177 assert_eq!(modes.major(), Some(lattice_syntax::MarkdownMode.id()));
178 assert!(modes.has_minor(lattice_mode::modes::HelpMode.id()));
179 assert!(a.pane_render_provider(help_id).is_some());
180 }
181}