Skip to main content

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}