Skip to main content

lattice_host/
dashboard.rs

1//! Host-side applier for the `*dashboard*` launch page (DB.2).
2//!
3//! `lattice-dashboard` owns the section registry, the fragment content
4//! contract, the `dashboard-mode` major, and the config. The host owns what a
5//! crate cannot: buffer creation (mutates `&mut Editor`) and the fragment →
6//! `HelpContent` conversion (the host owns the help machinery). `:dashboard`
7//! and the startup trigger (DB.5) both route through `Effect::OpenDashboard`
8//! to [`Editor::do_open_dashboard`] — the sanctioned lifecycle boundary,
9//! mirroring `:messages`→`do_open_messages`.
10//!
11//! See `docs/dev/architecture/dashboard.md` §9.
12
13use lattice_dashboard::{
14    DashboardBrandingProvider, DashboardCtx, DashboardFragment, DashboardRegistryHandle,
15    DashboardRole, DashboardRow, DashboardSource, LinkTarget, SectionSelection,
16};
17
18use crate::dispatch::RendererSignal;
19use crate::editor::Editor;
20
21impl Editor {
22    /// Open (or re-compose + activate) the `*dashboard*` buffer. Idempotent:
23    /// a second call re-seeds the existing buffer in place rather than
24    /// creating a duplicate.
25    pub fn do_open_dashboard(&mut self) -> Vec<RendererSignal> {
26        let content = self.build_dashboard_content();
27        // DB.4: the content block width (widest body line vs the branding
28        // block) drives gutter-based horizontal centring — the renderer pads
29        // the gutter by `(viewport_width - block_width)/2` so the banner + body
30        // share one centred margin, with no text mutation (markdown intact).
31        let body_max = content
32            .buffer
33            .content
34            .as_string()
35            .lines()
36            .map(|l| l.chars().count() as u32)
37            .max()
38            .unwrap_or(0);
39        let block_width = body_max.max(lattice_dashboard::branding_block_width());
40        let id = match self.buffers.by_name("*dashboard*") {
41            Some(existing) => {
42                // Re-compose in place (config / future refresh triggers) —
43                // keeps the BufferId stable like the help back-stack swap.
44                let text = content.buffer.content.as_string();
45                self.replace_owned_document_text(existing, &text);
46                self.seed_help_metadata_locals(existing, content.metadata);
47                existing
48            }
49            None => {
50                let id = self.register_dashboard_document(content, Self::SYNTHETIC_BUFFER_FLAGS);
51                // Assign the major explicitly (synthetic buffers bypass
52                // language detection); this also recomputes options so
53                // dashboard-mode's ReadOnly/NoFile take effect.
54                self.activate_major_by_id(id, lattice_dashboard::DashboardMode::mode_id());
55                id
56            }
57        };
58        // DB.4: mark the buffer for gutter-based horizontal centring (read by
59        // rebuild_option_cache to compute content_left_pad).
60        self.buffer_locals
61            .entry(id)
62            .or_default()
63            .insert(crate::modes::CenterContentWidth(block_width));
64        // (re-)register the branding virtual-row block for this buffer.
65        self.register_dashboard_branding(id);
66        let signals = if self.activate_buffer(id) {
67            self.activate_buffer_state()
68        } else {
69            Vec::new()
70        };
71        // Activate help-mode as a companion minor for its good defaults —
72        // read-only, wrap, no-file, gutterless (no line numbers / signcolumn).
73        // Done after activate_buffer_state so its major re-activation doesn't
74        // clobber the minor; then recompute so the renderer's option cache
75        // reflects the gutterless treatment.
76        use lattice_mode::ModeActivator;
77        self.activate_minor_by_id(id, lattice_mode::HelpMode::mode_id());
78        self.recompute_options_for_buffer(id);
79        self.rebuild_option_cache();
80        // Dashboard is a static splash page: force cursor + scroll to
81        // the top so the user lands on the branding on every open.
82        // Without this, `activate_document`'s `load_active_pane`
83        // restores the stale pane cursor from the previous buffer
84        // (snapshot_active_pane writes it, then load_active_pane
85        // overrides the explicit ZERO set at dispatch.rs:27914).
86        self.cursor = lattice_protocol::position::Position::ZERO;
87        self.scroll = 0;
88        // OWC: populating the dashboard body is an owner write, which the
89        // in-core selection transform clamps the document selection to EOF.
90        // The dashboard has no editable tail, so `maybe_adopt_owner_write`
91        // will NOT adopt that EOF back into `Editor::cursor` (see
92        // `should_adopt_owner_write`); the forced top-of-page caret stands
93        // without any per-site reconcile here.
94        signals
95    }
96
97    /// DB.4: register the branding virtual-row provider for the dashboard
98    /// buffer (idempotent — unregister-first, mirroring tutor). The provider
99    /// resolves the `dashboard.*` colours (DB.3) at collect time. Skipped when
100    /// the theme service is absent (headless harness); production always has
101    /// it.
102    fn register_dashboard_branding(&mut self, id: lattice_core::BufferId) {
103        let provider_id = DashboardBrandingProvider::provider_id_for(id.0 as u64);
104        self.virtual_row_providers.unregister(id, provider_id);
105        let Some(theme) = self
106            .services
107            .get::<lattice_theme::ThemeRegistryHandle>()
108            .map(|outer| (*outer).clone())
109        else {
110            return;
111        };
112        let owner = lattice_theme::ElementOwner::Mode(
113            lattice_dashboard::DashboardMode::mode_id()
114                .as_str()
115                .to_string()
116                .into(),
117        );
118        // Idempotent: returns the same interned ids on-activate already
119        // registered.
120        let ids = lattice_dashboard::register_dashboard_theme_elements(theme.as_ref(), owner);
121        let provider = DashboardBrandingProvider::new(provider_id, Some(theme), ids);
122        self.virtual_row_providers
123            .register(id, std::sync::Arc::new(provider));
124    }
125
126    /// Compose the dashboard body into a `HelpContent`. `dashboard.source`
127    /// (DB.6, design §8) is the "author the entire page" escape hatch: when
128    /// set to a readable path, its content REPLACES section composition
129    /// entirely. Unset, empty, missing, or unreadable ⇒ fall back to the
130    /// normal registry-composed sections — never a panic, never an empty
131    /// page (a read error is logged once per call, not silently dropped).
132    fn build_dashboard_content(&self) -> lattice_help::HelpContent {
133        if let Some(path) = self.dashboard_source_path() {
134            match std::fs::read_to_string(&path) {
135                Ok(text) => {
136                    let lines: Vec<String> = text.lines().map(str::to_string).collect();
137                    return lattice_help::HelpContent::from_lines("*dashboard*", lines);
138                }
139                Err(err) => {
140                    tracing::warn!(
141                        path = %path,
142                        error = %err,
143                        "dashboard.source unreadable; falling back to section composition"
144                    );
145                }
146            }
147        }
148        self.compose_dashboard_sections()
149    }
150
151    /// `dashboard.source`, trimmed and filtered to `None` when unset/blank
152    /// (the config system has no native `Option<String>`, so empty-string is
153    /// the "unset" sentinel — design §8).
154    fn dashboard_source_path(&self) -> Option<String> {
155        self.config
156            .get_typed::<DashboardSource>()
157            .map(|raw| raw.trim().to_string())
158            .filter(|s| !s.is_empty())
159    }
160
161    /// Compose the config-selected built-in sections into a `HelpContent`.
162    /// `pane_width` / `nerd_fonts` read the live viewport + `ui.nerd_fonts`
163    /// (DB.6 — no built-in section consumes either yet, but the plumbing is
164    /// real rather than a placeholder, so a future icon-aware section needs
165    /// no host-side change).
166    fn compose_dashboard_sections(&self) -> lattice_help::HelpContent {
167        let selection = self
168            .config
169            .get_typed::<lattice_dashboard::DashboardSections>()
170            .map(|raw| SectionSelection::parse(raw.as_str()))
171            .unwrap_or(SectionSelection::Default);
172
173        let nerd_fonts = self
174            .config
175            .get_typed::<crate::ui::theme_options::UiNerdFonts>()
176            .map(|v| *v)
177            .unwrap_or(false);
178        let ctx = DashboardCtx {
179            pane_width: self.pane_tree.active().viewport_width as usize,
180            nerd_fonts,
181            version: env!("CARGO_PKG_VERSION").to_string(),
182        };
183
184        // CR.2: one snapshot of the RCU handle per compose — a plugin
185        // loading while the page builds lands in the next compose, not
186        // halfway through this one.
187        let fragments = match self.services.get::<DashboardRegistryHandle>() {
188            Some(registry) => registry.load().compose(&ctx, &selection),
189            None => {
190                tracing::warn!("dashboard registry service missing; rendering an empty dashboard");
191                Vec::new()
192            }
193        };
194        // Body is left-aligned for now: block-centring by padding the text
195        // broke markdown header styling (leading spaces => indented code
196        // block). Centring-with-markdown needs renderer-side content align
197        // (see DB.4 slice plan) — pending.
198        dashboard_fragments_to_help_content(fragments)
199    }
200}
201
202/// Render one dashboard row to a markdown line. Link spans become
203/// `[label](scheme:value)` using the help-link schemes (`exec:` for
204/// commands, `help:` for topics); a `Title` / `SectionHeading` first span
205/// gets a markdown heading prefix so the help markdown highlighter styles it.
206/// Centre alignment is ignored here (DB.4 wires branding centring).
207fn render_row(row: &DashboardRow) -> String {
208    let mut body = String::new();
209    for span in &row.spans {
210        match &span.link {
211            Some(target) => {
212                body.push_str(&format!("[{}]({})", span.text, help_link_scheme(target)));
213            }
214            None => body.push_str(&span.text),
215        }
216    }
217    match row.spans.first().map(|s| s.role) {
218        Some(DashboardRole::Title) => format!("# {body}"),
219        Some(DashboardRole::SectionHeading) => format!("## {body}"),
220        _ => body,
221    }
222}
223
224/// Map a dashboard [`LinkTarget`] to the help-link `scheme:value` form the
225/// follow handler consumes. These scheme names MUST match
226/// `lattice_help::classify_link_url` exactly, or the seeded link classifies
227/// as `Unresolved` and the `<CR>`-follow is a silent no-op.
228///
229/// - `exec:CMD` → `HelpLinkTarget::Execute` → runs `:CMD` (so a dashboard
230///   `LinkTarget::Command("tutor")` actually STARTS the tutor, not describes
231///   it — every dashboard command link is an action to run).
232/// - `help:TOPIC` → `HelpLinkTarget::Topic` → opens the `:help TOPIC` page.
233///
234/// A URL renders verbatim; `classify_link_url` maps a real `scheme://…`
235/// (or `mailto:`) form to `HelpLinkTarget::Url`, which follow-link opens
236/// via the OS handler (default browser / app).
237fn help_link_scheme(target: &LinkTarget) -> String {
238    match target {
239        LinkTarget::Command(cmd) => format!("exec:{cmd}"),
240        LinkTarget::Topic(topic) => format!("help:{topic}"),
241        LinkTarget::Url(url) => url.clone(),
242    }
243}
244
245/// Convert composed fragments into a `HelpContent` titled `*dashboard*`
246/// (the title becomes the buffer name). Sections are separated by a blank
247/// spacer line.
248fn dashboard_fragments_to_help_content(
249    fragments: Vec<DashboardFragment>,
250) -> lattice_help::HelpContent {
251    let mut lines: Vec<String> = Vec::new();
252    for (i, fragment) in fragments.iter().enumerate() {
253        if i > 0 {
254            lines.push(String::new());
255        }
256        for row in &fragment.rows {
257            lines.push(render_row(row));
258        }
259    }
260    lattice_help::HelpContent::from_lines("*dashboard*", lines)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use lattice_dashboard::{DashboardRow, DashboardSpan};
267
268    #[test]
269    fn command_link_becomes_exec_scheme() {
270        // `exec:` (not `execute:`) — the scheme `classify_link_url`
271        // recognizes as "run this ex-command". `execute:` classifies as
272        // Unresolved, i.e. a dead link.
273        let row = DashboardRow::line("Run ", DashboardRole::Body).push(DashboardSpan::link(
274            ":tutor",
275            LinkTarget::Command("tutor".into()),
276        ));
277        assert_eq!(render_row(&row), "Run [:tutor](exec:tutor)");
278    }
279
280    #[test]
281    fn topic_link_becomes_help_scheme() {
282        // `help:` (not `topic:`) — the scheme `classify_link_url` maps to
283        // `HelpLinkTarget::Topic`, opening the `:help` page.
284        let row = DashboardRow::line("", DashboardRole::Body).push(DashboardSpan::link(
285            ":help modes",
286            LinkTarget::Topic("modes".into()),
287        ));
288        assert_eq!(render_row(&row), "[:help modes](help:modes)");
289    }
290
291    #[test]
292    fn title_and_heading_get_markdown_prefixes() {
293        assert_eq!(
294            render_row(&DashboardRow::line("Lattice", DashboardRole::Title)),
295            "# Lattice"
296        );
297        assert_eq!(
298            render_row(&DashboardRow::line("About", DashboardRole::SectionHeading)),
299            "## About"
300        );
301    }
302}