use gtk4::cairo::Context;
use gtk4::pango;
use super::toolbar::PangoMeasure;
use crate::event::Rect as QRect;
use crate::primitives::tree::{TreeView, TreeViewLayout};
use crate::theme::Theme;
pub fn gtk_tree_layout(tree: &TreeView, area: QRect, line_height: f64) -> TreeViewLayout {
crate::primitives::layout_metrics::tree_layout(tree, area, line_height)
}
pub fn gtk_tree_layout_measured(
tree: &TreeView,
area: QRect,
line_height: f64,
pango_layout: Option<&pango::Layout>,
char_width: f64,
) -> TreeViewLayout {
let measure = PangoMeasure {
pango_layout,
char_width,
};
crate::primitives::layout_metrics::tree_layout_measured(tree, area, line_height, &measure)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_tree(
cr: &Context,
layout: &pango::Layout,
x: f64,
y: f64,
w: f64,
h: f64,
tree: &TreeView,
theme: &Theme,
line_height: f64,
nerd_fonts_enabled: bool,
) {
if w <= 0.0 || h <= 0.0 {
return;
}
layout.set_attributes(None);
let tree_layout = gtk_tree_layout_measured(
tree,
QRect::new(0.0, 0.0, w as f32, h as f32),
line_height,
Some(layout),
0.0,
);
let area = crate::event::Rect::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::tree::native_surface_paint::paint(
tree,
area,
&tree_layout,
line_height as f32,
nerd_fonts_enabled,
&mut surface,
theme,
);
layout.set_attributes(None);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::tree::{TreeRow, TreeView, TreeViewHit};
use crate::types::{Color, Decoration, SelectionMode, StyledText, TreeStyle, WidgetId};
use pangocairo::cairo::{Context, Format, ImageSurface};
const W: i32 = 200;
const H: i32 = 200;
const LINE_HEIGHT: f64 = 14.0;
fn test_theme() -> Theme {
Theme {
tab_bar_bg: Color::rgb(255, 255, 255),
background: Color::rgb(255, 255, 255),
..Theme::default()
}
}
fn leaf(idx: u16, label: &str) -> TreeRow {
TreeRow {
path: vec![idx],
indent: 0,
icon: None,
text: StyledText::plain(label.to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
}
}
fn header_row(idx: u16, label: &str) -> TreeRow {
TreeRow {
path: vec![idx],
indent: 0,
icon: None,
text: StyledText::plain(label.to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Header,
edit: None,
}
}
fn make_tree(rows: Vec<TreeRow>) -> TreeView {
TreeView {
id: WidgetId::new("tree"),
rows,
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: TreeStyle::default(),
has_focus: true,
}
}
fn paint_then_layout(tree: &TreeView) -> (ImageSurface, TreeViewLayout) {
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 pango_layout = pangocairo::functions::create_layout(&cr);
draw_tree(
&cr,
&pango_layout,
0.0,
0.0,
W as f64,
H as f64,
tree,
&test_theme(),
LINE_HEIGHT,
false,
);
}
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_tree_layout(tree, area, 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_clicks_land_on_painted_row() {
let tree = make_tree(vec![
leaf(0, "alpha"),
leaf(1, "beta"),
leaf(2, "gamma"),
leaf(3, "delta"),
]);
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
for vis in &layout.visible_rows {
let bounds = vis.bounds;
let x_range = (
(bounds.x + 1.0).floor() as i32,
(bounds.x + bounds.width - 1.0).floor() as i32,
);
let y_range = (
(bounds.y + 1.0).floor() as i32,
(bounds.y + bounds.height - 1.0).floor() as i32,
);
let painted = first_painted_in(&data, stride, x_range, y_range).unwrap_or_else(|| {
panic!(
"row {} interior bounds (x {}..{}, y {}..{}) contained no painted pixel",
vis.row_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 {
TreeViewHit::Row(idx) => assert_eq!(
idx, vis.row_idx,
"pixel ({}, {}) painted in row {} but hit_test returned Row({})",
painted.0, painted.1, vis.row_idx, idx
),
other => panic!(
"pixel ({}, {}) painted in row {} but hit_test returned {:?}",
painted.0, painted.1, vis.row_idx, other
),
}
}
}
#[test]
fn gtk_click_below_last_row_returns_empty() {
let tree = make_tree(vec![leaf(0, "alpha"), leaf(1, "beta"), leaf(2, "gamma")]);
let (_surface, layout) = paint_then_layout(&tree);
let last = layout.visible_rows.last().expect("tree has visible rows");
let click_y = last.bounds.y + last.bounds.height + 5.0;
assert!(
click_y < H as f32,
"test setup: click_y must be inside surface bounds"
);
let hit = layout.hit_test(W as f32 / 2.0, click_y);
assert!(
matches!(hit, TreeViewHit::Empty),
"click below last row should return Empty, got {:?}",
hit
);
}
#[test]
fn gtk_scroll_offset_paint_and_click_agree() {
let labels = ["r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9"];
let mut tree = make_tree(
labels
.iter()
.enumerate()
.map(|(i, l)| leaf(i as u16, l))
.collect(),
);
tree.scroll_offset = 3;
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let first = layout
.visible_rows
.first()
.expect("scrolled tree still has visible rows");
assert_eq!(
first.row_idx, 3,
"scroll_offset=3 should put rows[3] at the top; got rows[{}]",
first.row_idx
);
let bounds = first.bounds;
let x_range = (
(bounds.x + 1.0).floor() as i32,
(bounds.x + bounds.width - 1.0).floor() as i32,
);
let y_range = (
(bounds.y + 1.0).floor() as i32,
(bounds.y + bounds.height - 1.0).floor() as i32,
);
let painted = first_painted_in(&data, stride, x_range, y_range).unwrap_or_else(|| {
panic!(
"scrolled-to-top row 3 contained no painted pixel in interior bounds ({:?})",
bounds
)
});
let hit = layout.hit_test(painted.0 as f32 + 0.5, painted.1 as f32 + 0.5);
assert!(
matches!(hit, TreeViewHit::Row(3)),
"post-scroll click on painted top row returned {:?}; expected Row(3)",
hit
);
}
#[test]
fn gtk_mixed_decoration_rows_round_trip() {
let tree = make_tree(vec![
header_row(0, "HEADER A"),
leaf(1, "leaf-a-0"),
leaf(2, "leaf-a-1"),
header_row(3, "HEADER B"),
leaf(4, "leaf-b-0"),
leaf(5, "leaf-b-1"),
]);
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
for vis in &layout.visible_rows {
let bounds = vis.bounds;
let click_x = (bounds.x + bounds.width / 2.0).max(1.0);
let click_y = bounds.y + bounds.height / 2.0;
let hit = layout.hit_test(click_x, click_y);
assert!(
matches!(hit, TreeViewHit::Row(idx) if idx == vis.row_idx),
"row {} center ({:.1}, {:.1}) hit_test returned {:?}; expected Row({})",
vis.row_idx,
click_x,
click_y,
hit,
vis.row_idx
);
let y_top = (bounds.y + 1.0).floor() as i32;
let y_bot = (bounds.y + bounds.height - 1.0).floor() as i32;
let painted = first_painted_in(&data, stride, (1, W - 1), (y_top, y_bot.min(H)));
assert!(
painted.is_some(),
"row {} interior y range {}..{} contained no paint — \
row-pitch drift suspected",
vis.row_idx,
y_top,
y_bot
);
}
}
use crate::primitives::tree::TreeRowEditState;
#[test]
fn gtk_editing_row_has_caret_pixels() {
let tree = make_tree(vec![
leaf(0, "alpha"),
TreeRow {
path: vec![1],
indent: 0,
icon: None,
text: StyledText::plain("old-name".to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: Some(TreeRowEditState {
text: "new-name".into(),
cursor: 3, selection_anchor: None,
placeholder: None,
}),
},
leaf(2, "gamma"),
]);
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let vis = &layout.visible_rows[1];
let bounds = vis.bounds;
let y_top = (bounds.y + 1.0).floor() as i32;
let y_bot = (bounds.y + bounds.height - 1.0).floor() as i32;
let painted = first_painted_in(&data, stride, (1, W - 1), (y_top, y_bot.min(H)));
assert!(
painted.is_some(),
"editing row interior should contain painted pixels (caret + text)"
);
}
#[test]
fn gtk_editing_row_with_multibyte_cursor_does_not_panic() {
let text = "cafÃ©ðŸŽ‰ä¸æ–‡";
let mid_char_cursor = 4;
let mid_char_anchor = 8;
assert!(!text.is_char_boundary(mid_char_cursor));
assert!(!text.is_char_boundary(mid_char_anchor));
let tree = make_tree(vec![
leaf(0, "alpha"),
TreeRow {
path: vec![1],
indent: 0,
icon: None,
text: StyledText::plain("old-name".to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: Some(TreeRowEditState {
text: text.to_string(),
cursor: mid_char_cursor,
selection_anchor: Some(mid_char_anchor),
placeholder: None,
}),
},
leaf(2, "gamma"),
]);
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let vis = &layout.visible_rows[1];
let bounds = vis.bounds;
let y_top = (bounds.y + 1.0).floor() as i32;
let y_bot = (bounds.y + bounds.height - 1.0).floor() as i32;
let painted = first_painted_in(&data, stride, (1, W - 1), (y_top, y_bot.min(H)));
assert!(
painted.is_some(),
"editing row with multibyte text should still paint (selection + caret + text)"
);
}
fn branch(idx: u16, label: &str, expanded: bool) -> TreeRow {
TreeRow {
path: vec![idx],
indent: 0,
icon: None,
text: StyledText::plain(label.to_string()),
badge: None,
is_expanded: Some(expanded),
decoration: Decoration::Normal,
edit: None,
}
}
#[test]
fn gtk_chevron_click_returns_chevron_hit() {
let tree = make_tree(vec![branch(0, "src", true), leaf(1, "beta")]);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_tree_layout(&tree, area, LINE_HEIGHT);
let hit = layout.hit_test(1.0, layout.visible_rows[0].bounds.y + 1.0);
assert!(
matches!(hit, TreeViewHit::Chevron(0)),
"x=1 on GTK branch row 0 should return Chevron(0), got {:?}",
hit
);
}
#[test]
fn gtk_body_click_returns_row_hit() {
let tree = make_tree(vec![branch(0, "src", true)]);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_tree_layout(&tree, area, LINE_HEIGHT);
let hit = layout.hit_test(100.0, layout.visible_rows[0].bounds.y + 1.0);
assert!(
matches!(hit, TreeViewHit::Row(0)),
"x=100 past chevron should return Row(0), got {:?}",
hit
);
}
#[test]
fn gtk_leaf_click_always_row_hit() {
let tree = make_tree(vec![leaf(0, "main.rs")]);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_tree_layout(&tree, area, LINE_HEIGHT);
for x in [0.5_f32, 1.0, 5.0, 50.0] {
let hit = layout.hit_test(x, layout.visible_rows[0].bounds.y + 1.0);
assert!(
matches!(hit, TreeViewHit::Row(0)),
"leaf click at x={x} should return Row(0), got {:?}",
hit
);
}
}
#[test]
fn gtk_tree_layout_measured_uses_real_chevron_glyph_width_not_the_estimate() {
let tree = make_tree(vec![branch(0, "alpha", true)]);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
let cr = Context::new(&surface).expect("Context::new");
let pango_layout = pangocairo::functions::create_layout(&cr);
let estimated = gtk_tree_layout(&tree, area, LINE_HEIGHT);
let measured = gtk_tree_layout_measured(&tree, area, LINE_HEIGHT, Some(&pango_layout), 0.0);
let est_x = estimated.hit_regions[0].0.width;
let measured_x = measured.hit_regions[0].0.width;
assert_ne!(
est_x, measured_x,
"measured chevron end-x must use the real Pango glyph width ({measured_x}), \
not the line_height * 0.65 estimate ({est_x})",
);
pango_layout.set_text(&crate::primitives::tree::tree_chevron_glyph(
&tree.style,
true,
));
let glyph_w = pango_layout.pixel_size().0.max(0) as f32;
let indent_px = (LINE_HEIGHT as f32 * 0.9).round();
let expected = 2.0 + tree.rows[0].indent as f32 * indent_px + glyph_w + 4.0;
assert!(
(measured_x - expected).abs() < 0.01,
"measured chevron end-x {measured_x} must equal the Pango-measured \
geometry {expected} (glyph advance {glyph_w})",
);
assert_eq!(
measured.hit_test(measured_x - 1.0, 4.0),
TreeViewHit::Chevron(0),
"the measured boundary itself must still resolve to the chevron hit region",
);
}
#[test]
fn gtk_row_height_override_pins_pitch_across_line_heights() {
let mut tree = make_tree(vec![
leaf(0, "alpha"),
leaf(1, "beta"),
leaf(2, "gamma"),
leaf(3, "delta"),
]);
tree.style.row_height = Some(30);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let small_font_layout = gtk_tree_layout(&tree, area, 14.0);
let large_font_layout = gtk_tree_layout(&tree, area, 48.0);
assert_eq!(
small_font_layout.visible_rows.len(),
large_font_layout.visible_rows.len(),
"same tree at two line_heights must show the same row count"
);
for (small, large) in small_font_layout
.visible_rows
.iter()
.zip(large_font_layout.visible_rows.iter())
{
assert_eq!(
small.bounds, large.bounds,
"row_height override must pin every row's bounds regardless \
of editor line_height (row {})",
small.row_idx
);
assert_eq!(
small.bounds.height, 30.0,
"row height must equal the override"
);
}
}
#[test]
fn gtk_row_height_override_click_resolves_to_same_row_at_any_line_height() {
let mut tree = make_tree(vec![
leaf(0, "alpha"),
leaf(1, "beta"),
leaf(2, "gamma"),
leaf(3, "delta"),
]);
tree.style.row_height = Some(22);
for line_height in [14.0_f64, 48.0] {
let surface = ImageSurface::create(Format::ARgb32, W, H).expect("create ImageSurface");
let layout = {
let cr = Context::new(&surface).expect("Context::new");
cr.set_source_rgb(1.0, 1.0, 1.0);
cr.paint().ok();
let pango_layout = pangocairo::functions::create_layout(&cr);
draw_tree(
&cr,
&pango_layout,
0.0,
0.0,
W as f64,
H as f64,
&tree,
&test_theme(),
line_height,
false,
);
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
gtk_tree_layout(&tree, area, line_height)
};
for row_idx in 0..tree.rows.len() {
let bounds = layout.visible_rows[row_idx].bounds;
let hit = layout.hit_test(bounds.x + 5.0, bounds.y + bounds.height / 2.0);
assert!(
matches!(hit, TreeViewHit::Row(idx) if idx == row_idx),
"at line_height={line_height}, row {row_idx} bounds {:?} should hit_test \
back to Row({row_idx}), got {:?}",
bounds,
hit
);
assert_eq!(
bounds.height, 22.0,
"row {row_idx} must keep the overridden height at line_height={line_height}"
);
}
}
}
#[test]
fn layout_returns_local_coords_when_area_offset() {
let tree = make_tree(vec![leaf(0, "alpha"), leaf(1, "beta"), leaf(2, "gamma")]);
let area = QRect::new(0.0, 60.0, 240.0, 180.0);
let layout = gtk_tree_layout(&tree, area, 16.0);
let first = &layout.visible_rows[0];
assert_eq!(
first.bounds.y, 0.0,
"visible_rows.bounds.y must be local (0.0), got {}",
first.bounds.y,
);
for vr in &layout.visible_rows {
let abs_x = area.x + vr.bounds.x + vr.bounds.width * 0.5;
let abs_y = area.y + vr.bounds.y + vr.bounds.height * 0.5;
let hit = layout.hit_test(abs_x - area.x, abs_y - area.y);
assert!(
matches!(hit, TreeViewHit::Row(idx) if idx == vr.row_idx),
"row {} at local bounds {:?} did not round-trip through localised hit_test",
vr.row_idx,
vr.bounds,
);
}
}
#[test]
fn icon_color_paints_icon_glyph_in_that_color_else_default_fg() {
use crate::types::Icon;
let icon_color = Color::rgb(220, 80, 20);
let default_fg = Color::rgb(10, 10, 10);
let bg = Color::rgb(255, 255, 255);
let theme = Theme {
tab_bar_bg: bg,
background: bg,
foreground: default_fg,
..Theme::default()
};
let mut colored_row = leaf(0, "");
colored_row.icon = Some(Icon::new("R", "R").with_color(icon_color));
let mut plain_row = leaf(1, "");
plain_row.icon = Some(Icon::new("R", "R"));
let tree = make_tree(vec![colored_row, plain_row]);
let mut 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 pango_layout = pangocairo::functions::create_layout(&cr);
draw_tree(
&cr,
&pango_layout,
0.0,
0.0,
W as f64,
H as f64,
&tree,
&theme,
LINE_HEIGHT,
false,
);
}
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let layout = gtk_tree_layout(&tree, area, LINE_HEIGHT);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
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)
};
for (vis, expect_colored) in layout.visible_rows.iter().zip([true, false]) {
let bounds = vis.bounds;
let x_range = (
(bounds.x + 1.0).floor() as i32,
(bounds.x + bounds.width - 1.0).floor() as i32,
);
let y_range = (
(bounds.y + 1.0).floor() as i32,
(bounds.y + bounds.height - 1.0).floor() as i32,
);
let mut best: Option<(u8, u8, u8)> = None;
let mut best_d = -1i32;
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;
}
let p = pixel(&data, stride, x, y);
let d = dist(p, bg);
if d > best_d {
best_d = d;
best = Some(p);
}
}
}
let color = best.unwrap_or_else(|| {
panic!(
"row {} contained no painted pixel — icon glyph missing",
vis.row_idx
)
});
let closer_to_icon_color = dist(color, icon_color) < dist(color, default_fg);
assert_eq!(
closer_to_icon_color, expect_colored,
"row {}: most-inked pixel {color:?} — expected closer to \
Icon::color {icon_color:?} than default fg {default_fg:?}: {expect_colored}",
vis.row_idx,
);
}
}
#[test]
fn gtk_paints_vertical_scrollbar_track_when_overflowing() {
let tree = make_tree((0..40).map(|i| leaf(i, &format!("row{i}"))).collect());
let area = QRect::new(0.0, 0.0, W as f32, H as f32);
let item_height =
crate::primitives::layout_metrics::tree_row_pitch(&tree, LINE_HEIGHT) as f32;
let expected = tree
.vscrollbar_with_track_width(
area,
item_height,
crate::primitives::layout_metrics::pixel::TREE_VSCROLLBAR_WIDTH,
)
.expect("40 rows in a 200px viewport must need a v-scrollbar");
let mut 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 pango_layout = pangocairo::functions::create_layout(&cr);
draw_tree(
&cr,
&pango_layout,
0.0,
0.0,
W as f64,
H as f64,
&tree,
&test_theme(),
LINE_HEIGHT,
false,
);
}
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let probe_x = (expected.track.x + expected.track.width / 2.0).round() as i32;
let probe_y = (expected.track.y + expected.track.height / 2.0).round() as i32;
let (r, g, b) = pixel(
&data,
stride,
probe_x.clamp(0, W - 1),
probe_y.clamp(0, H - 1),
);
assert_ne!(
(r, g, b),
(255, 255, 255),
"expected the v-scrollbar track at ({probe_x}, {probe_y}) to be painted \
(non-background), got white — the #1075 regression this test guards"
);
}
#[test]
fn gtk_editing_row_still_paints_caret_after_migration() {
let tree = make_tree(vec![TreeRow {
path: vec![0],
indent: 0,
icon: None,
text: StyledText::plain("old-name".to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: Some(crate::primitives::tree::TreeRowEditState {
text: "new-name".into(),
cursor: 3,
selection_anchor: None,
placeholder: None,
}),
}]);
let (mut surface, layout) = paint_then_layout(&tree);
let stride = surface.stride() as usize;
let data = surface.data().expect("surface data");
let vis = &layout.visible_rows[0];
let bounds = vis.bounds;
let y_top = (bounds.y + 1.0).floor() as i32;
let y_bot = (bounds.y + bounds.height - 1.0).floor() as i32;
let painted = first_painted_in(&data, stride, (1, W - 1), (y_top, y_bot.min(H)));
assert!(
painted.is_some(),
"editing row interior should still contain painted pixels (caret + text) \
after the #1075 migration"
);
}
}
#[cfg(test)]
mod multi_select_driver_tests {
use crate::gtk::testing::GtkDriver;
use crate::primitives::tree::{selected_paths_for_indices, TreeEvent, TreeRow, TreeRowMeasure};
use crate::runner::{AppLogic, Reaction};
use crate::{
apply_selection_gesture, Backend, Decoration, Modifiers, MouseButton, Point, Rect,
SelectionGesture, SelectionMode, StyledText, TreeView, TreeViewHit, UiEvent, WidgetId,
};
const VIEWPORT_W: f32 = 40.0;
const VIEWPORT_H: f32 = 10.0;
const ROW_H: f32 = 1.0;
fn leaf(idx: u16, label: &str) -> TreeRow {
TreeRow {
path: vec![idx],
indent: 0,
icon: None,
text: StyledText::plain(label.to_string()),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
}
}
fn sample_tree() -> TreeView {
TreeView {
id: WidgetId::new("t"),
rows: (0..5).map(|i| leaf(i, &format!("row {i}"))).collect(),
selection_mode: SelectionMode::Multi,
selected_path: None,
scroll_offset: 0,
style: Default::default(),
has_focus: true,
}
}
struct TreeApp {
tree: TreeView,
selected: Vec<usize>,
anchor: usize,
events: Vec<TreeEvent>,
}
impl TreeApp {
fn new() -> Self {
Self {
tree: sample_tree(),
selected: Vec::new(),
anchor: 0,
events: Vec::new(),
}
}
}
impl AppLogic for TreeApp {
type AreaId = ();
fn render(&self, backend: &mut dyn Backend, _area: ()) {
backend.draw_tree(Rect::new(0.0, 0.0, VIEWPORT_W, VIEWPORT_H), &self.tree);
}
fn handle(&mut self, event: UiEvent, _backend: &mut dyn Backend) -> Reaction {
let UiEvent::MouseDown {
position,
modifiers,
..
} = event
else {
return Reaction::Continue;
};
let layout = self
.tree
.layout(VIEWPORT_W, VIEWPORT_H, |_| TreeRowMeasure::new(ROW_H));
let hit = layout.hit_test(position.x, position.y);
let idx = match hit {
TreeViewHit::Row(idx) | TreeViewHit::Chevron(idx) => idx,
TreeViewHit::Empty => return Reaction::Continue,
};
let gesture = SelectionGesture::from_modifiers(modifiers);
let indices = apply_selection_gesture(&self.selected, self.anchor, idx, gesture);
self.selected = indices.clone();
if !matches!(gesture, SelectionGesture::Range) {
self.anchor = idx;
}
let paths = selected_paths_for_indices(&self.tree.rows, &indices);
self.events.push(TreeEvent::SelectionSet { paths });
Reaction::Redraw
}
}
fn mouse_down(x: f32, y: f32, modifiers: Modifiers) -> UiEvent {
UiEvent::MouseDown {
widget: None,
button: MouseButton::Left,
position: Point::new(x, y),
modifiers,
}
}
#[test]
fn plain_click_replaces_selection() {
let mut driver = GtkDriver::new(TreeApp::new(), 80, 40);
driver.dispatch(mouse_down(2.0, 2.0, Modifiers::default()));
assert_eq!(driver.app().selected, vec![2]);
}
#[test]
fn ctrl_click_toggles_rows_into_a_multi_selection() {
let mut driver = GtkDriver::new(TreeApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let ctrl = Modifiers {
ctrl: true,
..Default::default()
};
driver.dispatch(mouse_down(2.0, 2.0, ctrl));
assert_eq!(driver.app().selected, vec![0, 2]);
driver.dispatch(mouse_down(2.0, 2.0, ctrl));
assert_eq!(driver.app().selected, vec![0]);
assert_eq!(
driver.app().events.last(),
Some(&TreeEvent::SelectionSet {
paths: vec![vec![0]]
})
);
}
#[test]
fn shift_click_selects_the_range_from_the_anchor() {
let mut driver = GtkDriver::new(TreeApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 0.0, Modifiers::default()));
let shift = Modifiers {
shift: true,
..Default::default()
};
driver.dispatch(mouse_down(2.0, 3.0, shift));
assert_eq!(driver.app().selected, vec![0, 1, 2, 3]);
assert_eq!(
driver.app().events.last(),
Some(&TreeEvent::SelectionSet {
paths: vec![vec![0], vec![1], vec![2], vec![3]]
})
);
}
#[test]
fn cmd_click_toggles_the_same_as_ctrl_click() {
let mut driver = GtkDriver::new(TreeApp::new(), 80, 40);
driver.set_double_click_folding(false);
driver.dispatch(mouse_down(2.0, 1.0, Modifiers::default()));
driver.dispatch(mouse_down(
2.0,
4.0,
Modifiers {
cmd: true,
..Default::default()
},
));
assert_eq!(driver.app().selected, vec![1, 4]);
}
}