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}