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 std::sync::Arc;
31
32use crate::Palette;
33use crate::fill_rect;
34use crate::font;
35
36/// Tab strip strip height in pixels. 34 px gives a 14-px glyph row
37/// with comfortable padding above + below plus a 2-px progress bar
38/// reserved at the bottom. Bumping this requires the host window to
39/// re-layout the CEF child rect.
40pub const TAB_STRIP_HEIGHT: u32 = 34;
41
42/// Minimum width of a single tab pill in pixels. With 8 px gutter the
43/// layout falls back to overflow truncation when the strip is too
44/// narrow.
45pub const MIN_TAB_WIDTH: u32 = 80;
46
47/// Maximum width of a single tab pill in pixels. Beyond this the
48/// titles get long enough to be hard to glance at.
49pub const MAX_TAB_WIDTH: u32 = 220;
50
51/// Compact width for pinned tabs. Pinned tabs render as a square
52/// icon-only pill — no title text, just the favicon (or a fallback
53/// initial) — so the user can fit many anchors in a small strip.
54pub const PINNED_TAB_WIDTH: u32 = 32;
55
56/// Decoded favicon bitmap, BGRA `u32` packed (`0xAARRGGBB`). Wrapped in
57/// `Arc` so cloning a `TabView` is O(1).
58#[derive(Debug, Clone, PartialEq)]
59pub struct TabFavicon {
60    pub width: u32,
61    pub height: u32,
62    pub pixels: Arc<Vec<u32>>,
63}
64
65/// Target favicon side length in the strip, in pixels.
66pub const FAVICON_RENDER_SIZE: u32 = 16;
67
68/// Per-tab render input. `progress >= 1.0` hides the loading bar.
69#[derive(Debug, Clone, PartialEq)]
70pub struct TabView {
71    pub title: String,
72    pub progress: f32,
73    pub pinned: bool,
74    pub private: bool,
75    pub favicon: Option<TabFavicon>,
76}
77
78impl Default for TabView {
79    fn default() -> Self {
80        Self {
81            title: String::new(),
82            progress: 1.0,
83            pinned: false,
84            private: false,
85            favicon: None,
86        }
87    }
88}
89
90/// Whole-strip render input. Re-create per frame — the widget owns no
91/// state. `palette` is set once on startup from `config.theme` and
92/// drives every colour the widget paints.
93#[derive(Debug, Clone, Default)]
94pub struct TabStrip {
95    pub tabs: Vec<TabView>,
96    pub active: Option<usize>,
97    pub palette: Palette,
98}
99
100impl TabStrip {
101    /// Paint the tab strip into rows `[start_y, start_y + TAB_STRIP_HEIGHT)`
102    /// of the window buffer. `width` and `height` are the full window's
103    /// pixel dimensions; the caller passes `start_y` so the tab strip
104    /// can sit anywhere vertically.
105    ///
106    /// The widget is a no-op when the strip would not fit (e.g. window
107    /// shorter than `start_y + TAB_STRIP_HEIGHT`). When `tabs` is empty
108    /// the strip is filled with `TAB_STRIP_BG` only — useful while a
109    /// tab is being created on startup.
110    pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize, start_y: u32) {
111        let strip_h = TAB_STRIP_HEIGHT as usize;
112        let start_y = start_y as usize;
113        if width == 0 || start_y + strip_h > height {
114            return;
115        }
116        if buffer.len() < width * height {
117            return;
118        }
119
120        let p = &self.palette;
121
122        // Background fill.
123        fill_rect(
124            buffer,
125            width,
126            height,
127            0,
128            start_y as i32,
129            width,
130            strip_h,
131            p.bg,
132        );
133
134        if self.tabs.is_empty() {
135            return;
136        }
137
138        // Compute each tab's pixel rect. Pinned tabs are fixed-width
139        // (PINNED_TAB_WIDTH) icon-only pills; unpinned tabs share the
140        // remaining width equally, clamped to [MIN, MAX], with the
141        // rightmost overflow truncating.
142        let pinned_count = self.tabs.iter().filter(|t| t.pinned).count() as u32;
143        let unpinned_count = self.tabs.len() as u32 - pinned_count;
144        let pinned_total_w = pinned_count * PINNED_TAB_WIDTH;
145        let gutter_total = ((self.tabs.len() as u32) + 1) * GUTTER;
146        let avail_for_unpinned = (width as u32)
147            .saturating_sub(pinned_total_w)
148            .saturating_sub(gutter_total);
149        let raw_w = avail_for_unpinned.checked_div(unpinned_count).unwrap_or(0);
150        let tab_w = raw_w.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH);
151
152        let text_y = start_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
153        let progress_y = start_y as i32 + strip_h as i32 - 2;
154
155        let mut x = GUTTER as i32;
156        for (i, tab) in self.tabs.iter().enumerate() {
157            // Cap so we don't draw past the right edge.
158            let max_right = width as i32 - 1;
159            if x >= max_right {
160                break;
161            }
162            let target_w = if tab.pinned {
163                PINNED_TAB_WIDTH as i32
164            } else {
165                tab_w as i32
166            };
167            let pill_w = target_w.min(max_right - x);
168            let min_pill = if tab.pinned {
169                PINNED_TAB_WIDTH as i32 / 2
170            } else {
171                MIN_TAB_WIDTH as i32 / 2
172            };
173            if pill_w < min_pill {
174                break;
175            }
176            let is_active = self.active == Some(i);
177            let bg = if is_active { p.bg_lifted } else { p.bg };
178            let fg = if is_active { p.fg } else { p.fg_dim };
179
180            fill_rect(
181                buffer,
182                width,
183                height,
184                x,
185                start_y as i32,
186                pill_w as usize,
187                strip_h - 2,
188                bg,
189            );
190
191            // Active accent stripe along the bottom edge — a single
192            // bright row so the active tab pops even when colours are
193            // close.
194            if is_active {
195                fill_rect(
196                    buffer,
197                    width,
198                    height,
199                    x,
200                    start_y as i32 + strip_h as i32 - 4,
201                    pill_w as usize,
202                    2,
203                    p.accent,
204                );
205            }
206            // Private-tab marker — a thin pink top edge so private
207            // tabs read as distinct without the heavy purple fill the
208            // old palette used.
209            if tab.private {
210                fill_rect(
211                    buffer,
212                    width,
213                    height,
214                    x,
215                    start_y as i32,
216                    pill_w as usize,
217                    2,
218                    p.private,
219                );
220            }
221
222            if tab.pinned {
223                let icon_size = FAVICON_RENDER_SIZE as i32;
224                let icon_x = x + (pill_w - icon_size) / 2;
225                let icon_y = start_y as i32 + ((strip_h as i32 - icon_size) / 2);
226                if let Some(fav) = tab.favicon.as_ref() {
227                    blit_favicon(
228                        buffer,
229                        width,
230                        height,
231                        icon_x,
232                        icon_y,
233                        FAVICON_RENDER_SIZE,
234                        FAVICON_RENDER_SIZE,
235                        fav,
236                        bg,
237                    );
238                } else {
239                    // Stand-in until the favicon arrives: a single
240                    // capitalised glyph centered in the pill.
241                    let glyph: String = pinned_glyph(&tab.title);
242                    let glyph_px = font::text_width(&glyph) as i32;
243                    let glyph_x = x + (pill_w - glyph_px) / 2;
244                    font::draw_text(buffer, width, height, glyph_x, text_y, &glyph, fg);
245                }
246            } else {
247                // Favicon (when present) at the left edge, then title.
248                let icon_size = FAVICON_RENDER_SIZE as i32;
249                let icon_y = start_y as i32 + ((strip_h as i32 - icon_size) / 2);
250                let mut text_x = x + 6;
251                if let Some(fav) = tab.favicon.as_ref() {
252                    blit_favicon(
253                        buffer,
254                        width,
255                        height,
256                        x + 6,
257                        icon_y,
258                        FAVICON_RENDER_SIZE,
259                        FAVICON_RENDER_SIZE,
260                        fav,
261                        bg,
262                    );
263                    text_x = x + 6 + icon_size + 4;
264                }
265                let max_text_px = (pill_w as usize)
266                    .saturating_sub((text_x - x) as usize)
267                    .saturating_sub(6);
268                let label = truncate_to_width(&tab.title, max_text_px);
269                font::draw_text(buffer, width, height, text_x, text_y, label, fg);
270            }
271
272            // Progress bar across the bottom edge of the pill — hidden
273            // when idle (progress >= 1.0).
274            let frac = tab.progress.clamp(0.0, 1.0);
275            if frac > 0.0 && frac < 1.0 {
276                let bar_w = ((pill_w as f32) * frac) as i32;
277                fill_rect(
278                    buffer,
279                    width,
280                    height,
281                    x,
282                    progress_y,
283                    bar_w.max(1) as usize,
284                    2,
285                    p.progress,
286                );
287            }
288
289            x += pill_w + GUTTER as i32;
290        }
291    }
292}
293
294/// First printable letter of a tab's title, uppercased — a default
295/// "favicon" stand-in for pinned tabs. Falls back to `*` when the
296/// title has no usable codepoint. When the title looks like a URL
297/// (`scheme://…`) the scheme and a leading `www.` are skipped so the
298/// glyph reflects the meaningful host segment.
299fn pinned_glyph(title: &str) -> String {
300    let body = title
301        .split_once("://")
302        .map(|(_, rest)| rest)
303        .unwrap_or(title);
304    let body = body.strip_prefix("www.").unwrap_or(body);
305    for c in body.chars() {
306        if c.is_alphanumeric() {
307            return c.to_uppercase().to_string();
308        }
309    }
310    "*".to_string()
311}
312
313/// Truncate `s` to fit in `max_px` pixels, appending `..` when it
314/// didn't fit. Mirrors the logic in `Statusline::paint`'s URL cell.
315fn truncate_to_width(s: &str, max_px: usize) -> &str {
316    if font::text_width(s) <= max_px {
317        return s;
318    }
319    if max_px < font::text_width("..") {
320        return "";
321    }
322    let mut end = s.len();
323    while end > 0 {
324        if !s.is_char_boundary(end) {
325            end -= 1;
326            continue;
327        }
328        let prefix = &s[..end];
329        if font::text_width(prefix) + font::text_width("..") <= max_px {
330            return prefix;
331        }
332        end -= 1;
333    }
334    ""
335}
336
337const GUTTER: u32 = 4;
338
339/// Draw `fav` into the rect `(dst_x, dst_y, dst_w, dst_h)` using
340/// nearest-neighbour scaling and a `bg`-coloured backdrop for transparent
341/// pixels. Source pixels are BGRA-packed `0xAARRGGBB`; we composite over
342/// `bg` so antialiased edges read correctly on whichever pill colour the
343/// caller passes in.
344#[allow(clippy::too_many_arguments)]
345fn blit_favicon(
346    buffer: &mut [u32],
347    width: usize,
348    height: usize,
349    dst_x: i32,
350    dst_y: i32,
351    dst_w: u32,
352    dst_h: u32,
353    fav: &TabFavicon,
354    bg: u32,
355) {
356    if fav.width == 0 || fav.height == 0 || dst_w == 0 || dst_h == 0 {
357        return;
358    }
359    let src_w = fav.width as usize;
360    let src_h = fav.height as usize;
361    let dst_w_us = dst_w as usize;
362    let dst_h_us = dst_h as usize;
363    let src_pixels: &[u32] = fav.pixels.as_slice();
364    if src_pixels.len() < src_w * src_h {
365        return;
366    }
367    // Pre-extract the bg channels; reused for every alpha composite.
368    let bg_r = (bg >> 16) & 0xFF;
369    let bg_g = (bg >> 8) & 0xFF;
370    let bg_b = bg & 0xFF;
371    for dy in 0..dst_h_us {
372        let py = dst_y + dy as i32;
373        if py < 0 {
374            continue;
375        }
376        let py = py as usize;
377        if py >= height {
378            break;
379        }
380        let sy = (dy * src_h) / dst_h_us;
381        for dx in 0..dst_w_us {
382            let px = dst_x + dx as i32;
383            if px < 0 {
384                continue;
385            }
386            let px = px as usize;
387            if px >= width {
388                break;
389            }
390            let sx = (dx * src_w) / dst_w_us;
391            let src = src_pixels[sy * src_w + sx];
392            let a = (src >> 24) & 0xFF;
393            // Source alpha is premultiplied (we asked CEF for
394            // PREMULTIPLIED). Composite over the pill bg with
395            // `out = src + bg * (1 - a)`.
396            let sr = (src >> 16) & 0xFF;
397            let sg = (src >> 8) & 0xFF;
398            let sb = src & 0xFF;
399            let inv = 255 - a;
400            let r = (sr + ((bg_r * inv) + 127) / 255).min(255);
401            let g = (sg + ((bg_g * inv) + 127) / 255).min(255);
402            let b = (sb + ((bg_b * inv) + 127) / 255).min(255);
403            let out = 0xFF00_0000 | (r << 16) | (g << 8) | b;
404            if let Some(dst) = buffer.get_mut(py * width + px) {
405                *dst = out;
406            }
407        }
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn make_buf(w: usize, h: usize) -> Vec<u32> {
416        vec![0u32; w * h]
417    }
418
419    #[test]
420    fn paint_fills_strip_bg_when_no_tabs() {
421        let w = 200;
422        let h = TAB_STRIP_HEIGHT as usize;
423        let mut buf = make_buf(w, h);
424        let s = TabStrip::default();
425        s.paint(&mut buf, w, h, 0);
426        // Every pixel in the strip is the bg colour.
427        for &px in &buf {
428            assert_eq!(px, Palette::default().bg);
429        }
430    }
431
432    #[test]
433    fn paint_active_tab_has_accent_stripe_pixel() {
434        let w = 800;
435        let h = TAB_STRIP_HEIGHT as usize;
436        let mut buf = make_buf(w, h);
437        let s = TabStrip {
438            tabs: vec![
439                TabView {
440                    title: "one".into(),
441                    ..Default::default()
442                },
443                TabView {
444                    title: "two".into(),
445                    ..Default::default()
446                },
447            ],
448            active: Some(1),
449            ..TabStrip::default()
450        };
451        s.paint(&mut buf, w, h, 0);
452        // The accent stripe is 2 px tall starting at strip_h - 4.
453        let stripe_y = h - 4;
454        // Find at least one accent pixel on that row.
455        let row = &buf[stripe_y * w..(stripe_y + 1) * w];
456        assert!(
457            row.contains(&Palette::default().accent),
458            "no accent stripe pixel found on active tab row",
459        );
460    }
461
462    #[test]
463    fn paint_skips_when_strip_overflows_buffer() {
464        let w = 100;
465        let h = 10;
466        let mut buf = make_buf(w, h);
467        let s = TabStrip {
468            tabs: vec![TabView::default()],
469            active: Some(0),
470            ..TabStrip::default()
471        };
472        s.paint(&mut buf, w, h, 0);
473        assert!(buf.iter().all(|&p| p == 0));
474    }
475
476    #[test]
477    fn paint_with_start_y_offset_only_touches_strip_rows() {
478        let w = 200;
479        let strip_h = TAB_STRIP_HEIGHT as usize;
480        let h = strip_h + 10;
481        let mut buf = make_buf(w, h);
482        let s = TabStrip {
483            tabs: vec![TabView::default()],
484            active: Some(0),
485            ..TabStrip::default()
486        };
487        s.paint(&mut buf, w, h, 10);
488        // Rows 0..10 untouched.
489        for y in 0..10 {
490            for x in 0..w {
491                assert_eq!(buf[y * w + x], 0, "row {y} touched");
492            }
493        }
494    }
495
496    #[test]
497    fn pinned_tab_renders_distinctly_from_unpinned() {
498        let w = 600;
499        let h = TAB_STRIP_HEIGHT as usize;
500        let mut buf_pin = make_buf(w, h);
501        let mut buf_no_pin = make_buf(w, h);
502        let pin = TabStrip {
503            tabs: vec![TabView {
504                title: "x".into(),
505                pinned: true,
506                ..Default::default()
507            }],
508            active: Some(0),
509            ..TabStrip::default()
510        };
511        let no_pin = TabStrip {
512            tabs: vec![TabView {
513                title: "x".into(),
514                pinned: false,
515                ..Default::default()
516            }],
517            active: Some(0),
518            ..TabStrip::default()
519        };
520        pin.paint(&mut buf_pin, w, h, 0);
521        no_pin.paint(&mut buf_no_pin, w, h, 0);
522        assert_ne!(buf_pin, buf_no_pin, "pin glyph not visible");
523    }
524
525    #[test]
526    fn private_tab_uses_distinct_bg_when_inactive() {
527        let w = 600;
528        let h = TAB_STRIP_HEIGHT as usize;
529        let mut buf_priv = make_buf(w, h);
530        let mut buf_norm = make_buf(w, h);
531        let priv_strip = TabStrip {
532            tabs: vec![
533                TabView {
534                    title: "a".into(),
535                    ..Default::default()
536                },
537                TabView {
538                    title: "b".into(),
539                    private: true,
540                    ..Default::default()
541                },
542            ],
543            active: Some(0),
544            ..TabStrip::default()
545        };
546        let norm_strip = TabStrip {
547            tabs: vec![
548                TabView {
549                    title: "a".into(),
550                    ..Default::default()
551                },
552                TabView {
553                    title: "b".into(),
554                    private: false,
555                    ..Default::default()
556                },
557            ],
558            active: Some(0),
559            ..TabStrip::default()
560        };
561        priv_strip.paint(&mut buf_priv, w, h, 0);
562        norm_strip.paint(&mut buf_norm, w, h, 0);
563        assert_ne!(buf_priv, buf_norm, "private bg should differ");
564    }
565
566    #[test]
567    fn progress_bar_drawn_only_while_loading() {
568        let w = 600;
569        let h = TAB_STRIP_HEIGHT as usize;
570        let mut buf_loading = make_buf(w, h);
571        let mut buf_idle = make_buf(w, h);
572        let loading = TabStrip {
573            tabs: vec![TabView {
574                title: "x".into(),
575                progress: 0.5,
576                ..Default::default()
577            }],
578            active: Some(0),
579            ..TabStrip::default()
580        };
581        let idle = TabStrip {
582            tabs: vec![TabView {
583                title: "x".into(),
584                progress: 1.0,
585                ..Default::default()
586            }],
587            active: Some(0),
588            ..TabStrip::default()
589        };
590        loading.paint(&mut buf_loading, w, h, 0);
591        idle.paint(&mut buf_idle, w, h, 0);
592        let progress_y = h - 2;
593        let loading_row = &buf_loading[progress_y * w..(progress_y + 1) * w];
594        let idle_row = &buf_idle[progress_y * w..(progress_y + 1) * w];
595        let progress_color = Palette::default().progress;
596        assert!(loading_row.contains(&progress_color));
597        assert!(!idle_row.contains(&progress_color));
598    }
599
600    #[test]
601    fn truncate_returns_empty_when_too_narrow() {
602        assert_eq!(truncate_to_width("hello world", 1), "");
603    }
604
605    #[test]
606    fn truncate_returns_full_when_fits() {
607        assert_eq!(truncate_to_width("hi", 1000), "hi");
608    }
609
610    #[test]
611    fn many_tabs_truncate_at_strip_edge() {
612        // Strip is too narrow to fit more than a few pills; the widget
613        // must stop drawing rather than overflow.
614        let w = 200;
615        let h = TAB_STRIP_HEIGHT as usize;
616        let mut buf = make_buf(w, h);
617        let s = TabStrip {
618            tabs: (0..10)
619                .map(|i| TabView {
620                    title: format!("tab {i}"),
621                    ..Default::default()
622                })
623                .collect(),
624            active: Some(0),
625            ..TabStrip::default()
626        };
627        s.paint(&mut buf, w, h, 0);
628        // No panic, no buffer overrun. The far-right column should be
629        // either bg (if no pill reached it) or a pill colour, but never
630        // an out-of-bounds value.
631        let far_right = &buf[(h / 2) * w + (w - 1)];
632        let p = Palette::default();
633        let allowed = [p.bg, p.bg_lifted];
634        assert!(allowed.contains(far_right));
635    }
636
637    #[test]
638    fn pinned_glyph_skips_scheme() {
639        assert_eq!(pinned_glyph("https://example.com"), "E");
640        assert_eq!(pinned_glyph("http://kryptic.sh"), "K");
641        assert_eq!(pinned_glyph("buffr://new"), "N");
642    }
643
644    #[test]
645    fn pinned_glyph_skips_www() {
646        assert_eq!(pinned_glyph("https://www.google.com"), "G");
647        assert_eq!(pinned_glyph("www.example.com"), "E");
648    }
649
650    #[test]
651    fn pinned_glyph_uses_title_when_no_scheme() {
652        assert_eq!(pinned_glyph("GitHub"), "G");
653        assert_eq!(pinned_glyph("  hello"), "H");
654        assert_eq!(pinned_glyph(""), "*");
655    }
656}