1use crate::fill_rect;
31use crate::font;
32
33pub const TAB_STRIP_HEIGHT: u32 = 34;
38
39pub const MIN_TAB_WIDTH: u32 = 80;
43
44pub const MAX_TAB_WIDTH: u32 = 220;
47
48pub const PINNED_TAB_WIDTH: u32 = 32;
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct TabView {
56 pub title: String,
57 pub progress: f32,
58 pub pinned: bool,
59 pub private: bool,
60}
61
62impl Default for TabView {
63 fn default() -> Self {
64 Self {
65 title: String::new(),
66 progress: 1.0,
67 pinned: false,
68 private: false,
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default)]
76pub struct TabStrip {
77 pub tabs: Vec<TabView>,
78 pub active: Option<usize>,
79}
80
81impl TabStrip {
82 pub fn paint(&self, buffer: &mut [u32], width: usize, height: usize, start_y: u32) {
92 let strip_h = TAB_STRIP_HEIGHT as usize;
93 let start_y = start_y as usize;
94 if width == 0 || start_y + strip_h > height {
95 return;
96 }
97 if buffer.len() < width * height {
98 return;
99 }
100
101 fill_rect(
103 buffer,
104 width,
105 height,
106 0,
107 start_y as i32,
108 width,
109 strip_h,
110 TAB_STRIP_BG,
111 );
112
113 if self.tabs.is_empty() {
114 return;
115 }
116
117 let pinned_count = self.tabs.iter().filter(|t| t.pinned).count() as u32;
122 let unpinned_count = self.tabs.len() as u32 - pinned_count;
123 let pinned_total_w = pinned_count * PINNED_TAB_WIDTH;
124 let gutter_total = ((self.tabs.len() as u32) + 1) * GUTTER;
125 let avail_for_unpinned = (width as u32)
126 .saturating_sub(pinned_total_w)
127 .saturating_sub(gutter_total);
128 let raw_w = avail_for_unpinned.checked_div(unpinned_count).unwrap_or(0);
129 let tab_w = raw_w.clamp(MIN_TAB_WIDTH, MAX_TAB_WIDTH);
130
131 let text_y = start_y as i32 + ((strip_h as i32 - font::glyph_h() as i32) / 2);
132 let progress_y = start_y as i32 + strip_h as i32 - 2;
133
134 let mut x = GUTTER as i32;
135 for (i, tab) in self.tabs.iter().enumerate() {
136 let max_right = width as i32 - 1;
138 if x >= max_right {
139 break;
140 }
141 let target_w = if tab.pinned {
142 PINNED_TAB_WIDTH as i32
143 } else {
144 tab_w as i32
145 };
146 let pill_w = target_w.min(max_right - x);
147 let min_pill = if tab.pinned {
148 PINNED_TAB_WIDTH as i32 / 2
149 } else {
150 MIN_TAB_WIDTH as i32 / 2
151 };
152 if pill_w < min_pill {
153 break;
154 }
155 let is_active = self.active == Some(i);
156 let bg = if is_active {
157 TAB_BG_ACTIVE
158 } else if tab.private {
159 TAB_BG_PRIVATE
160 } else {
161 TAB_BG_INACTIVE
162 };
163 let fg = if is_active {
164 TAB_FG_ACTIVE
165 } else {
166 TAB_FG_INACTIVE
167 };
168
169 fill_rect(
170 buffer,
171 width,
172 height,
173 x,
174 start_y as i32,
175 pill_w as usize,
176 strip_h - 2,
177 bg,
178 );
179
180 if is_active {
184 fill_rect(
185 buffer,
186 width,
187 height,
188 x,
189 start_y as i32 + strip_h as i32 - 4,
190 pill_w as usize,
191 2,
192 TAB_ACCENT_ACTIVE,
193 );
194 }
195
196 if tab.pinned {
197 let glyph: String = pinned_glyph(&tab.title);
203 let glyph_px = font::text_width(&glyph) as i32;
204 let glyph_x = x + (pill_w - glyph_px) / 2;
205 font::draw_text(buffer, width, height, glyph_x, text_y, &glyph, fg);
206 } else {
207 let max_text_px = (pill_w as usize).saturating_sub(12);
210 let label = truncate_to_width(&tab.title, max_text_px);
211 font::draw_text(buffer, width, height, x + 6, text_y, label, fg);
212 }
213
214 let p = tab.progress.clamp(0.0, 1.0);
217 if p > 0.0 && p < 1.0 {
218 let bar_w = ((pill_w as f32) * p) as i32;
219 fill_rect(
220 buffer,
221 width,
222 height,
223 x,
224 progress_y,
225 bar_w.max(1) as usize,
226 2,
227 TAB_PROGRESS,
228 );
229 }
230
231 x += pill_w + GUTTER as i32;
232 }
233 }
234}
235
236fn pinned_glyph(title: &str) -> String {
242 let body = title
243 .split_once("://")
244 .map(|(_, rest)| rest)
245 .unwrap_or(title);
246 let body = body.strip_prefix("www.").unwrap_or(body);
247 for c in body.chars() {
248 if c.is_alphanumeric() {
249 return c.to_uppercase().to_string();
250 }
251 }
252 "*".to_string()
253}
254
255fn truncate_to_width(s: &str, max_px: usize) -> &str {
258 if font::text_width(s) <= max_px {
259 return s;
260 }
261 if max_px < font::text_width("..") {
262 return "";
263 }
264 let mut end = s.len();
265 while end > 0 {
266 if !s.is_char_boundary(end) {
267 end -= 1;
268 continue;
269 }
270 let prefix = &s[..end];
271 if font::text_width(prefix) + font::text_width("..") <= max_px {
272 return prefix;
273 }
274 end -= 1;
275 }
276 ""
277}
278
279const GUTTER: u32 = 4;
280
281const TAB_STRIP_BG: u32 = 0xFF_10_18_20;
283const TAB_BG_ACTIVE: u32 = 0xFF_22_2E_22;
284const TAB_BG_INACTIVE: u32 = 0xFF_18_1E_22;
285const TAB_BG_PRIVATE: u32 = 0xFF_2A_18_2A;
286const TAB_FG_ACTIVE: u32 = 0xFF_EE_EE_EE;
287const TAB_FG_INACTIVE: u32 = 0xFF_A0_A8_AC;
288const TAB_ACCENT_ACTIVE: u32 = 0xFF_4A_C9_5C;
289const TAB_PROGRESS: u32 = 0xFF_66_C2_FF;
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn make_buf(w: usize, h: usize) -> Vec<u32> {
296 vec![0u32; w * h]
297 }
298
299 #[test]
300 fn paint_fills_strip_bg_when_no_tabs() {
301 let w = 200;
302 let h = TAB_STRIP_HEIGHT as usize;
303 let mut buf = make_buf(w, h);
304 let s = TabStrip::default();
305 s.paint(&mut buf, w, h, 0);
306 for &px in &buf {
308 assert_eq!(px, TAB_STRIP_BG);
309 }
310 }
311
312 #[test]
313 fn paint_active_tab_has_accent_stripe_pixel() {
314 let w = 800;
315 let h = TAB_STRIP_HEIGHT as usize;
316 let mut buf = make_buf(w, h);
317 let s = TabStrip {
318 tabs: vec![
319 TabView {
320 title: "one".into(),
321 ..Default::default()
322 },
323 TabView {
324 title: "two".into(),
325 ..Default::default()
326 },
327 ],
328 active: Some(1),
329 };
330 s.paint(&mut buf, w, h, 0);
331 let stripe_y = h - 4;
333 let row = &buf[stripe_y * w..(stripe_y + 1) * w];
335 assert!(
336 row.contains(&TAB_ACCENT_ACTIVE),
337 "no accent stripe pixel found on active tab row",
338 );
339 }
340
341 #[test]
342 fn paint_skips_when_strip_overflows_buffer() {
343 let w = 100;
344 let h = 10;
345 let mut buf = make_buf(w, h);
346 let s = TabStrip {
347 tabs: vec![TabView::default()],
348 active: Some(0),
349 };
350 s.paint(&mut buf, w, h, 0);
351 assert!(buf.iter().all(|&p| p == 0));
352 }
353
354 #[test]
355 fn paint_with_start_y_offset_only_touches_strip_rows() {
356 let w = 200;
357 let strip_h = TAB_STRIP_HEIGHT as usize;
358 let h = strip_h + 10;
359 let mut buf = make_buf(w, h);
360 let s = TabStrip {
361 tabs: vec![TabView::default()],
362 active: Some(0),
363 };
364 s.paint(&mut buf, w, h, 10);
365 for y in 0..10 {
367 for x in 0..w {
368 assert_eq!(buf[y * w + x], 0, "row {y} touched");
369 }
370 }
371 }
372
373 #[test]
374 fn pinned_tab_renders_distinctly_from_unpinned() {
375 let w = 600;
376 let h = TAB_STRIP_HEIGHT as usize;
377 let mut buf_pin = make_buf(w, h);
378 let mut buf_no_pin = make_buf(w, h);
379 let pin = TabStrip {
380 tabs: vec![TabView {
381 title: "x".into(),
382 pinned: true,
383 ..Default::default()
384 }],
385 active: Some(0),
386 };
387 let no_pin = TabStrip {
388 tabs: vec![TabView {
389 title: "x".into(),
390 pinned: false,
391 ..Default::default()
392 }],
393 active: Some(0),
394 };
395 pin.paint(&mut buf_pin, w, h, 0);
396 no_pin.paint(&mut buf_no_pin, w, h, 0);
397 assert_ne!(buf_pin, buf_no_pin, "pin glyph not visible");
398 }
399
400 #[test]
401 fn private_tab_uses_distinct_bg_when_inactive() {
402 let w = 600;
403 let h = TAB_STRIP_HEIGHT as usize;
404 let mut buf_priv = make_buf(w, h);
405 let mut buf_norm = make_buf(w, h);
406 let priv_strip = TabStrip {
407 tabs: vec![
408 TabView {
409 title: "a".into(),
410 ..Default::default()
411 },
412 TabView {
413 title: "b".into(),
414 private: true,
415 ..Default::default()
416 },
417 ],
418 active: Some(0),
419 };
420 let norm_strip = TabStrip {
421 tabs: vec![
422 TabView {
423 title: "a".into(),
424 ..Default::default()
425 },
426 TabView {
427 title: "b".into(),
428 private: false,
429 ..Default::default()
430 },
431 ],
432 active: Some(0),
433 };
434 priv_strip.paint(&mut buf_priv, w, h, 0);
435 norm_strip.paint(&mut buf_norm, w, h, 0);
436 assert_ne!(buf_priv, buf_norm, "private bg should differ");
437 }
438
439 #[test]
440 fn progress_bar_drawn_only_while_loading() {
441 let w = 600;
442 let h = TAB_STRIP_HEIGHT as usize;
443 let mut buf_loading = make_buf(w, h);
444 let mut buf_idle = make_buf(w, h);
445 let loading = TabStrip {
446 tabs: vec![TabView {
447 title: "x".into(),
448 progress: 0.5,
449 ..Default::default()
450 }],
451 active: Some(0),
452 };
453 let idle = TabStrip {
454 tabs: vec![TabView {
455 title: "x".into(),
456 progress: 1.0,
457 ..Default::default()
458 }],
459 active: Some(0),
460 };
461 loading.paint(&mut buf_loading, w, h, 0);
462 idle.paint(&mut buf_idle, w, h, 0);
463 let progress_y = h - 2;
464 let loading_row = &buf_loading[progress_y * w..(progress_y + 1) * w];
465 let idle_row = &buf_idle[progress_y * w..(progress_y + 1) * w];
466 assert!(loading_row.contains(&TAB_PROGRESS));
467 assert!(!idle_row.contains(&TAB_PROGRESS));
468 }
469
470 #[test]
471 fn truncate_returns_empty_when_too_narrow() {
472 assert_eq!(truncate_to_width("hello world", 1), "");
473 }
474
475 #[test]
476 fn truncate_returns_full_when_fits() {
477 assert_eq!(truncate_to_width("hi", 1000), "hi");
478 }
479
480 #[test]
481 fn many_tabs_truncate_at_strip_edge() {
482 let w = 200;
485 let h = TAB_STRIP_HEIGHT as usize;
486 let mut buf = make_buf(w, h);
487 let s = TabStrip {
488 tabs: (0..10)
489 .map(|i| TabView {
490 title: format!("tab {i}"),
491 ..Default::default()
492 })
493 .collect(),
494 active: Some(0),
495 };
496 s.paint(&mut buf, w, h, 0);
497 let far_right = &buf[(h / 2) * w + (w - 1)];
501 let allowed = [TAB_STRIP_BG, TAB_BG_ACTIVE, TAB_BG_INACTIVE];
502 assert!(allowed.contains(far_right));
503 }
504
505 #[test]
506 fn pinned_glyph_skips_scheme() {
507 assert_eq!(pinned_glyph("https://example.com"), "E");
508 assert_eq!(pinned_glyph("http://kryptic.sh"), "K");
509 assert_eq!(pinned_glyph("buffr://new"), "N");
510 }
511
512 #[test]
513 fn pinned_glyph_skips_www() {
514 assert_eq!(pinned_glyph("https://www.google.com"), "G");
515 assert_eq!(pinned_glyph("www.example.com"), "E");
516 }
517
518 #[test]
519 fn pinned_glyph_uses_title_when_no_scheme() {
520 assert_eq!(pinned_glyph("GitHub"), "G");
521 assert_eq!(pinned_glyph(" hello"), "H");
522 assert_eq!(pinned_glyph(""), "*");
523 }
524}