use gtk4::cairo::Context;
use gtk4::pango;
use gtk4::pango::FontDescription;
use crate::primitives::activity_bar::{
native_surface_paint, ActivityBar, ActivityBarRowHit, ActivityBarStyle,
};
use crate::theme::Theme;
pub const ACTIVITY_ROW_PX: f64 = 48.0;
#[allow(dead_code)]
pub const ICON_FONT_DESC: &str = "Symbols Nerd Font, monospace 18";
fn activity_bar_icon_font(size_px: f32) -> FontDescription {
let pt = size_px * 72.0 / 96.0;
let mut f = FontDescription::from_string(&format!("monospace {pt}"));
f.set_family(&format!(
"{}, {}, monospace",
super::current_nerd_font_fallback_family(),
crate::codicon::FONT_FAMILY
));
f
}
#[allow(clippy::too_many_arguments)]
pub fn draw_activity_bar(
cr: &Context,
pango_layout: &pango::Layout,
width: f64,
height: f64,
bar: &ActivityBar,
theme: &Theme,
hovered_idx: Option<usize>,
nerd_fonts_enabled: bool,
) -> Vec<ActivityBarRowHit> {
draw_activity_bar_with_style(
cr,
pango_layout,
width,
height,
bar,
&ActivityBarStyle::default(),
theme,
hovered_idx,
nerd_fonts_enabled,
)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_activity_bar_with_style(
cr: &Context,
pango_layout: &pango::Layout,
width: f64,
height: f64,
bar: &ActivityBar,
style: &ActivityBarStyle,
theme: &Theme,
hovered_idx: Option<usize>,
nerd_fonts_enabled: bool,
) -> Vec<ActivityBarRowHit> {
let saved_font = pango_layout.font_description().unwrap_or_default();
let icon_font = activity_bar_icon_font(style.resolved_icon_size_px());
pango_layout.set_font_description(Some(&icon_font));
pango_layout.set_attributes(None);
let layout = bar.layout(width as f32, height as f32, ACTIVITY_ROW_PX as f32);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(pango_layout),
translucent_fill: true,
};
let regions = native_surface_paint::paint(
bar,
&layout,
style,
&mut surface,
theme,
hovered_idx,
nerd_fonts_enabled,
);
pango_layout.set_font_description(Some(&saved_font));
regions
}
#[cfg(test)]
mod tests {
use super::*;
use pangocairo::cairo::{Context, Format, ImageSurface};
#[test]
fn icon_glyph_shrinks_while_row_height_is_unchanged() {
assert_eq!(
ACTIVITY_ROW_PX, 48.0,
"the 48px row metric must not move — only the glyph size (#620)"
);
assert!(
ICON_FONT_DESC.ends_with(" 18"),
"icon font point size should be 18 (≈ 24px @ 96dpi), got {ICON_FONT_DESC:?}"
);
let surface = ImageSurface::create(Format::ARgb32, 64, 64).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let measure = |desc: &str| -> (i32, i32) {
let font = FontDescription::from_string(desc);
pango_layout.set_font_description(Some(&font));
pango_layout.set_text("\u{f07b}");
pango_layout.pixel_size()
};
let (old_w, old_h) = measure("Symbols Nerd Font, monospace 20");
let (new_w, new_h) = measure(ICON_FONT_DESC);
assert!(
new_h < old_h,
"new icon glyph height ({new_h}) should be smaller than the pre-#620 \
20pt height ({old_h})"
);
assert!(
new_w <= old_w,
"new icon glyph width ({new_w}) should not exceed the pre-#620 20pt \
width ({old_w})"
);
}
#[test]
fn icon_font_matches_the_pinned_default_description() {
use crate::primitives::activity_bar::DEFAULT_ACTIVITY_ICON_SIZE_PX;
let desc = activity_bar_icon_font(DEFAULT_ACTIVITY_ICON_SIZE_PX);
let pinned = FontDescription::from_string(ICON_FONT_DESC);
assert_eq!(
desc.size(),
pinned.size(),
"activity_bar_icon_font({DEFAULT_ACTIVITY_ICON_SIZE_PX}) should match \
ICON_FONT_DESC's pinned point size"
);
}
#[test]
fn icon_ink_height_follows_style_not_the_callers_font() {
let bar = one_item_bar(None);
let ink_height = |style: &ActivityBarStyle, callers_font_pt: f64| -> i32 {
let mut surface =
ImageSurface::create(Format::ARgb32, ROW_W, ACTIVITY_ROW_PX as i32).unwrap();
{
let cr = Context::new(&surface).unwrap();
let pango_layout = pangocairo::functions::create_layout(&cr);
pango_layout.set_font_description(Some(&FontDescription::from_string(&format!(
"monospace {callers_font_pt}"
))));
draw_activity_bar_with_style(
&cr,
&pango_layout,
ROW_W as f64,
ACTIVITY_ROW_PX,
&bar,
style,
&Theme::default(),
None,
false,
);
}
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().unwrap();
let theme = Theme::default();
let bg = (theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b);
let (mut min_y, mut max_y) = (i32::MAX, i32::MIN);
for y in 0..ACTIVITY_ROW_PX as i32 {
for x in 0..ROW_W - 1 {
if pixel(&data, stride, x, y) != bg {
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
if min_y > max_y {
0
} else {
max_y - min_y + 1
}
};
let default_at_small_font = ink_height(&ActivityBarStyle::default(), 6.0);
let default_at_huge_font = ink_height(&ActivityBarStyle::default(), 60.0);
assert_eq!(
default_at_small_font, default_at_huge_font,
"default icon ink height must not track the caller's font size \
(6pt vs 60pt should paint identically)"
);
let small_icon = ink_height(&ActivityBarStyle::new().with_icon_size_px(10.0), 14.0);
assert!(
small_icon < default_at_huge_font,
"icon_size_px(10.0) ({small_icon}px ink) should paint smaller than \
the 24px default ({default_at_huge_font}px ink)"
);
}
use crate::primitives::activity_bar::ActivityItem;
use crate::types::{Color, WidgetId};
const ROW_W: i32 = 48;
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 one_item_bar(active_accent: Option<Color>) -> ActivityBar {
ActivityBar {
id: WidgetId::new("bar"),
top_items: vec![ActivityItem {
id: WidgetId::new("activity:explorer"),
icon: "E".into(),
tooltip: String::new(),
is_active: true,
is_keyboard_selected: false,
}],
bottom_items: vec![],
active_accent,
selection_bg: None,
is_keyboard_focused: false,
}
}
fn paint_one_row(bar: &ActivityBar, style: &ActivityBarStyle) -> ImageSurface {
let surface = ImageSurface::create(Format::ARgb32, ROW_W, ACTIVITY_ROW_PX as i32)
.expect("create ImageSurface");
{
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
draw_activity_bar_with_style(
&cr,
&pango_layout,
ROW_W as f64,
ACTIVITY_ROW_PX,
bar,
style,
&Theme::default(),
None,
false,
);
}
surface.flush();
surface
}
#[test]
fn active_bg_fills_row_with_zero_accent_pixels_when_accent_is_none() {
let active_bg = Color::rgb(49, 50, 51);
let bar = one_item_bar(None);
let style = ActivityBarStyle::new().with_active_bg(active_bg);
let mut surface = paint_one_row(&bar, &style);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let mid_y = (ACTIVITY_ROW_PX as i32) / 2;
for x in 0..2 {
let px = pixel(&data, stride, x, mid_y);
assert_eq!(
px,
(active_bg.r, active_bg.g, active_bg.b),
"x={x} is inside the legacy accent column; with active_accent \
None it must show the active_bg fill, not any accent tint \
(zero accent-line pixels)"
);
}
let px = pixel(&data, stride, ROW_W - 4, mid_y);
assert_eq!(px, (active_bg.r, active_bg.g, active_bg.b));
}
#[test]
fn active_accent_paints_two_px_line_when_set() {
let accent = Color::rgb(80, 140, 255);
let bar = one_item_bar(Some(accent));
let mut surface = paint_one_row(&bar, &ActivityBarStyle::default());
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let mid_y = (ACTIVITY_ROW_PX as i32) / 2;
assert_eq!(
pixel(&data, stride, 0, mid_y),
(accent.r, accent.g, accent.b)
);
assert_eq!(
pixel(&data, stride, 1, mid_y),
(accent.r, accent.g, accent.b)
);
let theme = Theme::default();
let bg_px = pixel(&data, stride, 2, 2);
assert_eq!(
bg_px,
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
"x=2 is past the 2px accent strip and should be tab_bar_bg"
);
}
#[test]
fn neither_knob_set_paints_plain_row() {
let bar = one_item_bar(None);
let mut surface = paint_one_row(&bar, &ActivityBarStyle::default());
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let theme = Theme::default();
let mid_y = (ACTIVITY_ROW_PX as i32) / 2;
for x in [0, 1, ROW_W - 4] {
let px = pixel(&data, stride, x, mid_y);
assert_eq!(
px,
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
"x={x} should be plain tab_bar_bg when neither active_bg nor \
active_accent is set"
);
}
}
#[test]
fn nerd_fonts_flag_selects_glyph_or_fallback() {
use crate::types::Icon;
let bar = ActivityBar {
id: WidgetId::new("bar"),
top_items: vec![ActivityItem {
id: WidgetId::new("activity:explorer"),
icon: Icon::new("WWWW", "E"),
tooltip: String::new(),
is_active: false,
is_keyboard_selected: false,
}],
bottom_items: vec![],
active_accent: None,
selection_bg: None,
is_keyboard_focused: false,
};
let painted_width = |nerd_fonts_enabled: bool| -> i32 {
let mut surface =
ImageSurface::create(Format::ARgb32, ROW_W, ACTIVITY_ROW_PX as i32).unwrap();
{
let cr = Context::new(&surface).unwrap();
let pango_layout = pangocairo::functions::create_layout(&cr);
draw_activity_bar_with_style(
&cr,
&pango_layout,
ROW_W as f64,
ACTIVITY_ROW_PX,
&bar,
&ActivityBarStyle::default(),
&Theme::default(),
None,
nerd_fonts_enabled,
);
}
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().unwrap();
let theme = Theme::default();
let bg = (theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b);
let mid_y = (ACTIVITY_ROW_PX as i32) / 2;
let mut left = None;
let mut right = None;
for x in 0..ROW_W {
if pixel(&data, stride, x, mid_y) != bg {
left.get_or_insert(x);
right = Some(x);
}
}
match (left, right) {
(Some(l), Some(r)) => r - l + 1,
_ => 0,
}
};
let glyph_width = painted_width(true);
let fallback_width = painted_width(false);
assert!(
glyph_width > fallback_width,
"nerd_fonts_enabled: true should paint the wider glyph half \
(\"WWWW\", measured {glyph_width}px) vs the narrower fallback \
half (\"E\", measured {fallback_width}px) painted when false"
);
}
}