lattice_dashboard/branding.rs
1//! The branding block (DB.4): the Lattice mark + wordmark, rendered as
2//! custom-colored virtual rows anchored above the first document line.
3//!
4//! The mark is the interlocking "L" bracket from `assets/lattice-mark.svg`
5//! (brand blue) with the amber cursor bar inside it, drawn with BMP block
6//! glyphs so it renders in every terminal font (the graceful-degradation
7//! default per the icon-palette rule; a Nerd-Font variant is a later polish
8//! — blocks already degrade perfectly). The "Lattice" wordmark + tagline sit
9//! to the right, vertically centred against the mark with a tight gap — the
10//! `banner-dark.svg` symmetry.
11//!
12//! Colours resolve from the `dashboard.*` theme elements (DB.3) at
13//! `collect()` time, so themes restyle the banner. Horizontal pane-centring
14//! (which needs live pane width fed to the provider) is deferred; the block
15//! is left-aligned with a small indent for now (design §5.3 — the icon↔
16//! wordmark symmetry, the stated priority, is internal to the block and needs
17//! no width).
18
19use std::sync::Arc;
20
21use lattice_cells::{
22 AnchorPosition, BASE_SCALE, Cell, ProviderId, VirtualRow, VirtualRowKind, VirtualRowProvider,
23};
24use lattice_theme::{Color, ElementId, ResolvedTheme, ThemeRegistryHandle};
25
26use crate::theme::{BRAND_AMBER, BRAND_BLUE, DashboardElementIds};
27
28/// XOR tag mixed into the per-buffer [`ProviderId`] so the dashboard provider
29/// never collides with other providers on the same buffer.
30pub const DASHBOARD_BRANDING_TAG: u64 = 0xDA5B_0A2D_0000_0000;
31
32/// The tagline (matches the brand assets).
33const TAGLINE: &str = "A modal, GPU-accelerated, plugin-first text editor in Rust";
34/// Gap (cells) between the mark and the wordmark.
35const GAP: usize = 2;
36/// The "Lattice" wordmark.
37const WORDMARK: &str = "Lattice";
38/// F.3 (Thread F): the wordmark's font scale in hundredths (`100` =
39/// base). The GPUI peer renders the "Lattice" run larger than the mark
40/// blocks and tagline via the per-token virtual-row scaling primitive
41/// (`VirtualRow::scales`); the TUI peer ignores it (terminal cells can't
42/// vary size) and renders every cell base-size. Kept modest so the shared
43/// row's growth stays subtle.
44const WORDMARK_SCALE: u16 = 150;
45/// Version string, compiled from `CARGO_PKG_VERSION` at build time.
46/// Displayed after the tagline on the dashboard branding block so GPUI
47/// shapes it at tagline scale (1.15x) rather than the wordmark's 3.7x.
48const VERSION: &str = concat!("v", env!("CARGO_PKG_VERSION"));
49
50/// The mark as a glyph grid: `L` = logo block, `C` = cursor block, ` ` =
51/// empty. **10 cols × 6 rows**, a 10-unit rasterisation of
52/// `assets/lattice-mark.svg` (100×120 = 5:6 portrait): a hollow bracket
53/// with 2-cell-thick walls and a 6-wide interior, formed by the two
54/// interlocking SVG paths — the `L` foot (left wall x0-20 + bottom bar
55/// x0-80) and the `7` hook (top bar x20-100 + right wall x80-100). The two
56/// diagonally-opposite corners are cut (top-left and bottom-right open),
57/// giving the interlocking look. The amber cursor bar (`C`, SVG rect
58/// x40-60 y36-84) is 2 cells tall, centred at cols 4-5, rows 2-3.
59///
60/// The width is doubled from the original 5-col raster to compensate for
61/// the terminal cell aspect ratio (~2:1 height:width). With 10 columns × 6
62/// rows of full-block characters, the visual ratio matches the original
63/// SVG's 5:6 portrait proportion — the same square-tile appearance the
64/// GPUI peer achieves with its 2-D quad composition.
65const MARK: [&str; 6] = [
66 " LLLLLLLL", // top bar (cols 2-9); top-left corner open at cols 0-1
67 "LL LL", // left wall (cols 0-1) + right wall (cols 8-9)
68 "LL CC LL", // walls + amber cursor bar at cols 4-5
69 "LL CC LL", // walls + amber cursor bar at cols 4-5
70 "LL LL", // left wall (cols 0-1) + right wall (cols 8-9)
71 "LLLLLLLL ", // bottom bar (cols 0-7); bottom-right corner open at cols 8-9
72];
73/// Full block glyph for the mark segments.
74const BLOCK: char = '█';
75
76/// A provider that emits the dashboard branding rows for one buffer.
77pub struct DashboardBrandingProvider {
78 provider_id: ProviderId,
79 theme: Option<ThemeRegistryHandle>,
80 ids: DashboardElementIds,
81}
82
83impl std::fmt::Debug for DashboardBrandingProvider {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("DashboardBrandingProvider")
86 .field("provider_id", &self.provider_id)
87 .field("has_theme", &self.theme.is_some())
88 .finish()
89 }
90}
91
92impl DashboardBrandingProvider {
93 pub fn new(
94 provider_id: ProviderId,
95 theme: Option<ThemeRegistryHandle>,
96 ids: DashboardElementIds,
97 ) -> Self {
98 Self {
99 provider_id,
100 theme,
101 ids,
102 }
103 }
104
105 /// Derive a stable per-buffer provider id.
106 pub fn provider_id_for(buffer_id: u64) -> ProviderId {
107 buffer_id ^ DASHBOARD_BRANDING_TAG
108 }
109}
110
111/// Resolve an element's foreground to a packed `0xRRGGBB`, or `fallback`.
112fn fg_or(resolved: Option<&ResolvedTheme>, id: ElementId, fallback: Color) -> u32 {
113 resolved
114 .map(|r| r.get(id))
115 .and_then(|s| s.fg)
116 .unwrap_or(fallback)
117 .to_rgb_u32(fallback.to_rgb_u32(0))
118}
119
120/// Build one row's cells from styled runs. Each run is `(text, fg)`; a blank
121/// run uses `fg = 0`.
122fn row_cells(runs: &[(String, u32)]) -> Vec<Cell> {
123 let mut cells: Vec<Cell> = Vec::new();
124 for (text, fg) in runs {
125 for ch in text.chars() {
126 cells.push(Cell::new(ch as u32, *fg, 0, 0));
127 }
128 }
129 cells
130}
131
132impl VirtualRowProvider for DashboardBrandingProvider {
133 fn id(&self) -> ProviderId {
134 self.provider_id
135 }
136
137 fn version(&self) -> u64 {
138 // Re-collect when the theme changes (colours) — the row set is
139 // otherwise static.
140 self.theme
141 .as_ref()
142 .map(|t| t.resolved().version())
143 .unwrap_or(0)
144 }
145
146 fn collect(&self) -> Vec<VirtualRow> {
147 let resolved = self.theme.as_ref().map(|t| t.resolved());
148 let r = resolved.as_deref();
149 let logo_fg = fg_or(r, self.ids.logo, BRAND_BLUE);
150 let cursor_fg = fg_or(r, self.ids.cursor, BRAND_AMBER);
151 let title_fg = fg_or(r, self.ids.title, BRAND_BLUE);
152 let tagline_fg = fg_or(r, self.ids.tagline, Color::Rgb(0x93, 0x99, 0xb2));
153 // Version inherits the default foreground by default (fg = 0),
154 // but can be overridden through the dashboard.version theme element.
155 let version_fg = r
156 .map(|r| r.get(self.ids.version))
157 .and_then(|s| s.fg)
158 .map(|c| c.to_rgb_u32(0))
159 .unwrap_or(0);
160
161 let gap = (" ".repeat(GAP), 0u32);
162
163 // Build each row's cells + per-column scales. The wordmark block
164 // (name + tagline) is vertically centred against the five-row mark:
165 // name on row 2, tagline on row 3. A blank spacer row above AND below
166 // the mark gives the banner breathing room. Horizontal centring is
167 // handled by the gutter (content_left_pad) — rows are left-aligned
168 // within the gutter, so the mark's columns line up.
169 //
170 // F.3: the "Lattice" run carries `WORDMARK_SCALE` in its per-column
171 // `scales` (the mark blocks + gap stay base); GPUI renders it larger
172 // via the shared-baseline per-token scaling path, the TUI ignores it.
173 let mut cell_rows: Vec<(Vec<Cell>, Option<Vec<u16>>)> = Vec::with_capacity(MARK.len() + 2);
174 cell_rows.push((Vec::new(), None)); // spacer above
175 for (i, mark_row) in MARK.iter().enumerate() {
176 let mut runs = mark_runs_for(mark_row, logo_fg, cursor_fg);
177 // The wordmark row appends a scaled "Lattice" run; every other
178 // run (mark blocks, gap, tagline) stays base size.
179 let mut wordmark_at: Option<usize> = None;
180 match i {
181 2 => {
182 runs.push(gap.clone());
183 wordmark_at = Some(runs.iter().map(|(t, _)| t.chars().count()).sum());
184 runs.push((WORDMARK.to_string(), title_fg));
185 // Version in its own theme colour (defaults to regular
186 // foreground, stylable via dashboard.version).
187 runs.push((" ".to_string(), 0));
188 runs.push((VERSION.to_string(), version_fg));
189 }
190 3 => {
191 runs.push(gap.clone());
192 runs.push((TAGLINE.to_string(), tagline_fg));
193 }
194 _ => {}
195 }
196 let cells = row_cells(&runs);
197 let scales = wordmark_at.map(|start| {
198 let mut s = vec![BASE_SCALE; cells.len()];
199 for slot in s.iter_mut().skip(start).take(WORDMARK.chars().count()) {
200 *slot = WORDMARK_SCALE;
201 }
202 s
203 });
204 cell_rows.push((cells, scales));
205 }
206 cell_rows.push((Vec::new(), None)); // spacer below
207
208 cell_rows
209 .into_iter()
210 .map(|(cells, scales)| {
211 VirtualRow {
212 media: None,
213 anchor_line: 0,
214 position: AnchorPosition::Above,
215 cells: Arc::from(cells.into_boxed_slice()),
216 height: 1,
217 // BrandingBlock (DB.4-gpui): the GPUI peer intercepts this
218 // row group and paints a 2-D composition (quad mark +
219 // shaped wordmark); the TUI paints the cells as-is. No
220 // backdrop either way (was Filler).
221 kind: VirtualRowKind::BrandingBlock,
222 bg: None,
223 scales: scales.map(|s| Arc::from(s.into_boxed_slice())),
224 gutter_line: None,
225 gutter_fg: None,
226 }
227 })
228 .collect()
229 }
230}
231
232/// Split one mark grid row into colored runs (coalescing adjacent same-color
233/// glyphs). `L` → logo block, `C` → cursor block, ` ` → blank.
234fn mark_runs_for(mark_row: &str, logo_fg: u32, cursor_fg: u32) -> Vec<(String, u32)> {
235 let mut runs: Vec<(String, u32)> = Vec::new();
236 let push = |ch: char, fg: u32, runs: &mut Vec<(String, u32)>| {
237 if let Some(last) = runs.last_mut()
238 && last.1 == fg
239 {
240 last.0.push(ch);
241 } else {
242 runs.push((ch.to_string(), fg));
243 }
244 };
245 for c in mark_row.chars() {
246 match c {
247 'L' => push(BLOCK, logo_fg, &mut runs),
248 'C' => push(BLOCK, cursor_fg, &mut runs),
249 _ => push(' ', 0, &mut runs),
250 }
251 }
252 runs
253}
254
255/// The number of display rows the branding block occupies (mark rows + a
256/// spacer above and below). Used by tests and future layout.
257pub const BRANDING_ROW_COUNT: usize = MARK.len() + 2;
258
259/// The branding block width in cells (mark + gap + the wider of wordmark /
260/// tagline). The host feeds this into the content-centring block width so the
261/// banner and body share one centred margin.
262pub fn branding_block_width() -> u32 {
263 let mark_w = MARK.iter().map(|r| r.chars().count()).max().unwrap_or(0);
264 let text_w = WORDMARK.chars().count().max(TAGLINE.chars().count());
265 (mark_w + GAP + text_w) as u32
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::theme::register_dashboard_theme_elements;
272 use lattice_theme::{ElementOwner, InMemoryThemeRegistry, default_palette};
273
274 fn provider_with_theme() -> DashboardBrandingProvider {
275 let reg: ThemeRegistryHandle = Arc::new(InMemoryThemeRegistry::new(default_palette()));
276 let ids = register_dashboard_theme_elements(
277 reg.as_ref(),
278 ElementOwner::Mode("dashboard-mode".into()),
279 );
280 DashboardBrandingProvider::new(0xABCD, Some(reg), ids)
281 }
282
283 #[test]
284 fn emits_all_mark_rows_plus_spacer() {
285 let p = provider_with_theme();
286 let rows = p.collect();
287 assert_eq!(rows.len(), BRANDING_ROW_COUNT);
288 for row in &rows {
289 assert_eq!(row.anchor_line, 0);
290 assert_eq!(row.position, AnchorPosition::Above);
291 assert_eq!(row.height, 1);
292 }
293 }
294
295 #[test]
296 fn mark_uses_brand_colors() {
297 let p = provider_with_theme();
298 let rows = p.collect();
299 let all_fgs: std::collections::HashSet<u32> = rows
300 .iter()
301 .flat_map(|r| r.cells.iter())
302 .filter(|c| c.codepoint == BLOCK as u32)
303 .map(|c| c.fg)
304 .collect();
305 // The mark carries both brand blue (logo) and brand amber (cursor).
306 assert!(
307 all_fgs.contains(&BRAND_BLUE.to_rgb_u32(0)),
308 "logo blue present"
309 );
310 assert!(
311 all_fgs.contains(&BRAND_AMBER.to_rgb_u32(0)),
312 "cursor amber present"
313 );
314 }
315
316 #[test]
317 fn wordmark_is_vertically_centered_against_the_mark() {
318 // Row 0 is the spacer above, so MARK rows are 1..=5 and the wordmark
319 // (MARK row 2 / 3) lands on rows 3 / 4.
320 let p = provider_with_theme();
321 let rows = p.collect();
322 let text_of = |row: &VirtualRow| -> String {
323 row.cells
324 .iter()
325 .filter_map(|c| char::from_u32(c.codepoint))
326 .collect()
327 };
328 assert!(text_of(&rows[3]).contains("Lattice"), "wordmark on row 3");
329 assert!(text_of(&rows[4]).contains("modal"), "tagline on row 4");
330 }
331
332 #[test]
333 fn wordmark_run_carries_scale_over_base_mark_and_gap() {
334 // F.3: the "Lattice" run scales (WORDMARK_SCALE); the mark blocks,
335 // gap, and everything else on the row stay base. Only row 3 (the
336 // wordmark row, after the spacer) carries a `scales` channel.
337 let p = provider_with_theme();
338 let rows = p.collect();
339 let wordmark_row = &rows[3];
340 let scales = wordmark_row
341 .scales
342 .as_ref()
343 .expect("wordmark row carries per-column scales");
344 assert_eq!(scales.len(), wordmark_row.cells.len());
345 // The scaled columns are exactly the "Lattice" run.
346 let scaled = scales.iter().filter(|s| **s == WORDMARK_SCALE).count();
347 assert_eq!(scaled, WORDMARK.chars().count(), "only the wordmark scales");
348 // The leading mark blocks + gap are base size.
349 assert_eq!(scales[0], BASE_SCALE, "mark blocks stay base size");
350 // The tagline row (row 4) and mark-only rows carry no scale channel.
351 assert!(
352 rows[4].scales.is_none(),
353 "tagline stays base (no scale channel)"
354 );
355 assert!(
356 rows[1].scales.is_none(),
357 "mark-only row has no scale channel"
358 );
359 }
360
361 #[test]
362 fn has_spacer_rows_above_and_below() {
363 // Horizontal centring is gutter-based (content_left_pad, host-side);
364 // the rows are plain left-aligned cells so the mark's columns line up.
365 let p = provider_with_theme();
366 let rows = p.collect();
367 assert!(rows.first().unwrap().cells.is_empty(), "spacer above");
368 assert!(rows.last().unwrap().cells.is_empty(), "spacer below");
369 }
370
371 #[test]
372 fn falls_back_to_literal_brand_colors_without_theme() {
373 // No theme service: still renders with the literal brand colours.
374 let ids = DashboardElementIds {
375 logo: ElementId::INVALID,
376 cursor: ElementId::INVALID,
377 title: ElementId::INVALID,
378 tagline: ElementId::INVALID,
379 section: ElementId::INVALID,
380 key: ElementId::INVALID,
381 link: ElementId::INVALID,
382 body: ElementId::INVALID,
383 version: ElementId::INVALID,
384 };
385 let p = DashboardBrandingProvider::new(1, None, ids);
386 let rows = p.collect();
387 let has_blue = rows
388 .iter()
389 .flat_map(|r| r.cells.iter())
390 .any(|c| c.fg == BRAND_BLUE.to_rgb_u32(0));
391 assert!(has_blue, "brand blue present even without a theme");
392 }
393}