Skip to main content

lattice_dashboard/
fragment.rs

1//! The content contract between a section and the dashboard compositor.
2//!
3//! A [`DashboardSection`](crate::DashboardSection) never touches cells,
4//! theme ids, or the renderer. It emits a [`DashboardFragment`]: styled,
5//! aligned, optionally-linked text. The compositor (DB.2+) turns fragments
6//! into a branding virtual-row block plus document body. Keeping this the
7//! single content type is what lets built-in sections (native Rust) and
8//! future plugin sections share one pipeline.
9
10/// One section's rendered contribution: an ordered list of visual lines.
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct DashboardFragment {
13    pub rows: Vec<DashboardRow>,
14}
15
16impl DashboardFragment {
17    pub fn new() -> Self {
18        Self::default()
19    }
20
21    /// True when the section rendered nothing (all sections should render
22    /// at least one row; an empty fragment is a section bug the registry
23    /// tests guard against).
24    pub fn is_empty(&self) -> bool {
25        self.rows.is_empty()
26    }
27
28    /// Push a whole row.
29    pub fn push(&mut self, row: DashboardRow) -> &mut Self {
30        self.rows.push(row);
31        self
32    }
33
34    /// Convenience: a single left-aligned span of one role.
35    pub fn line(&mut self, text: impl Into<String>, role: DashboardRole) -> &mut Self {
36        self.rows.push(DashboardRow::line(text, role));
37        self
38    }
39
40    /// Convenience: a blank spacer row.
41    pub fn blank(&mut self) -> &mut Self {
42        self.rows.push(DashboardRow::default());
43        self
44    }
45}
46
47/// One visual line: spans laid out left→right, with a line-level alignment.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct DashboardRow {
50    pub spans: Vec<DashboardSpan>,
51    pub align: Align,
52}
53
54impl DashboardRow {
55    /// A single-span line of one role, left-aligned.
56    pub fn line(text: impl Into<String>, role: DashboardRole) -> Self {
57        Self {
58            spans: vec![DashboardSpan::new(text, role)],
59            align: Align::Left,
60        }
61    }
62
63    /// A single-span line of one role, centred.
64    pub fn centered(text: impl Into<String>, role: DashboardRole) -> Self {
65        Self {
66            spans: vec![DashboardSpan::new(text, role)],
67            align: Align::Center,
68        }
69    }
70
71    pub fn with_align(mut self, align: Align) -> Self {
72        self.align = align;
73        self
74    }
75
76    pub fn push(mut self, span: DashboardSpan) -> Self {
77        self.spans.push(span);
78        self
79    }
80
81    /// Total display text of the row (spans concatenated), ignoring style.
82    pub fn text(&self) -> String {
83        self.spans.iter().map(|s| s.text.as_str()).collect()
84    }
85}
86
87/// A run of text with a semantic role and an optional link target.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct DashboardSpan {
90    pub text: String,
91    pub role: DashboardRole,
92    pub link: Option<LinkTarget>,
93}
94
95impl DashboardSpan {
96    pub fn new(text: impl Into<String>, role: DashboardRole) -> Self {
97        Self {
98            text: text.into(),
99            role,
100            link: None,
101        }
102    }
103
104    /// A link span: the visible label carries [`DashboardRole::Link`] and a
105    /// follow target.
106    pub fn link(label: impl Into<String>, target: LinkTarget) -> Self {
107        Self {
108            text: label.into(),
109            role: DashboardRole::Link,
110            link: Some(target),
111        }
112    }
113}
114
115/// Semantic style role. Never a colour — each role resolves to a
116/// `dashboard.*` theme element at compose time (DB.3).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum DashboardRole {
119    /// The brand mark art.
120    Logo,
121    /// The blinking-cursor bar inside the mark (amber by default).
122    Cursor,
123    /// The "Lattice" wordmark.
124    Title,
125    /// The one-line tagline.
126    Tagline,
127    /// A section heading.
128    SectionHeading,
129    /// Body prose.
130    Body,
131    /// A key cap (e.g. `:`, `<leader>`).
132    Key,
133    /// Muted hint / secondary text.
134    Hint,
135    /// A followable link label.
136    Link,
137}
138
139/// Line-level alignment. `Right` is reserved (no consumer yet).
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
141pub enum Align {
142    #[default]
143    Left,
144    Center,
145}
146
147/// A link a `<CR>` follows. Mirrors the help-buffer `scheme:value` model so
148/// DB.2 reuses the help follow mechanism rather than inventing one.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum LinkTarget {
151    /// Run an ex-command, e.g. `cmd:tutor`.
152    Command(String),
153    /// Open a `:help` topic, e.g. `topic:getting-started`.
154    Topic(String),
155    /// Open a URL externally, e.g. `url:https://…`.
156    Url(String),
157}
158
159impl LinkTarget {
160    /// Parse a `scheme:value` string. Unknown or scheme-less input is
161    /// treated as a URL only when it looks like one; otherwise `None`.
162    pub fn parse(raw: &str) -> Option<Self> {
163        let (scheme, value) = raw.split_once(':')?;
164        let value = value.trim();
165        if value.is_empty() {
166            return None;
167        }
168        match scheme {
169            "cmd" => Some(LinkTarget::Command(value.to_string())),
170            "topic" => Some(LinkTarget::Topic(value.to_string())),
171            // A `url:` prefix, or a bare http(s) URL (split_once left the
172            // scheme as "https"/"http").
173            "url" => Some(LinkTarget::Url(value.to_string())),
174            "http" | "https" => Some(LinkTarget::Url(raw.to_string())),
175            _ => None,
176        }
177    }
178
179    /// Serialise back to `scheme:value` form (the form the help follow
180    /// mechanism consumes).
181    pub fn to_scheme_string(&self) -> String {
182        match self {
183            LinkTarget::Command(v) => format!("cmd:{v}"),
184            LinkTarget::Topic(v) => format!("topic:{v}"),
185            LinkTarget::Url(v) => {
186                if v.starts_with("http://") || v.starts_with("https://") {
187                    v.clone()
188                } else {
189                    format!("url:{v}")
190                }
191            }
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn link_target_parses_known_schemes() {
202        assert_eq!(
203            LinkTarget::parse("cmd:tutor"),
204            Some(LinkTarget::Command("tutor".into()))
205        );
206        assert_eq!(
207            LinkTarget::parse("topic:getting-started"),
208            Some(LinkTarget::Topic("getting-started".into()))
209        );
210        assert_eq!(
211            LinkTarget::parse("url:https://example.com"),
212            Some(LinkTarget::Url("https://example.com".into()))
213        );
214    }
215
216    #[test]
217    fn link_target_parses_bare_url() {
218        assert_eq!(
219            LinkTarget::parse("https://github.com/dhruvasagar/lattice"),
220            Some(LinkTarget::Url(
221                "https://github.com/dhruvasagar/lattice".into()
222            ))
223        );
224    }
225
226    #[test]
227    fn link_target_rejects_unknown_and_empty() {
228        assert_eq!(LinkTarget::parse("bogus:thing"), None);
229        assert_eq!(LinkTarget::parse("cmd:"), None);
230        assert_eq!(LinkTarget::parse("no-colon"), None);
231    }
232
233    #[test]
234    fn link_target_round_trips() {
235        for raw in ["cmd:tutor", "topic:help", "url:mailto:x@y.z"] {
236            let t = LinkTarget::parse(raw).unwrap();
237            assert_eq!(t.to_scheme_string(), raw);
238        }
239        // Bare URL round-trips without the url: prefix.
240        let t = LinkTarget::parse("https://x.y").unwrap();
241        assert_eq!(t.to_scheme_string(), "https://x.y");
242    }
243
244    #[test]
245    fn row_text_concatenates_spans() {
246        let row = DashboardRow::line("Open ", DashboardRole::Body).push(DashboardSpan::link(
247            ":tutor",
248            LinkTarget::Command("tutor".into()),
249        ));
250        assert_eq!(row.text(), "Open :tutor");
251    }
252}