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/// bilinear scaling and a `bg`-coloured backdrop for transparent pixels.
341/// Source pixels are BGRA-packed `0xAARRGGBB` with PREMULTIPLIED alpha
342/// (we ask CEF for `COLOR_TYPE_BGRA_8888` premul), so linear blends of
343/// the four taps are mathematically correct without an un-premul pass.
344/// We composite over `bg` so the antialiased edges read correctly on
345/// whichever pill colour the caller passes in.
346#[allow(clippy::too_many_arguments)]
347fn blit_favicon(
348    buffer: &mut [u32],
349    width: usize,
350    height: usize,
351    dst_x: i32,
352    dst_y: i32,
353    dst_w: u32,
354    dst_h: u32,
355    fav: &TabFavicon,
356    bg: u32,
357) {
358    if fav.width == 0 || fav.height == 0 || dst_w == 0 || dst_h == 0 {
359        return;
360    }
361    let src_w = fav.width as usize;
362    let src_h = fav.height as usize;
363    let dst_w_us = dst_w as usize;
364    let dst_h_us = dst_h as usize;
365    let src_pixels: &[u32] = fav.pixels.as_slice();
366    if src_pixels.len() < src_w * src_h {
367        return;
368    }
369    // Pre-extract the bg channels; reused for every alpha composite.
370    let bg_r = ((bg >> 16) & 0xFF) as i32;
371    let bg_g = ((bg >> 8) & 0xFF) as i32;
372    let bg_b = (bg & 0xFF) as i32;
373
374    // Pixel-centre sampling: dst pixel (dx, dy) maps to source coord
375    // ((dx + 0.5) * src/dst - 0.5). Working in fixed-point Q16 so the
376    // hot loop stays integer-only. fx_step / fy_step are the source
377    // increment per dst pixel; fx0 / fy0 are the source coords of the
378    // first dst pixel's centre. Negative values clamp to 0 below.
379    let fx_step: i64 = ((src_w as i64) << 16) / (dst_w_us as i64);
380    let fy_step: i64 = ((src_h as i64) << 16) / (dst_h_us as i64);
381    let fx0: i64 = (fx_step >> 1) - (1 << 15);
382    let fy0: i64 = (fy_step >> 1) - (1 << 15);
383
384    let max_sx = src_w as i64 - 1;
385    let max_sy = src_h as i64 - 1;
386
387    for dy in 0..dst_h_us {
388        let py = dst_y + dy as i32;
389        if py < 0 {
390            continue;
391        }
392        let py = py as usize;
393        if py >= height {
394            break;
395        }
396        // Source y in Q16, clamped to [0, max_sy << 16].
397        let fy = (fy0 + fy_step * dy as i64).clamp(0, max_sy << 16);
398        let sy0 = (fy >> 16) as usize;
399        let sy1 = (sy0 + 1).min(src_h - 1);
400        let wy: i32 = ((fy & 0xFFFF) >> 8) as i32; // 0..=255
401
402        let row0 = sy0 * src_w;
403        let row1 = sy1 * src_w;
404
405        for dx in 0..dst_w_us {
406            let px = dst_x + dx as i32;
407            if px < 0 {
408                continue;
409            }
410            let px = px as usize;
411            if px >= width {
412                break;
413            }
414            let fx = (fx0 + fx_step * dx as i64).clamp(0, max_sx << 16);
415            let sx0 = (fx >> 16) as usize;
416            let sx1 = (sx0 + 1).min(src_w - 1);
417            let wx: i32 = ((fx & 0xFFFF) >> 8) as i32; // 0..=255
418
419            // Four taps in source-space corners.
420            let p00 = src_pixels[row0 + sx0];
421            let p10 = src_pixels[row0 + sx1];
422            let p01 = src_pixels[row1 + sx0];
423            let p11 = src_pixels[row1 + sx1];
424
425            // Bilinear over each premultiplied channel. Linear blend of
426            // premultiplied values is correct without un-premul.
427            #[inline(always)]
428            fn lerp(a: i32, b: i32, w: i32) -> i32 {
429                // w in 0..=255; return rounded a + (b - a) * w / 255.
430                a + (((b - a) * w) + 127) / 255
431            }
432            #[inline(always)]
433            fn ch(p: u32, shift: u32) -> i32 {
434                ((p >> shift) & 0xFF) as i32
435            }
436
437            let a = lerp(
438                lerp(ch(p00, 24), ch(p10, 24), wx),
439                lerp(ch(p01, 24), ch(p11, 24), wx),
440                wy,
441            );
442            let r = lerp(
443                lerp(ch(p00, 16), ch(p10, 16), wx),
444                lerp(ch(p01, 16), ch(p11, 16), wx),
445                wy,
446            );
447            let g = lerp(
448                lerp(ch(p00, 8), ch(p10, 8), wx),
449                lerp(ch(p01, 8), ch(p11, 8), wx),
450                wy,
451            );
452            let b = lerp(
453                lerp(ch(p00, 0), ch(p10, 0), wx),
454                lerp(ch(p01, 0), ch(p11, 0), wx),
455                wy,
456            );
457
458            // Composite premultiplied src over bg: out = src + bg * (1 - a).
459            let inv = 255 - a;
460            let out_r = (r + ((bg_r * inv) + 127) / 255).clamp(0, 255) as u32;
461            let out_g = (g + ((bg_g * inv) + 127) / 255).clamp(0, 255) as u32;
462            let out_b = (b + ((bg_b * inv) + 127) / 255).clamp(0, 255) as u32;
463            let out = 0xFF00_0000 | (out_r << 16) | (out_g << 8) | out_b;
464            if let Some(dst) = buffer.get_mut(py * width + px) {
465                *dst = out;
466            }
467        }
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn make_buf(w: usize, h: usize) -> Vec<u32> {
476        vec![0u32; w * h]
477    }
478
479    #[test]
480    fn paint_fills_strip_bg_when_no_tabs() {
481        let w = 200;
482        let h = TAB_STRIP_HEIGHT as usize;
483        let mut buf = make_buf(w, h);
484        let s = TabStrip::default();
485        s.paint(&mut buf, w, h, 0);
486        // Every pixel in the strip is the bg colour.
487        for &px in &buf {
488            assert_eq!(px, Palette::default().bg);
489        }
490    }
491
492    #[test]
493    fn paint_active_tab_has_accent_stripe_pixel() {
494        let w = 800;
495        let h = TAB_STRIP_HEIGHT as usize;
496        let mut buf = make_buf(w, h);
497        let s = TabStrip {
498            tabs: vec![
499                TabView {
500                    title: "one".into(),
501                    ..Default::default()
502                },
503                TabView {
504                    title: "two".into(),
505                    ..Default::default()
506                },
507            ],
508            active: Some(1),
509            ..TabStrip::default()
510        };
511        s.paint(&mut buf, w, h, 0);
512        // The accent stripe is 2 px tall starting at strip_h - 4.
513        let stripe_y = h - 4;
514        // Find at least one accent pixel on that row.
515        let row = &buf[stripe_y * w..(stripe_y + 1) * w];
516        assert!(
517            row.contains(&Palette::default().accent),
518            "no accent stripe pixel found on active tab row",
519        );
520    }
521
522    #[test]
523    fn paint_skips_when_strip_overflows_buffer() {
524        let w = 100;
525        let h = 10;
526        let mut buf = make_buf(w, h);
527        let s = TabStrip {
528            tabs: vec![TabView::default()],
529            active: Some(0),
530            ..TabStrip::default()
531        };
532        s.paint(&mut buf, w, h, 0);
533        assert!(buf.iter().all(|&p| p == 0));
534    }
535
536    #[test]
537    fn paint_with_start_y_offset_only_touches_strip_rows() {
538        let w = 200;
539        let strip_h = TAB_STRIP_HEIGHT as usize;
540        let h = strip_h + 10;
541        let mut buf = make_buf(w, h);
542        let s = TabStrip {
543            tabs: vec![TabView::default()],
544            active: Some(0),
545            ..TabStrip::default()
546        };
547        s.paint(&mut buf, w, h, 10);
548        // Rows 0..10 untouched.
549        for y in 0..10 {
550            for x in 0..w {
551                assert_eq!(buf[y * w + x], 0, "row {y} touched");
552            }
553        }
554    }
555
556    #[test]
557    fn pinned_tab_renders_distinctly_from_unpinned() {
558        let w = 600;
559        let h = TAB_STRIP_HEIGHT as usize;
560        let mut buf_pin = make_buf(w, h);
561        let mut buf_no_pin = make_buf(w, h);
562        let pin = TabStrip {
563            tabs: vec![TabView {
564                title: "x".into(),
565                pinned: true,
566                ..Default::default()
567            }],
568            active: Some(0),
569            ..TabStrip::default()
570        };
571        let no_pin = TabStrip {
572            tabs: vec![TabView {
573                title: "x".into(),
574                pinned: false,
575                ..Default::default()
576            }],
577            active: Some(0),
578            ..TabStrip::default()
579        };
580        pin.paint(&mut buf_pin, w, h, 0);
581        no_pin.paint(&mut buf_no_pin, w, h, 0);
582        assert_ne!(buf_pin, buf_no_pin, "pin glyph not visible");
583    }
584
585    #[test]
586    fn private_tab_uses_distinct_bg_when_inactive() {
587        let w = 600;
588        let h = TAB_STRIP_HEIGHT as usize;
589        let mut buf_priv = make_buf(w, h);
590        let mut buf_norm = make_buf(w, h);
591        let priv_strip = TabStrip {
592            tabs: vec![
593                TabView {
594                    title: "a".into(),
595                    ..Default::default()
596                },
597                TabView {
598                    title: "b".into(),
599                    private: true,
600                    ..Default::default()
601                },
602            ],
603            active: Some(0),
604            ..TabStrip::default()
605        };
606        let norm_strip = TabStrip {
607            tabs: vec![
608                TabView {
609                    title: "a".into(),
610                    ..Default::default()
611                },
612                TabView {
613                    title: "b".into(),
614                    private: false,
615                    ..Default::default()
616                },
617            ],
618            active: Some(0),
619            ..TabStrip::default()
620        };
621        priv_strip.paint(&mut buf_priv, w, h, 0);
622        norm_strip.paint(&mut buf_norm, w, h, 0);
623        assert_ne!(buf_priv, buf_norm, "private bg should differ");
624    }
625
626    #[test]
627    fn progress_bar_drawn_only_while_loading() {
628        let w = 600;
629        let h = TAB_STRIP_HEIGHT as usize;
630        let mut buf_loading = make_buf(w, h);
631        let mut buf_idle = make_buf(w, h);
632        let loading = TabStrip {
633            tabs: vec![TabView {
634                title: "x".into(),
635                progress: 0.5,
636                ..Default::default()
637            }],
638            active: Some(0),
639            ..TabStrip::default()
640        };
641        let idle = TabStrip {
642            tabs: vec![TabView {
643                title: "x".into(),
644                progress: 1.0,
645                ..Default::default()
646            }],
647            active: Some(0),
648            ..TabStrip::default()
649        };
650        loading.paint(&mut buf_loading, w, h, 0);
651        idle.paint(&mut buf_idle, w, h, 0);
652        let progress_y = h - 2;
653        let loading_row = &buf_loading[progress_y * w..(progress_y + 1) * w];
654        let idle_row = &buf_idle[progress_y * w..(progress_y + 1) * w];
655        let progress_color = Palette::default().progress;
656        assert!(loading_row.contains(&progress_color));
657        assert!(!idle_row.contains(&progress_color));
658    }
659
660    #[test]
661    fn truncate_returns_empty_when_too_narrow() {
662        assert_eq!(truncate_to_width("hello world", 1), "");
663    }
664
665    #[test]
666    fn truncate_returns_full_when_fits() {
667        assert_eq!(truncate_to_width("hi", 1000), "hi");
668    }
669
670    #[test]
671    fn many_tabs_truncate_at_strip_edge() {
672        // Strip is too narrow to fit more than a few pills; the widget
673        // must stop drawing rather than overflow.
674        let w = 200;
675        let h = TAB_STRIP_HEIGHT as usize;
676        let mut buf = make_buf(w, h);
677        let s = TabStrip {
678            tabs: (0..10)
679                .map(|i| TabView {
680                    title: format!("tab {i}"),
681                    ..Default::default()
682                })
683                .collect(),
684            active: Some(0),
685            ..TabStrip::default()
686        };
687        s.paint(&mut buf, w, h, 0);
688        // No panic, no buffer overrun. The far-right column should be
689        // either bg (if no pill reached it) or a pill colour, but never
690        // an out-of-bounds value.
691        let far_right = &buf[(h / 2) * w + (w - 1)];
692        let p = Palette::default();
693        let allowed = [p.bg, p.bg_lifted];
694        assert!(allowed.contains(far_right));
695    }
696
697    #[test]
698    fn pinned_glyph_skips_scheme() {
699        assert_eq!(pinned_glyph("https://example.com"), "E");
700        assert_eq!(pinned_glyph("http://kryptic.sh"), "K");
701        assert_eq!(pinned_glyph("buffr://new"), "N");
702    }
703
704    #[test]
705    fn pinned_glyph_skips_www() {
706        assert_eq!(pinned_glyph("https://www.google.com"), "G");
707        assert_eq!(pinned_glyph("www.example.com"), "E");
708    }
709
710    #[test]
711    fn pinned_glyph_uses_title_when_no_scheme() {
712        assert_eq!(pinned_glyph("GitHub"), "G");
713        assert_eq!(pinned_glyph("  hello"), "H");
714        assert_eq!(pinned_glyph(""), "*");
715    }
716}