Skip to main content

lattice_dashboard/
sections.rs

1//! The nine built-in dashboard sections (native Rust — the built-in surface
2//! stays native, like the vim grammar). Each is a small pure renderer; the
3//! branding art and custom theme roles are layered on in later slices, but
4//! the text content and roles are real here.
5//!
6//! [`builtin_registry`] returns a [`DashboardRegistry`] with all nine
7//! registered in their default order.
8
9use std::sync::Arc;
10
11use crate::fragment::{DashboardFragment, DashboardRole, DashboardRow, DashboardSpan, LinkTarget};
12use crate::registry::DashboardRegistry;
13use crate::section::{DashboardCtx, DashboardSection};
14
15/// The canonical GitHub repository.
16const REPO_URL: &str = "https://github.com/dhruvasagar/lattice";
17// The one-line tagline (matches the brand assets).
18
19/// Build a registry pre-loaded with every built-in section.
20///
21/// Note: the brand mark + wordmark are NOT a document section — they render
22/// as the DB.4 branding virtual-row block above the body (see
23/// [`crate::branding`]). The body starts with `about`.
24pub fn builtin_registry() -> DashboardRegistry {
25    let mut reg = DashboardRegistry::new();
26    reg.register(Arc::new(About));
27    reg.register(Arc::new(Survival));
28    reg.register(Arc::new(Links));
29    reg.register(Arc::new(Tutor));
30    reg.register(Arc::new(Commands));
31    reg.register(Arc::new(HelpAndBindings));
32    reg.register(Arc::new(Describe));
33    reg.register(Arc::new(HelpTopics));
34    reg
35}
36
37/// Convenience: a heading row followed by a blank line separator.
38fn heading(frag: &mut DashboardFragment, text: &str) {
39    frag.push(DashboardRow::line(text, DashboardRole::SectionHeading));
40}
41
42/// Convenience: a body line that ends in a followable link.
43///
44/// e.g. `body_link("Open the interactive tutorial: ", ":tutor", cmd:tutor)`.
45fn body_link(prefix: &str, label: &str, target: LinkTarget) -> DashboardRow {
46    DashboardRow::line(prefix, DashboardRole::Body).push(DashboardSpan::link(label, target))
47}
48
49// ---------------------------------------------------------------------------
50// about
51// ---------------------------------------------------------------------------
52
53/// Identity + the four paramount goals.
54struct About;
55
56impl DashboardSection for About {
57    fn id(&self) -> &str {
58        "about"
59    }
60    fn order(&self) -> i32 {
61        10
62    }
63    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
64        let mut f = DashboardFragment::new();
65        heading(&mut f, "About");
66        f.line(
67            "Lattice combines vim's modal editing with emacs's extensibility",
68            DashboardRole::Body,
69        );
70        f.line(
71            "on a non-blocking, multi-threaded, GPU-accelerated core.",
72            DashboardRole::Body,
73        );
74        f.blank();
75        f.line("Paramount goals:", DashboardRole::Body);
76        f.line(
77            "  1. Performance — imperceptible keystroke latency",
78            DashboardRole::Body,
79        );
80        f.line(
81            "  2. Extensibility — WebAssembly plugins from day one",
82            DashboardRole::Body,
83        );
84        f.line("  3. Extensible vim modal editing", DashboardRole::Body);
85        f.line(
86            "  4. Asynchronicity — nothing blocks the UI",
87            DashboardRole::Body,
88        );
89        f
90    }
91}
92
93// ---------------------------------------------------------------------------
94// survival
95// ---------------------------------------------------------------------------
96
97/// The two dead ends a brand-new user hits first: not knowing how to leave,
98/// and not knowing a config exists. Each is one row, ahead of everything
99/// else that assumes they're already staying.
100struct Survival;
101
102impl DashboardSection for Survival {
103    fn id(&self) -> &str {
104        "survival"
105    }
106    fn order(&self) -> i32 {
107        15
108    }
109    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
110        let mut f = DashboardFragment::new();
111        heading(&mut f, "Survival");
112        f.push(body_link(
113            "Quit      ",
114            ":q",
115            LinkTarget::Command("q".to_string()),
116        ));
117        f.push(
118            DashboardRow::line("Config    ", DashboardRole::Body)
119                .push(DashboardSpan::new(
120                    "lattice --scaffold-init",
121                    DashboardRole::Key,
122                ))
123                .push(DashboardSpan::new(
124                    "  — writes a starter WASM config crate (Cargo.toml, plugin.toml, src/lib.rs)",
125                    DashboardRole::Hint,
126                )),
127        );
128        f
129    }
130}
131
132// ---------------------------------------------------------------------------
133// links
134// ---------------------------------------------------------------------------
135
136/// External links (repo, docs).
137struct Links;
138
139impl DashboardSection for Links {
140    fn id(&self) -> &str {
141        "links"
142    }
143    fn order(&self) -> i32 {
144        20
145    }
146    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
147        let mut f = DashboardFragment::new();
148        heading(&mut f, "Links");
149        f.push(body_link(
150            "GitHub    ",
151            REPO_URL,
152            LinkTarget::Url(REPO_URL.to_string()),
153        ));
154        f.push(body_link(
155            "Issues    ",
156            "report a bug",
157            LinkTarget::Url(format!("{REPO_URL}/issues")),
158        ));
159        f
160    }
161}
162
163// ---------------------------------------------------------------------------
164// tutor
165// ---------------------------------------------------------------------------
166
167/// Pointer to the interactive tutor.
168struct Tutor;
169
170impl DashboardSection for Tutor {
171    fn id(&self) -> &str {
172        "tutor"
173    }
174    fn order(&self) -> i32 {
175        40
176    }
177    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
178        let mut f = DashboardFragment::new();
179        heading(&mut f, "Learn");
180        f.push(body_link(
181            "Interactive lessons  ",
182            ":tutor",
183            LinkTarget::Command("tutor".to_string()),
184        ));
185        f
186    }
187}
188
189// ---------------------------------------------------------------------------
190// help-and-bindings
191// ---------------------------------------------------------------------------
192
193/// Pointers to help + key-binding discovery.
194struct HelpAndBindings;
195
196impl DashboardSection for HelpAndBindings {
197    fn id(&self) -> &str {
198        "help-and-bindings"
199    }
200    fn order(&self) -> i32 {
201        50
202    }
203    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
204        let mut f = DashboardFragment::new();
205        heading(&mut f, "Help & key bindings");
206        f.push(body_link(
207            "Help browser         ",
208            ":help",
209            LinkTarget::Command("help".to_string()),
210        ));
211        f.push(body_link(
212            "What does a key do?  ",
213            ":describe-key",
214            LinkTarget::Command("describe-key".to_string()),
215        ));
216        f.push(body_link(
217            "All key bindings     ",
218            ":keymap",
219            LinkTarget::Command("keymap".to_string()),
220        ));
221        f
222    }
223}
224
225// ---------------------------------------------------------------------------
226// describe
227// ---------------------------------------------------------------------------
228
229/// The `:describe-*` introspection family.
230struct Describe;
231
232impl DashboardSection for Describe {
233    fn id(&self) -> &str {
234        "describe"
235    }
236    fn order(&self) -> i32 {
237        60
238    }
239    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
240        let mut f = DashboardFragment::new();
241        heading(&mut f, "Introspection (:describe-*)");
242        // Each entry opens the command's own help page (`:describe-command
243        // <cmd>`) rather than *running* the bare command — several of these
244        // (`describe-key`, `describe-option`) would otherwise sit waiting
245        // for an interactive argument. `:describe-command` is always
246        // available (every command is self-documenting), so none of these
247        // are dead links. The `commands` section below is where the
248        // click-to-run entries live.
249        for (cmd, what) in [
250            ("describe-key", "what a key is bound to"),
251            ("describe-command", "what a command does"),
252            ("describe-option", "an option's type and value"),
253            ("describe-mode", "the active modes"),
254            ("apropos", "search everything by keyword"),
255        ] {
256            f.push(
257                body_link(
258                    "",
259                    &format!(":{cmd}"),
260                    LinkTarget::Command(format!("describe-command {cmd}")),
261                )
262                .push(DashboardSpan::new(
263                    format!("  — {what}"),
264                    DashboardRole::Hint,
265                )),
266            );
267        }
268        f
269    }
270}
271
272// ---------------------------------------------------------------------------
273// commands
274// ---------------------------------------------------------------------------
275
276/// Commonly useful commands, run on click. Unlike the introspection
277/// section (which opens help *about* each command), these entries invoke
278/// the command directly — they are safe, non-destructive discovery tools.
279struct Commands;
280
281impl DashboardSection for Commands {
282    fn id(&self) -> &str {
283        "commands"
284    }
285    fn order(&self) -> i32 {
286        45
287    }
288    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
289        let mut f = DashboardFragment::new();
290        heading(&mut f, "Commonly useful commands");
291        for (cmd, what) in [
292            ("files", "fuzzy-find files in the project"),
293            ("buffers", "switch buffers (fuzzy picker)"),
294            ("recent", "reopen a recently edited file"),
295            ("marks", "jump to a mark"),
296            ("registers", "inspect register contents"),
297        ] {
298            f.push(
299                body_link("", &format!(":{cmd}"), LinkTarget::Command(cmd.to_string())).push(
300                    DashboardSpan::new(format!("  — {what}"), DashboardRole::Hint),
301                ),
302            );
303        }
304        f
305    }
306}
307
308// ---------------------------------------------------------------------------
309// help-topics
310// ---------------------------------------------------------------------------
311
312/// Entry points into `:help <topic>`.
313struct HelpTopics;
314
315impl DashboardSection for HelpTopics {
316    fn id(&self) -> &str {
317        "help-topics"
318    }
319    fn order(&self) -> i32 {
320        70
321    }
322    fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
323        let mut f = DashboardFragment::new();
324        heading(&mut f, "Help topics");
325        // Topic names must match a registered `:help` topic (the doc's
326        // file stem under `docs/user/`), or the `<CR>`-follow is a dead
327        // link. `commands`/`config` were such dead links — the actual
328        // docs are `ex-commands.md` and `options.md`.
329        for topic in ["getting-started", "modes", "ex-commands", "options"] {
330            f.push(body_link(
331                "",
332                &format!(":help {topic}"),
333                LinkTarget::Topic(topic.to_string()),
334            ));
335        }
336        f
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::registry::SectionSelection;
344
345    #[test]
346    fn builtin_registry_sections_in_order() {
347        // Branding is a virtual-row block (DB.4), not a document section.
348        let reg = builtin_registry();
349        let ids: Vec<String> = reg
350            .ordered(&SectionSelection::Default)
351            .iter()
352            .map(|s| s.id().to_string())
353            .collect();
354        assert_eq!(
355            ids,
356            [
357                "about",
358                "survival",
359                "links",
360                "tutor",
361                "commands",
362                "help-and-bindings",
363                "describe",
364                "help-topics",
365            ]
366        );
367    }
368
369    #[test]
370    fn every_builtin_renders_non_empty() {
371        let reg = builtin_registry();
372        let ctx = DashboardCtx::default();
373        for section in reg.ordered(&SectionSelection::Default) {
374            let frag = section.render(&ctx);
375            assert!(
376                !frag.is_empty(),
377                "section {} rendered an empty fragment",
378                section.id()
379            );
380        }
381    }
382
383    #[test]
384    fn links_section_carries_followable_links() {
385        let reg = builtin_registry();
386        let ctx = DashboardCtx::default();
387        let links = reg
388            .ordered(&SectionSelection::Explicit(vec!["links".into()]))
389            .into_iter()
390            .next()
391            .unwrap();
392        let frag = links.render(&ctx);
393        let has_link = frag
394            .rows
395            .iter()
396            .flat_map(|r| &r.spans)
397            .any(|s| matches!(&s.link, Some(LinkTarget::Url(u)) if u.contains("github.com")));
398        assert!(has_link, "links section should carry a GitHub url link");
399    }
400
401    #[test]
402    fn tutor_section_links_to_tutor_command() {
403        let reg = builtin_registry();
404        let frag = reg
405            .ordered(&SectionSelection::Explicit(vec!["tutor".into()]))
406            .into_iter()
407            .next()
408            .unwrap()
409            .render(&DashboardCtx::default());
410        let has_cmd = frag
411            .rows
412            .iter()
413            .flat_map(|r| &r.spans)
414            .any(|s| matches!(&s.link, Some(LinkTarget::Command(c)) if c == "tutor"));
415        assert!(has_cmd, "tutor section should link to cmd:tutor");
416    }
417
418    /// Collect the `LinkTarget::Command` payloads from a section's fragment.
419    fn command_links(section_id: &str) -> Vec<String> {
420        let reg = builtin_registry();
421        reg.ordered(&SectionSelection::Explicit(vec![section_id.into()]))
422            .into_iter()
423            .next()
424            .unwrap_or_else(|| panic!("section {section_id} missing"))
425            .render(&DashboardCtx::default())
426            .rows
427            .iter()
428            .flat_map(|r| &r.spans)
429            .filter_map(|s| match &s.link {
430                Some(LinkTarget::Command(c)) => Some(c.clone()),
431                _ => None,
432            })
433            .collect()
434    }
435
436    #[test]
437    fn describe_section_links_to_help_pages_not_bare_commands() {
438        // Introspection entries open each command's help page
439        // (`:describe-command <cmd>`), not the bare command — so clicking
440        // `describe-key` explains it instead of waiting for a keypress.
441        let cmds = command_links("describe");
442        assert!(!cmds.is_empty(), "describe section should carry links");
443        assert!(
444            cmds.iter().all(|c| c.starts_with("describe-command ")),
445            "all introspection links must open help pages: {cmds:?}"
446        );
447        assert!(
448            cmds.iter().any(|c| c == "describe-command describe-key"),
449            "expected a help-page link for describe-key: {cmds:?}"
450        );
451    }
452
453    #[test]
454    fn the_dashboard_tells_a_first_time_user_how_to_leave_and_how_to_configure() {
455        // A brand-new user's two dead ends: not knowing how to quit, and not
456        // knowing a config exists. Both are one row each. `rendered.contains(":q")`
457        // alone doesn't pin the Survival row -- any `:q!` / `:quit` elsewhere on
458        // the dashboard would satisfy it too, so assert the Survival section's
459        // rows directly.
460        let survival = Survival.render(&DashboardCtx::default());
461        let survival_text = survival
462            .rows
463            .iter()
464            .map(|row| row.text())
465            .collect::<Vec<_>>()
466            .join("\n");
467        assert!(
468            survival_text.contains(":q"),
469            "the Survival row must say how to quit"
470        );
471        assert!(
472            survival_text.contains("lattice --scaffold-init"),
473            "the Survival row must point at config scaffolding"
474        );
475        // Pin what --scaffold-init actually writes (crates/lattice-cli/src/scaffold.rs
476        // write_scaffold_init: Cargo.toml, plugin.toml, src/lib.rs, wit/ -- no
477        // config.toml, and "config.toml" isn't even the user TOML's name).
478        assert!(
479            survival_text.contains("src/lib.rs"),
480            "the Survival row must describe what --scaffold-init actually writes"
481        );
482        assert!(
483            !survival_text.contains("config.toml"),
484            "the Survival row must not claim --scaffold-init writes config.toml -- it doesn't"
485        );
486    }
487
488    #[test]
489    fn commands_section_runs_useful_commands() {
490        // The commands section invokes each command directly (safe,
491        // non-destructive discovery tools).
492        let cmds = command_links("commands");
493        for expect in ["files", "buffers", "recent", "marks", "registers"] {
494            assert!(
495                cmds.iter().any(|c| c == expect),
496                "missing :{expect} run-link in {cmds:?}"
497            );
498        }
499    }
500}