use std::sync::Arc;
use crate::Palette;
use crate::fill_rect;
use crate::font;
pub const TAB_STRIP_HEIGHT: u32 = 34;
pub const MIN_TAB_WIDTH: u32 = 80;
pub const MAX_TAB_WIDTH: u32 = 220;
pub const PINNED_TAB_WIDTH: u32 = 32;
#[derive(Debug, Clone, PartialEq)]
pub struct TabFavicon {
pub width: u32,
pub height: u32,
pub pixels: Arc<Vec<u32>>,
}
pub const FAVICON_RENDER_SIZE: u32 = 16;
#[derive(Debug, Clone, PartialEq)]
pub struct TabView {
pub title: String,
pub progress: f32,
pub pinned: bool,
pub private: bool,
pub favicon: Option<TabFavicon>,
}
impl Default for TabView {
fn default() -> Self {
Self {
title: String::new(),
progress: 1.0,
pinned: false,
private: false,
favicon: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TabStrip {
pub tabs: Vec<TabView>,
pub active: Option<usize>,
pub palette: Palette,
}
impl TabStrip {
pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize, start_y: u32) {
let strip_h = TAB_STRIP_HEIGHT as usize;
let start_y = start_y as usize;
if width == 0 || start_y + strip_h > height {
return;
}
if buffer.len() < width * height {
return;
}
let p = &self.palette;
fill_rect(
buffer,
width,
height,
0,
start_y as i32,
width,
strip_h,
p.bg,
);
if self.tabs.is_empty() {
return;
}
let pinned_count = self.tabs.iter().filter(|t| t.pinned).count() as u32;
let unpinned_count = self.tabs.len() as u32 - pinned_count;
let pinned_total_w = pinned_count * PINNED_TAB_WIDTH;
let gutter_total = ((self.tabs.len() as u32) + 1) * GUTTER;
let avail_for_unpinned = (width as u32)
.saturating_sub(pinned_total_w)
.saturating_sub(gutter_total);
let raw_w = avail_for_unpinned.checked_div(unpinned_count).unwrap_or(0);
let tab_w = raw_w.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH);
let text_y = start_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
let progress_y = start_y as i32 + strip_h as i32 - 2;
let mut x = GUTTER as i32;
for (i, tab) in self.tabs.iter().enumerate() {
let max_right = width as i32 - 1;
if x >= max_right {
break;
}
let target_w = if tab.pinned {
PINNED_TAB_WIDTH as i32
} else {
tab_w as i32
};
let pill_w = target_w.min(max_right - x);
let min_pill = if tab.pinned {
PINNED_TAB_WIDTH as i32 / 2
} else {
MIN_TAB_WIDTH as i32 / 2
};
if pill_w < min_pill {
break;
}
let is_active = self.active == Some(i);
let bg = if is_active { p.bg_lifted } else { p.bg };
let fg = if is_active { p.fg } else { p.fg_dim };
fill_rect(
buffer,
width,
height,
x,
start_y as i32,
pill_w as usize,
strip_h - 2,
bg,
);
if is_active {
fill_rect(
buffer,
width,
height,
x,
start_y as i32 + strip_h as i32 - 4,
pill_w as usize,
2,
p.accent,
);
}
if tab.private {
fill_rect(
buffer,
width,
height,
x,
start_y as i32,
pill_w as usize,
2,
p.private,
);
}
if tab.pinned {
let icon_size = FAVICON_RENDER_SIZE as i32;
let icon_x = x + (pill_w - icon_size) / 2;
let icon_y = start_y as i32 + ((strip_h as i32 - icon_size) / 2);
if let Some(fav) = tab.favicon.as_ref() {
blit_favicon(
buffer,
width,
height,
icon_x,
icon_y,
FAVICON_RENDER_SIZE,
FAVICON_RENDER_SIZE,
fav,
bg,
);
} else {
let glyph: String = pinned_glyph(&tab.title);
let glyph_px = font::text_width(&glyph) as i32;
let glyph_x = x + (pill_w - glyph_px) / 2;
font::draw_text(buffer, width, height, glyph_x, text_y, &glyph, fg);
}
} else {
let icon_size = FAVICON_RENDER_SIZE as i32;
let icon_y = start_y as i32 + ((strip_h as i32 - icon_size) / 2);
let mut text_x = x + 6;
if let Some(fav) = tab.favicon.as_ref() {
blit_favicon(
buffer,
width,
height,
x + 6,
icon_y,
FAVICON_RENDER_SIZE,
FAVICON_RENDER_SIZE,
fav,
bg,
);
text_x = x + 6 + icon_size + 4;
}
let max_text_px = (pill_w as usize)
.saturating_sub((text_x - x) as usize)
.saturating_sub(6);
let label = truncate_to_width(&tab.title, max_text_px);
font::draw_text(buffer, width, height, text_x, text_y, label, fg);
}
let frac = tab.progress.clamp(0.0, 1.0);
if frac > 0.0 && frac < 1.0 {
let bar_w = ((pill_w as f32) * frac) as i32;
fill_rect(
buffer,
width,
height,
x,
progress_y,
bar_w.max(1) as usize,
2,
p.progress,
);
}
x += pill_w + GUTTER as i32;
}
}
}
fn pinned_glyph(title: &str) -> String {
let body = title
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(title);
let body = body.strip_prefix("www.").unwrap_or(body);
for c in body.chars() {
if c.is_alphanumeric() {
return c.to_uppercase().to_string();
}
}
"*".to_string()
}
fn truncate_to_width(s: &str, max_px: usize) -> &str {
if font::text_width(s) <= max_px {
return s;
}
if max_px < font::text_width("..") {
return "";
}
let mut end = s.len();
while end > 0 {
if !s.is_char_boundary(end) {
end -= 1;
continue;
}
let prefix = &s[..end];
if font::text_width(prefix) + font::text_width("..") <= max_px {
return prefix;
}
end -= 1;
}
""
}
const GUTTER: u32 = 4;
#[allow(clippy::too_many_arguments)]
fn blit_favicon(
buffer: &mut [u32],
width: usize,
height: usize,
dst_x: i32,
dst_y: i32,
dst_w: u32,
dst_h: u32,
fav: &TabFavicon,
bg: u32,
) {
if fav.width == 0 || fav.height == 0 || dst_w == 0 || dst_h == 0 {
return;
}
let src_w = fav.width as usize;
let src_h = fav.height as usize;
let dst_w_us = dst_w as usize;
let dst_h_us = dst_h as usize;
let src_pixels: &[u32] = fav.pixels.as_slice();
if src_pixels.len() < src_w * src_h {
return;
}
let bg_r = ((bg >> 16) & 0xFF) as i32;
let bg_g = ((bg >> 8) & 0xFF) as i32;
let bg_b = (bg & 0xFF) as i32;
let fx_step: i64 = ((src_w as i64) << 16) / (dst_w_us as i64);
let fy_step: i64 = ((src_h as i64) << 16) / (dst_h_us as i64);
let fx0: i64 = (fx_step >> 1) - (1 << 15);
let fy0: i64 = (fy_step >> 1) - (1 << 15);
let max_sx = src_w as i64 - 1;
let max_sy = src_h as i64 - 1;
for dy in 0..dst_h_us {
let py = dst_y + dy as i32;
if py < 0 {
continue;
}
let py = py as usize;
if py >= height {
break;
}
let fy = (fy0 + fy_step * dy as i64).clamp(0, max_sy << 16);
let sy0 = (fy >> 16) as usize;
let sy1 = (sy0 + 1).min(src_h - 1);
let wy: i32 = ((fy & 0xFFFF) >> 8) as i32;
let row0 = sy0 * src_w;
let row1 = sy1 * src_w;
for dx in 0..dst_w_us {
let px = dst_x + dx as i32;
if px < 0 {
continue;
}
let px = px as usize;
if px >= width {
break;
}
let fx = (fx0 + fx_step * dx as i64).clamp(0, max_sx << 16);
let sx0 = (fx >> 16) as usize;
let sx1 = (sx0 + 1).min(src_w - 1);
let wx: i32 = ((fx & 0xFFFF) >> 8) as i32;
let p00 = src_pixels[row0 + sx0];
let p10 = src_pixels[row0 + sx1];
let p01 = src_pixels[row1 + sx0];
let p11 = src_pixels[row1 + sx1];
#[inline(always)]
fn lerp(a: i32, b: i32, w: i32) -> i32 {
a + (((b - a) * w) + 127) / 255
}
#[inline(always)]
fn ch(p: u32, shift: u32) -> i32 {
((p >> shift) & 0xFF) as i32
}
let a = lerp(
lerp(ch(p00, 24), ch(p10, 24), wx),
lerp(ch(p01, 24), ch(p11, 24), wx),
wy,
);
let r = lerp(
lerp(ch(p00, 16), ch(p10, 16), wx),
lerp(ch(p01, 16), ch(p11, 16), wx),
wy,
);
let g = lerp(
lerp(ch(p00, 8), ch(p10, 8), wx),
lerp(ch(p01, 8), ch(p11, 8), wx),
wy,
);
let b = lerp(
lerp(ch(p00, 0), ch(p10, 0), wx),
lerp(ch(p01, 0), ch(p11, 0), wx),
wy,
);
let inv = 255 - a;
let out_r = (r + ((bg_r * inv) + 127) / 255).clamp(0, 255) as u32;
let out_g = (g + ((bg_g * inv) + 127) / 255).clamp(0, 255) as u32;
let out_b = (b + ((bg_b * inv) + 127) / 255).clamp(0, 255) as u32;
let out = 0xFF00_0000 | (out_r << 16) | (out_g << 8) | out_b;
if let Some(dst) = buffer.get_mut(py * width + px) {
*dst = out;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_buf(w: usize, h: usize) -> Vec<u32> {
vec![0u32; w * h]
}
#[test]
fn paint_fills_strip_bg_when_no_tabs() {
let w = 200;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf = make_buf(w, h);
let s = TabStrip::default();
s.paint(&mut buf, w, h, 0);
for &px in &buf {
assert_eq!(px, Palette::default().bg);
}
}
#[test]
fn paint_active_tab_has_accent_stripe_pixel() {
let w = 800;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf = make_buf(w, h);
let s = TabStrip {
tabs: vec![
TabView {
title: "one".into(),
..Default::default()
},
TabView {
title: "two".into(),
..Default::default()
},
],
active: Some(1),
..TabStrip::default()
};
s.paint(&mut buf, w, h, 0);
let stripe_y = h - 4;
let row = &buf[stripe_y * w..(stripe_y + 1) * w];
assert!(
row.contains(&Palette::default().accent),
"no accent stripe pixel found on active tab row",
);
}
#[test]
fn paint_skips_when_strip_overflows_buffer() {
let w = 100;
let h = 10;
let mut buf = make_buf(w, h);
let s = TabStrip {
tabs: vec![TabView::default()],
active: Some(0),
..TabStrip::default()
};
s.paint(&mut buf, w, h, 0);
assert!(buf.iter().all(|&p| p == 0));
}
#[test]
fn paint_with_start_y_offset_only_touches_strip_rows() {
let w = 200;
let strip_h = TAB_STRIP_HEIGHT as usize;
let h = strip_h + 10;
let mut buf = make_buf(w, h);
let s = TabStrip {
tabs: vec![TabView::default()],
active: Some(0),
..TabStrip::default()
};
s.paint(&mut buf, w, h, 10);
for y in 0..10 {
for x in 0..w {
assert_eq!(buf[y * w + x], 0, "row {y} touched");
}
}
}
#[test]
fn pinned_tab_renders_distinctly_from_unpinned() {
let w = 600;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf_pin = make_buf(w, h);
let mut buf_no_pin = make_buf(w, h);
let pin = TabStrip {
tabs: vec![TabView {
title: "x".into(),
pinned: true,
..Default::default()
}],
active: Some(0),
..TabStrip::default()
};
let no_pin = TabStrip {
tabs: vec![TabView {
title: "x".into(),
pinned: false,
..Default::default()
}],
active: Some(0),
..TabStrip::default()
};
pin.paint(&mut buf_pin, w, h, 0);
no_pin.paint(&mut buf_no_pin, w, h, 0);
assert_ne!(buf_pin, buf_no_pin, "pin glyph not visible");
}
#[test]
fn private_tab_uses_distinct_bg_when_inactive() {
let w = 600;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf_priv = make_buf(w, h);
let mut buf_norm = make_buf(w, h);
let priv_strip = TabStrip {
tabs: vec![
TabView {
title: "a".into(),
..Default::default()
},
TabView {
title: "b".into(),
private: true,
..Default::default()
},
],
active: Some(0),
..TabStrip::default()
};
let norm_strip = TabStrip {
tabs: vec![
TabView {
title: "a".into(),
..Default::default()
},
TabView {
title: "b".into(),
private: false,
..Default::default()
},
],
active: Some(0),
..TabStrip::default()
};
priv_strip.paint(&mut buf_priv, w, h, 0);
norm_strip.paint(&mut buf_norm, w, h, 0);
assert_ne!(buf_priv, buf_norm, "private bg should differ");
}
#[test]
fn progress_bar_drawn_only_while_loading() {
let w = 600;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf_loading = make_buf(w, h);
let mut buf_idle = make_buf(w, h);
let loading = TabStrip {
tabs: vec![TabView {
title: "x".into(),
progress: 0.5,
..Default::default()
}],
active: Some(0),
..TabStrip::default()
};
let idle = TabStrip {
tabs: vec![TabView {
title: "x".into(),
progress: 1.0,
..Default::default()
}],
active: Some(0),
..TabStrip::default()
};
loading.paint(&mut buf_loading, w, h, 0);
idle.paint(&mut buf_idle, w, h, 0);
let progress_y = h - 2;
let loading_row = &buf_loading[progress_y * w..(progress_y + 1) * w];
let idle_row = &buf_idle[progress_y * w..(progress_y + 1) * w];
let progress_color = Palette::default().progress;
assert!(loading_row.contains(&progress_color));
assert!(!idle_row.contains(&progress_color));
}
#[test]
fn truncate_returns_empty_when_too_narrow() {
assert_eq!(truncate_to_width("hello world", 1), "");
}
#[test]
fn truncate_returns_full_when_fits() {
assert_eq!(truncate_to_width("hi", 1000), "hi");
}
#[test]
fn many_tabs_truncate_at_strip_edge() {
let w = 200;
let h = TAB_STRIP_HEIGHT as usize;
let mut buf = make_buf(w, h);
let s = TabStrip {
tabs: (0..10)
.map(|i| TabView {
title: format!("tab {i}"),
..Default::default()
})
.collect(),
active: Some(0),
..TabStrip::default()
};
s.paint(&mut buf, w, h, 0);
let far_right = &buf[(h / 2) * w + (w - 1)];
let p = Palette::default();
let allowed = [p.bg, p.bg_lifted];
assert!(allowed.contains(far_right));
}
#[test]
fn pinned_glyph_skips_scheme() {
assert_eq!(pinned_glyph("https://example.com"), "E");
assert_eq!(pinned_glyph("http://kryptic.sh"), "K");
assert_eq!(pinned_glyph("buffr://new"), "N");
}
#[test]
fn pinned_glyph_skips_www() {
assert_eq!(pinned_glyph("https://www.google.com"), "G");
assert_eq!(pinned_glyph("www.example.com"), "E");
}
#[test]
fn pinned_glyph_uses_title_when_no_scheme() {
assert_eq!(pinned_glyph("GitHub"), "G");
assert_eq!(pinned_glyph(" hello"), "H");
assert_eq!(pinned_glyph(""), "*");
}
}