use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;
use crate::primitives::activity_bar::{
native_surface_paint, ActivityBar, ActivityBarRowHit, ActivityBarStyle,
};
use crate::theme::Theme;
pub const ACTIVITY_ROW_PX: f64 = 48.0;
pub fn mac_activity_bar_layout(
width: f64,
height: f64,
bar: &ActivityBar,
) -> Vec<ActivityBarRowHit> {
bar.layout(width as f32, height as f32, ACTIVITY_ROW_PX as f32)
.visible_items
.into_iter()
.map(|vi| {
let item = match vi.side {
crate::primitives::activity_bar::ActivitySide::Top => &bar.top_items[vi.item_idx],
crate::primitives::activity_bar::ActivitySide::Bottom => {
&bar.bottom_items[vi.item_idx]
}
};
ActivityBarRowHit {
y_start: vi.bounds.y,
y_end: vi.bounds.y + vi.bounds.height,
id: item.id,
tooltip: item.tooltip.clone(),
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_activity_bar(
ctx: CGContextRef,
font: &CTFont,
width: f64,
height: f64,
bar: &ActivityBar,
theme: &Theme,
hovered_idx: Option<usize>,
nerd_fonts_enabled: bool,
) -> Vec<ActivityBarRowHit> {
draw_activity_bar_with_style(
ctx,
font,
width,
height,
bar,
&ActivityBarStyle::default(),
theme,
hovered_idx,
nerd_fonts_enabled,
)
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_activity_bar_with_style(
ctx: CGContextRef,
font: &CTFont,
width: f64,
height: f64,
bar: &ActivityBar,
style: &ActivityBarStyle,
theme: &Theme,
hovered_idx: Option<usize>,
nerd_fonts_enabled: bool,
) -> Vec<ActivityBarRowHit> {
CGContextSaveGState(ctx);
let layout = bar.layout(width as f32, height as f32, ACTIVITY_ROW_PX as f32);
let icon_font = font.clone_with_font_size(style.resolved_icon_size_px() as f64);
let mut surface = super::surface::CgSurface {
ctx,
font: Some(&icon_font),
};
let regions = native_surface_paint::paint(
bar,
&layout,
style,
&mut surface,
theme,
hovered_idx,
nerd_fonts_enabled,
);
CGContextRestoreGState(ctx);
regions
}
extern "C" {
fn CGContextSaveGState(c: CGContextRef);
fn CGContextRestoreGState(c: CGContextRef);
}
#[cfg(test)]
mod tests {
use super::super::headless::BitmapSurface;
use super::super::text::{make_font, system_ui_font};
use super::super::MacBackend;
use super::*;
use crate::event::{Rect as QRect, Viewport};
use crate::primitives::activity_bar::ActivityItem;
use crate::theme::Theme;
use crate::types::{Color, WidgetId};
use crate::Backend;
const W: u32 = 48;
const H: u32 = 240;
fn font() -> CTFont {
make_font("Menlo", 14.0).expect("Menlo installed")
}
fn sample_bar() -> ActivityBar {
ActivityBar {
id: WidgetId::new("activity"),
top_items: vec![
ActivityItem {
id: WidgetId::new("activity:explorer"),
icon: "E".into(),
tooltip: "Explorer".into(),
is_active: true,
is_keyboard_selected: false,
},
ActivityItem {
id: WidgetId::new("activity:search"),
icon: "S".into(),
tooltip: "Search".into(),
is_active: false,
is_keyboard_selected: false,
},
],
bottom_items: vec![ActivityItem {
id: WidgetId::new("activity:settings"),
icon: "G".into(),
tooltip: "Settings".into(),
is_active: false,
is_keyboard_selected: false,
}],
active_accent: Some(Color::rgb(80, 140, 255)),
selection_bg: None,
is_keyboard_focused: false,
}
}
fn paint_via_backend(
bar: &ActivityBar,
hovered: Option<usize>,
) -> (BitmapSurface, Vec<ActivityBarRowHit>) {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
let regions = std::cell::RefCell::new(Vec::new());
backend.enter_frame_scope(surface.context_ptr(), |b| {
let r = b.draw_activity_bar(QRect::new(0.0, 0.0, W as f32, H as f32), bar, hovered);
*regions.borrow_mut() = r;
});
backend.end_frame();
(surface, regions.into_inner())
}
fn paint_via_backend_at(
bar: &ActivityBar,
hovered: Option<usize>,
x: f32,
y: f32,
canvas_w: u32,
canvas_h: u32,
) -> (BitmapSurface, Vec<ActivityBarRowHit>) {
let surface = BitmapSurface::new(canvas_w, canvas_h);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.begin_frame(Viewport::new(canvas_w as f32, canvas_h as f32, 1.0));
let regions = std::cell::RefCell::new(Vec::new());
backend.enter_frame_scope(surface.context_ptr(), |b| {
let r = b.draw_activity_bar(QRect::new(x, y, W as f32, H as f32), bar, hovered);
*regions.borrow_mut() = r;
});
backend.end_frame();
(surface, regions.into_inner())
}
fn paint_via_backend_with_style(
bar: &ActivityBar,
style: &crate::ActivityBarStyle,
hovered: Option<usize>,
) -> (BitmapSurface, Vec<ActivityBarRowHit>) {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_current_font(font());
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
let regions = std::cell::RefCell::new(Vec::new());
backend.enter_frame_scope(surface.context_ptr(), |b| {
let r = b.draw_activity_bar_with_style(
QRect::new(0.0, 0.0, W as f32, H as f32),
bar,
hovered,
style,
);
*regions.borrow_mut() = r;
});
backend.end_frame();
(surface, regions.into_inner())
}
fn ink_height_in_row(surface: &BitmapSurface, y0: u32, y1: u32) -> u32 {
let theme = Theme::default();
let bg = (theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b);
let mut found: Option<(u32, u32)> = None;
for y in y0..y1 {
for x in 0..(W - 1) {
let (r, g, b, _) = surface.pixel(x, y);
if (r, g, b) != bg {
found = Some(match found {
Some((min_y, max_y)) => (min_y.min(y), max_y.max(y)),
None => (y, y),
});
}
}
}
found.map_or(0, |(min_y, max_y)| max_y - min_y + 1)
}
#[test]
fn icon_ink_height_follows_style_not_chrome_font_size() {
use crate::primitives::activity_bar::DEFAULT_ACTIVITY_ICON_SIZE_PX;
let mut bar = sample_bar();
bar.active_accent = None;
let settings_y0 = H - ACTIVITY_ROW_PX as u32;
let settings_y1 = H;
let paint_with = |style: &crate::ActivityBarStyle, chrome_size_pt: f64| -> u32 {
let surface = BitmapSurface::new(W, H);
surface.fill(0.0, 0.0, 0.0, 0.0);
let mut backend = MacBackend::new();
backend.set_chrome_font(system_ui_font(chrome_size_pt));
backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_activity_bar_with_style(
QRect::new(0.0, 0.0, W as f32, H as f32),
&bar,
None,
style,
);
});
backend.end_frame();
ink_height_in_row(&surface, settings_y0, settings_y1)
};
let default_style = crate::ActivityBarStyle::default();
let default_small_chrome = paint_with(&default_style, 6.0);
let default_huge_chrome = paint_with(&default_style, 40.0);
assert!(
default_small_chrome > 0,
"sanity: the glyph should paint some ink"
);
assert_eq!(
default_small_chrome, default_huge_chrome,
"default icon ink height must not track chrome_font's own size \
(6pt vs 40pt chrome font should paint identically)"
);
let small_style = crate::ActivityBarStyle::new().with_icon_size_px(10.0);
let small_icon = paint_with(&small_style, 11.0);
assert!(
small_icon < default_huge_chrome,
"icon_size_px(10.0) ({small_icon}px ink) should paint smaller than \
the {DEFAULT_ACTIVITY_ICON_SIZE_PX}px default ({default_huge_chrome}px ink)"
);
}
#[test]
fn background_is_tab_bar_bg() {
let bar = sample_bar();
let (surface, _) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W - 6, (ACTIVITY_ROW_PX as u32) + 4);
assert_eq!(
(r, g, b),
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
);
}
#[test]
fn active_item_paints_accent_strip_on_left_edge() {
let bar = sample_bar();
let (surface, _) = paint_via_backend(&bar, None);
let accent = bar.active_accent.unwrap();
let probe_y = (ACTIVITY_ROW_PX as u32) / 2;
let (r, g, b, _) = surface.pixel(0, probe_y);
assert_eq!((r, g, b), (accent.r, accent.g, accent.b));
let (r2, g2, b2, _) = surface.pixel(1, probe_y);
assert_eq!((r2, g2, b2), (accent.r, accent.g, accent.b));
let (r3, g3, b3, _) = surface.pixel(2, 2);
let theme = Theme::default();
assert_eq!(
(r3, g3, b3),
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
"x=2 should be tab_bar_bg, not accent",
);
}
#[test]
fn active_bg_fills_row_with_zero_accent_pixels_when_accent_is_none() {
let mut bar = sample_bar();
bar.active_accent = None;
let fill = Color::rgb(49, 50, 51);
let style = crate::ActivityBarStyle::new().with_active_bg(fill);
let (surface, _) = paint_via_backend_with_style(&bar, &style, None);
let probe_y = (ACTIVITY_ROW_PX as u32) / 2;
for x in [0, 1] {
let (r, g, b, _) = surface.pixel(x, probe_y);
assert_eq!(
(r, g, b),
(fill.r, fill.g, fill.b),
"x={x} is inside the legacy accent strip; with active_accent \
None it must show the active_bg fill, not any accent tint"
);
}
let (r, g, b, _) = surface.pixel(W - 6, probe_y);
assert_eq!((r, g, b), (fill.r, fill.g, fill.b));
}
#[test]
fn row_hits_cover_painted_rows() {
let bar = sample_bar();
let (_surface, regions) = paint_via_backend(&bar, None);
assert_eq!(regions.len(), 3);
assert_eq!(regions[0].y_start, H as f32 - ACTIVITY_ROW_PX as f32);
assert_eq!(regions[0].y_end, H as f32);
assert_eq!(regions[0].id, WidgetId::new("activity:settings"));
assert_eq!(regions[1].y_start, 0.0);
assert_eq!(regions[1].y_end, ACTIVITY_ROW_PX as f32);
assert_eq!(regions[1].id, WidgetId::new("activity:explorer"));
assert_eq!(regions[2].y_start, ACTIVITY_ROW_PX as f32);
assert_eq!(regions[2].y_end, 2.0 * ACTIVITY_ROW_PX as f32);
assert_eq!(regions[2].id, WidgetId::new("activity:search"));
}
#[test]
fn right_edge_has_separator_pixel() {
let bar = sample_bar();
let (surface, _) = paint_via_backend(&bar, None);
let theme = Theme::default();
let (r, g, b, _) = surface.pixel(W - 1, H / 2);
assert_eq!(
(r, g, b),
(theme.separator.r, theme.separator.g, theme.separator.b),
);
}
#[test]
fn keyboard_selected_row_paints_selection_bg() {
let mut bar = sample_bar();
bar.top_items[1].is_keyboard_selected = true;
let (surface, _) = paint_via_backend(&bar, None);
let theme = Theme::default();
let expected = theme.tab_bar_bg.lighten(0.20);
let probe_y = ACTIVITY_ROW_PX as u32 + ACTIVITY_ROW_PX as u32 / 2;
let (r, g, b, _) = surface.pixel(W - 6, probe_y);
assert_eq!(
(r, g, b),
(expected.r, expected.g, expected.b),
"keyboard-selected row should paint the selection background \
(quadraui#1081 — this used to be a no-op on macOS)",
);
}
#[test]
#[ignore = "writes /tmp/quadraui_activity_bar.ppm — opt in with --ignored"]
fn dump_smoke_ppm() {
let bar = sample_bar();
let (surface, _) = paint_via_backend(&bar, Some(1));
surface.write_ppm_and_open("/tmp/quadraui_activity_bar.ppm");
}
#[test]
fn hover_lightens_row_background() {
let bar = sample_bar();
let (surface, _) = paint_via_backend(&bar, Some(1));
let theme = Theme::default();
let expected = theme.tab_bar_bg.lighten(0.10);
let (r, g, b, _) = surface.pixel(W - 6, 4);
assert_eq!(
(r, g, b),
(expected.r, expected.g, expected.b),
"hovered row should paint the lightened bg",
);
}
#[test]
fn layout_twin_matches_the_painted_rows() {
let bar = sample_bar();
let (_surface, painted) = paint_via_backend(&bar, None);
let mut backend = MacBackend::new();
backend.set_current_font(font());
let computed = backend.activity_bar_layout(QRect::new(0.0, 0.0, W as f32, H as f32), &bar);
assert_eq!(painted.len(), computed.len());
for (p, c) in painted.iter().zip(computed.iter()) {
assert_eq!(p.id, c.id);
assert_eq!(p.tooltip, c.tooltip);
assert!((p.y_start - c.y_start).abs() < 0.001);
assert!((p.y_end - c.y_end).abs() < 0.001);
}
}
#[test]
fn layout_twin_spans_are_bar_relative_at_a_nonzero_origin() {
let bar = sample_bar();
let mut backend = MacBackend::new();
backend.set_current_font(font());
let at_origin = backend.activity_bar_layout(QRect::new(0.0, 0.0, W as f32, H as f32), &bar);
let moved = backend.activity_bar_layout(QRect::new(23.0, 41.0, W as f32, H as f32), &bar);
assert_eq!(at_origin.len(), moved.len());
assert!((at_origin[0].y_start - (H as f32 - ACTIVITY_ROW_PX as f32)).abs() < 0.001);
for (a, m) in at_origin.iter().zip(moved.iter()) {
assert!(
(a.y_start - m.y_start).abs() < 0.001,
"row {:?} moved with the bar origin: {} vs {}",
a.id,
a.y_start,
m.y_start,
);
}
}
#[test]
fn background_paints_at_rect_origin_not_at_window_origin() {
const ORIGIN_X: f32 = 30.0;
const ORIGIN_Y: f32 = 28.0; let canvas_w = ORIGIN_X as u32 + W;
let canvas_h = ORIGIN_Y as u32 + H;
let bar = sample_bar();
let (surface, regions) =
paint_via_backend_at(&bar, None, ORIGIN_X, ORIGIN_Y, canvas_w, canvas_h);
let theme = Theme::default();
let (_, _, _, a) = surface.pixel(0, 0);
assert_eq!(
a, 0,
"window origin (0,0) must stay untouched when the bar sits at ({ORIGIN_X}, {ORIGIN_Y})",
);
let probe_x = ORIGIN_X as u32 + W - 6;
let probe_y = ORIGIN_Y as u32 + ACTIVITY_ROW_PX as u32 + 4;
let (r, g, b, _) = surface.pixel(probe_x, probe_y);
assert_eq!(
(r, g, b),
(theme.tab_bar_bg.r, theme.tab_bar_bg.g, theme.tab_bar_bg.b),
"activity bar background should paint at rect's origin, not the window origin",
);
let (sr, sg, sb, _) = surface.pixel(ORIGIN_X as u32 + W - 1, ORIGIN_Y as u32 + H / 2);
assert_eq!(
(sr, sg, sb),
(theme.separator.r, theme.separator.g, theme.separator.b),
"right-edge separator should shift with the bar's origin",
);
assert_eq!(regions[0].y_start, H as f32 - ACTIVITY_ROW_PX as f32);
}
}