use gtk4::cairo::Context;
use gtk4::pango;
use super::{cairo_rgb, set_source};
use crate::backend::tab_bar_hits_from_layout;
use crate::primitives::layout_metrics::TextMeasure;
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits, TabBarLayout, TabChrome, TabFrame};
use crate::theme::Theme;
const TAB_PAD: f64 = 14.0;
const TAB_INNER_GAP: f64 = 10.0;
const TAB_OUTER_GAP: f64 = 1.0;
pub(crate) const TAB_ICON_GAP: f64 = 6.0;
const TAB_ACTIVE_BORDER_TOP_PX: f64 = 1.0;
const TAB_CHIP_INSET_Y: f64 = 4.0;
const TAB_CHIP_RADIUS: f64 = 4.0;
struct PangoTabMeasure<'a> {
layout: &'a pango::Layout,
}
impl TextMeasure for PangoTabMeasure<'_> {
fn width_of(&self, text: &str) -> f32 {
self.layout.set_text(text);
self.layout.pixel_size().0 as f32
}
}
pub(crate) fn tab_icon_font(base: &pango::FontDescription) -> pango::FontDescription {
let mut f = base.clone();
f.set_family(&format!(
"{}, {}, monospace",
super::current_nerd_font_fallback_family(),
crate::codicon::FONT_FAMILY
));
f
}
pub(crate) fn tab_icon_extras(
pango_layout: &pango::Layout,
icon_font: &pango::FontDescription,
tab_count: usize,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
) -> Vec<f64> {
(0..tab_count)
.map(
|i| match crate::primitives::tab_bar::tab_icon_at(icons, i) {
Some(icon) => {
pango_layout.set_font_description(Some(icon_font));
pango_layout.set_text(&icon.glyph);
let (icon_w, _) = pango_layout.pixel_size();
icon_w as f64 + TAB_ICON_GAP
}
None => 0.0,
},
)
.collect()
}
fn rounded_rect_path(cr: &Context, x: f64, y: f64, w: f64, h: f64, radius: f64) {
cr.new_path();
cr.arc(
x + w - radius,
y + radius,
radius,
-std::f64::consts::FRAC_PI_2,
0.0,
);
cr.arc(
x + w - radius,
y + h - radius,
radius,
0.0,
std::f64::consts::FRAC_PI_2,
);
cr.arc(
x + radius,
y + h - radius,
radius,
std::f64::consts::FRAC_PI_2,
std::f64::consts::PI,
);
cr.arc(
x + radius,
y + radius,
radius,
std::f64::consts::PI,
3.0 * std::f64::consts::FRAC_PI_2,
);
cr.close_path();
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
theme: &Theme,
hovered_close_tab: Option<usize>,
) -> (TabBarHits, TabBarLayout) {
draw_tab_bar_icons(
cr,
pango_layout,
x_offset,
width,
line_height,
y_offset,
row_height,
bar,
&[],
theme,
hovered_close_tab,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar_icons(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
theme: &Theme,
hovered_close_tab: Option<usize>,
) -> (TabBarHits, TabBarLayout) {
draw_tab_bar_icons_with_chrome(
cr,
pango_layout,
x_offset,
width,
line_height,
y_offset,
row_height,
bar,
icons,
&TabChrome::default(),
theme,
hovered_close_tab,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar_with_chrome(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
chrome: &TabChrome,
theme: &Theme,
hovered_close_tab: Option<usize>,
) -> (TabBarHits, TabBarLayout) {
draw_tab_bar_with_chrome_and_glyphs(
cr,
pango_layout,
x_offset,
width,
line_height,
y_offset,
row_height,
bar,
chrome,
theme,
hovered_close_tab,
None,
false,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar_with_chrome_and_glyphs(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
chrome: &TabChrome,
theme: &Theme,
hovered_close_tab: Option<usize>,
glyphs: Option<&crate::primitives::tab_bar::TabBarGlyphs>,
nerd_fonts_enabled: bool,
) -> (TabBarHits, TabBarLayout) {
draw_tab_bar_icons_with_chrome_and_glyphs(
cr,
pango_layout,
x_offset,
width,
line_height,
y_offset,
row_height,
bar,
&[],
chrome,
theme,
hovered_close_tab,
glyphs,
nerd_fonts_enabled,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar_icons_with_chrome(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
chrome: &TabChrome,
theme: &Theme,
hovered_close_tab: Option<usize>,
) -> (TabBarHits, TabBarLayout) {
draw_tab_bar_icons_with_chrome_and_glyphs(
cr,
pango_layout,
x_offset,
width,
line_height,
y_offset,
row_height,
bar,
icons,
chrome,
theme,
hovered_close_tab,
None,
false,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub fn draw_tab_bar_icons_with_chrome_and_glyphs(
cr: &Context,
pango_layout: &pango::Layout,
x_offset: f64,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
icons: &[Option<crate::primitives::tab_bar::TabIcon>],
chrome: &TabChrome,
theme: &Theme,
hovered_close_tab: Option<usize>,
glyphs: Option<&crate::primitives::tab_bar::TabBarGlyphs>,
nerd_fonts_enabled: bool,
) -> (TabBarHits, TabBarLayout) {
let tab_row_height = row_height;
let text_y_offset = y_offset + (tab_row_height - line_height) / 2.0;
let tab_pad = if bar.compact { 2.0 } else { TAB_PAD };
let tab_inner_gap = if bar.compact { 4.0 } else { TAB_INNER_GAP };
let tab_outer_gap = if bar.compact { 0.0 } else { TAB_OUTER_GAP };
set_source(cr, theme.tab_bar_bg);
cr.rectangle(x_offset, y_offset, width, tab_row_height);
cr.fill().ok();
pango_layout.set_attributes(None);
let saved_font = pango_layout.font_description().unwrap_or_default();
let normal_font = saved_font.clone();
let mut italic_font = normal_font.clone();
italic_font.set_style(pango::Style::Italic);
let icon_font = tab_icon_font(&normal_font);
let close_glyph_str: std::borrow::Cow<'_, str> = glyphs
.and_then(crate::primitives::tab_bar::TabBarGlyphs::close_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(crate::codicon::CLOSE.to_string()));
let dirty_glyph_str: std::borrow::Cow<'_, str> = glyphs
.and_then(crate::primitives::tab_bar::TabBarGlyphs::dirty_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(crate::codicon::DIRTY.to_string()));
let close_glyph_w = if bar.show_tab_close {
pango_layout.set_font_description(Some(&icon_font));
pango_layout.set_text(close_glyph_str.as_ref());
let (w, _) = pango_layout.pixel_size();
w as f64
} else {
0.0
};
let tab_name_widths: Vec<f64> = bar
.tabs
.iter()
.map(|tab| {
if tab.is_preview {
pango_layout.set_font_description(Some(&italic_font));
} else {
pango_layout.set_font_description(Some(&normal_font));
}
pango_layout.set_text(&tab.label);
let (name_w, _) = pango_layout.pixel_size();
name_w as f64
})
.collect();
let tab_icon_extras = tab_icon_extras(pango_layout, &icon_font, bar.tabs.len(), icons);
let brackets = matches!(chrome.active_frame, TabFrame::Brackets);
pango_layout.set_font_description(Some(&normal_font));
let bracket_open_w = if brackets {
pango_layout.set_text("[");
pango_layout.pixel_size().0 as f64
} else {
0.0
};
pango_layout.set_font_description(Some(&normal_font));
let measure = PangoTabMeasure {
layout: pango_layout,
};
let tab_name_widths_f32: Vec<f32> = tab_name_widths.iter().map(|&w| w as f32).collect();
let tab_icon_extras_f32: Vec<f32> = tab_icon_extras.iter().map(|&w| w as f32).collect();
let (layout, corrected_scroll_offset, available_cols) =
crate::primitives::layout_metrics::pixel_tab_bar_layout(
bar,
width as f32,
row_height as f32,
tab_pad as f32,
tab_inner_gap as f32,
tab_outer_gap as f32,
&tab_name_widths_f32,
&tab_icon_extras_f32,
chrome,
&measure,
);
for vt in &layout.visible_tabs {
let tab = &bar.tabs[vt.tab_idx];
let tab_x = vt.bounds.x as f64;
let tab_visual_w = vt.bounds.width as f64 - tab_outer_gap;
let chip_y = y_offset + TAB_CHIP_INSET_Y;
let chip_h = (tab_row_height - 2.0 * TAB_CHIP_INSET_Y).max(0.0);
if tab.is_active {
set_source(cr, theme.tab_active_bg);
rounded_rect_path(
cr,
x_offset + tab_x,
chip_y,
tab_visual_w,
chip_h,
TAB_CHIP_RADIUS,
);
cr.fill().ok();
} else {
set_source(cr, theme.tab_bar_bg);
cr.rectangle(x_offset + tab_x, y_offset, tab_visual_w, tab_row_height);
cr.fill().ok();
}
if tab.is_active {
if let Some(accent) = bar.active_accent {
set_source(cr, accent);
cr.rectangle(
x_offset + tab_x,
chip_y,
tab_visual_w,
TAB_ACTIVE_BORDER_TOP_PX,
);
cr.fill().ok();
}
}
let fg_col = match (tab.is_active, tab.is_preview) {
(true, true) => theme.tab_preview_active_fg,
(true, false) => theme.tab_active_fg,
(false, true) => theme.tab_preview_inactive_fg,
(false, false) => theme.tab_inactive_fg,
};
let is_bracket = brackets && tab.is_active;
let content_pad = tab_pad + if is_bracket { bracket_open_w } else { 0.0 };
if is_bracket {
set_source(cr, fg_col);
pango_layout.set_font_description(Some(&normal_font));
pango_layout.set_text("[");
cr.move_to(x_offset + tab_x + tab_pad, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
}
let icon_extra = tab_icon_extras[vt.tab_idx];
if let Some(icon) = crate::primitives::tab_bar::tab_icon_at(icons, vt.tab_idx) {
set_source(cr, icon.color);
pango_layout.set_font_description(Some(&icon_font));
pango_layout.set_text(&icon.glyph);
cr.move_to(x_offset + tab_x + content_pad, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
}
set_source(cr, fg_col);
pango_layout.set_font_description(Some(if tab.is_preview {
&italic_font
} else {
&normal_font
}));
pango_layout.set_text(&tab.label);
cr.move_to(x_offset + tab_x + content_pad + icon_extra, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
if is_bracket && !(bar.show_tab_close && tab.is_closable) {
set_source(cr, fg_col);
pango_layout.set_font_description(Some(&normal_font));
pango_layout.set_text("]");
cr.move_to(
x_offset + tab_x + content_pad + icon_extra + tab_name_widths[vt.tab_idx],
text_y_offset,
);
super::painted_text::show_layout(cr, pango_layout);
}
if bar.show_tab_close && tab.is_closable {
if let Some(cb) = vt.close_bounds {
let close_x = cb.x as f64 + tab_inner_gap;
let is_close_hovered = hovered_close_tab == Some(vt.tab_idx);
if is_close_hovered {
let pad = 2.0;
let rx = x_offset + close_x - pad;
let ry = text_y_offset + pad;
let rw = close_glyph_w + pad * 2.0;
let rh = line_height - pad * 2.0;
let (hr, hg, hb) = cairo_rgb(theme.foreground);
cr.set_source_rgba(hr, hg, hb, 0.15);
let radius = 3.0;
rounded_rect_path(cr, rx, ry, rw, rh, radius);
cr.fill().ok();
}
let selected_glyph = if tab.is_dirty && !is_close_hovered {
dirty_glyph_str.as_ref()
} else {
close_glyph_str.as_ref()
};
let close_fg = if tab.is_dirty || is_close_hovered {
theme.foreground
} else if tab.is_active {
theme.tab_inactive_fg
} else {
theme.separator
};
set_source(cr, close_fg);
pango_layout.set_font_description(Some(&icon_font));
pango_layout.set_text(selected_glyph);
cr.move_to(x_offset + close_x, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
pango_layout.set_font_description(Some(&normal_font));
if is_bracket {
set_source(cr, fg_col);
pango_layout.set_font_description(Some(&normal_font));
pango_layout.set_text("]");
cr.move_to(x_offset + close_x + close_glyph_w, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
}
}
}
}
for vs in &layout.visible_segments {
let seg = &bar.right_segments[vs.segment_idx];
let fg_col = if seg.is_active {
theme.tab_active_fg
} else {
theme.tab_inactive_fg
};
set_source(cr, fg_col);
pango_layout.set_font_description(Some(&normal_font));
pango_layout.set_text(&seg.text);
cr.move_to(x_offset + vs.bounds.x as f64, text_y_offset);
super::painted_text::show_layout(cr, pango_layout);
}
pango_layout.set_font_description(Some(&saved_font));
let mut hits = tab_bar_hits_from_layout(&layout, bar);
crate::backend::shift_tab_bar_hits(&mut hits, x_offset);
hits.correct_scroll_offset = corrected_scroll_offset;
hits.available_cols = available_cols;
(hits, layout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::tab_bar::{TabBar, TabBarSegment, TabItem};
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
use pangocairo::cairo::{Context, Format, ImageSurface};
const W: i32 = 300;
const ROW_H: i32 = 24;
const LINE_H: f64 = 14.0;
const X_OFFSET: f64 = 50.0;
fn pixel(data: &[u8], stride: usize, x: i32, y: i32) -> (u8, u8, u8) {
let off = y as usize * stride + x as usize * 4;
(data[off + 2], data[off + 1], data[off])
}
fn make_bar() -> TabBar {
TabBar {
id: WidgetId::new("test-tabs"),
tabs: vec![TabItem {
label: "main.rs".to_string(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
}],
scroll_offset: 0,
right_segments: vec![TabBarSegment {
text: " ⇅ ".to_string(),
width_cells: 3,
id: None,
is_active: false,
}],
active_accent: None,
show_tab_close: false,
compact: false,
}
}
fn make_theme() -> Theme {
Theme {
tab_bar_bg: Color::rgb(40, 44, 52),
tab_active_bg: Color::rgb(60, 66, 80),
..Theme::default()
}
}
fn paint_at_offset() -> ImageSurface {
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
let theme = make_theme();
draw_tab_bar(
&cr,
&pango_layout,
X_OFFSET,
(W as f64) - X_OFFSET,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
}
surface
}
#[test]
fn tab_bar_does_not_paint_before_x_offset() {
let mut surface = paint_at_offset();
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let mid_y = ROW_H / 2;
for x in 0..(X_OFFSET as i32) {
let px = pixel(&data, stride, x, mid_y);
assert_eq!(
px,
(255, 255, 255),
"pixel at x={x} should be untouched white, got {px:?}"
);
}
}
#[test]
fn tab_bar_starts_painting_at_x_offset() {
let mut surface = paint_at_offset();
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let mid_y = ROW_H / 2;
let px = pixel(&data, stride, X_OFFSET as i32, mid_y);
assert_ne!(
px,
(255, 255, 255),
"pixel at x={X_OFFSET} should be tab bar chrome, not white background"
);
}
#[test]
#[allow(deprecated)]
fn icon_reserves_extra_width_and_close_still_hit_tests() {
use crate::primitives::tab_bar::{TabBarHit, TabIcon};
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: "main.rs".to_string(),
is_active: true,
..Default::default()
}],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let theme = make_theme();
let (_, no_icon_layout) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
let icons = vec![Some(TabIcon {
glyph: "\u{f09b}".to_string(),
color: Color::rgb(240, 150, 60),
})];
let (icon_hits, icon_layout) = draw_tab_bar_icons(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&icons,
&theme,
None,
);
let no_icon_w = no_icon_layout.visible_tabs[0].bounds.width;
let icon_w = icon_layout.visible_tabs[0].bounds.width;
assert!(
icon_w > no_icon_w,
"tab with an icon should reserve more width than an otherwise-identical \
icon-less tab: {icon_w} vs {no_icon_w}"
);
let close_bounds = icon_layout.visible_tabs[0]
.close_bounds
.expect("icon tab should still have a close button reserved");
let click_x = close_bounds.x + close_bounds.width / 2.0;
let click_y = close_bounds.y + close_bounds.height / 2.0;
match icon_layout.hit_test(click_x, click_y) {
TabBarHit::TabClose(0) => {}
other => panic!("expected TabClose(0) at close bounds, got {other:?}"),
}
assert!(
icon_hits.close_bounds[0].is_some(),
"TabBarHits should also report the close bounds for the icon tab"
);
}
#[test]
fn active_tab_top_row_is_accent_when_opted_in() {
let accent = Color::rgb(90, 170, 255);
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let mut bar = make_bar();
bar.active_accent = Some(accent);
let theme = make_theme();
draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let chip_top = TAB_CHIP_INSET_Y as i32;
let margin = pixel(&data, stride, 5, 0);
assert_ne!(
margin,
(accent.r, accent.g, accent.b),
"the strip's inset margin above the chip must not be the accent colour"
);
let top = pixel(&data, stride, 5, chip_top);
assert_eq!(
top,
(accent.r, accent.g, accent.b),
"the chip's inset top edge should be the opted-in active_accent colour"
);
let below = pixel(&data, stride, 5, chip_top + 3);
assert_ne!(
below,
(accent.r, accent.g, accent.b),
"a few rows below the top accent should be the tab body, not the accent colour"
);
}
#[test]
fn active_tab_top_row_has_no_accent_when_none() {
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let theme = make_theme();
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
assert_eq!(bar.active_accent, None, "test fixture must exercise None");
draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let border_top = theme.tab_active_border_top();
let active_bg = theme.tab_active_bg;
let chip_top = TAB_CHIP_INSET_Y as i32;
let top = pixel(&data, stride, 5, chip_top);
assert_ne!(
top,
(border_top.r, border_top.g, border_top.b),
"with active_accent: None, the chip's top row must NOT be theme.tab_active_border_top()"
);
assert_eq!(
top,
(active_bg.r, active_bg.g, active_bg.b),
"with active_accent: None, the chip's top row should just be the active tab's \
background — no accent strip painted over it"
);
}
#[test]
#[allow(deprecated)]
fn bracket_frame_widens_active_tab_and_close_bounds_excludes_the_bracket() {
use crate::primitives::tab_bar::{TabBarHit, TabChrome, TabFrame};
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![TabItem {
label: "main.rs".to_string(),
is_active: true,
..Default::default()
}],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let theme = make_theme();
let (_, plain_layout) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
let chrome = TabChrome::new(TabFrame::Brackets);
let (chrome_hits, chrome_layout) = draw_tab_bar_with_chrome(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&chrome,
&theme,
None,
);
let plain_w = plain_layout.visible_tabs[0].bounds.width;
let chrome_w = chrome_layout.visible_tabs[0].bounds.width;
assert!(
chrome_w > plain_w,
"bracket framing should widen the active tab: {chrome_w} vs {plain_w}"
);
let plain_close = plain_layout.visible_tabs[0]
.close_bounds
.expect("plain tab should still report close bounds");
let chrome_close = chrome_layout.visible_tabs[0]
.close_bounds
.expect("bracket-framed tab should still report close bounds");
assert!(
(plain_close.width - chrome_close.width).abs() < 0.01,
"post-#1080, the plain and bracket-framed close regions should be \
the same tight width (glyph + leading gap only): plain={}, chrome={}",
plain_close.width,
chrome_close.width
);
assert!(
chrome_close.x > plain_close.x,
"close region should shift right to make room for the leading bracket"
);
let plain_tab_end =
plain_layout.visible_tabs[0].bounds.x + plain_layout.visible_tabs[0].bounds.width;
assert!(
plain_close.x + plain_close.width < plain_tab_end,
"#1080: the plain close bounds must also stop before the tab's own \
right edge — a click in the trailing pad/gap must not resolve to \
TabClose"
);
let chrome_tab_end =
chrome_layout.visible_tabs[0].bounds.x + chrome_layout.visible_tabs[0].bounds.width;
assert!(
chrome_close.x + chrome_close.width < chrome_tab_end,
"close bounds must stop before the tab's right edge, leaving room for ']'"
);
let click_x = chrome_close.x + chrome_close.width / 2.0;
let click_y = chrome_close.y + chrome_close.height / 2.0;
match chrome_layout.hit_test(click_x, click_y) {
TabBarHit::TabClose(0) => {}
other => panic!("expected TabClose(0) at close bounds, got {other:?}"),
}
assert_eq!(chrome_hits.close_bounds.len(), 1);
assert!(chrome_hits.close_bounds[0].is_some());
}
#[test]
fn active_chip_height_scales_with_row_height() {
let theme = make_theme();
for row_h in [24.0_f64, 40.0_f64] {
let h = row_h.ceil() as i32;
let surface = ImageSurface::create(Format::ARgb32, W, h).expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_antialias(pangocairo::cairo::Antialias::None);
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
row_h,
&bar,
&theme,
None,
);
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let active_bg = theme.tab_active_bg;
let x = TAB_CHIP_RADIUS as i32 + 4;
let mut count = 0;
for y in 0..h {
if pixel(&data, stride, x, y) == (active_bg.r, active_bg.g, active_bg.b) {
count += 1;
}
}
let expected = (row_h - 2.0 * TAB_CHIP_INSET_Y).round() as i32;
assert_eq!(
count, expected,
"chip fill height at row_height={row_h} should be row_height - 2*inset, \
got a run of {count} active_bg rows"
);
}
}
#[test]
fn active_chip_inset_is_symmetric_around_a_nonzero_y_offset() {
const Y_OFFSET: f64 = 10.0;
const ROW_H_F: f64 = 24.0;
let full_h = (Y_OFFSET + ROW_H_F).ceil() as i32;
let surface = ImageSurface::create(Format::ARgb32, W, full_h).expect("create ImageSurface");
let theme = make_theme();
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_antialias(pangocairo::cairo::Antialias::None);
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
Y_OFFSET,
ROW_H_F,
&bar,
&theme,
None,
);
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let active_bg = theme.tab_active_bg;
let x = TAB_CHIP_RADIUS as i32 + 4;
let is_chip =
|y: i32| pixel(&data, stride, x, y) == (active_bg.r, active_bg.g, active_bg.b);
let first_chip_row = (0..full_h)
.find(|&y| is_chip(y))
.expect("chip should paint");
let last_chip_row = (0..full_h)
.rev()
.find(|&y| is_chip(y))
.expect("chip should paint");
let top_margin = first_chip_row as f64 - Y_OFFSET;
let bottom_margin = (Y_OFFSET + ROW_H_F) - (last_chip_row as f64 + 1.0);
assert_eq!(
top_margin, TAB_CHIP_INSET_Y,
"chip top edge should sit TAB_CHIP_INSET_Y below the bar's y_offset"
);
assert_eq!(
bottom_margin, TAB_CHIP_INSET_Y,
"chip bottom edge should sit TAB_CHIP_INSET_Y above the bar's bottom, \
symmetric with the top margin"
);
}
#[test]
fn active_chip_corners_are_bar_background_not_active_fill() {
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let theme = make_theme();
let layout;
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_antialias(pangocairo::cairo::Antialias::None);
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
let (_, l) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
layout = l;
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let bounds = layout.visible_tabs[0].bounds;
let chip_x0 = bounds.x as i32;
let chip_x1 = (bounds.x + bounds.width - TAB_OUTER_GAP as f32) as i32 - 1;
let chip_y0 = TAB_CHIP_INSET_Y as i32;
let chip_y1 = ROW_H - TAB_CHIP_INSET_Y as i32 - 1;
let bar_bg = theme.tab_bar_bg;
for &(x, y) in &[
(chip_x0, chip_y0),
(chip_x1, chip_y0),
(chip_x0, chip_y1),
(chip_x1, chip_y1),
] {
let px = pixel(&data, stride, x, y);
assert_eq!(
px,
(bar_bg.r, bar_bg.g, bar_bg.b),
"chip bounding-rect corner ({x},{y}) should be bar background \
(rounded off by TAB_CHIP_RADIUS), not the active fill"
);
}
}
#[test]
fn hit_test_in_top_margin_above_chip_still_resolves_to_tab() {
use crate::primitives::tab_bar::TabBarHit;
assert!(
TAB_CHIP_INSET_Y > 1.0,
"test assumes the chip's inset margin is more than 1px"
);
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let bar = make_bar();
let theme = make_theme();
let (_hits, layout) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
let bounds = layout.visible_tabs[0].bounds;
assert_eq!(
bounds.height, ROW_H as f32,
"the tab's hit rect must stay the full row_height, not the visible chip height"
);
let click_x = bounds.x + 5.0;
match layout.hit_test(click_x, 1.0) {
TabBarHit::Tab(0) => {}
other => panic!(
"expected Tab(0) for a click in the top margin above the chip, got {other:?}"
),
}
}
#[test]
fn inactive_tab_never_paints_the_active_chip_colour() {
let theme = make_theme();
assert_ne!(
theme.tab_active_bg, theme.tab_bar_bg,
"fixture requires active/inactive colours to differ"
);
let bar = TabBar {
id: WidgetId::new("test-tabs"),
tabs: vec![TabItem {
label: "main.rs".to_string(),
is_active: false,
..Default::default()
}],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: false,
compact: false,
};
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let layout;
{
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
let (_, l) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
layout = l;
}
let mut surface = surface;
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let bounds = layout.visible_tabs[0].bounds;
let x = bounds.x as i32 + 3;
let active_bg = theme.tab_active_bg;
let bar_bg = theme.tab_bar_bg;
for y in 0..ROW_H {
let px = pixel(&data, stride, x, y);
assert_ne!(
px,
(active_bg.r, active_bg.g, active_bg.b),
"inactive tab must never paint the active chip colour at y={y}"
);
assert_eq!(
px,
(bar_bg.r, bar_bg.g, bar_bg.b),
"inactive tab should fill its full row_height with bar background, \
unrestricted by the chip's inset, at y={y}"
);
}
}
#[test]
fn close_region_excludes_trailing_padding_via_draw_tab_bar() {
use crate::primitives::tab_bar::TabBarHit;
let mut bar = make_bar();
bar.show_tab_close = true;
let theme = make_theme();
let surface = ImageSurface::create(Format::ARgb32, W, ROW_H).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let (_, layout) = draw_tab_bar(
&cr,
&pango_layout,
0.0,
W as f64,
LINE_H,
0.0,
ROW_H as f64,
&bar,
&theme,
None,
);
let vt = &layout.visible_tabs[0];
let close = vt
.close_bounds
.expect("closable tab must report close bounds");
let tab_right_edge = vt.bounds.x + vt.bounds.width;
let probe_y = vt.bounds.y + vt.bounds.height / 2.0;
assert!(
close.x + close.width < tab_right_edge,
"close region must stop before the tab's right edge: close ends at {}, \
tab ends at {tab_right_edge}",
close.x + close.width,
);
match layout.hit_test(tab_right_edge - 1.0, probe_y) {
TabBarHit::Tab(0) => {}
other => panic!(
"click 1px inside the tab's right edge (old dead padding) \
expected Tab(0), got {other:?}"
),
}
match layout.hit_test(close.x + close.width / 2.0, probe_y) {
TabBarHit::TabClose(0) => {}
other => {
panic!("click at the close region's centre expected TabClose(0), got {other:?}")
}
}
}
}