use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;
use super::cg::*;
use super::backend::ns_fill_rounded_rect;
use super::text::{draw_text, measure_text};
use crate::event::Rect;
use crate::primitives::tab_bar::{
tab_icon_at, TabBarGlyphs, TabBarHit, TabBarLayout, TabIcon, VisibleSegment, VisibleTab,
};
#[allow(deprecated)]
use crate::primitives::tab_bar::{TabBar, TabBarHits};
use crate::theme::Theme;
const TAB_PAD: f64 = 14.0;
const TAB_INNER_GAP: f64 = 10.0;
const TAB_OUTER_GAP: f64 = 1.0;
const ACCENT_HEIGHT: f64 = 1.0;
const CELL_WIDTH_SAMPLE: &str = "ABCDabcd0123.:_";
const CLOSE_PAD: f64 = 2.0;
const TAB_ICON_GAP: f64 = 6.0;
const TAB_CHIP_INSET_Y: f64 = 4.0;
const TAB_CHIP_RADIUS: f64 = 4.0;
pub(crate) fn mac_tab_icon_extras(
font: &CTFont,
tab_count: usize,
icons: &[Option<TabIcon>],
) -> Vec<f64> {
(0..tab_count)
.map(|i| match tab_icon_at(icons, i) {
Some(icon) => measure_text(font, &icon.glyph).0 + TAB_ICON_GAP,
None => 0.0,
})
.collect()
}
#[allow(deprecated)]
pub fn mac_tab_bar_layout(font: &CTFont, width: f64, bar: &TabBar) -> TabBarHits {
mac_tab_bar_layout_icons(font, width, bar, &[])
}
#[allow(deprecated)]
pub fn mac_tab_bar_layout_icons(
font: &CTFont,
width: f64,
bar: &TabBar,
icons: &[Option<TabIcon>],
) -> TabBarHits {
let native = mac_tab_bar_native_layout_icons(font, width, 1.0, bar, icons);
let mut hits = crate::backend::tab_bar_hits_from_layout(&native, bar);
hits.correct_scroll_offset = native.resolved_scroll_offset;
let right_widths: f64 = bar
.right_segments
.iter()
.map(|seg| measure_text(font, &seg.text).0)
.sum();
let effective_tab_area = (width - right_widths).max(0.0);
let (sample_px, _) = measure_text(font, CELL_WIDTH_SAMPLE);
let char_w = (sample_px / CELL_WIDTH_SAMPLE.chars().count() as f64).max(1.0);
hits.available_cols = (effective_tab_area / char_w).floor().max(0.0) as usize;
hits
}
pub fn mac_tab_bar_native_layout(
font: &CTFont,
width: f64,
height: f64,
bar: &TabBar,
) -> TabBarLayout {
mac_tab_bar_native_layout_icons(font, width, height, bar, &[])
}
pub fn mac_tab_bar_native_layout_icons(
font: &CTFont,
width: f64,
height: f64,
bar: &TabBar,
icons: &[Option<TabIcon>],
) -> TabBarLayout {
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 };
let right_widths: Vec<f64> = bar
.right_segments
.iter()
.map(|seg| measure_text(font, &seg.text).0)
.collect();
let reserved_px: f64 = right_widths.iter().sum();
let effective_tab_area = (width - reserved_px).max(0.0);
let close_w = if bar.show_tab_close {
measure_text(font, &crate::codicon::CLOSE.to_string()).0
} else {
0.0
};
let close_extra_for = |tab_idx: usize| -> f64 {
if bar.show_tab_close && bar.tabs[tab_idx].is_closable {
tab_inner_gap + close_w
} else {
0.0
}
};
let icon_extras = mac_tab_icon_extras(font, bar.tabs.len(), icons);
let tab_slot_widths: Vec<f64> = bar
.tabs
.iter()
.enumerate()
.map(|(i, tab)| {
let (name_w, _) = measure_text(font, &tab.label);
tab_pad + icon_extras[i] + name_w + close_extra_for(i) + tab_pad + tab_outer_gap
})
.collect();
let active_idx = bar.tabs.iter().position(|t| t.is_active);
let resolved_scroll_offset = if let Some(active) = active_idx {
TabBar::fit_active_scroll_offset(active, bar.tabs.len(), effective_tab_area as usize, |i| {
tab_slot_widths[i] as usize
})
} else {
bar.scroll_offset
};
let mut visible_tabs: Vec<VisibleTab> = Vec::new();
let mut close_regions: Vec<(Rect, TabBarHit)> = Vec::new();
let mut body_regions: Vec<(Rect, TabBarHit)> = Vec::new();
let mut x = 0.0_f64;
for (tab_idx, tab) in bar.tabs.iter().enumerate().skip(bar.scroll_offset) {
let (tab_name_w, _) = measure_text(font, &tab.label);
let icon_extra = icon_extras[tab_idx];
let tab_content_w = tab_pad + icon_extra + tab_name_w + close_extra_for(tab_idx) + tab_pad;
let slot_w = tab_content_w + tab_outer_gap;
if x + slot_w > effective_tab_area {
break;
}
let bounds = Rect::new(x as f32, 0.0, slot_w as f32, height as f32);
let close_bounds = if bar.show_tab_close && tab.is_closable {
let close_x = x + tab_pad + icon_extra + tab_name_w + tab_inner_gap;
let cb = Rect::new(
(close_x - CLOSE_PAD) as f32,
0.0,
(close_w + 2.0 * CLOSE_PAD) as f32,
height as f32,
);
close_regions.push((cb, TabBarHit::TabClose(tab_idx)));
Some(cb)
} else {
None
};
body_regions.push((bounds, TabBarHit::Tab(tab_idx)));
visible_tabs.push(VisibleTab {
tab_idx,
bounds,
close_bounds,
});
x += slot_w;
}
let mut hit_regions: Vec<(Rect, TabBarHit)> =
Vec::with_capacity(close_regions.len() + body_regions.len());
hit_regions.extend(close_regions);
hit_regions.extend(body_regions);
let mut visible_segments: Vec<VisibleSegment> = Vec::with_capacity(right_widths.len());
let mut sx = width - reserved_px;
for (segment_idx, seg_w) in right_widths.iter().enumerate() {
let bounds = Rect::new(sx as f32, 0.0, *seg_w as f32, height as f32);
let seg = &bar.right_segments[segment_idx];
let clickable = seg.id.is_some();
if let Some(id) = &seg.id {
hit_regions.push((bounds, TabBarHit::RightSegment(*id)));
}
visible_segments.push(VisibleSegment {
segment_idx,
bounds,
clickable,
});
sx += seg_w;
}
TabBarLayout {
bar_width: width as f32,
bar_height: height as f32,
visible_tabs,
visible_segments,
scroll_left: None,
scroll_right: None,
hit_regions,
resolved_scroll_offset,
}
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub unsafe fn draw_tab_bar(
ctx: CGContextRef,
font: &CTFont,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
theme: &Theme,
hovered_close_tab: Option<usize>,
) -> TabBarHits {
draw_tab_bar_icons(
ctx,
font,
width,
line_height,
y_offset,
row_height,
bar,
theme,
hovered_close_tab,
&[],
)
}
fn tab_glyph_text(
icon: Option<&crate::types::Icon>,
nerd_fonts_enabled: bool,
default: char,
) -> String {
icon.map(|icon| icon.resolve(nerd_fonts_enabled).to_string())
.unwrap_or_else(|| default.to_string())
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub unsafe fn draw_tab_bar_icons(
ctx: CGContextRef,
font: &CTFont,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
theme: &Theme,
hovered_close_tab: Option<usize>,
icons: &[Option<TabIcon>],
) -> TabBarHits {
draw_tab_bar_icons_with_glyphs(
ctx,
font,
width,
line_height,
y_offset,
row_height,
bar,
theme,
hovered_close_tab,
icons,
None,
false,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(deprecated)]
pub unsafe fn draw_tab_bar_icons_with_glyphs(
ctx: CGContextRef,
font: &CTFont,
width: f64,
line_height: f64,
y_offset: f64,
row_height: f64,
bar: &TabBar,
theme: &Theme,
hovered_close_tab: Option<usize>,
icons: &[Option<TabIcon>],
glyphs: Option<&TabBarGlyphs>,
nerd_fonts_enabled: bool,
) -> TabBarHits {
let text_y_offset = y_offset + (row_height - line_height) / 2.0;
let tab_pad = if bar.compact { 2.0 } else { TAB_PAD };
let tab_outer_gap = if bar.compact { 0.0 } else { TAB_OUTER_GAP };
let hits = mac_tab_bar_layout_icons(font, width, bar, icons);
let icon_extras = mac_tab_icon_extras(font, bar.tabs.len(), icons);
CGContextSaveGState(ctx);
fill_rect(ctx, 0.0, y_offset, width, row_height, theme.tab_bar_bg);
let max_chip_inset_y = ((row_height - line_height) / 2.0).max(0.0);
let chip_inset_y = TAB_CHIP_INSET_Y.min(max_chip_inset_y);
let chip_y = y_offset + chip_inset_y;
let chip_h = (row_height - 2.0 * chip_inset_y).max(0.0);
for (tab_idx, tab) in bar
.tabs
.iter()
.enumerate()
.skip(bar.scroll_offset)
.take(hits.slot_positions.len().saturating_sub(bar.scroll_offset))
{
let (slot_x, slot_end) = hits.slot_positions[tab_idx];
let tab_content_w = slot_end - slot_x - tab_outer_gap;
if tab.is_active {
unsafe {
ns_fill_rounded_rect(
ctx,
Rect::new(
slot_x as f32,
chip_y as f32,
tab_content_w as f32,
chip_h as f32,
),
TAB_CHIP_RADIUS as f32,
theme.tab_active_bg,
);
}
} else {
fill_rect(
ctx,
slot_x,
y_offset,
tab_content_w,
row_height,
theme.tab_bar_bg,
);
}
if tab.is_active {
if let Some(accent) = bar.active_accent {
fill_rect(ctx, slot_x, chip_y, tab_content_w, ACCENT_HEIGHT, accent);
}
}
let icon_extra = icon_extras[tab_idx];
if let Some(icon) = tab_icon_at(icons, tab_idx) {
draw_text(
ctx,
font,
&icon.glyph,
slot_x + tab_pad,
text_y_offset,
color_to_cg(icon.color),
);
}
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,
};
draw_text(
ctx,
font,
&tab.label,
slot_x + tab_pad + icon_extra,
text_y_offset,
color_to_cg(fg_col),
);
if let Some((close_lo, _)) = hits.close_bounds[tab_idx] {
let close_x = close_lo + CLOSE_PAD;
let is_close_hovered = hovered_close_tab == Some(tab_idx);
let close_glyph = if tab.is_dirty && !is_close_hovered {
tab_glyph_text(
glyphs.and_then(TabBarGlyphs::dirty_glyph),
nerd_fonts_enabled,
crate::codicon::DIRTY,
)
} else {
tab_glyph_text(
glyphs.and_then(TabBarGlyphs::close_glyph),
nerd_fonts_enabled,
crate::codicon::CLOSE,
)
};
let close_fg = if tab.is_dirty || is_close_hovered {
theme.foreground
} else if tab.is_active {
theme.tab_inactive_fg
} else {
theme.separator
};
draw_text(
ctx,
font,
&close_glyph,
close_x,
text_y_offset,
color_to_cg(close_fg),
);
}
}
for (i, seg) in bar.right_segments.iter().enumerate() {
let (sx, _) = hits.right_segment_bounds[i];
let fg_col = if seg.is_active {
theme.tab_active_fg
} else {
theme.tab_inactive_fg
};
draw_text(ctx, font, &seg.text, sx, text_y_offset, color_to_cg(fg_col));
}
CGContextRestoreGState(ctx);
hits
}
#[cfg(test)]
mod tests {
use super::super::headless::BitmapSurface;
use super::super::text::make_font;
use super::super::MacBackend;
use super::*;
use crate::event::{Rect as QRect, Viewport};
use crate::primitives::tab_bar::{TabBar, TabBarSegment, TabIcon, TabItem};
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
use crate::Backend;
const W: u32 = 480;
const H: u32 = 28;
const FONT_SIZE: f64 = 14.0;
fn font() -> CTFont {
make_font("Menlo", FONT_SIZE).expect("Menlo installed")
}
fn backend_with_test_fonts() -> MacBackend {
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.set_chrome_font(font());
backend
}
fn sample_bar() -> TabBar {
TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "main.rs".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "lib.rs".into(),
is_active: false,
is_dirty: true,
is_preview: false,
is_closable: true,
},
],
scroll_offset: 0,
right_segments: vec![],
active_accent: Some(Color::rgb(80, 140, 255)),
show_tab_close: true,
compact: false,
}
}
#[allow(deprecated)]
fn paint_via_backend(
bar: &TabBar,
hovered_close: Option<usize>,
) -> (BitmapSurface, TabBarHits) {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
let hits = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar(QRect::new(0.0, 0.0, W as f32, H as f32), bar, hovered_close);
*hits.borrow_mut() = Some(h);
});
backend.end_frame();
(surface, hits.into_inner().unwrap())
}
#[test]
#[allow(deprecated)]
fn active_tab_paints_active_bg() {
let bar = sample_bar();
let (surface, hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (start, end) = hits.slot_positions[0];
assert!(end > start, "active tab slot must have non-zero width");
let probe_x = (start + 2.0) as u32;
let probe_y = H / 2;
let (r, g, b, _) = surface.pixel(probe_x, probe_y);
let expected = theme.tab_active_bg;
assert_eq!(
(r, g, b),
(expected.r, expected.g, expected.b),
"active tab bg at ({}, {}) should be tab_active_bg",
probe_x,
probe_y,
);
}
#[test]
#[allow(deprecated)] fn active_chip_is_inset_from_top_and_bottom_of_the_row() {
let bar = sample_bar();
let (surface, hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let active_bg = theme.tab_active_bg;
let (start, _) = hits.slot_positions[0];
let probe_x = (start + TAB_CHIP_RADIUS + 4.0) as u32;
let is_active = |y: u32| {
let (r, g, b, _) = surface.pixel(probe_x, y);
(r, g, b) == (active_bg.r, active_bg.g, active_bg.b)
};
assert!(
!is_active(0),
"top scanline of the row must be outside the inset chip"
);
assert!(
!is_active(H - 1),
"bottom scanline of the row must be outside the inset chip"
);
assert!(
is_active(H / 2),
"chip must paint the active fill in the middle of the row"
);
}
#[test]
#[allow(deprecated)] fn active_chip_corners_are_bar_background_not_active_fill() {
let mut bar = sample_bar();
bar.active_accent = None;
let (surface, hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let bar_bg = theme.tab_bar_bg;
let (slot_x, slot_end) = hits.slot_positions[0];
let tab_content_w = slot_end - slot_x - TAB_OUTER_GAP;
let chip_x0 = slot_x as u32;
let chip_x1 = (slot_x + tab_content_w) as u32 - 1;
let chip_y0 = TAB_CHIP_INSET_Y as u32;
let chip_y1 = H - TAB_CHIP_INSET_Y as u32 - 1;
for (x, y) in [
(chip_x0, chip_y0),
(chip_x1, chip_y0),
(chip_x0, chip_y1),
(chip_x1, chip_y1),
] {
let (r, g, b, _) = surface.pixel(x, y);
assert_eq!(
(r, g, b),
(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]
#[allow(deprecated)]
fn active_accent_paints_at_top_of_active_tab_chip() {
let bar = sample_bar();
let (surface, hits) = paint_via_backend(&bar, None);
let (start, _) = hits.slot_positions[0];
let probe_x = (start + 4.0) as u32;
let probe_y = TAB_CHIP_INSET_Y as u32;
let (r, g, b, _) = surface.pixel(probe_x, probe_y);
let accent = bar.active_accent.unwrap();
assert_eq!(
(r, g, b),
(accent.r, accent.g, accent.b),
"accent strip at the chip's inset top edge should match TabBar.active_accent",
);
let (r0, g0, b0, _) = surface.pixel(probe_x, 0);
assert_ne!(
(r0, g0, b0),
(accent.r, accent.g, accent.b),
"accent strip must not paint at the strip's own top edge once inset"
);
}
#[test]
#[allow(deprecated)]
fn close_bounds_round_trip_via_hits_struct() {
let bar = sample_bar();
let (_surface, hits) = paint_via_backend(&bar, None);
let close = hits.close_bounds[0].expect("active tab has close bounds");
let mid_x = (close.0 + close.1) / 2.0;
assert!(
mid_x >= close.0 && mid_x < close.1,
"midpoint must be inside close bounds [{}, {})",
close.0,
close.1,
);
let (slot_start, slot_end) = hits.slot_positions[0];
assert!(
close.0 >= slot_start && close.1 <= slot_end,
"close bounds [{}, {}) must be inside tab slot [{}, {})",
close.0,
close.1,
slot_start,
slot_end,
);
}
#[test]
#[allow(deprecated)]
fn dirty_tab_uses_filled_circle_glyph() {
let bar = sample_bar();
let (surface, hits) = paint_via_backend(&bar, None);
let active_close = hits.close_bounds[0].unwrap();
let dirty_close = hits.close_bounds[1].unwrap();
fn ink_density(surface: &BitmapSurface, x: u32, y0: u32, y1: u32, bg: Color) -> u32 {
(y0..y1)
.filter(|&y| {
let (r, g, b, _) = surface.pixel(x, y);
!(r == bg.r && g == bg.g && b == bg.b)
})
.count() as u32
}
let theme = Theme::default();
let chip_y0 = TAB_CHIP_INSET_Y as u32;
let chip_y1 = H - TAB_CHIP_INSET_Y as u32;
let active_ink = ink_density(
&surface,
((active_close.0 + active_close.1) / 2.0) as u32,
chip_y0,
chip_y1,
theme.tab_active_bg,
);
let dirty_ink = ink_density(
&surface,
((dirty_close.0 + dirty_close.1) / 2.0) as u32,
chip_y0,
chip_y1,
theme.tab_bar_bg,
);
assert!(
dirty_ink > active_ink,
"dirty glyph should ink more rows ({}) than the active close glyph ({})",
dirty_ink,
active_ink,
);
}
#[test]
#[allow(deprecated)]
fn non_closable_tab_has_no_close_bounds_even_when_bar_show_close_is_true() {
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: vec![
TabItem {
label: "tab.rs".into(),
is_active: true,
is_dirty: false,
is_preview: false,
is_closable: true,
},
TabItem {
label: "tab.rs".into(),
is_active: false,
is_dirty: false,
is_preview: false,
is_closable: false,
},
],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let (_surface, hits) = paint_via_backend(&bar, None);
assert!(
hits.close_bounds[0].is_some(),
"closable tab must have close bounds",
);
assert!(
hits.close_bounds[1].is_none(),
"non-closable tab must have no close bounds even when bar.show_tab_close is true",
);
let (s0, e0) = hits.slot_positions[0];
let (s1, e1) = hits.slot_positions[1];
let closable_slot = e0 - s0;
let pinned_slot = e1 - s1;
assert!(
closable_slot > pinned_slot,
"closable tab slot ({closable_slot}) should reserve more width than pinned slot ({pinned_slot})",
);
}
#[test]
#[allow(deprecated)]
fn empty_bar_paints_only_tab_bar_bg() {
let bar = TabBar {
id: WidgetId::new("empty"),
tabs: vec![],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let (surface, hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W / 2, H / 2);
assert_eq!(
(r, g, b),
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b)
);
assert!(hits.slot_positions.is_empty());
assert!(hits.close_bounds.is_empty());
}
#[test]
#[allow(deprecated)]
fn layout_twin_matches_the_painted_hits() {
let bar = sample_bar();
let (_surface, painted) = paint_via_backend(&bar, None);
let backend = backend_with_test_fonts();
let computed = backend.tab_bar_layout(QRect::new(0.0, 0.0, W as f32, H as f32), &bar);
assert_eq!(painted.slot_positions, computed.slot_positions);
assert_eq!(painted.close_bounds, computed.close_bounds);
assert_eq!(painted.right_segment_bounds, computed.right_segment_bounds);
assert_eq!(painted.available_cols, computed.available_cols);
assert_eq!(
painted.correct_scroll_offset,
computed.correct_scroll_offset
);
}
#[test]
#[allow(deprecated)]
fn native_layout_agrees_with_hits_layout() {
let bar = sample_bar();
let f = font();
let hits = mac_tab_bar_layout(&f, W as f64, &bar);
let native = mac_tab_bar_native_layout(&f, W as f64, H as f64, &bar);
let native_as_hits = crate::backend::tab_bar_hits_from_layout(&native, &bar);
assert_eq!(
hits.slot_positions, native_as_hits.slot_positions,
"tab slots must agree between the two independently-computed layouts"
);
assert_eq!(
hits.close_bounds, native_as_hits.close_bounds,
"close-button spans must agree too"
);
assert_eq!(
hits.right_segment_bounds, native_as_hits.right_segment_bounds,
"right-segment spans must agree too"
);
assert_eq!(
hits.correct_scroll_offset, native.resolved_scroll_offset,
"the \"fit active tab\" scroll correction must agree"
);
}
#[test]
#[allow(deprecated)]
fn close_box_centre_is_where_the_glyph_was_painted() {
let bar = sample_bar();
let (surface, hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (lo, hi) = hits.close_bounds[0].expect("tab 0 is closable");
let bg = theme.tab_active_bg;
let chip_y0 = TAB_CHIP_INSET_Y as u32;
let chip_y1 = H - TAB_CHIP_INSET_Y as u32;
let inked = (lo.ceil() as u32..hi.floor() as u32).any(|x| {
(chip_y0..chip_y1).any(|y| {
let (r, g, b, _) = surface.pixel(x.min(W - 1), y);
(r, g, b) != (bg.r, bg.g, bg.b)
})
});
assert!(
inked,
"close box [{lo}, {hi}] contains no painted glyph — hits and paint have drifted",
);
}
#[allow(deprecated)]
fn paint_icons_via_backend(
bar: &TabBar,
icons: &[Option<TabIcon>],
hovered_close: Option<usize>,
) -> (BitmapSurface, TabBarHits) {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
let hits = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar_icons(
QRect::new(0.0, 0.0, W as f32, H as f32),
bar,
icons,
hovered_close,
);
*hits.borrow_mut() = Some(h);
});
backend.end_frame();
(surface, hits.into_inner().unwrap())
}
fn sample_icons() -> Vec<Option<TabIcon>> {
vec![
Some(TabIcon {
glyph: "R".into(),
color: Color::rgb(220, 120, 60),
}),
None,
]
}
#[test]
#[allow(deprecated)]
fn draw_tab_bar_icons_with_non_empty_sidecar_does_not_abort() {
let bar = sample_bar();
let icons = sample_icons();
let (_surface, hits) = paint_icons_via_backend(&bar, &icons, None);
let (_icon_less_surface, icon_less_hits) = paint_via_backend(&bar, None);
let expected_extra = mac_tab_icon_extras(&font(), bar.tabs.len(), &icons)[0];
assert!(
expected_extra > TAB_ICON_GAP,
"an ASCII icon glyph must measure non-zero under Menlo (got \
{expected_extra} including a {TAB_ICON_GAP}pt gap)",
);
let (lo, hi) = hits.slot_positions[0];
let (plain_lo, plain_hi) = icon_less_hits.slot_positions[0];
assert_eq!(lo, plain_lo, "the first tab still starts at the bar origin");
assert!(
((hi - lo) - ((plain_hi - plain_lo) + expected_extra)).abs() < 0.01,
"decorated slot width {} should be the icon-less width {} plus the \
reservation {expected_extra}",
hi - lo,
plain_hi - plain_lo,
);
let (t1_lo, t1_hi) = hits.slot_positions[1];
let (p1_lo, p1_hi) = icon_less_hits.slot_positions[1];
assert!(
((t1_hi - t1_lo) - (p1_hi - p1_lo)).abs() < 0.01,
"an undecorated tab must keep its icon-less width",
);
assert!(
((t1_lo - p1_lo) - expected_extra).abs() < 0.01,
"an undecorated tab shifts right by exactly the preceding tab's \
icon reservation",
);
}
#[test]
#[allow(deprecated)]
fn layout_only_icon_twins_agree_with_the_icon_paint() {
let bar = sample_bar();
let icons = sample_icons();
let rect = QRect::new(0.0, 0.0, W as f32, H as f32);
let (_surface, painted) = paint_icons_via_backend(&bar, &icons, None);
let mut backend = backend_with_test_fonts();
let icon_hits = backend.tab_bar_layout_icons(rect, &bar, &icons);
assert_eq!(icon_hits.slot_positions, painted.slot_positions);
assert_eq!(icon_hits.close_bounds, painted.close_bounds);
let plain_hits = backend.tab_bar_layout(rect, &bar);
assert_ne!(
icon_hits.slot_positions, plain_hits.slot_positions,
"#926: a non-empty sidecar must change the reported geometry",
);
let icon_layout = backend.resolve_tab_bar_layout_icons(rect, &bar, &icons);
let as_hits = crate::backend::tab_bar_hits_from_layout(&icon_layout, &bar);
assert_eq!(
as_hits.slot_positions, painted.slot_positions,
"the two independently-computed icon layouts must agree",
);
assert_eq!(as_hits.close_bounds, painted.close_bounds);
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
let drawn = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
*drawn.borrow_mut() = Some(b.draw_tab_bar_icons_layout(rect, &bar, &icons, None));
});
backend.end_frame();
assert_eq!(
drawn.into_inner().unwrap().visible_tabs,
icon_layout.visible_tabs,
);
}
#[test]
#[allow(deprecated)]
fn close_box_still_lands_on_the_close_glyph_with_an_icon_present() {
let bar = sample_bar();
let icons = sample_icons();
let (surface, hits) = paint_icons_via_backend(&bar, &icons, None);
let (_plain_surface, plain_hits) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (lo, hi) = hits.close_bounds[0].expect("tab 0 is closable");
let (plain_lo, _) = plain_hits.close_bounds[0].expect("tab 0 is closable");
assert!(
lo > plain_lo,
"the icon must push the close box right (icon {lo} vs icon-less {plain_lo})",
);
let (slot_lo, slot_hi) = hits.slot_positions[0];
assert!(
lo >= slot_lo && hi <= slot_hi,
"close box [{lo}, {hi}) must stay inside tab slot [{slot_lo}, {slot_hi})",
);
let bg = theme.tab_active_bg;
let chip_y0 = TAB_CHIP_INSET_Y as u32;
let chip_y1 = H - TAB_CHIP_INSET_Y as u32;
let inked = (lo.ceil() as u32..hi.floor() as u32).any(|x| {
(chip_y0..chip_y1).any(|y| {
let (r, g, b, _) = surface.pixel(x.min(W - 1), y);
(r, g, b) != (bg.r, bg.g, bg.b)
})
});
assert!(
inked,
"close box [{lo}, {hi}) contains no painted glyph — the icon \
reservation moved the hit box off the × it draws",
);
}
#[test]
#[allow(deprecated)]
fn icon_glyph_paints_before_the_label_in_its_own_colour() {
let bar = sample_bar();
let icons = sample_icons();
let icon_color = icons[0].as_ref().unwrap().color;
let (surface, hits) = paint_icons_via_backend(&bar, &icons, None);
let (slot_lo, _) = hits.slot_positions[0];
let icon_extra = mac_tab_icon_extras(&font(), bar.tabs.len(), &icons)[0];
let glyph_lo = slot_lo + TAB_PAD;
let glyph_hi = glyph_lo + icon_extra - TAB_ICON_GAP;
assert!(glyph_hi > glyph_lo, "icon span must be non-empty");
let bg = Theme::default().tab_active_bg;
let dist = |a: (u8, u8, u8), c: Color| {
let d = |x: u8, y: u8| (x as i32 - y as i32).pow(2);
d(a.0, c.r) + d(a.1, c.g) + d(a.2, c.b)
};
let chip_y0 = TAB_CHIP_INSET_Y as u32;
let chip_y1 = H - TAB_CHIP_INSET_Y as u32;
let found = (glyph_lo.floor() as u32..glyph_hi.ceil() as u32).any(|x| {
(chip_y0..chip_y1).any(|y| {
let (r, g, b, _) = surface.pixel(x.min(W - 1), y);
dist((r, g, b), icon_color) < dist((r, g, b), bg)
})
});
assert!(
found,
"no pixel in the icon span [{glyph_lo}, {glyph_hi}) leans towards \
TabIcon::color {icon_color:?} — the glyph was not painted, or was \
painted in the tab foreground",
);
}
#[test]
#[allow(deprecated)]
fn empty_sidecar_is_pixel_identical_to_the_icon_less_paint() {
let bar = sample_bar();
let (icon_surface, icon_hits) = paint_icons_via_backend(&bar, &[], None);
let (plain_surface, plain_hits) = paint_via_backend(&bar, None);
assert_eq!(icon_hits.slot_positions, plain_hits.slot_positions);
assert_eq!(icon_hits.close_bounds, plain_hits.close_bounds);
assert_eq!(icon_hits.available_cols, plain_hits.available_cols);
for y in 0..H {
for x in 0..W {
assert_eq!(
icon_surface.pixel(x, y),
plain_surface.pixel(x, y),
"pixel ({x}, {y}) differs with an empty sidecar",
);
}
}
}
#[test]
#[allow(deprecated)]
fn short_and_overlong_sidecars_are_tolerated() {
let bar = sample_bar();
let f = font();
let short = vec![Some(TabIcon {
glyph: "R".into(),
color: Color::rgb(220, 120, 60),
})];
let hits = mac_tab_bar_layout_icons(&f, W as f64, &bar, &short);
assert_eq!(hits.slot_positions.len(), bar.tabs.len());
assert_eq!(
hits.slot_positions[0],
mac_tab_bar_layout_icons(&f, W as f64, &bar, &sample_icons()).slot_positions[0],
"a short sidecar decorates the entries it does have",
);
let long = vec![
None,
None,
Some(TabIcon {
glyph: "R".into(),
color: Color::rgb(220, 120, 60),
}),
];
let long_hits = mac_tab_bar_layout_icons(&f, W as f64, &bar, &long);
let plain = mac_tab_bar_layout(&f, W as f64, &bar);
assert_eq!(
long_hits.slot_positions, plain.slot_positions,
"an overlong sidecar whose in-range entries are all None must \
reproduce the icon-less geometry",
);
}
#[test]
#[allow(deprecated)] fn icon_reservation_feeds_the_scroll_offset_correction() {
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: (0..6)
.map(|i| TabItem {
label: format!(" a_rather_long_file_name_{i}.rs "),
is_active: i == 5,
is_dirty: false,
is_preview: false,
is_closable: true,
})
.collect(),
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let icons: Vec<Option<TabIcon>> = (0..6)
.map(|_| {
Some(TabIcon {
glyph: "RR".into(),
color: Color::rgb(220, 120, 60),
})
})
.collect();
let f = font();
let plain = mac_tab_bar_layout(&f, W as f64, &bar);
let decorated = mac_tab_bar_layout_icons(&f, W as f64, &bar, &icons);
let slot_w = |h: &TabBarHits| {
let (lo, hi) = h.slot_positions[0];
hi - lo
};
assert!(
slot_w(&decorated) > slot_w(&plain),
"decorated slots must be wider than icon-less ones \
({} vs {})",
slot_w(&decorated),
slot_w(&plain),
);
assert!(
decorated.correct_scroll_offset >= plain.correct_scroll_offset,
"wider tabs can only need the same or a later scroll offset \
(icon-less {}, decorated {})",
plain.correct_scroll_offset,
decorated.correct_scroll_offset,
);
let visible = |h: &TabBarHits| h.slot_positions.len();
assert!(
visible(&decorated) <= visible(&plain),
"decorated tabs are wider, so no more of them can fit \
(icon-less {}, decorated {})",
visible(&plain),
visible(&decorated),
);
}
#[test]
#[allow(deprecated)] fn native_layout_agrees_with_hits_layout_with_icons() {
let bar = sample_bar();
let icons = sample_icons();
let f = font();
let hits = mac_tab_bar_layout_icons(&f, W as f64, &bar, &icons);
let native = mac_tab_bar_native_layout_icons(&f, W as f64, H as f64, &bar, &icons);
let native_as_hits = crate::backend::tab_bar_hits_from_layout(&native, &bar);
assert_eq!(hits.slot_positions, native_as_hits.slot_positions);
assert_eq!(hits.close_bounds, native_as_hits.close_bounds);
assert_eq!(
hits.right_segment_bounds,
native_as_hits.right_segment_bounds
);
assert_eq!(hits.correct_scroll_offset, native.resolved_scroll_offset);
}
#[test]
#[allow(deprecated)] fn tab_bar_paints_at_rect_x_not_at_window_origin() {
const SIDEBAR_W: f32 = 120.0;
let canvas_w = SIDEBAR_W as u32 + W;
let surface = BitmapSurface::new(canvas_w, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let bar = TabBar {
id: WidgetId::new("empty"),
tabs: vec![],
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(canvas_w as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
let _ = b.draw_tab_bar(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar, None);
});
backend.end_frame();
let (_, _, _, a) = surface.pixel(4, H / 2);
assert_eq!(
a, 0,
"sidebar column (x=4) must stay untouched by the tab bar fill",
);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(SIDEBAR_W as u32 + 4, H / 2);
assert_eq!(
(r, g, b),
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
"tab bar background should start at rect.x, not the window origin",
);
}
#[test]
#[allow(deprecated)] fn tab_bar_hits_are_absolute_at_nonzero_rect_x() {
const SIDEBAR_W: f32 = 120.0;
let canvas_w = SIDEBAR_W as u32 + W;
let surface = BitmapSurface::new(canvas_w, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let bar = sample_bar();
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(canvas_w as f32, H as f32, 1.0));
let hits = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar, None);
*hits.borrow_mut() = Some(h);
});
backend.end_frame();
let hits = hits.into_inner().unwrap();
let (first_start, _) = hits.slot_positions[0];
assert!(
first_start >= SIDEBAR_W as f64,
"first tab slot should start at/after rect.x={SIDEBAR_W}, got {first_start}",
);
}
#[test]
#[allow(deprecated)] fn layout_twin_matches_the_painted_hits_at_nonzero_origin() {
const SIDEBAR_W: f32 = 120.0;
let canvas_w = SIDEBAR_W as u32 + W;
let surface = BitmapSurface::new(canvas_w, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let bar = sample_bar();
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(canvas_w as f32, H as f32, 1.0));
let painted = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar, None);
*painted.borrow_mut() = Some(h);
});
backend.end_frame();
let painted = painted.into_inner().unwrap();
let computed = backend.tab_bar_layout(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar);
assert_eq!(painted.slot_positions, computed.slot_positions);
assert_eq!(painted.close_bounds, computed.close_bounds);
assert_eq!(painted.right_segment_bounds, computed.right_segment_bounds);
assert_eq!(painted.available_cols, computed.available_cols);
assert_eq!(
painted.correct_scroll_offset,
computed.correct_scroll_offset
);
assert!(painted.slot_positions[0].0 >= SIDEBAR_W as f64);
}
#[test]
#[allow(deprecated)] fn right_segments_are_shifted_by_rect_x_like_the_tabs() {
const SIDEBAR_W: f32 = 120.0;
let canvas_w = SIDEBAR_W as u32 + W;
let surface = BitmapSurface::new(canvas_w, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut bar = sample_bar();
bar.right_segments = vec![TabBarSegment {
text: "\u{22ef}".into(), width_cells: 3,
id: Some(WidgetId::new("tb:overflow")),
is_active: false,
}];
let bar_relative = mac_tab_bar_layout(&font(), W as f64, &bar);
assert_eq!(
bar_relative.right_segment_bounds.len(),
1,
"fixture bar should have exactly one right segment",
);
let (rel_start, rel_end) = bar_relative.right_segment_bounds[0];
let mut backend = backend_with_test_fonts();
backend.begin_frame(Viewport::new(canvas_w as f32, H as f32, 1.0));
let hits = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar, None);
*hits.borrow_mut() = Some(h);
});
backend.end_frame();
let hits = hits.into_inner().unwrap();
assert_eq!(hits.right_segment_bounds.len(), 1);
assert_eq!(
hits.right_segment_bounds[0],
(rel_start + SIDEBAR_W as f64, rel_end + SIDEBAR_W as f64),
"right segment bounds should be the bar-relative geometry shifted by rect.x={SIDEBAR_W}",
);
let computed = backend.tab_bar_layout(QRect::new(SIDEBAR_W, 0.0, W as f32, H as f32), &bar);
assert_eq!(hits.right_segment_bounds, computed.right_segment_bounds);
}
#[test]
#[allow(deprecated)]
fn active_chip_fully_encloses_the_painted_label_at_the_real_chrome_font() {
let icons = vec![
Some(TabIcon {
glyph: "R".into(),
color: Color::rgb(222, 165, 132),
}),
Some(TabIcon {
glyph: "T".into(),
color: Color::rgb(160, 190, 210),
}),
Some(TabIcon {
glyph: "M".into(),
color: Color::rgb(120, 170, 240),
}),
];
let bar = TabBar {
id: WidgetId::new("tabs"),
tabs: [" main.rs ", " Cargo.toml ", " README.md "]
.iter()
.enumerate()
.map(|(i, label)| TabItem {
label: (*label).to_string(),
is_active: i == 1,
..Default::default()
})
.collect(),
scroll_offset: 0,
right_segments: vec![],
active_accent: None,
show_tab_close: true,
compact: false,
};
const WIDE_W: u32 = 600;
const DEMO_ROW_H: u32 = 16;
let surface = BitmapSurface::new(WIDE_W, DEMO_ROW_H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.begin_frame(Viewport::new(WIDE_W as f32, DEMO_ROW_H as f32, 1.0));
let (_, chrome_line_height) = backend.measure_text("M", crate::FontRole::Chrome);
let hits = std::cell::RefCell::new(None);
backend.enter_frame_scope(surface.context_ptr(), |b| {
let h = b.draw_tab_bar_icons(
QRect::new(0.0, 0.0, WIDE_W as f32, DEMO_ROW_H as f32),
&bar,
&icons,
None,
);
*hits.borrow_mut() = Some(h);
});
backend.end_frame();
let hits = hits.into_inner().unwrap();
let theme = Theme::default();
let (slot_x, slot_end) = hits.slot_positions[1];
let chip_x1 = slot_end - TAB_OUTER_GAP;
fn luma(c: Color) -> f64 {
0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64
}
let ink_luma_threshold = ((luma(theme.tab_bar_bg).max(luma(theme.tab_active_bg))
+ luma(theme.tab_active_fg))
/ 2.0)
.max(luma(theme.tab_bar_bg).max(luma(theme.tab_active_bg)) + 20.0);
let inset = (TAB_CHIP_RADIUS as u32) + 6;
let probe_x0 = (slot_x as u32) + inset;
let probe_x1 = (chip_x1 as u32).saturating_sub(inset).max(probe_x0);
let bar_bg = theme.tab_bar_bg;
const BG_DIFF_EPSILON: f64 = 4.0;
let mut violations: Vec<u32> = vec![];
for y in 0..DEMO_ROW_H {
let mut row_has_ink = false;
let mut bg_color_counts: std::collections::HashMap<(u8, u8, u8), u32> =
std::collections::HashMap::new();
for x in probe_x0..probe_x1 {
let (r, g, b, _) = surface.pixel(x, y);
let pixel_luma = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
if pixel_luma > ink_luma_threshold {
row_has_ink = true;
} else {
*bg_color_counts.entry((r, g, b)).or_insert(0) += 1;
}
}
let row_has_chip_coverage = bg_color_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.is_some_and(|((r, g, b), _)| {
(r as f64 - bar_bg.r as f64).abs() > BG_DIFF_EPSILON
|| (g as f64 - bar_bg.g as f64).abs() > BG_DIFF_EPSILON
|| (b as f64 - bar_bg.b as f64).abs() > BG_DIFF_EPSILON
});
if row_has_ink && !row_has_chip_coverage {
violations.push(y);
}
}
assert!(
violations.is_empty(),
"label ink painted in a row with no chip colour beneath it \
(chrome_line_height={chrome_line_height}, demo_row_h={DEMO_ROW_H}): \
rows={violations:?}"
);
}
#[test]
fn close_dirty_glyph_override_paints_app_glyph_default_paints_codicon() {
use crate::primitives::tab_bar::TabChrome;
let bar = sample_bar();
let chrome = TabChrome::default();
let rect = QRect::new(0.0, 0.0, W as f32, H as f32);
let glyphs = TabBarGlyphs::new()
.with_close_glyph("x")
.with_dirty_glyph("*");
let paint = |glyphs: Option<&TabBarGlyphs>| -> Vec<crate::testing::TextRun> {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = backend_with_test_fonts();
backend.set_painted_text_recording(true);
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
let _ = b.draw_tab_bar_with_chrome_and_glyphs(rect, &bar, None, &chrome, glyphs);
});
let runs = backend.text_runs().to_vec();
backend.end_frame();
runs
};
let overridden = paint(Some(&glyphs));
assert!(
overridden.iter().any(|r| r.text == "*"),
"expected the overridden dirty glyph among painted runs, got {:?}",
overridden
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
);
assert!(!overridden
.iter()
.any(|r| r.text.contains(crate::codicon::DIRTY)));
assert!(
overridden.iter().any(|r| r.text == "x"),
"expected the overridden close glyph among painted runs, got {:?}",
overridden
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
);
assert!(!overridden
.iter()
.any(|r| r.text.contains(crate::codicon::CLOSE)));
let default_runs = paint(None);
assert!(
default_runs
.iter()
.any(|r| r.text.contains(crate::codicon::DIRTY)),
"expected the default codicon dirty glyph among painted runs, got {:?}",
default_runs
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
);
assert!(
default_runs
.iter()
.any(|r| r.text.contains(crate::codicon::CLOSE)),
"expected the default codicon close glyph among painted runs, got {:?}",
default_runs
.iter()
.map(|r| r.text.as_str())
.collect::<Vec<_>>()
);
}
}