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)]
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 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 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 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 let stripe_y = h - 4;
454 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 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 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 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}