Skip to main content

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}