Skip to main content

buffr_ui/
tab_strip.rs

1//! Tab strip — horizontal row of tab pills above the input bar /
2//! statusline.
3//!
4//! Layout (top to bottom in the buffr window):
5//!
6//! ```text
7//! +---------------------------------------------+
8//! | input bar (when overlay open)               |
9//! +---------------------------------------------+
10//! | tab strip — TAB_STRIP_HEIGHT px             |
11//! +---------------------------------------------+
12//! | CEF child window (page area)                |
13//! +---------------------------------------------+
14//! | statusline — STATUSLINE_HEIGHT px           |
15//! +---------------------------------------------+
16//! ```
17//!
18//! Each tab is a fixed-width pill. The active tab uses the mode-accent
19//! colour at full intensity; inactive tabs are dimmed. Pinned tabs
20//! show a leading `*` glyph and sort first (sorting is the host's job;
21//! this widget only renders what it's given).
22//!
23//! The widget owns no state and pulls no winit / softbuffer types into
24//! its public API; like [`crate::Statusline`], embedders pass a raw
25//! `&mut [u32]` slice each frame.
26//!
27//! Loading indicator: a 2-px progress bar at the bottom edge of each
28//! tab. `progress >= 1.0` is treated as idle and the bar is hidden.
29
30use crate::fill_rect;
31use crate::font;
32
33/// Tab strip strip height in pixels. 34 px gives a 14-px glyph row
34/// with comfortable padding above + below plus a 2-px progress bar
35/// reserved at the bottom. Bumping this requires the host window to
36/// re-layout the CEF child rect.
37pub const TAB_STRIP_HEIGHT: u32 = 34;
38
39/// Minimum width of a single tab pill in pixels. With 8 px gutter the
40/// layout falls back to overflow truncation when the strip is too
41/// narrow.
42pub const MIN_TAB_WIDTH: u32 = 80;
43
44/// Maximum width of a single tab pill in pixels. Beyond this the
45/// titles get long enough to be hard to glance at.
46pub const MAX_TAB_WIDTH: u32 = 220;
47
48/// Compact width for pinned tabs. Pinned tabs render as a square
49/// icon-only pill — no title text, just the favicon (or a fallback
50/// initial) — so the user can fit many anchors in a small strip.
51pub const PINNED_TAB_WIDTH: u32 = 32;
52
53/// Per-tab render input. `progress >= 1.0` hides the loading bar.
54#[derive(Debug, Clone, PartialEq)]
55pub struct TabView {
56    pub title: String,
57    pub progress: f32,
58    pub pinned: bool,
59    pub private: bool,
60}
61
62impl Default for TabView {
63    fn default() -> Self {
64        Self {
65            title: String::new(),
66            progress: 1.0,
67            pinned: false,
68            private: false,
69        }
70    }
71}
72
73/// Whole-strip render input. Re-create per frame — the widget owns no
74/// state.
75#[derive(Debug, Clone, Default)]
76pub struct TabStrip {
77    pub tabs: Vec<TabView>,
78    pub active: Option<usize>,
79}
80
81impl TabStrip {
82    /// Paint the tab strip into rows `[start_y, start_y + TAB_STRIP_HEIGHT)`
83    /// of the window buffer. `width` and `height` are the full window's
84    /// pixel dimensions; the caller passes `start_y` so the tab strip
85    /// can sit anywhere vertically.
86    ///
87    /// The widget is a no-op when the strip would not fit (e.g. window
88    /// shorter than `start_y + TAB_STRIP_HEIGHT`). When `tabs` is empty
89    /// the strip is filled with `TAB_STRIP_BG` only — useful while a
90    /// tab is being created on startup.
91    pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize, start_y: u32) {
92        let strip_h = TAB_STRIP_HEIGHT as usize;
93        let start_y = start_y as usize;
94        if width == 0 || start_y + strip_h > height {
95            return;
96        }
97        if buffer.len() < width * height {
98            return;
99        }
100
101        // Background fill.
102        fill_rect(
103            buffer,
104            width,
105            height,
106            0,
107            start_y as i32,
108            width,
109            strip_h,
110            TAB_STRIP_BG,
111        );
112
113        if self.tabs.is_empty() {
114            return;
115        }
116
117        // Compute each tab's pixel rect. Pinned tabs are fixed-width
118        // (PINNED_TAB_WIDTH) icon-only pills; unpinned tabs share the
119        // remaining width equally, clamped to [MIN, MAX], with the
120        // rightmost overflow truncating.
121        let pinned_count = self.tabs.iter().filter(|t| t.pinned).count() as u32;
122        let unpinned_count = self.tabs.len() as u32 - pinned_count;
123        let pinned_total_w = pinned_count * PINNED_TAB_WIDTH;
124        let gutter_total = ((self.tabs.len() as u32) + 1) * GUTTER;
125        let avail_for_unpinned = (width as u32)
126            .saturating_sub(pinned_total_w)
127            .saturating_sub(gutter_total);
128        let raw_w = avail_for_unpinned.checked_div(unpinned_count).unwrap_or(0);
129        let tab_w = raw_w.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH);
130
131        let text_y = start_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
132        let progress_y = start_y as i32 + strip_h as i32 - 2;
133
134        let mut x = GUTTER as i32;
135        for (i, tab) in self.tabs.iter().enumerate() {
136            // Cap so we don't draw past the right edge.
137            let max_right = width as i32 - 1;
138            if x >= max_right {
139                break;
140            }
141            let target_w = if tab.pinned {
142                PINNED_TAB_WIDTH as i32
143            } else {
144                tab_w as i32
145            };
146            let pill_w = target_w.min(max_right - x);
147            let min_pill = if tab.pinned {
148                PINNED_TAB_WIDTH as i32 / 2
149            } else {
150                MIN_TAB_WIDTH as i32 / 2
151            };
152            if pill_w < min_pill {
153                break;
154            }
155            let is_active = self.active == Some(i);
156            let bg = if is_active {
157                TAB_BG_ACTIVE
158            } else if tab.private {
159                TAB_BG_PRIVATE
160            } else {
161                TAB_BG_INACTIVE
162            };
163            let fg = if is_active {
164                TAB_FG_ACTIVE
165            } else {
166                TAB_FG_INACTIVE
167            };
168
169            fill_rect(
170                buffer,
171                width,
172                height,
173                x,
174                start_y as i32,
175                pill_w as usize,
176                strip_h - 2,
177                bg,
178            );
179
180            // Active accent stripe along the bottom edge — a single
181            // bright row so the active tab pops even when colours are
182            // close.
183            if is_active {
184                fill_rect(
185                    buffer,
186                    width,
187                    height,
188                    x,
189                    start_y as i32 + strip_h as i32 - 4,
190                    pill_w as usize,
191                    2,
192                    TAB_ACCENT_ACTIVE,
193                );
194            }
195
196            if tab.pinned {
197                // Default-favicon stand-in: a single capitalized letter
198                // pulled from the title (or `*` if the title is empty)
199                // centered in the pill. Real favicon image rendering
200                // is a follow-up — this gives users an at-a-glance
201                // anchor today without any network IO.
202                let glyph: String = pinned_glyph(&tab.title);
203                let glyph_px = font::text_width(&glyph) as i32;
204                let glyph_x = x + (pill_w - glyph_px) / 2;
205                font::draw_text(buffer, width, height, glyph_x, text_y, &glyph, fg);
206            } else {
207                // Title for unpinned tabs. Pre-truncate so the text
208                // never bleeds onto the next pill.
209                let max_text_px = (pill_w as usize).saturating_sub(12);
210                let label = truncate_to_width(&tab.title, max_text_px);
211                font::draw_text(buffer, width, height, x + 6, text_y, label, fg);
212            }
213
214            // Progress bar across the bottom edge of the pill — hidden
215            // when idle (progress >= 1.0).
216            let p = tab.progress.clamp(0.0, 1.0);
217            if p > 0.0 && p < 1.0 {
218                let bar_w = ((pill_w as f32) * p) as i32;
219                fill_rect(
220                    buffer,
221                    width,
222                    height,
223                    x,
224                    progress_y,
225                    bar_w.max(1) as usize,
226                    2,
227                    TAB_PROGRESS,
228                );
229            }
230
231            x += pill_w + GUTTER as i32;
232        }
233    }
234}
235
236/// First printable letter of a tab's title, uppercased — a default
237/// "favicon" stand-in for pinned tabs. Falls back to `*` when the
238/// title has no usable codepoint. When the title looks like a URL
239/// (`scheme://…`) the scheme and a leading `www.` are skipped so the
240/// glyph reflects the meaningful host segment.
241fn pinned_glyph(title: &str) -> String {
242    let body = title
243        .split_once("://")
244        .map(|(_, rest)| rest)
245        .unwrap_or(title);
246    let body = body.strip_prefix("www.").unwrap_or(body);
247    for c in body.chars() {
248        if c.is_alphanumeric() {
249            return c.to_uppercase().to_string();
250        }
251    }
252    "*".to_string()
253}
254
255/// Truncate `s` to fit in `max_px` pixels, appending `..` when it
256/// didn't fit. Mirrors the logic in `Statusline::paint`'s URL cell.
257fn truncate_to_width(s: &str, max_px: usize) -> &str {
258    if font::text_width(s) <= max_px {
259        return s;
260    }
261    if max_px < font::text_width("..") {
262        return "";
263    }
264    let mut end = s.len();
265    while end > 0 {
266        if !s.is_char_boundary(end) {
267            end -= 1;
268            continue;
269        }
270        let prefix = &s[..end];
271        if font::text_width(prefix) + font::text_width("..") <= max_px {
272            return prefix;
273        }
274        end -= 1;
275    }
276    ""
277}
278
279const GUTTER: u32 = 4;
280
281// vim-flavoured palette consistent with the Statusline accents (opaque BGRA).
282const TAB_STRIP_BG: u32 = 0xFF_10_18_20;
283const TAB_BG_ACTIVE: u32 = 0xFF_22_2E_22;
284const TAB_BG_INACTIVE: u32 = 0xFF_18_1E_22;
285const TAB_BG_PRIVATE: u32 = 0xFF_2A_18_2A;
286const TAB_FG_ACTIVE: u32 = 0xFF_EE_EE_EE;
287const TAB_FG_INACTIVE: u32 = 0xFF_A0_A8_AC;
288const TAB_ACCENT_ACTIVE: u32 = 0xFF_4A_C9_5C;
289const TAB_PROGRESS: u32 = 0xFF_66_C2_FF;
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    fn make_buf(w: usize, h: usize) -> Vec<u32> {
296        vec![0u32; w * h]
297    }
298
299    #[test]
300    fn paint_fills_strip_bg_when_no_tabs() {
301        let w = 200;
302        let h = TAB_STRIP_HEIGHT as usize;
303        let mut buf = make_buf(w, h);
304        let s = TabStrip::default();
305        s.paint(&mut buf, w, h, 0);
306        // Every pixel in the strip is the bg colour.
307        for &px in &buf {
308            assert_eq!(px, TAB_STRIP_BG);
309        }
310    }
311
312    #[test]
313    fn paint_active_tab_has_accent_stripe_pixel() {
314        let w = 800;
315        let h = TAB_STRIP_HEIGHT as usize;
316        let mut buf = make_buf(w, h);
317        let s = TabStrip {
318            tabs: vec![
319                TabView {
320                    title: "one".into(),
321                    ..Default::default()
322                },
323                TabView {
324                    title: "two".into(),
325                    ..Default::default()
326                },
327            ],
328            active: Some(1),
329        };
330        s.paint(&mut buf, w, h, 0);
331        // The accent stripe is 2 px tall starting at strip_h - 4.
332        let stripe_y = h - 4;
333        // Find at least one accent pixel on that row.
334        let row = &buf[stripe_y * w..(stripe_y + 1) * w];
335        assert!(
336            row.contains(&TAB_ACCENT_ACTIVE),
337            "no accent stripe pixel found on active tab row",
338        );
339    }
340
341    #[test]
342    fn paint_skips_when_strip_overflows_buffer() {
343        let w = 100;
344        let h = 10;
345        let mut buf = make_buf(w, h);
346        let s = TabStrip {
347            tabs: vec![TabView::default()],
348            active: Some(0),
349        };
350        s.paint(&mut buf, w, h, 0);
351        assert!(buf.iter().all(|&p| p == 0));
352    }
353
354    #[test]
355    fn paint_with_start_y_offset_only_touches_strip_rows() {
356        let w = 200;
357        let strip_h = TAB_STRIP_HEIGHT as usize;
358        let h = strip_h + 10;
359        let mut buf = make_buf(w, h);
360        let s = TabStrip {
361            tabs: vec![TabView::default()],
362            active: Some(0),
363        };
364        s.paint(&mut buf, w, h, 10);
365        // Rows 0..10 untouched.
366        for y in 0..10 {
367            for x in 0..w {
368                assert_eq!(buf[y * w + x], 0, "row {y} touched");
369            }
370        }
371    }
372
373    #[test]
374    fn pinned_tab_renders_distinctly_from_unpinned() {
375        let w = 600;
376        let h = TAB_STRIP_HEIGHT as usize;
377        let mut buf_pin = make_buf(w, h);
378        let mut buf_no_pin = make_buf(w, h);
379        let pin = TabStrip {
380            tabs: vec![TabView {
381                title: "x".into(),
382                pinned: true,
383                ..Default::default()
384            }],
385            active: Some(0),
386        };
387        let no_pin = TabStrip {
388            tabs: vec![TabView {
389                title: "x".into(),
390                pinned: false,
391                ..Default::default()
392            }],
393            active: Some(0),
394        };
395        pin.paint(&mut buf_pin, w, h, 0);
396        no_pin.paint(&mut buf_no_pin, w, h, 0);
397        assert_ne!(buf_pin, buf_no_pin, "pin glyph not visible");
398    }
399
400    #[test]
401    fn private_tab_uses_distinct_bg_when_inactive() {
402        let w = 600;
403        let h = TAB_STRIP_HEIGHT as usize;
404        let mut buf_priv = make_buf(w, h);
405        let mut buf_norm = make_buf(w, h);
406        let priv_strip = TabStrip {
407            tabs: vec![
408                TabView {
409                    title: "a".into(),
410                    ..Default::default()
411                },
412                TabView {
413                    title: "b".into(),
414                    private: true,
415                    ..Default::default()
416                },
417            ],
418            active: Some(0),
419        };
420        let norm_strip = TabStrip {
421            tabs: vec![
422                TabView {
423                    title: "a".into(),
424                    ..Default::default()
425                },
426                TabView {
427                    title: "b".into(),
428                    private: false,
429                    ..Default::default()
430                },
431            ],
432            active: Some(0),
433        };
434        priv_strip.paint(&mut buf_priv, w, h, 0);
435        norm_strip.paint(&mut buf_norm, w, h, 0);
436        assert_ne!(buf_priv, buf_norm, "private bg should differ");
437    }
438
439    #[test]
440    fn progress_bar_drawn_only_while_loading() {
441        let w = 600;
442        let h = TAB_STRIP_HEIGHT as usize;
443        let mut buf_loading = make_buf(w, h);
444        let mut buf_idle = make_buf(w, h);
445        let loading = TabStrip {
446            tabs: vec![TabView {
447                title: "x".into(),
448                progress: 0.5,
449                ..Default::default()
450            }],
451            active: Some(0),
452        };
453        let idle = TabStrip {
454            tabs: vec![TabView {
455                title: "x".into(),
456                progress: 1.0,
457                ..Default::default()
458            }],
459            active: Some(0),
460        };
461        loading.paint(&mut buf_loading, w, h, 0);
462        idle.paint(&mut buf_idle, w, h, 0);
463        let progress_y = h - 2;
464        let loading_row = &buf_loading[progress_y * w..(progress_y + 1) * w];
465        let idle_row = &buf_idle[progress_y * w..(progress_y + 1) * w];
466        assert!(loading_row.contains(&TAB_PROGRESS));
467        assert!(!idle_row.contains(&TAB_PROGRESS));
468    }
469
470    #[test]
471    fn truncate_returns_empty_when_too_narrow() {
472        assert_eq!(truncate_to_width("hello world", 1), "");
473    }
474
475    #[test]
476    fn truncate_returns_full_when_fits() {
477        assert_eq!(truncate_to_width("hi", 1000), "hi");
478    }
479
480    #[test]
481    fn many_tabs_truncate_at_strip_edge() {
482        // Strip is too narrow to fit more than a few pills; the widget
483        // must stop drawing rather than overflow.
484        let w = 200;
485        let h = TAB_STRIP_HEIGHT as usize;
486        let mut buf = make_buf(w, h);
487        let s = TabStrip {
488            tabs: (0..10)
489                .map(|i| TabView {
490                    title: format!("tab {i}"),
491                    ..Default::default()
492                })
493                .collect(),
494            active: Some(0),
495        };
496        s.paint(&mut buf, w, h, 0);
497        // No panic, no buffer overrun. The far-right column should be
498        // either bg (if no pill reached it) or a pill colour, but never
499        // an out-of-bounds value.
500        let far_right = &buf[(h / 2) * w + (w - 1)];
501        let allowed = [TAB_STRIP_BG, TAB_BG_ACTIVE, TAB_BG_INACTIVE];
502        assert!(allowed.contains(far_right));
503    }
504
505    #[test]
506    fn pinned_glyph_skips_scheme() {
507        assert_eq!(pinned_glyph("https://example.com"), "E");
508        assert_eq!(pinned_glyph("http://kryptic.sh"), "K");
509        assert_eq!(pinned_glyph("buffr://new"), "N");
510    }
511
512    #[test]
513    fn pinned_glyph_skips_www() {
514        assert_eq!(pinned_glyph("https://www.google.com"), "G");
515        assert_eq!(pinned_glyph("www.example.com"), "E");
516    }
517
518    #[test]
519    fn pinned_glyph_uses_title_when_no_scheme() {
520        assert_eq!(pinned_glyph("GitHub"), "G");
521        assert_eq!(pinned_glyph("  hello"), "H");
522        assert_eq!(pinned_glyph(""), "*");
523    }
524}