1#[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 pub fn is_empty(&self) -> bool {
25 self.rows.is_empty()
26 }
27
28 pub fn push(&mut self, row: DashboardRow) -> &mut Self {
30 self.rows.push(row);
31 self
32 }
33
34 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 pub fn blank(&mut self) -> &mut Self {
42 self.rows.push(DashboardRow::default());
43 self
44 }
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct DashboardRow {
50 pub spans: Vec<DashboardSpan>,
51 pub align: Align,
52}
53
54impl DashboardRow {
55 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 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 pub fn text(&self) -> String {
83 self.spans.iter().map(|s| s.text.as_str()).collect()
84 }
85}
86
87#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118pub enum DashboardRole {
119 Logo,
121 Cursor,
123 Title,
125 Tagline,
127 SectionHeading,
129 Body,
131 Key,
133 Hint,
135 Link,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
141pub enum Align {
142 #[default]
143 Left,
144 Center,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum LinkTarget {
151 Command(String),
153 Topic(String),
155 Url(String),
157}
158
159impl LinkTarget {
160 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 "url" => Some(LinkTarget::Url(value.to_string())),
174 "http" | "https" => Some(LinkTarget::Url(raw.to_string())),
175 _ => None,
176 }
177 }
178
179 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 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}