use core_graphics::geometry::CGRect;
use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;
use super::text::measure_text;
use crate::primitives::list::{ListView, ListViewLayout};
use crate::theme::Theme;
pub fn mac_list_layout(
list: &ListView,
w: f64,
h: f64,
line_height: f64,
char_width: f64,
) -> ListViewLayout {
crate::primitives::layout_metrics::list_layout(list, w, h, line_height, char_width, 0.0)
}
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_list(
ctx: CGContextRef,
font: &CTFont,
x: f64,
y: f64,
w: f64,
h: f64,
list: &ListView,
theme: &Theme,
line_height: f64,
nerd_fonts_enabled: bool,
) -> ListViewLayout {
let (char_w, _) = measure_text(font, "M");
let char_w = char_w.max(1.0);
if w <= 0.0 || h <= 0.0 {
return mac_list_layout(list, w.max(0.0), h.max(0.0), line_height, char_w);
}
let layout = mac_list_layout(list, w, h, line_height, char_w);
CGContextSaveGState(ctx);
CGContextClipToRect(ctx, CGRect::new_xywh(x, y, w, h));
let area = crate::event::Rect::new(x as f32, y as f32, w as f32, h as f32);
let mut surface = super::surface::CgSurface {
ctx,
font: Some(font),
};
crate::primitives::list::native_surface_paint::paint(
list,
area,
&layout,
line_height as f32,
nerd_fonts_enabled,
false,
true,
&mut surface,
theme,
);
CGContextRestoreGState(ctx);
layout
}
trait CGRectExt {
fn new_xywh(x: f64, y: f64, w: f64, h: f64) -> Self;
}
impl CGRectExt for CGRect {
fn new_xywh(x: f64, y: f64, w: f64, h: f64) -> Self {
use core_graphics::geometry::{CGPoint, CGSize};
CGRect::new(&CGPoint::new(x, y), &CGSize::new(w, h))
}
}
extern "C" {
fn CGContextSaveGState(c: CGContextRef);
fn CGContextRestoreGState(c: CGContextRef);
fn CGContextClipToRect(c: CGContextRef, rect: CGRect);
}
#[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::list::{ListItem, ListViewHit};
use crate::types::{Color, Decoration, StyledSpan, StyledText, WidgetId};
use crate::Backend;
const W: u32 = 240;
const H: u32 = 160;
fn font() -> CTFont {
make_font("Menlo", 14.0).expect("Menlo installed")
}
fn sample_item(label: &str, bg: Color) -> ListItem {
ListItem {
text: StyledText {
spans: vec![StyledSpan {
text: label.into(),
fg: Some(Color::rgb(255, 255, 255)),
bg: Some(bg),
bold: false,
italic: false,
underline: false,
}],
},
icon: None,
detail: None,
decoration: Decoration::Normal,
}
}
fn sample_list() -> ListView {
ListView {
id: WidgetId::new("lv"),
title: Some(StyledText::plain("Quick fix")),
items: vec![
sample_item("alpha", Color::rgb(10, 20, 30)),
sample_item("beta", Color::rgb(10, 20, 30)),
sample_item("gamma", Color::rgb(10, 20, 30)),
sample_item("delta", Color::rgb(10, 20, 30)),
],
selected_idx: 1,
scroll_offset: 0,
has_focus: true,
bordered: false,
h_scroll: 0,
max_content_width: None,
show_v_scrollbar: false,
}
}
fn paint_via_backend(list: &ListView) -> (BitmapSurface, ListViewLayout) {
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 layout = std::cell::RefCell::new(None);
let rect = QRect::new(0.0, 0.0, W as f32, H as f32);
backend.enter_frame_scope(surface.context_ptr(), |b| {
b.draw_list(rect, list);
let l = b.list_layout(rect, list);
*layout.borrow_mut() = Some(l);
});
backend.end_frame();
(surface, layout.into_inner().unwrap())
}
#[test]
fn title_strip_paints_header_bg() {
let list = sample_list();
let (surface, layout) = paint_via_backend(&list);
let theme = Theme::default();
let tb = layout.title_bounds.expect("title present");
let px = (tb.x + tb.width - 2.0) as u32;
let py = (tb.y + tb.height / 2.0) as u32;
let (r, g, b, _) = surface.pixel(px, py);
assert_eq!(
(r, g, b),
(theme.header_bg.r, theme.header_bg.g, theme.header_bg.b),
);
}
#[test]
fn selected_row_paints_selected_bg() {
let list = sample_list();
let (surface, layout) = paint_via_backend(&list);
let theme = Theme::default();
let sel = layout
.visible_items
.iter()
.find(|v| v.item_idx == 1)
.expect("selected row visible");
let px = (sel.bounds.x + sel.bounds.width - 2.0) as u32;
let py = (sel.bounds.y + sel.bounds.height / 2.0) as u32;
let (r, g, b, _) = surface.pixel(px, py);
assert_eq!(
(r, g, b),
(
theme.selected_bg.r,
theme.selected_bg.g,
theme.selected_bg.b
),
);
}
#[test]
fn unselected_row_paints_background() {
let list = sample_list();
let (surface, layout) = paint_via_backend(&list);
let theme = Theme::default();
let other = layout
.visible_items
.iter()
.find(|v| v.item_idx == 2)
.expect("non-selected row visible");
let px = (other.bounds.x + other.bounds.width - 2.0) as u32;
let py = (other.bounds.y + other.bounds.height / 2.0) as u32;
let (r, g, b, _) = surface.pixel(px, py);
assert_eq!(
(r, g, b),
(theme.background.r, theme.background.g, theme.background.b),
);
}
#[test]
fn hit_test_resolves_painted_rows() {
let list = sample_list();
let (_surface, layout) = paint_via_backend(&list);
for vis in &layout.visible_items {
let cx = vis.bounds.x + vis.bounds.width * 0.5;
let cy = vis.bounds.y + vis.bounds.height * 0.5;
assert_eq!(
layout.hit_test(cx, cy),
ListViewHit::Item(vis.item_idx),
"row {} hit-test",
vis.item_idx,
);
}
}
#[test]
fn layout_returns_local_coords_when_area_offset() {
let list = sample_list();
let area_x: f32 = 0.0;
let area_y: f32 = 60.0;
let rect = QRect::new(area_x, area_y, W as f32, H as f32);
let mut backend = MacBackend::new();
backend.set_current_line_height(16.0);
backend.set_current_char_width(8.0);
let layout = backend.list_layout(rect, &list);
let tb = layout.title_bounds.expect("title present");
assert_eq!(
tb.y, 0.0,
"title_bounds.y must be local (0.0), got {}",
tb.y,
);
for vi in &layout.visible_items {
let abs_x = area_x + vi.bounds.x + vi.bounds.width * 0.5;
let abs_y = area_y + vi.bounds.y + vi.bounds.height * 0.5;
let local_x = abs_x - area_x;
let local_y = abs_y - area_y;
assert_eq!(
layout.hit_test(local_x, local_y),
ListViewHit::Item(vi.item_idx),
"row {} click → wrong hit (coord-frame drift)",
vi.item_idx,
);
}
}
#[test]
fn hit_test_below_last_row_is_empty() {
let list = ListView {
items: vec![
sample_item("alpha", Color::rgb(10, 20, 30)),
sample_item("beta", Color::rgb(10, 20, 30)),
],
..sample_list()
};
let (_surface, layout) = paint_via_backend(&list);
let last = layout.visible_items.last().expect("at least one row");
let cx = last.bounds.x + last.bounds.width * 0.5;
let below_y = last.bounds.y + last.bounds.height + 4.0;
assert_eq!(layout.hit_test(cx, below_y), ListViewHit::Empty);
}
#[test]
fn hscrollbar_reservation_matches_layout_and_paint() {
let mut list = sample_list();
list.max_content_width = Some(1000);
let (_surface, painted_layout) = paint_via_backend(&list);
let mut backend = MacBackend::new();
backend.set_current_font(font());
let no_paint_layout =
Backend::list_layout(&backend, QRect::new(0.0, 0.0, W as f32, H as f32), &list);
assert_eq!(
no_paint_layout, painted_layout,
"Backend::list_layout must equal the layout draw_list actually \
painted once an h-scrollbar row is reserved"
);
let last = no_paint_layout
.visible_items
.last()
.expect("at least one row visible");
assert!(
last.bounds.y + last.bounds.height <= H as f32 - backend.line_height(),
"content must stop before the reserved h-scrollbar row: \
last row bottom = {}, viewport H = {H}, line_height = {}",
last.bounds.y + last.bounds.height,
backend.line_height(),
);
}
#[test]
fn paints_vertical_scrollbar_track_when_enabled() {
let mut list = ListView {
title: None,
items: (0..30)
.map(|i| sample_item(&format!("row-{i}"), Color::rgb(10, 20, 30)))
.collect(),
show_v_scrollbar: true,
..sample_list()
};
list.selected_idx = 0;
let mut backend = MacBackend::new();
backend.set_current_font(font());
let rect = QRect::new(0.0, 0.0, W as f32, H as f32);
let expected = backend
.list_vscrollbar(rect, &list)
.expect("30 rows in a small viewport must need a v-scrollbar");
let (surface, _layout) = paint_via_backend(&list);
let theme = Theme::default();
let probe_x = (expected.track.x + expected.track.width / 2.0) as u32;
let probe_y = (expected.track.y + expected.track.height / 2.0) as u32;
let (r, g, b, _) = surface.pixel(probe_x.min(W - 1), probe_y.min(H - 1));
assert_ne!(
(r, g, b),
(theme.background.r, theme.background.g, theme.background.b),
"expected the v-scrollbar track at ({probe_x}, {probe_y}) to be painted \
(non-background) — the #1075 regression this test guards"
);
}
#[test]
fn paints_item_icon_when_present() {
use crate::types::Icon;
let inked_width = |icon: Option<Icon>| -> u32 {
let list = ListView {
title: None,
items: vec![ListItem {
text: StyledText::plain("x"),
icon,
detail: None,
decoration: Decoration::Normal,
}],
selected_idx: usize::MAX, ..sample_list()
};
let (surface, layout) = paint_via_backend(&list);
let row = layout.visible_items.first().expect("row painted");
let mid_y = (row.bounds.y + row.bounds.height / 2.0) as u32;
let theme = Theme::default();
let bg = (theme.background.r, theme.background.g, theme.background.b);
let mut left = None;
let mut right = None;
for x in row.bounds.x as u32..(row.bounds.x + row.bounds.width) as u32 {
let (r, g, b, _) = surface.pixel(x.min(W - 1), mid_y.min(H - 1));
if (r, g, b) != bg {
left.get_or_insert(x);
right = Some(x);
}
}
match (left, right) {
(Some(l), Some(r)) => r - l + 1,
_ => 0,
}
};
let without_icon = inked_width(None);
let with_icon = inked_width(Some(Icon::new("WWWW", "WWWW")));
assert!(
with_icon > without_icon,
"a row with an icon should paint strictly more ink ({with_icon}px) than \
the same row without one ({without_icon}px) — the #1075 icon-paint \
regression this test guards"
);
}
}