1use std::sync::Arc;
31
32use crate::Palette;
33use crate::fill_rect;
34use crate::font;
35
36pub const TAB_STRIP_HEIGHT: u32 = 34;
41
42pub const MIN_TAB_WIDTH: u32 = 80;
46
47pub const MAX_TAB_WIDTH: u32 = 220;
50
51pub const PINNED_TAB_WIDTH: u32 = 32;
55
56#[derive(Debug, Clone, PartialEq)]
59pub struct TabFavicon {
60 pub width: u32,
61 pub height: u32,
62 pub pixels: Arc<Vec<u32>>,
63}
64
65pub const FAVICON_RENDER_SIZE: u32 = 16;
67
68#[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#[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 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 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 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 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 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 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 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 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 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
294fn 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
313fn 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#[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 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 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 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; 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; 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 #[inline(always)]
428 fn lerp(a: i32, b: i32, w: i32) -> i32 {
429 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 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 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 let stripe_y = h - 4;
514 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 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 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 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}