use gtk4::cairo::Context;
use gtk4::pango;
use super::{cairo_rgb, draw_list, draw_message_list, draw_tree};
use crate::event::Rect as QRect;
use crate::primitives::multi_section_view::{
Axis, EmptyBody, MsvLayoutMetrics, MultiSectionView, MultiSectionViewLayout, SectionAux,
SectionBody, SectionHeader,
};
use crate::theme::Theme;
use crate::types::StyledText;
pub fn metrics_for(line_height: f64, allow_resize: bool) -> MsvLayoutMetrics {
crate::primitives::layout_metrics::msv_metrics(line_height, allow_resize)
}
pub fn gtk_msv_layout(
view: &MultiSectionView,
bounds: QRect,
line_height: f64,
) -> MultiSectionViewLayout {
crate::primitives::layout_metrics::msv_layout(view, bounds, line_height)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_multi_section_view(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
view: &MultiSectionView,
theme: &Theme,
line_height: f64,
nerd_fonts_enabled: bool,
caret_visible: bool,
) {
if w <= 0.0 || h <= 0.0 || view.axis == Axis::Horizontal {
return;
}
let bg = cairo_rgb(theme.background);
cr.set_source_rgb(bg.0, bg.1, bg.2);
cr.rectangle(x, y, w, h);
cr.fill().ok();
layout.set_attributes(None);
let bounds = QRect::new(x as f32, y as f32, w as f32, h as f32);
let view_layout = gtk_msv_layout(view, bounds, line_height);
cr.save().ok();
cr.rectangle(x, y, w, h);
cr.clip();
for s_layout in &view_layout.sections {
let section = &view.sections[s_layout.section_idx];
paint_header(
cr,
layout,
s_layout.header_bounds,
§ion.header,
section.collapsed,
theme,
);
if !s_layout.collapsed {
if let (Some(aux), Some(aux_b)) = (§ion.aux, s_layout.aux_bounds) {
paint_aux(cr, layout, aux_b, aux, theme, caret_visible);
}
paint_body(
cr,
layout,
s_layout.body_bounds,
§ion.body,
theme,
line_height,
nerd_fonts_enabled,
);
if let Some(sb_b) = s_layout.scrollbar_bounds {
paint_scrollbar(cr, sb_b, s_layout.thumb_bounds, theme);
}
}
}
if view.allow_resize {
for d in &view_layout.dividers {
paint_divider(cr, d.bounds, theme);
}
}
cr.restore().ok();
if let Some(panel_sb) = view_layout.panel_scrollbar {
paint_panel_scrollbar(cr, panel_sb, view_layout.panel_scrollbar_thumb, theme);
}
}
fn paint_header(
cr: &Context,
layout: &pango::Layout,
bounds: QRect,
header: &SectionHeader,
collapsed: bool,
theme: &Theme,
) {
let rounded = QRect::new(
bounds.x,
bounds.y.round(),
bounds.width,
bounds.height.round(),
);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_header(
&mut surface,
rounded,
header,
collapsed,
theme,
);
}
fn paint_aux(
cr: &Context,
layout: &pango::Layout,
bounds: QRect,
aux: &SectionAux,
theme: &Theme,
caret_visible: bool,
) {
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_aux(
&mut surface,
bounds,
aux,
theme,
caret_visible,
);
}
#[allow(clippy::too_many_arguments)]
fn paint_body(
cr: &Context,
layout: &pango::Layout,
bounds: QRect,
body: &SectionBody,
theme: &Theme,
line_height: f64,
nerd_fonts_enabled: bool,
) {
let x = bounds.x as f64;
let y = bounds.y as f64;
let w = bounds.width as f64;
let h = bounds.height as f64;
if w <= 0.0 || h <= 0.0 {
return;
}
cr.save().ok();
cr.rectangle(x, y, w, h);
cr.clip();
match body {
SectionBody::Tree(t) => {
draw_tree(
cr,
layout,
x,
y,
w,
h,
t,
theme,
line_height,
nerd_fonts_enabled,
);
}
SectionBody::List(l) => {
draw_list(
cr,
layout,
x,
y,
w,
h,
l,
theme,
line_height,
nerd_fonts_enabled,
);
}
SectionBody::Form(f) => {
draw_form_body(cr, layout, x, y, w, h, f, theme, line_height);
}
SectionBody::Chart(c) => {
layout.set_text("M");
let char_width = layout.pixel_size().0 as f64;
let chart_layout =
super::gtk_chart_layout(c, x, y, w, h, line_height, char_width.max(1.0));
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: false,
};
crate::primitives::chart::paint(c, &chart_layout, &mut surface, theme, None, None);
}
SectionBody::MessageList(m) => {
draw_message_list(cr, layout, m, x, y, w, y + h, line_height);
}
SectionBody::Terminal(_) => {
}
SectionBody::Text(lines) => {
paint_text_lines(cr, layout, x, y, w, h, lines, theme, line_height);
}
SectionBody::Empty(empty) => {
paint_empty_body(cr, layout, x, y, w, h, empty, theme, line_height);
}
SectionBody::Custom(_) => {
}
}
cr.restore().ok();
}
#[allow(clippy::too_many_arguments)]
fn draw_form_body(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
form: &crate::Form,
theme: &Theme,
line_height: f64,
) {
let row_h = crate::primitives::layout_metrics::form_row_height(line_height);
let measure = super::toolbar::PangoMeasure {
pango_layout: Some(layout),
char_width: 8.0,
};
let flayout = form.layout(w as f32, h as f32, |i| {
crate::primitives::layout_metrics::form_field_measure(&form.fields[i], row_h, &measure)
});
let origin = crate::Point::new(x as f32, y as f32);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: false,
};
crate::primitives::form::paint(form, &flayout, &mut surface, theme, origin);
for vf in &flayout.visible_fields {
let Some(field) = form.fields.get(vf.field_idx) else {
continue;
};
let crate::FieldKind::Toolbar(toolbar) = &field.kind else {
continue;
};
let label_text: String = field.label.spans.iter().map(|s| s.text.as_str()).collect();
let no_label = label_text.is_empty();
layout.set_text(&label_text);
let (label_w, _) = layout.pixel_size();
let row_x = x + vf.bounds.x as f64;
let row_y = y + vf.bounds.y as f64;
let row_w = vf.bounds.width as f64;
let toolbar_row_h = vf.bounds.height as f64;
let toolbar_x = if no_label {
row_x + 6.0
} else {
row_x + 6.0 + label_w as f64 + 12.0
};
let toolbar_w = row_x + row_w - toolbar_x;
if toolbar_w > 0.0 {
super::toolbar::draw_toolbar(
cr,
layout,
toolbar_x,
row_y,
toolbar_w,
toolbar_row_h,
toolbar,
theme,
None,
None,
);
layout.set_attributes(None);
}
}
}
#[allow(clippy::too_many_arguments)]
fn paint_text_lines(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
lines: &[StyledText],
theme: &Theme,
line_height: f64,
) {
let bounds = QRect::new(x as f32, y as f32, w as f32, h as f32);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_text_lines(
&mut surface,
bounds,
lines,
theme,
line_height as f32,
);
}
#[allow(clippy::too_many_arguments)]
fn paint_empty_body(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
empty: &EmptyBody,
theme: &Theme,
line_height: f64,
) {
let bounds = QRect::new(x as f32, y as f32, w as f32, h as f32);
let mut surface = super::surface::CairoSurface {
cr,
layout: Some(layout),
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_empty_body(
&mut surface,
bounds,
empty,
theme,
line_height as f32,
);
}
fn paint_scrollbar(cr: &Context, gutter: QRect, thumb_bounds: Option<QRect>, theme: &Theme) {
let mut surface = super::surface::CairoSurface {
cr,
layout: None,
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_scrollbar(
&mut surface,
gutter,
thumb_bounds,
theme,
);
}
fn paint_panel_scrollbar(cr: &Context, bounds: QRect, thumb_bounds: Option<QRect>, theme: &Theme) {
let mut surface = super::surface::CairoSurface {
cr,
layout: None,
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_panel_scrollbar(
&mut surface,
bounds,
thumb_bounds,
theme,
);
}
fn paint_divider(cr: &Context, bounds: QRect, theme: &Theme) {
let mut surface = super::surface::CairoSurface {
cr,
layout: None,
translucent_fill: true,
};
crate::primitives::multi_section_view::native_surface_paint::paint_divider(
&mut surface,
bounds,
theme,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::multi_section_view::{
InlineInput, MultiSectionViewHit, ScrollMode, ScrollbarHit, Section, SectionSize,
};
use crate::primitives::tree::{TreeRow, TreeView};
use crate::types::{Color, Decoration, SelectionMode, WidgetId};
use pangocairo::cairo::{Format, ImageSurface};
const W: i32 = 200;
const H: i32 = 200;
const LINE_HEIGHT: f64 = 14.0;
fn test_theme() -> Theme {
Theme {
background: Color::rgb(255, 255, 255),
..Theme::default()
}
}
fn tree_section(id: &str, items: &[&str]) -> Section {
let rows: Vec<TreeRow> = items
.iter()
.enumerate()
.map(|(i, t)| TreeRow {
path: vec![i as u16],
indent: 0,
icon: None,
text: StyledText::plain((*t).to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
})
.collect();
Section {
id: id.into(),
header: SectionHeader {
title: StyledText::plain(id.to_uppercase()),
show_chevron: false,
..Default::default()
},
body: SectionBody::Tree(TreeView {
id: WidgetId::new(format!("{}-tree", id)),
rows,
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: Default::default(),
has_focus: true,
}),
aux: None,
size: SectionSize::EqualShare,
collapsed: false,
min_size: None,
max_size: None,
}
}
fn view_with(sections: Vec<Section>) -> MultiSectionView {
MultiSectionView {
id: WidgetId::new("v"),
sections,
active_section: None,
axis: Axis::Vertical,
allow_resize: false,
allow_collapse: true,
scroll_mode: ScrollMode::PerSection,
has_focus: true,
panel_scroll: 0.0,
}
}
fn search_section() -> MultiSectionView {
view_with(vec![Section {
id: "search".into(),
header: SectionHeader {
title: StyledText::plain("Search"),
show_chevron: false,
..Default::default()
},
aux: Some(SectionAux::Search(InlineInput {
id: WidgetId::new("query"),
text: String::new(),
caret: 0,
placeholder: None,
has_focus: true,
})),
body: SectionBody::Tree(TreeView {
id: WidgetId::new("tree:search"),
rows: vec![],
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: Default::default(),
has_focus: false,
}),
size: SectionSize::EqualShare,
collapsed: false,
min_size: None,
max_size: None,
}])
}
fn paint_then_layout(view: &MultiSectionView) -> (ImageSurface, MultiSectionViewLayout) {
paint_then_layout_with_caret(view, true)
}
fn paint_then_layout_with_caret(
view: &MultiSectionView,
caret_visible: bool,
) -> (ImageSurface, MultiSectionViewLayout) {
let surface = ImageSurface::create(Format::ARgb32, W, 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 layout = pangocairo::functions::create_layout(&cr);
draw_multi_section_view(
&cr,
&layout,
0.0,
0.0,
W as f64,
H as f64,
view,
&test_theme(),
LINE_HEIGHT,
false,
caret_visible,
);
}
let bounds = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_msv_layout(view, bounds, LINE_HEIGHT);
(surface, layout)
}
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 is_painted(data: &[u8], stride: usize, x: i32, y: i32) -> bool {
let (r, g, b) = pixel(data, stride, x, y);
!(r == 255 && g == 255 && b == 255)
}
fn first_painted_in(
data: &[u8],
stride: usize,
x_range: (i32, i32),
y_range: (i32, i32),
) -> Option<(i32, i32)> {
for y in y_range.0..y_range.1 {
for x in x_range.0..x_range.1 {
if x < 0 || y < 0 || x >= W || y >= H {
continue;
}
if is_painted(data, stride, x, y) {
return Some((x, y));
}
}
}
None
}
#[test]
fn gtk_header_round_trip_paint_to_pixel_to_hit_test() {
let v = view_with(vec![
tree_section("alpha", &["a0", "a1", "a2"]),
tree_section("beta", &["b0", "b1", "b2"]),
tree_section("gamma", &["g0", "g1", "g2"]),
]);
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
for s in &layout.sections {
let hb = s.header_bounds;
let x_range = (
(hb.x + 1.0).floor() as i32,
(hb.x + hb.width - 1.0).floor() as i32,
);
let y_range = (
(hb.y + 1.0).floor() as i32,
(hb.y + hb.height - 1.0).floor() as i32,
);
let painted = first_painted_in(&data, stride, x_range, y_range).unwrap_or_else(|| {
panic!(
"section {} interior header bounds (x {}..{}, y {}..{}) contained no painted pixel",
s.section_idx, x_range.0, x_range.1, y_range.0, y_range.1
)
});
let hit = layout.hit_test(painted.0 as f32 + 0.5, painted.1 as f32 + 0.5);
match hit {
MultiSectionViewHit::Header { section, .. } => assert_eq!(
section, s.section_idx,
"pixel ({}, {}) painted in section {} header but hit_test returned section {}",
painted.0, painted.1, s.section_idx, section
),
other => panic!(
"pixel ({}, {}) painted in section {} header but hit_test returned {:?}",
painted.0, painted.1, s.section_idx, other
),
}
}
}
#[test]
fn gtk_body_round_trip_paint_to_pixel_to_hit_test() {
let v = view_with(vec![
tree_section("alpha", &["a0", "a1", "a2"]),
tree_section("beta", &["b0", "b1", "b2"]),
tree_section("gamma", &["g0", "g1", "g2"]),
]);
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
for s in &layout.sections {
let bb = s.body_bounds;
if bb.height < 3.0 || bb.width < 3.0 {
continue;
}
let x_range = (
(bb.x + 1.0).floor() as i32,
(bb.x + bb.width - 1.0).floor() as i32,
);
let y_range = (
(bb.y + 1.0).floor() as i32,
(bb.y + bb.height - 1.0).floor() as i32,
);
let painted = first_painted_in(&data, stride, x_range, y_range).unwrap_or_else(|| {
panic!(
"section {} interior body bounds (x {}..{}, y {}..{}) contained no painted pixel",
s.section_idx, x_range.0, x_range.1, y_range.0, y_range.1
)
});
let hit = layout.hit_test(painted.0 as f32 + 0.5, painted.1 as f32 + 0.5);
match hit {
MultiSectionViewHit::Body { section } => assert_eq!(
section, s.section_idx,
"pixel ({}, {}) painted in section {} body but hit_test returned Body{{{}}}",
painted.0, painted.1, s.section_idx, section
),
other => panic!(
"pixel ({}, {}) painted in section {} body but hit_test returned {:?}",
painted.0, painted.1, s.section_idx, other
),
}
}
}
#[test]
fn gtk_panel_scrollbar_thumb_paints_at_layout_position() {
let items = [
"r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10",
];
let v = MultiSectionView {
id: WidgetId::new("v"),
sections: vec![tree_section("a", &items), tree_section("b", &items)],
active_section: None,
axis: Axis::Vertical,
allow_resize: true,
allow_collapse: true,
scroll_mode: ScrollMode::WholePanel,
has_focus: true,
panel_scroll: 0.0,
};
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let panel_sb = layout
.panel_scrollbar
.expect("11-row sections in a 200px canvas should overflow");
let thumb = layout
.panel_scrollbar_thumb
.expect("panel_scrollbar present implies panel_scrollbar_thumb present");
assert!(
thumb.height < panel_sb.height - 4.0,
"test assumes the thumb doesn't fill the whole track (thumb={thumb:?}, \
track={panel_sb:?})"
);
let theme = test_theme();
let gx = (panel_sb.x + panel_sb.width / 2.0).floor() as i32;
let thumb_y = (thumb.y + 3.0).floor() as i32;
assert_eq!(
pixel(&data, stride, gx, thumb_y),
(
theme.scrollbar_thumb.r,
theme.scrollbar_thumb.g,
theme.scrollbar_thumb.b
),
"pixel inside panel_scrollbar_thumb should be the thumb colour"
);
let track_y = (panel_sb.y + panel_sb.height - 3.0).floor() as i32;
assert_eq!(
pixel(&data, stride, gx, track_y),
(
theme.scrollbar_track.r,
theme.scrollbar_track.g,
theme.scrollbar_track.b
),
"pixel in the track below the thumb should be the track colour"
);
match layout.hit_test(gx as f32 + 0.5, thumb_y as f32 + 0.5) {
MultiSectionViewHit::PanelScrollbar {
kind: ScrollbarHit::Thumb,
} => {}
other => panic!(
"hit at painted panel-thumb pixel ({}, {}) returned {:?}",
gx, thumb_y, other
),
}
}
#[test]
fn gtk_scrollbar_column_hits_scrollbar_not_body_when_overflowing() {
let v = view_with(vec![tree_section(
"lots",
&[
"r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", "r10", "r11", "r12",
"r13", "r14", "r15",
],
)]);
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let s = &layout.sections[0];
let sb = s
.scrollbar_bounds
.expect("overflowing section must reserve a scrollbar gutter (paint↔click contract)");
let click_x = sb.x + sb.width / 2.0;
let click_y = sb.y + sb.height / 2.0;
match layout.hit_test(click_x, click_y) {
MultiSectionViewHit::Scrollbar { section, .. } => assert_eq!(section, 0),
other => panic!(
"click at gutter centre ({:.1}, {:.1}) returned {:?}",
click_x, click_y, other
),
}
let gx = sb.x.floor() as i32 + 1;
let gy = sb.y.floor() as i32 + 5;
if gx < W && gy < H {
assert!(
is_painted(&data, stride, gx, gy),
"scrollbar gutter at pixel ({}, {}) was not painted",
gx,
gy
);
}
let body_b = s.body_bounds;
if body_b.width >= 2.0 {
let click = layout.hit_test(body_b.x + 1.0, body_b.y + 1.0);
assert!(
matches!(click, MultiSectionViewHit::Body { section: 0 }),
"click at body interior returned {:?}; expected Body{{0}}",
click
);
}
}
#[test]
fn gtk_subpixel_section_bounds_round_trip() {
let v = view_with(vec![
tree_section("alpha", &["a0", "a1"]),
tree_section("beta", &["b0", "b1"]),
tree_section("gamma", &["g0", "g1"]),
]);
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
for s in &layout.sections {
let section_top = s.header_bounds.y;
let section_bot = section_top + s.resolved_size;
let y_top = (section_top + 1.0).floor() as i32;
let y_bot = (section_bot - 1.0).floor() as i32;
let painted = first_painted_in(&data, stride, (1, W - 1), (y_top, y_bot.min(H)))
.unwrap_or_else(|| {
panic!(
"section {} interior (y={}..{}) contained no painted pixel",
s.section_idx, y_top, y_bot
)
});
let hit = layout.hit_test(painted.0 as f32 + 0.5, painted.1 as f32 + 0.5);
let hit_section = match hit {
MultiSectionViewHit::Header { section, .. } => section,
MultiSectionViewHit::Body { section } => section,
MultiSectionViewHit::Scrollbar { section, .. } => section,
other => panic!(
"section {} (y={}..{}) painted at pixel ({}, {}) but hit_test returned {:?}",
s.section_idx, y_top, y_bot, painted.0, painted.1, other
),
};
assert_eq!(
hit_section, s.section_idx,
"pixel ({}, {}) painted in section {} (y range {}..{}) but hit_test returned section {}",
painted.0, painted.1, s.section_idx, y_top, y_bot, hit_section
);
}
}
#[test]
fn gtk_header_decoration_row_boundary_round_trip() {
use crate::gtk::tree::gtk_tree_layout;
use crate::primitives::tree::TreeViewHit;
let rows = vec![
TreeRow {
path: vec![0],
indent: 0,
icon: None,
text: StyledText::plain("src/main.rs"),
badge: None,
is_expanded: Some(true),
decoration: Decoration::Header,
edit: None,
},
TreeRow {
path: vec![0, 0],
indent: 1,
icon: None,
text: StyledText::plain("line 12: fn main()"),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
},
TreeRow {
path: vec![0, 1],
indent: 1,
icon: None,
text: StyledText::plain("line 45: let config"),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
},
];
let tree = TreeView {
id: WidgetId::new("results"),
rows,
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: Default::default(),
has_focus: true,
};
let section = Section {
id: "results".into(),
header: SectionHeader {
title: StyledText::plain("RESULTS"),
show_chevron: false,
..Default::default()
},
body: SectionBody::Tree(tree),
aux: None,
size: SectionSize::EqualShare,
collapsed: false,
min_size: None,
max_size: None,
};
let view = view_with(vec![section]);
let bounds = QRect::new(0.0, 0.0, W as f32, H as f32);
let msv_layout = gtk_msv_layout(&view, bounds, LINE_HEIGHT);
let s = &msv_layout.sections[0];
let body_b = s.body_bounds;
let tree_ref = match &view.sections[0].body {
SectionBody::Tree(t) => t,
_ => panic!("expected tree body"),
};
let tree_layout = gtk_tree_layout(tree_ref, body_b, LINE_HEIGHT);
let header_h = (LINE_HEIGHT * 1.2).round();
let item_h = (LINE_HEIGHT * 1.4).round();
assert_eq!(tree_layout.visible_rows.len(), 3);
let r0 = &tree_layout.visible_rows[0];
let r1 = &tree_layout.visible_rows[1];
assert!(
(r0.bounds.height as f64 - header_h).abs() < 0.01,
"header row height {}, expected {}",
r0.bounds.height,
header_h
);
assert!(
(r1.bounds.y as f64 - header_h).abs() < 0.01,
"child row starts at {}, expected {}",
r1.bounds.y,
header_h
);
let bottom_of_header = header_h as f32 - 0.5;
match tree_layout.hit_test(5.0, bottom_of_header) {
TreeViewHit::Row(0) | TreeViewHit::Chevron(0) => {}
other => panic!(
"click at y={bottom_of_header} returned {:?}, expected Row(0) or Chevron(0)",
other
),
}
let top_of_child = header_h as f32 + 0.5;
match tree_layout.hit_test(5.0, top_of_child) {
TreeViewHit::Row(idx) => assert_eq!(
idx, 1,
"click at y={top_of_child} (top of child) hit row {idx}, expected 1"
),
other => panic!(
"click at y={top_of_child} returned {:?}, expected Row(1)",
other
),
}
let expected_content = header_h as f32 + 2.0 * item_h as f32;
let actual_content = crate::primitives::layout_metrics::msv_body_measure(
&view.sections[0].body,
&view.sections[0].aux,
LINE_HEIGHT,
);
assert!(
(actual_content.content_size - expected_content).abs() < 0.01,
"msv_body_measure content_size {}, expected {} (header_h={}, item_h={})",
actual_content.content_size,
expected_content,
header_h,
item_h
);
}
fn header_hit_round_trip_at(x: f32, y: f32) {
let view = view_with(vec![tree_section("alpha", &["a1", "a2"])]);
let bounds = QRect::new(x, y, 100.0, 60.0);
let layout = gtk_msv_layout(&view, bounds, LINE_HEIGHT);
let sl = &layout.sections[0];
assert_eq!(sl.header_bounds.x, x);
assert_eq!(sl.header_bounds.y, y);
let hx = sl.header_bounds.x + 5.0;
let hy = sl.header_bounds.y + sl.header_bounds.height / 2.0;
match layout.hit_test(hx, hy) {
MultiSectionViewHit::Header { section, .. } => assert_eq!(section, 0),
other => panic!("expected Header hit at ({hx}, {hy}), got {other:?}"),
}
let bx = sl.body_bounds.x + 1.0;
let by = sl.body_bounds.y + 1.0;
match layout.hit_test(bx, by) {
MultiSectionViewHit::Body { section } => assert_eq!(section, 0),
other => panic!("expected Body hit at ({bx}, {by}), got {other:?}"),
}
}
#[test]
fn header_hit_round_trip() {
header_hit_round_trip_at(0.0, 0.0);
}
#[test]
fn header_hit_round_trip_at_nonzero_origin() {
header_hit_round_trip_at(7.0, 13.0);
}
#[test]
fn gtk_header_clips_long_title_before_right_margin() {
let section = Section {
id: "s".into(),
header: SectionHeader {
icon: None,
title: StyledText::plain("W".repeat(200)),
badge: None,
actions: vec![],
show_chevron: false,
},
body: SectionBody::Empty(EmptyBody {
text: StyledText::plain(""),
..Default::default()
}),
aux: None,
size: SectionSize::EqualShare,
collapsed: false,
min_size: None,
max_size: None,
};
let v = view_with(vec![section]);
let (mut surface, layout) = paint_then_layout(&v);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let hdr = layout.sections[0].header_bounds;
let probe_x = (hdr.x + hdr.width - 2.0) as i32;
let probe_y = (hdr.y + hdr.height / 2.0) as i32;
let (r, g, b) = pixel(&data, stride, probe_x, probe_y);
let bg = Theme::default().header_bg;
assert_eq!(
(r, g, b),
(bg.r, bg.g, bg.b),
"an overlong title must not bleed ink into the header's trailing margin \
at ({probe_x}, {probe_y}); got ({r}, {g}, {b}), expected header_bg {bg:?}",
);
}
fn caret_pixel(layout: &MultiSectionViewLayout) -> (i32, i32) {
let aux = layout.sections[0]
.aux_bounds
.expect("search_section always has an aux row");
((aux.x + 4.0) as i32, (aux.y + aux.height / 2.0) as i32)
}
#[test]
fn gtk_caret_visible_true_paints_foreground_at_caret_position() {
let v = search_section();
let (mut surface, layout) = paint_then_layout_with_caret(&v, true);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let (x, y) = caret_pixel(&layout);
let fg = test_theme().foreground;
assert_eq!(
pixel(&data, stride, x, y),
(fg.r, fg.g, fg.b),
"caret_visible=true must paint the caret at ({x}, {y})",
);
}
#[test]
fn gtk_caret_visible_false_leaves_caret_column_as_input_bg() {
let v = search_section();
let (mut surface, layout) = paint_then_layout_with_caret(&v, false);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let (x, y) = caret_pixel(&layout);
let input_bg = test_theme().input_bg;
assert_eq!(
pixel(&data, stride, x, y),
(input_bg.r, input_bg.g, input_bg.b),
"caret_visible=false must skip the caret fill at ({x}, {y})",
);
}
#[test]
fn gtk_unfocused_input_skips_caret_regardless_of_phase() {
let mut v = search_section();
if let Some(SectionAux::Search(input)) = v.sections[0].aux.as_mut() {
input.has_focus = false;
} else {
panic!("search_section must build a Search aux");
}
let (mut surface, layout) = paint_then_layout_with_caret(&v, true);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let (x, y) = caret_pixel(&layout);
let input_bg = test_theme().input_bg;
assert_eq!(
pixel(&data, stride, x, y),
(input_bg.r, input_bg.g, input_bg.b),
"an unfocused input must never paint a caret, even with caret_visible=true",
);
}
}