From ba4b5c9551046fb7ba017e6cdc65696f1e5eabfb Mon Sep 17 00:00:00 2001 From: cartermp Date: Sun, 29 Mar 2026 09:16:04 -0700 Subject: [PATCH] tab rendering and json pp --- Cargo.lock | 26 ++++ Cargo.toml | 5 + src/bin/tjson.rs | 173 +++++++++++++++++++++++++ src/main.rs | 13 +- src/renderer.rs | 328 +++++++++++++++++++++++++++++++++-------------- 5 files changed, 450 insertions(+), 95 deletions(-) create mode 100644 src/bin/tjson.rs diff --git a/Cargo.lock b/Cargo.lock index 79c5406..ad73aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,6 +765,12 @@ dependencies = [ "libc", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -1660,6 +1666,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serial" version = "0.4.0" @@ -1855,6 +1874,7 @@ dependencies = [ "objc2", "pollster", "portable-pty", + "serde_json", "syntect", "vte", "wgpu", @@ -2752,3 +2772,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index eda5683..a9f219f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,10 @@ path = "src/bin/tcat.rs" name = "tdiff" path = "src/bin/tdiff.rs" +[[bin]] +name = "tjson" +path = "src/bin/tjson.rs" + [dependencies] winit = "0.30" wgpu = "0.20" @@ -29,6 +33,7 @@ vte = "0.13" portable-pty = "0.8" fontdue = "0.8" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "parsing", "regex-fancy"] } +serde_json = "1" [profile.release] opt-level = 3 diff --git a/src/bin/tjson.rs b/src/bin/tjson.rs new file mode 100644 index 0000000..d3f3472 --- /dev/null +++ b/src/bin/tjson.rs @@ -0,0 +1,173 @@ +//! tjson — streaming JSON prettifier with syntax highlighting. +//! +//! Reads stdin line by line. Lines that parse as JSON objects or arrays are +//! pretty-printed with 24-bit ANSI colour (syntect, base16-ocean.dark theme). +//! All other lines pass through unchanged. +//! +//! Usage: +//! pnpm dev | tjson +//! some-command | tjson + +use std::io::{self, BufRead, Write}; +use syntect::easy::HighlightLines; +use syntect::highlighting::{FontStyle, Style, ThemeSet}; +use syntect::parsing::SyntaxSet; +use syntect::util::LinesWithEndings; + +// ── ANSI helpers (mirrors tcat) ─────────────────────────────────────────────── + +fn fg(out: &mut impl Write, r: u8, g: u8, b: u8) -> io::Result<()> { + write!(out, "\x1b[38;2;{r};{g};{b}m") +} +fn reset(out: &mut impl Write) -> io::Result<()> { + out.write_all(b"\x1b[0m") +} +fn write_span(out: &mut impl Write, style: Style, text: &str) -> io::Result<()> { + let s = style.foreground; + fg(out, s.r, s.g, s.b)?; + if style.font_style.contains(FontStyle::BOLD) { out.write_all(b"\x1b[1m")?; } + if style.font_style.contains(FontStyle::ITALIC) { out.write_all(b"\x1b[3m")?; } + if style.font_style.contains(FontStyle::UNDERLINE) { out.write_all(b"\x1b[4m")?; } + out.write_all(text.as_bytes())?; + reset(out) +} + +// ── Pretty-print one JSON value with syntax highlighting ────────────────────── + +fn print_highlighted( + out: &mut impl Write, + pretty: &str, + ps: &SyntaxSet, + syntax: &syntect::parsing::SyntaxReference, + theme: &syntect::highlighting::Theme, +) -> io::Result<()> { + let mut h = HighlightLines::new(syntax, theme); + for line in LinesWithEndings::from(pretty) { + let ranges = h.highlight_line(line, ps).unwrap_or_default(); + for (style, text) in &ranges { + let t = text.strip_suffix('\n').unwrap_or(text); + let t = t.strip_suffix('\r').unwrap_or(t); + if !t.is_empty() { + write_span(out, *style, t)?; + } + } + writeln!(out)?; + } + Ok(()) +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +fn main() { + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + + let syntax = ps + .find_syntax_by_extension("json") + .unwrap_or_else(|| ps.find_syntax_plain_text()); + + let theme = ["base16-ocean.dark", "Solarized (dark)"] + .iter() + .find_map(|n| ts.themes.get(*n)) + .or_else(|| ts.themes.values().next()) + .expect("syntect has no themes"); + + let stdout = io::stdout(); + let mut out = io::BufWriter::new(stdout.lock()); + + for line in io::stdin().lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { eprintln!("tjson: read error: {e}"); break; } + }; + + let trimmed = line.trim(); + + // Only attempt to parse lines that look like JSON objects or arrays. + if trimmed.starts_with('{') || trimmed.starts_with('[') { + if let Ok(val) = serde_json::from_str::(trimmed) { + if let Ok(pretty) = serde_json::to_string_pretty(&val) { + if print_highlighted(&mut out, &pretty, &ps, syntax, theme).is_ok() { + continue; + } + } + } + } + + // Non-JSON or failed parse: pass through unchanged. + if let Err(e) = writeln!(out, "{line}") { + eprintln!("tjson: write error: {e}"); + break; + } + } + + let _ = out.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn highlighted(json: &str) -> String { + let ps = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let syntax = ps.find_syntax_by_extension("json") + .unwrap_or_else(|| ps.find_syntax_plain_text()); + let theme = ts.themes.values().next().expect("no theme"); + let val: serde_json::Value = serde_json::from_str(json).unwrap(); + let pretty = serde_json::to_string_pretty(&val).unwrap(); + let mut buf = Vec::new(); + print_highlighted(&mut buf, &pretty, &ps, syntax, theme).unwrap(); + String::from_utf8(buf).unwrap() + } + + #[test] + fn test_json_object_contains_keys() { + let out = highlighted(r#"{"level":30,"msg":"hello"}"#); + assert!(out.contains("level"), "key 'level' missing from output"); + assert!(out.contains("msg"), "key 'msg' missing from output"); + assert!(out.contains("hello"), "string value missing from output"); + assert!(out.contains("30"), "number value missing from output"); + } + + #[test] + fn test_json_array_rendered() { + let out = highlighted(r#"[1,2,3]"#); + assert!(out.contains('1'.to_string().as_str())); + assert!(out.contains('3'.to_string().as_str())); + } + + #[test] + fn test_output_has_ansi_escapes() { + let out = highlighted(r#"{"x":1}"#); + assert!(out.contains("\x1b["), "expected ANSI escape sequences in output"); + } + + #[test] + fn test_pretty_printed_multiline() { + let out = highlighted(r#"{"a":1,"b":2}"#); + // Pretty-printing should produce at least 3 lines: opening brace, fields, closing brace + let lines: Vec<&str> = out.lines().collect(); + assert!(lines.len() >= 3, "expected multi-line pretty output, got: {out:?}"); + } + + // parse_check: non-JSON lines should not be accidentally parsed + #[test] + fn test_non_json_passthrough() { + // Simulate the passthrough branch directly. + let line = "Next.js 16.2.0 (Turbopack)"; + let trimmed = line.trim(); + let would_parse = (trimmed.starts_with('{') || trimmed.starts_with('[')) + && serde_json::from_str::(trimmed).is_ok(); + assert!(!would_parse, "plain text must not be treated as JSON"); + } + + #[test] + fn test_partial_json_not_parsed() { + let line = r#"{"incomplete":"#; + let trimmed = line.trim(); + let would_parse = trimmed.starts_with('{') + && serde_json::from_str::(trimmed).is_ok(); + assert!(!would_parse, "partial JSON must fall through to passthrough"); + } +} diff --git a/src/main.rs b/src/main.rs index 4e3edec..e1957bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1469,6 +1469,10 @@ fn setup_shell_env(cmd: &mut CommandBuilder) { .as_ref() .map(|d| d.join("tdiff")) .filter(|p| p.exists()); + let tjson = exe_dir + .as_ref() + .map(|d| d.join("tjson")) + .filter(|p| p.exists()); let zdotdir = std::env::temp_dir().join(format!("term_zsh_{}", std::process::id())); if std::fs::create_dir_all(&zdotdir).is_err() { @@ -1494,6 +1498,13 @@ fn setup_shell_env(cmd: &mut CommandBuilder) { ), None => String::new(), }; + let json_fn = match &tjson { + Some(p) => format!( + "_TJSON='{}'\nfunction json() {{ \"$_TJSON\"; }}\n", + p.display() + ), + None => String::new(), + }; let zle_hooks = r#" _term_buf_report() { printf '\033]9001;%s\034%d\033\\' "$BUFFER" "$CURSOR"; } @@ -1525,7 +1536,7 @@ zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' "ZDOTDIR='{home}'\n\ [ -f '{home}/.zprofile' ] && source '{home}/.zprofile'\n\ [ -f '{home}/.zshrc' ] && source '{home}/.zshrc'\n\ - {cat_fn}{diff_fn}{zle_hooks}" + {cat_fn}{diff_fn}{json_fn}{zle_hooks}" ); let _ = std::fs::write(zdotdir.join(".zshrc"), &zshrc); diff --git a/src/renderer.rs b/src/renderer.rs index 05cbc95..4fdce5e 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -754,6 +754,120 @@ impl Renderer { // ── Tab bar builder ─────────────────────────────────────────────────────── +} // end impl Renderer (temporarily close so tab_bar_rects can be a free fn) + +/// Pure rect geometry for the tab bar — no GPU, no glyph cache, fully testable. +/// +/// Returns `(bg_rects, fg_rects)`. **bg_rects must be submitted to the GPU +/// before the glyph pass** so that tab titles are drawn on top of the pill +/// backgrounds. fg_rects (separators, drag overlays, + button) go after. +fn tab_bar_rects( + tby: f32, + bw: f32, + n_tabs: usize, + active: usize, + hover: Option, + drag: Option<(usize, usize, f64)>, +) -> (Vec, Vec) { + let mut bg: Vec = Vec::new(); + let mut fg: Vec = Vec::new(); + + let bar_bg = rgb_f(0x14, 0x14, 0x14); + let outline = rgb_f(0x58, 0x58, 0x58); + let hover_bg = rgb_f(0x26, 0x26, 0x26); + let sep_col = rgb_f(0x2e, 0x2e, 0x2e); + let bottom = rgb_f(0x2e, 0x2e, 0x2e); + + // Bar background + bottom border + push_rect(&mut bg, 0., 0., bw, tby, bar_bg); + push_rect(&mut bg, 0., tby - 1., bw, 1., bottom); + + let plus_area = tby; + let tabs_w = bw - plus_area; + let n = n_tabs.max(1); + let tab_w = tabs_w / n as f32; + let pad_v = 4.; + let pill_h = tby - pad_v * 2.; + + let visual_order: Vec = if let Some((from, to, _)) = drag { + let mut order: Vec = (0..n_tabs).collect(); + if from < order.len() { + let item = order.remove(from); + order.insert(to.min(order.len()), item); + } + order + } else { + (0..n_tabs).collect() + }; + let visual_active = visual_order.iter().position(|&i| i == active).unwrap_or(active); + let drag_orig = drag.map(|(from, _, _)| from); + + for (vi, &orig_idx) in visual_order.iter().enumerate() { + let is_active = orig_idx == active; + let is_dragging = drag_orig == Some(orig_idx); + let is_hover = drag.is_none() && hover == Some(vi) && !is_active; + + let tx = vi as f32 * tab_w; + if tx >= tabs_w { break; } + let tw = if vi + 1 == n { tabs_w - tx } else { tab_w }; + let pill_x = tx + 4.; + let pill_w = tw - 8.; + + if is_dragging { + if vi > 0 && vi != visual_active && vi != visual_active + 1 { + push_rect(&mut fg, tx, pad_v + 4., 1., tby - (pad_v + 4.) * 2., sep_col); + } + let ghost = rgb_f(0x38, 0x38, 0x38); + if pill_w > 2. && pill_h > 2. { + push_rect(&mut fg, pill_x, pad_v, pill_w, pill_h, ghost); + push_rect(&mut fg, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., bar_bg); + } + continue; + } + + // Active pill and hover fill go in bg_rects so glyphs render on top. + if is_hover { + push_rect(&mut bg, pill_x, pad_v, pill_w, pill_h, hover_bg); + } + if is_active && pill_w > 2. && pill_h > 2. { + push_rect(&mut bg, pill_x, pad_v, pill_w, pill_h, outline); + push_rect(&mut bg, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., bar_bg); + } + + // Separator + if vi > 0 && vi != visual_active && vi != visual_active + 1 { + push_rect(&mut fg, tx, pad_v + 4., 1., tby - (pad_v + 4.) * 2., sep_col); + } + } + + // Floating dragged tab pill + if let Some((_, _, cursor_x)) = drag { + let half = tab_w / 2.; + let float_left = (cursor_x as f32 - half).max(0.).min(tabs_w - tab_w); + let pill_x = float_left + 4.; + let pill_w = tab_w - 8.; + let lifted_outline = rgb_f(0xa0, 0xa0, 0xa0); + let lifted_bg = rgb_f(0x20, 0x20, 0x20); + if pill_w > 2. && pill_h > 2. { + push_rect(&mut fg, pill_x, pad_v, pill_w, pill_h, lifted_outline); + push_rect(&mut fg, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., lifted_bg); + } + } + + // + button (two thin rects forming a cross) + let plus_hover = hover == Some(n_tabs); + let plus_col = if plus_hover { rgb_f(0x88, 0x88, 0x88) } else { rgb_f(0x44, 0x44, 0x44) }; + let plus_cx = bw - plus_area / 2.; + let plus_cy = tby / 2.; + let arm = 5.; + push_rect(&mut fg, plus_cx - arm, plus_cy - 1., arm * 2., 2., plus_col); + push_rect(&mut fg, plus_cx - 1., plus_cy - arm, 2., arm * 2., plus_col); + + (bg, fg) +} + +impl Renderer { // re-open impl + fn build_tab_bar( &mut self, bg_rects: &mut Vec, @@ -769,29 +883,23 @@ impl Renderer { let cw = self.cell_width as f32; let ch = self.cell_height as f32; - let bar_bg = rgb_f(0x14, 0x14, 0x14); - let outline = rgb_f(0x58, 0x58, 0x58); - let hover_bg = rgb_f(0x26, 0x26, 0x26); - let sep_col = rgb_f(0x2e, 0x2e, 0x2e); - let bottom = rgb_f(0x2e, 0x2e, 0x2e); + // All rect geometry delegated to the pure helper (testable without GPU). + let (new_bg, new_fg) = tab_bar_rects(tby, bw, tabs.len(), active, hover, drag); + bg_rects.extend(new_bg); + fg_rects.extend(new_fg); + + // ── Glyph rendering ─────────────────────────────────────────────────── let fg_act = c2f(DEFAULT_FG); let fg_inact = rgb_f(0x66, 0x66, 0x66); let fg_sc = rgb_f(0x3a, 0x3a, 0x3a); - // Bar background + bottom border - push_rect(bg_rects, 0., 0., bw, tby, bar_bg); - push_rect(bg_rects, 0., tby - 1., bw, 1., bottom); - let plus_area = tby; - let tabs_w = bw - plus_area; - let n = tabs.len().max(1); - let tab_w = tabs_w / n as f32; - let pad_v = 4.; - let pill_h = tby - pad_v * 2.; - let text_y = (tby - ch) / 2.; + let tabs_w = bw - plus_area; + let n = tabs.len().max(1); + let tab_w = tabs_w / n as f32; + let text_y = (tby - ch) / 2.; let shortcut_w = 3. * cw; - // Compute visual order for drag let visual_order: Vec = if let Some((from, to, _)) = drag { let mut order: Vec = (0..tabs.len()).collect(); if from < order.len() { @@ -802,52 +910,18 @@ impl Renderer { } else { (0..tabs.len()).collect() }; - let visual_active = visual_order - .iter() - .position(|&i| i == active) - .unwrap_or(active); let drag_orig = drag.map(|(from, _, _)| from); for (vi, &orig_idx) in visual_order.iter().enumerate() { let title = &tabs[orig_idx]; - let is_active = orig_idx == active; + let is_active = orig_idx == active; let is_dragging = drag_orig == Some(orig_idx); - let is_hover = drag.is_none() && hover == Some(vi) && !is_active; let tx = vi as f32 * tab_w; - if tx >= tabs_w { - break; - } + if tx >= tabs_w { break; } let tw = if vi + 1 == n { tabs_w - tx } else { tab_w }; - let pill_x = tx + 4.; - let pill_w = tw - 8.; - - if is_dragging { - // Separator on dragged slot - if vi > 0 && vi != visual_active && vi != visual_active + 1 { - push_rect(fg_rects, tx, pad_v + 4., 1., tby - (pad_v + 4.) * 2., sep_col); - } - // Ghost outline (simple rect approximation for rounded rect) - let ghost = rgb_f(0x38, 0x38, 0x38); - if pill_w > 2. && pill_h > 2. { - push_rect(fg_rects, pill_x, pad_v, pill_w, pill_h, ghost); - push_rect(fg_rects, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., bar_bg); - } - continue; - } - if is_hover { - push_rect(fg_rects, pill_x, pad_v, pill_w, pill_h, hover_bg); - } - if is_active && pill_w > 2. && pill_h > 2. { - push_rect(fg_rects, pill_x, pad_v, pill_w, pill_h, outline); - push_rect(fg_rects, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., bar_bg); - } - - // Separator - if vi > 0 && vi != visual_active && vi != visual_active + 1 { - push_rect(fg_rects, tx, pad_v + 4., 1., tby - (pad_v + 4.) * 2., sep_col); - } + if is_dragging { continue; } // ⌘N shortcut let shortcut = format!("\u{2318}{}", orig_idx + 1); @@ -861,66 +935,38 @@ impl Renderer { // Title let fg = if is_active { fg_act } else { fg_inact }; - let left_pad = tx + cw; + let left_pad = tx + cw; let right_edge = tx + tw - shortcut_w - cw; - let max_cols = ((right_edge - left_pad) / cw).max(0.) as usize; + let max_cols = ((right_edge - left_pad) / cw).max(0.) as usize; let chars: Vec = title.chars().collect(); - let show_n = chars.len().min(max_cols); + let show_n = chars.len().min(max_cols); let truncated = show_n < chars.len(); for (ci, &c) in chars[..show_n].iter().enumerate() { - let cpx = left_pad + ci as f32 * cw; + let cpx = left_pad + ci as f32 * cw; let draw_c = if truncated && ci + 1 == show_n { '\u{2026}' } else { c }; self.emit_char(glyphs, draw_c, cpx, text_y, fg); } } - // Floating dragged tab + // Floating dragged tab title if let Some((from_orig, _, cursor_x)) = drag { - let title = &tabs[from_orig]; + let title = &tabs[from_orig]; let is_active = from_orig == active; - - let half = tab_w / 2.; - let float_left = (cursor_x as f32 - half) - .max(0.) - .min(tabs_w - tab_w); - let pill_x = float_left + 4.; - let pill_w = tab_w - 8.; - - let lifted_outline = rgb_f(0xa0, 0xa0, 0xa0); - let lifted_bg = rgb_f(0x20, 0x20, 0x20); - if pill_w > 2. && pill_h > 2. { - push_rect(fg_rects, pill_x, pad_v, pill_w, pill_h, lifted_outline); - push_rect(fg_rects, pill_x + 1., pad_v + 1., pill_w - 2., pill_h - 2., lifted_bg); - } - - let fg = if is_active { fg_act } else { rgb_f(0xcc, 0xcc, 0xcc) }; - let left_pad = float_left + cw; + let half = tab_w / 2.; + let float_left = (cursor_x as f32 - half).max(0.).min(tabs_w - tab_w); + let left_pad = float_left + cw; let right_edge = float_left + tab_w - shortcut_w - cw; - let max_cols = ((right_edge - left_pad) / cw).max(0.) as usize; + let max_cols = ((right_edge - left_pad) / cw).max(0.) as usize; let chars: Vec = title.chars().collect(); - let show_n = chars.len().min(max_cols); + let show_n = chars.len().min(max_cols); let truncated = show_n < chars.len(); + let fg = if is_active { fg_act } else { rgb_f(0xcc, 0xcc, 0xcc) }; for (ci, &c) in chars[..show_n].iter().enumerate() { - let cpx = left_pad + ci as f32 * cw; + let cpx = left_pad + ci as f32 * cw; let draw_c = if truncated && ci + 1 == show_n { '\u{2026}' } else { c }; self.emit_char(glyphs, draw_c, cpx, text_y, fg); } } - - // + button: draw a simple cross (two thin rects) - let plus_hover = hover == Some(tabs.len()); - let plus_col = if plus_hover { - rgb_f(0x88, 0x88, 0x88) - } else { - rgb_f(0x44, 0x44, 0x44) - }; - let plus_cx = bw - plus_area / 2.; - let plus_cy = tby / 2.; - let arm = 5.; - // horizontal bar - push_rect(fg_rects, plus_cx - arm, plus_cy - 1., arm * 2., 2., plus_col); - // vertical bar - push_rect(fg_rects, plus_cx - 1., plus_cy - arm, 2., arm * 2., plus_col); } // ── Public render ───────────────────────────────────────────────────────── @@ -1203,3 +1249,97 @@ impl Renderer { self.queue.submit(std::iter::once(encoder.finish())); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Convenience: call tab_bar_rects with typical screen dimensions. + fn geom( + n_tabs: usize, + active: usize, + hover: Option, + drag: Option<(usize, usize, f64)>, + ) -> (Vec, Vec) { + tab_bar_rects( + 36., // tby — typical tab bar height + 800., // bw — typical window width + n_tabs, + active, + hover, + drag, + ) + } + + // ── Regression: active tab pill must not overwrite tab title text ───────── + // + // Draw order: bg_rects (pass 1) → glyphs (pass 3) → fg_rects (pass 4). + // If the active pill lands in fg_rects it is drawn AFTER the glyphs and + // paints over the title, making it invisible. + + #[test] + fn active_pill_outline_in_bg_not_fg() { + let outline = rgb_f(0x58, 0x58, 0x58); + let (bg, fg) = geom(2, 0, None, None); + assert!( + bg.iter().any(|r| r.color == outline), + "active tab outline must be in bg_rects so glyphs render on top" + ); + assert!( + !fg.iter().any(|r| r.color == outline), + "active tab outline in fg_rects — it would be drawn after glyphs and cover the title" + ); + } + + #[test] + fn active_pill_only_one_outline_rect() { + // Exactly one outline rect regardless of how many tabs exist. + let outline = rgb_f(0x58, 0x58, 0x58); + for n in 1..=5 { + let (bg, _) = geom(n, 0, None, None); + let count = bg.iter().filter(|r| r.color == outline).count(); + assert_eq!(count, 1, "expected exactly 1 outline rect with {n} tabs"); + } + } + + #[test] + fn hover_bg_in_bg_not_fg() { + let hover_bg = rgb_f(0x26, 0x26, 0x26); + // Hover over tab 1 while tab 0 is active. + let (bg, fg) = geom(3, 0, Some(1), None); + assert!( + bg.iter().any(|r| r.color == hover_bg), + "hover background must be in bg_rects" + ); + assert!( + !fg.iter().any(|r| r.color == hover_bg), + "hover background in fg_rects — it would cover the tab title" + ); + } + + #[test] + fn inactive_tab_gets_no_outline() { + // Only the active tab should have an outline rect. + let outline = rgb_f(0x58, 0x58, 0x58); + let (bg, _) = geom(3, 1, None, None); // active = 1 + let count = bg.iter().filter(|r| r.color == outline).count(); + assert_eq!(count, 1, "inactive tabs must not get an outline pill"); + } + + #[test] + fn bar_background_is_first_bg_rect() { + let bar_bg = rgb_f(0x14, 0x14, 0x14); + let (bg, _) = geom(1, 0, None, None); + assert!(!bg.is_empty()); + assert_eq!(bg[0].color, bar_bg, "first bg rect must be the bar background fill"); + } + + #[test] + fn plus_button_rects_are_in_fg() { + // The + button is always in fg_rects (it's an overlay, not a background). + let plus_col = rgb_f(0x44, 0x44, 0x44); // non-hover colour + let (_, fg) = geom(1, 0, None, None); + let count = fg.iter().filter(|r| r.color == plus_col).count(); + assert_eq!(count, 2, "plus button needs 2 fg rects (horizontal + vertical arm)"); + } +} -- 2.51.2