use crate::event::Rect;
use crate::primitives::scrollbar::Scrollbar;
use crate::types::{
Badge, Decoration, Icon, Modifiers, SelectionMode, StyledText, TreePath, TreeStyle, WidgetId,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeView {
pub id: WidgetId,
pub rows: Vec<TreeRow>,
pub selection_mode: SelectionMode,
pub selected_path: Option<TreePath>,
#[serde(default)]
pub scroll_offset: usize,
pub style: TreeStyle,
#[serde(default)]
pub has_focus: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeRow {
pub path: TreePath,
pub indent: u16,
pub icon: Option<Icon>,
pub text: StyledText,
pub badge: Option<Badge>,
pub is_expanded: Option<bool>,
#[serde(default)]
pub decoration: Decoration,
#[serde(default)]
pub edit: Option<TreeRowEditState>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TreeRowEditState {
pub text: String,
pub cursor: usize,
pub selection_anchor: Option<usize>,
#[serde(default)]
pub placeholder: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TreeRowMeasure {
pub height: f32,
pub chevron_end_x: Option<f32>,
}
impl TreeRowMeasure {
pub fn new(height: f32) -> Self {
Self {
height,
chevron_end_x: None,
}
}
pub fn with_chevron(height: f32, chevron_end_x: f32) -> Self {
Self {
height,
chevron_end_x: Some(chevron_end_x),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VisibleTreeRow {
pub row_idx: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeViewHit {
Row(usize),
Chevron(usize),
Empty,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TreeViewLayout {
pub viewport_width: f32,
pub viewport_height: f32,
pub visible_rows: Vec<VisibleTreeRow>,
pub hit_regions: Vec<(Rect, TreeViewHit)>,
pub resolved_scroll_offset: usize,
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
pub(crate) fn tree_chevron_glyph(style: &TreeStyle, expanded: bool) -> std::borrow::Cow<'_, str> {
let default_style = TreeStyle::default();
if expanded {
if style.chevron_expanded == default_style.chevron_expanded {
std::borrow::Cow::Owned(crate::codicon::CHEVRON_DOWN.to_string())
} else {
std::borrow::Cow::Borrowed(style.chevron_expanded.as_str())
}
} else if style.chevron_collapsed == default_style.chevron_collapsed {
std::borrow::Cow::Owned(crate::codicon::CHEVRON_RIGHT.to_string())
} else {
std::borrow::Cow::Borrowed(style.chevron_collapsed.as_str())
}
}
impl TreeViewLayout {
pub fn hit_test(&self, x: f32, y: f32) -> TreeViewHit {
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
TreeViewHit::Empty
}
}
impl TreeView {
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
measure_row: F,
) -> TreeViewLayout
where
F: Fn(usize) -> TreeRowMeasure,
{
let mut visible_rows: Vec<VisibleTreeRow> = Vec::new();
let mut hit_regions: Vec<(Rect, TreeViewHit)> = Vec::new();
let resolved_scroll_offset =
crate::primitives::scrollbar::clamp_scroll_offset(self.scroll_offset, self.rows.len());
let mut y = 0.0_f32;
for i in resolved_scroll_offset..self.rows.len() {
if y >= viewport_height {
break;
}
let m = measure_row(i);
let remaining = viewport_height - y;
let height = m.height.min(remaining).max(0.0);
if height <= 0.0 {
break;
}
let bounds = Rect::new(0.0, y, viewport_width, height);
visible_rows.push(VisibleTreeRow { row_idx: i, bounds });
let row = &self.rows[i];
if let (Some(_), Some(chev_x)) = (row.is_expanded, m.chevron_end_x) {
let chev_x = chev_x.clamp(0.0, viewport_width);
if chev_x > 0.0 {
hit_regions.push((Rect::new(0.0, y, chev_x, height), TreeViewHit::Chevron(i)));
}
let body_w = (viewport_width - chev_x).max(0.0);
if body_w > 0.0 {
hit_regions.push((Rect::new(chev_x, y, body_w, height), TreeViewHit::Row(i)));
}
} else {
hit_regions.push((bounds, TreeViewHit::Row(i)));
}
y += m.height;
}
TreeViewLayout {
viewport_width,
viewport_height,
visible_rows,
hit_regions,
resolved_scroll_offset,
}
}
pub fn vscrollbar(&self, area: Rect, row_height: f32) -> Option<Scrollbar> {
self.vscrollbar_with_track_width(area, row_height, row_height)
}
pub fn vscrollbar_with_track_width(
&self,
area: Rect,
row_height: f32,
track_width: f32,
) -> Option<Scrollbar> {
if row_height <= 0.0 || track_width <= 0.0 {
return None;
}
let total = self.rows.len() as f32;
if total == 0.0 {
return None;
}
let visible = (area.height / row_height).floor();
if total <= visible {
return None;
}
let track = Rect::new(
area.x + area.width - track_width,
area.y,
track_width,
area.height,
);
Some(Scrollbar::vertical(
self.id,
track,
self.scroll_offset as f32,
total,
visible,
row_height,
))
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{TreeRowEditState, TreeView, TreeViewLayout};
use crate::paint_surface::PaintSurface;
use crate::text_util::{safe_prefix, snap_to_char_boundary};
use crate::theme::Theme;
use crate::types::Decoration;
use crate::Rect;
pub(crate) fn paint(
tree: &TreeView,
area: Rect,
tree_layout: &TreeViewLayout,
line_height: f32,
nerd_fonts_enabled: bool,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
if area.width <= 0.0 || area.height <= 0.0 {
return;
}
surface.surface_push_clip(area);
surface.surface_fill_rect(area, theme.tab_bar_bg);
let indent_px = (line_height * 0.9).round();
let header_height = (line_height * 1.2).round();
let item_height =
crate::primitives::layout_metrics::tree_row_pitch(tree, line_height as f64) as f32;
for vis_row in &tree_layout.visible_rows {
let row = &tree.rows[vis_row.row_idx];
let row_x = area.x + vis_row.bounds.x;
let row_y = area.y + vis_row.bounds.y;
let row_w = vis_row.bounds.width;
let row_h = vis_row.bounds.height;
let is_header = matches!(row.decoration, Decoration::Header);
let full_h = if is_header {
header_height
} else {
item_height
};
if row_h < full_h - 0.5 {
continue;
}
let path_selected = tree.selected_path.as_ref().is_some_and(|p| p == &row.path);
let is_selected = tree.has_focus && path_selected;
let is_inactive_selected = !tree.has_focus && path_selected;
let (def_fg, row_bg) = if is_selected {
(theme.header_fg, theme.selected_bg)
} else if is_inactive_selected {
(theme.foreground, theme.inactive_selected_bg)
} else if is_header {
(theme.header_fg, theme.header_bg)
} else {
match row.decoration {
Decoration::Muted => (theme.muted_fg, theme.tab_bar_bg),
Decoration::Error => (theme.error_fg, theme.tab_bar_bg),
Decoration::Warning => (theme.warning_fg, theme.tab_bar_bg),
_ => (theme.foreground, theme.tab_bar_bg),
}
};
let row_rect = Rect::new(row_x, row_y, row_w, row_h);
surface.surface_fill_rect(row_rect, row_bg);
let mut cursor_x = row_x + 2.0 + row.indent as f32 * indent_px;
if let Some(expanded) = row.is_expanded {
if tree.style.show_chevrons {
let chevron = super::tree_chevron_glyph(&tree.style, expanded);
let (cw, ch) = surface.surface_measure_text(&chevron);
let cy = row_y + (row_h - ch) / 2.0;
surface.surface_draw_icon_glyph(
Rect::new(cursor_x, cy, cw, ch),
&chevron,
def_fg,
);
cursor_x += cw + 4.0;
}
} else {
cursor_x += line_height * 0.8;
}
if let Some(ref icon) = row.icon {
let glyph = if nerd_fonts_enabled {
icon.glyph.as_str()
} else {
icon.fallback.as_str()
};
let icon_fg = icon.color.unwrap_or(def_fg);
let (iw, ih) = surface.surface_measure_text(glyph);
let iy = row_y + (row_h - ih) / 2.0;
surface.surface_draw_text_run(Rect::new(cursor_x, iy, iw, ih), glyph, icon_fg);
cursor_x += iw + 6.0;
}
if let Some(ref edit) = row.edit {
paint_edit_input(
surface,
cursor_x,
row_y,
row_h,
row_x + row_w,
edit,
def_fg,
theme.selection_bg,
theme.muted_fg,
);
continue;
}
let badge_info = row.badge.as_ref().map(|badge| {
let (bw, _) = surface.surface_measure_text(&badge.text);
let bfg = badge.fg.unwrap_or(theme.muted_fg);
let bbg = badge.bg.unwrap_or(row_bg);
(badge.text.clone(), bw, bfg, bbg)
});
let badge_reserve = badge_info
.as_ref()
.map(|(_, bw, ..)| *bw + 8.0)
.unwrap_or(0.0);
let text_right_limit = row_x + row_w - badge_reserve - 4.0;
let text_start_x = cursor_x;
let avail = (text_right_limit - cursor_x).max(0.0);
let total_w: f32 = row
.text
.spans
.iter()
.map(|span| surface.surface_measure_text_styled(&span.text, span.bold).0)
.sum();
let needs_ellipsis = total_w > avail;
surface.surface_push_clip(Rect::new(cursor_x, row_y, avail, row_h));
let mut remaining = avail;
for span in &row.text.spans {
if remaining <= 0.0 {
break;
}
let span_fg = if let Some(c) = span.fg {
c
} else if matches!(row.decoration, Decoration::Muted) {
theme.muted_fg
} else {
def_fg
};
let (sw, sh) = surface.surface_measure_text_styled(&span.text, span.bold);
let (paint_text, paint_w, truncated) = if !needs_ellipsis || sw <= remaining {
(span.text.clone(), sw, false)
} else {
let (t, w) = crate::paint_surface::fit_text_prefix(
surface, &span.text, span.bold, remaining,
);
(t, w, true)
};
if truncated && paint_w <= 0.0 {
break;
}
if let Some(sbg) = span.bg {
surface.surface_fill_rect(Rect::new(cursor_x, row_y, paint_w, row_h), sbg);
}
let sy = row_y + (row_h - sh) / 2.0;
surface.surface_draw_text_run_styled(
Rect::new(cursor_x, sy, paint_w, sh),
&paint_text,
span_fg,
span.bold,
false,
false,
1.0,
);
cursor_x += paint_w;
remaining -= paint_w;
if paint_text != span.text {
break;
}
}
surface.surface_pop_clip();
if let Some((btext, bw, bfg, bbg)) = badge_info {
let bx = row_x + row_w - bw - 4.0;
if text_right_limit > text_start_x {
if bbg != row_bg {
surface.surface_fill_rect(Rect::new(bx - 2.0, row_y, bw + 4.0, row_h), bbg);
}
let (_, bh) = surface.surface_measure_text(&btext);
let by = row_y + (row_h - bh) / 2.0;
surface.surface_draw_text_run(Rect::new(bx, by, bw, bh), &btext, bfg);
}
}
}
if let Some(vsb) = tree.vscrollbar_with_track_width(
area,
item_height,
crate::primitives::layout_metrics::pixel::TREE_VSCROLLBAR_WIDTH,
) {
crate::primitives::scrollbar::native_surface_paint::paint(&vsb, surface, theme);
}
surface.surface_pop_clip();
}
#[allow(clippy::too_many_arguments)]
fn paint_edit_input(
surface: &mut dyn PaintSurface,
text_x: f32,
row_y: f32,
row_h: f32,
right_edge: f32,
edit: &TreeRowEditState,
fg: crate::types::Color,
sel_bg: crate::types::Color,
dim: crate::types::Color,
) {
let text_w = right_edge - text_x - 4.0;
if text_w <= 0.0 {
return;
}
if edit.text.is_empty() {
if let Some(ref ph) = edit.placeholder {
let (_, th) = surface.surface_measure_text(ph);
surface.surface_draw_text_run(
Rect::new(text_x, row_y + (row_h - th) / 2.0, text_w, th),
ph,
dim,
);
}
surface.surface_fill_rect(Rect::new(text_x, row_y + 3.0, 1.5, row_h - 6.0), fg);
return;
}
if let Some(anchor) = edit.selection_anchor {
if anchor != edit.cursor {
let lo = snap_to_char_boundary(&edit.text, anchor.min(edit.cursor));
let hi = snap_to_char_boundary(&edit.text, anchor.max(edit.cursor));
let prefix = &edit.text[..lo];
let sel_text = &edit.text[lo..hi];
let (prefix_w, _) = surface.surface_measure_text(prefix);
let (sel_w, _) = surface.surface_measure_text(sel_text);
surface.surface_fill_rect(
Rect::new(text_x + prefix_w, row_y + 2.0, sel_w, row_h - 4.0),
sel_bg,
);
}
}
let (_, th) = surface.surface_measure_text(&edit.text);
surface.surface_draw_text_run(
Rect::new(text_x, row_y + (row_h - th) / 2.0, text_w, th),
&edit.text,
fg,
);
let cursor_prefix = safe_prefix(&edit.text, edit.cursor);
let (cx_off, _) = surface.surface_measure_text(cursor_prefix);
let caret_x = text_x + cx_off;
surface.surface_fill_rect(Rect::new(caret_x, row_y + 3.0, 1.5, row_h - 6.0), fg);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::{Point, Viewport};
use crate::primitives::tree::TreeRow;
use crate::types::{
Badge, Color, SelectionMode, StyledSpan, StyledText, TreeStyle, WidgetId,
};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
texts: Vec<(Rect, String, Color)>,
clips: Vec<Rect>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts.push((rect, text.to_string(), color));
}
fn surface_draw_line(&mut self, _from: Point, _to: Point, _color: Color, _sw: f32) {}
fn surface_push_clip(&mut self, rect: Rect) {
self.clips.push(rect);
}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
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 make_tree(rows: Vec<TreeRow>) -> TreeView {
TreeView {
id: WidgetId::new("t"),
rows,
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: 0,
style: TreeStyle::default(),
has_focus: true,
}
}
const LINE_HEIGHT: f32 = 16.0;
const AREA: Rect = Rect::new(0.0, 0.0, 100.0, 80.0);
#[test]
fn paints_vertical_scrollbar_track_when_overflowing() {
let tree = make_tree((0..50).map(|i| leaf(i, &format!("row{i}"))).collect());
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
let item_height =
crate::primitives::layout_metrics::tree_row_pitch(&tree, LINE_HEIGHT as f64) as f32;
let expected = tree
.vscrollbar_with_track_width(
AREA,
item_height,
crate::primitives::layout_metrics::pixel::TREE_VSCROLLBAR_WIDTH,
)
.expect("50 rows in an 80px/22px viewport must need a v-scrollbar");
assert!(
surface
.fills
.iter()
.any(|(r, _)| (r.x - expected.track.x).abs() < 0.01
&& (r.y - expected.track.y).abs() < 0.01
&& (r.width - expected.track.width).abs() < 0.01
&& (r.height - expected.track.height).abs() < 0.01),
"expected a fill at the v-scrollbar track {:?}, got fills: {:?}",
expected.track,
surface.fills
);
}
#[test]
fn default_style_chevron_paints_as_codicon_glyph() {
let mut rows = vec![leaf(0, "branch")];
rows[0].is_expanded = Some(true);
let tree = make_tree(rows);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
assert!(
surface
.texts
.iter()
.any(|(_, t, _)| t == crate::codicon::CHEVRON_DOWN.to_string().as_str()),
"expected the codicon expanded-chevron glyph among painted text runs, got: {:?}",
surface.texts
);
assert!(
!surface.texts.iter().any(|(_, t, _)| t == "▾"),
"the plain `▾` default must not be painted verbatim on GUI, got: {:?}",
surface.texts
);
}
#[test]
fn non_default_chevron_override_is_honoured_verbatim_on_gui() {
let mut rows = vec![leaf(0, "branch")];
rows[0].is_expanded = Some(true);
let mut tree = make_tree(rows);
tree.style.chevron_expanded = "+".into();
tree.style.chevron_collapsed = "-".into();
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
assert!(
surface.texts.iter().any(|(_, t, _)| t == "+"),
"expected the app's own override glyph among painted text runs, got: {:?}",
surface.texts
);
assert!(
!surface
.texts
.iter()
.any(|(_, t, _)| t == crate::codicon::CHEVRON_DOWN.to_string().as_str()),
"an explicit override must not be silently replaced by the codicon glyph, got: {:?}",
surface.texts
);
}
#[test]
fn paints_caret_for_row_being_edited() {
let mut rows = vec![leaf(0, "alpha"), leaf(1, "old-name"), leaf(2, "gamma")];
rows[1].edit = Some(TreeRowEditState {
text: "new-name".into(),
cursor: 3,
selection_anchor: None,
placeholder: None,
});
let tree = make_tree(rows);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
let row1 = &layout.visible_rows[1];
let caret_fill = surface.fills.iter().find(|(r, _)| {
(r.width - 1.5).abs() < 0.01
&& r.y >= row1.bounds.y
&& r.y < row1.bounds.y + row1.bounds.height
});
assert!(
caret_fill.is_some(),
"expected a 1.5px-wide caret fill inside row 1's bounds {:?}, got fills: {:?}",
row1.bounds,
surface.fills
);
}
#[test]
fn paints_selection_highlight_for_edited_row_with_selection() {
let mut rows = vec![leaf(0, "old-name")];
rows[0].edit = Some(TreeRowEditState {
text: "new-name".into(),
cursor: 3,
selection_anchor: Some(0),
placeholder: None,
});
let tree = make_tree(rows);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
assert!(
surface
.fills
.iter()
.any(|(r, c)| *c == theme.selection_bg && r.width > 1.5),
"expected a selection-highlight fill in theme.selection_bg wider than \
the caret bar, got fills: {:?}",
surface.fills
);
}
#[test]
fn selected_row_paints_selected_bg() {
let mut tree = make_tree(vec![leaf(0, "alpha"), leaf(1, "beta"), leaf(2, "gamma")]);
tree.selected_path = Some(vec![1]);
tree.has_focus = true;
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
let row1 = &layout.visible_rows[1];
assert!(
surface
.fills
.iter()
.any(|(r, c)| (r.x - row1.bounds.x).abs() < 0.01
&& (r.y - row1.bounds.y).abs() < 0.01
&& *c == theme.selected_bg),
"selected row must paint theme.selected_bg at its own bounds"
);
}
#[test]
fn error_and_warning_decoration_use_dedicated_fg() {
let mut err_row = leaf(0, "boom");
err_row.decoration = Decoration::Error;
let mut warn_row = leaf(1, "careful");
warn_row.decoration = Decoration::Warning;
let tree = make_tree(vec![err_row, warn_row]);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
assert!(surface
.texts
.iter()
.any(|(_, t, c)| t == "boom" && *c == theme.error_fg));
assert!(surface
.texts
.iter()
.any(|(_, t, c)| t == "careful" && *c == theme.warning_fg));
}
#[test]
fn long_label_with_badge_ellipsizes_before_badge() {
let mut row = leaf(0, "BACKEND_SPECULATIVE_EXECUTION_MODULE");
row.badge = Some(Badge::plain("U"));
let tree = make_tree(vec![row]);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
let (badge_bw, _) = surface.surface_measure_text("U");
let row_bounds = layout.visible_rows[0].bounds;
let row_x = AREA.x + row_bounds.x;
let row_w = row_bounds.width;
let text_right_limit = row_x + row_w - (badge_bw + 8.0) - 4.0;
let ellipsized = surface
.texts
.iter()
.find(|(_, t, _)| t.ends_with('…'))
.expect("an over-long label must paint a run ending in an ellipsis");
let (rect, text, _color) = ellipsized;
assert!(
text.chars().count() < "BACKEND_SPECULATIVE_EXECUTION_MODULE".chars().count(),
"the ellipsized run must be a strict prefix of the full label, got {text:?}"
);
assert!(
rect.x + rect.width <= text_right_limit + 0.01,
"the ellipsized run's right edge ({}) must fit within the label's \
budget ({text_right_limit}), leaving the badge's reserved gap intact",
rect.x + rect.width
);
assert!(
surface.texts.iter().any(|(_, t, _)| t == "U"),
"badge text should still paint when the label is ellipsized, got texts: {:?}",
surface.texts
);
}
#[test]
fn empty_leading_span_does_not_swallow_later_spans() {
let mut row = leaf(0, "placeholder");
row.text = StyledText {
spans: vec![StyledSpan::plain(""), StyledSpan::plain("ok")],
};
let tree = make_tree(vec![row]);
let layout =
crate::primitives::layout_metrics::tree_layout(&tree, AREA, LINE_HEIGHT as f64);
let mut surface = RecordingSurface::default();
let theme = Theme::default();
paint(
&tree,
AREA,
&layout,
LINE_HEIGHT,
false,
&mut surface,
&theme,
);
assert!(
surface.texts.iter().any(|(_, t, _)| t == "ok"),
"a span after an empty, non-truncated span must still paint, got texts: {:?}",
surface.texts
);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum TreeEvent {
RowClicked {
path: TreePath,
modifiers: Modifiers,
},
RowDoubleClicked { path: TreePath },
RowToggleExpand { path: TreePath },
SelectionChanged { path: TreePath },
KeyPressed { key: String, modifiers: Modifiers },
SelectionSet { paths: Vec<TreePath> },
}
pub fn selected_paths_for_indices(rows: &[TreeRow], indices: &[usize]) -> Vec<TreePath> {
indices
.iter()
.filter_map(|&idx| rows.get(idx).map(|row| row.path.clone()))
.collect()
}
#[cfg(test)]
mod multi_select_tests {
use super::{selected_paths_for_indices, Decoration, TreeEvent, TreeRow};
use crate::types::{apply_selection_gesture, Modifiers, SelectionGesture, StyledText};
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,
}
}
#[test]
fn shift_range_maps_flattened_indices_back_to_paths() {
let rows = vec![leaf(0, "a"), leaf(1, "b"), leaf(2, "c"), leaf(3, "d")];
let gesture = SelectionGesture::from_modifiers(Modifiers {
shift: true,
..Default::default()
});
let indices = apply_selection_gesture(&[], 0, 2, gesture);
let paths = selected_paths_for_indices(&rows, &indices);
assert_eq!(paths, vec![vec![0], vec![1], vec![2]]);
assert_eq!(
TreeEvent::SelectionSet {
paths: paths.clone()
},
TreeEvent::SelectionSet { paths }
);
}
#[test]
fn ctrl_toggle_removes_a_previously_selected_row() {
let rows = vec![leaf(0, "a"), leaf(1, "b"), leaf(2, "c")];
let gesture = SelectionGesture::from_modifiers(Modifiers {
ctrl: true,
..Default::default()
});
let indices = apply_selection_gesture(&[0, 1, 2], 1, 1, gesture);
let paths = selected_paths_for_indices(&rows, &indices);
assert_eq!(paths, vec![vec![0], vec![2]]);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tree_view_roundtrip_serde() {
let tree = TreeView {
id: WidgetId::new("sc"),
rows: vec![TreeRow {
path: vec![0],
indent: 0,
icon: None,
text: StyledText::plain("Staged Changes"),
badge: Some(Badge::plain("3")),
is_expanded: Some(true),
decoration: Decoration::Normal,
edit: None,
}],
selection_mode: SelectionMode::Single,
selected_path: Some(vec![0]),
scroll_offset: 0,
style: TreeStyle::default(),
has_focus: true,
};
let json = serde_json::to_string(&tree).unwrap();
let back: TreeView = serde_json::from_str(&json).unwrap();
assert_eq!(tree, back);
}
#[test]
fn tree_style_row_height_defaults_to_22_when_absent_from_json() {
let legacy = r#"{
"indent": 2,
"show_chevrons": true,
"chevron_expanded": "▾",
"chevron_collapsed": "▸"
}"#;
let style: TreeStyle =
serde_json::from_str(legacy).expect("pre-#623 TreeStyle must decode");
assert_eq!(
style.row_height,
Some(22),
"absent row_height falls back to the px-first default, not a decode error"
);
assert_eq!(style, TreeStyle::default());
}
#[test]
fn tree_style_row_height_override_roundtrips_serde() {
let style = TreeStyle {
row_height: Some(22),
..TreeStyle::default()
};
let json = serde_json::to_string(&style).unwrap();
let back: TreeStyle = serde_json::from_str(&json).unwrap();
assert_eq!(back.row_height, Some(22));
assert_eq!(style, back);
}
fn make_tree_row(path: &[u16], indent: u16, label: &str) -> TreeRow {
TreeRow {
path: path.to_vec(),
indent,
icon: None,
text: StyledText::plain(label),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
}
}
fn make_tree(rows: Vec<TreeRow>, scroll: usize) -> TreeView {
TreeView {
id: WidgetId::new("t"),
rows,
selection_mode: SelectionMode::Single,
selected_path: None,
scroll_offset: scroll,
style: TreeStyle::default(),
has_focus: true,
}
}
#[test]
fn tree_view_layout_empty() {
let tree = make_tree(vec![], 0);
let layout = tree.layout(40.0, 20.0, |_| TreeRowMeasure::new(1.0));
assert_eq!(layout.visible_rows.len(), 0);
assert_eq!(layout.hit_regions.len(), 0);
assert_eq!(layout.resolved_scroll_offset, 0);
assert_eq!(layout.hit_test(5.0, 5.0), TreeViewHit::Empty);
}
#[test]
fn tree_view_layout_all_rows_fit() {
let tree = make_tree(
(0..3)
.map(|i| make_tree_row(&[i], 0, &format!("row{i}")))
.collect(),
0,
);
let layout = tree.layout(40.0, 10.0, |_| TreeRowMeasure::new(1.0));
assert_eq!(layout.visible_rows.len(), 3);
assert_eq!(layout.visible_rows[0].bounds.y, 0.0);
assert_eq!(layout.visible_rows[1].bounds.y, 1.0);
assert_eq!(layout.visible_rows[2].bounds.y, 2.0);
assert_eq!(layout.hit_test(10.0, 0.5), TreeViewHit::Row(0));
assert_eq!(layout.hit_test(10.0, 1.5), TreeViewHit::Row(1));
assert_eq!(layout.hit_test(10.0, 2.5), TreeViewHit::Row(2));
assert_eq!(layout.hit_test(10.0, 5.0), TreeViewHit::Empty);
}
#[test]
fn tree_view_layout_scroll_offset_applies() {
let tree = make_tree(
(0..5)
.map(|i| make_tree_row(&[i], 0, &format!("row{i}")))
.collect(),
2, );
let layout = tree.layout(40.0, 10.0, |_| TreeRowMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 2);
assert_eq!(layout.visible_rows.len(), 3);
assert_eq!(layout.visible_rows[0].row_idx, 2);
assert_eq!(layout.visible_rows[1].row_idx, 3);
assert_eq!(layout.visible_rows[2].row_idx, 4);
}
#[test]
fn tree_view_layout_viewport_overflow_clips() {
let tree = make_tree(
(0..10)
.map(|i| make_tree_row(&[i], 0, &format!("row{i}")))
.collect(),
0,
);
let layout = tree.layout(40.0, 5.0, |_| TreeRowMeasure::new(2.0));
assert_eq!(layout.visible_rows.len(), 3);
assert_eq!(layout.visible_rows[2].bounds.height, 1.0);
assert_eq!(layout.hit_test(10.0, 5.0), TreeViewHit::Empty);
}
#[test]
fn tree_view_layout_varying_row_heights() {
let mut rows = vec![
make_tree_row(&[0], 0, "branch"),
make_tree_row(&[0, 0], 1, "leaf0"),
make_tree_row(&[0, 1], 1, "leaf1"),
];
rows[0].is_expanded = Some(true);
let tree = make_tree(rows.clone(), 0);
let layout = tree.layout(40.0, 10.0, |i| {
let h = if rows[i].is_expanded.is_some() {
1.4
} else {
1.0
};
TreeRowMeasure::new(h)
});
assert_eq!(layout.visible_rows.len(), 3);
assert_eq!(layout.visible_rows[0].bounds.height, 1.4);
assert_eq!(layout.visible_rows[1].bounds.y, 1.4);
assert_eq!(layout.visible_rows[1].bounds.height, 1.0);
assert!((layout.visible_rows[2].bounds.y - 2.4).abs() < 0.001);
}
#[test]
fn tree_view_layout_pixel_units_fractional() {
let tree = make_tree(
(0..5)
.map(|i| make_tree_row(&[i], 0, &format!("r{i}")))
.collect(),
0,
);
let layout = tree.layout(300.0, 60.0, |_| TreeRowMeasure::new(18.5));
assert_eq!(layout.visible_rows.len(), 4);
assert!((layout.visible_rows[3].bounds.height - 4.5).abs() < 0.001);
}
#[test]
fn tree_view_layout_scroll_offset_clamped() {
let tree = make_tree(
(0..3)
.map(|i| make_tree_row(&[i], 0, &format!("r{i}")))
.collect(),
99,
);
let layout = tree.layout(40.0, 10.0, |_| TreeRowMeasure::new(1.0));
assert_eq!(layout.resolved_scroll_offset, 2);
assert_eq!(layout.visible_rows.len(), 1);
assert_eq!(layout.visible_rows[0].row_idx, 2);
}
mod vscrollbar_tests {
use super::*;
fn vtree(n_rows: usize, scroll_offset: usize) -> TreeView {
make_tree(
(0..n_rows)
.map(|i| make_tree_row(&[i as u16], 0, &format!("row{i}")))
.collect(),
scroll_offset,
)
}
#[test]
fn none_when_tree_is_empty() {
let t = vtree(0, 0);
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.is_none());
}
#[test]
fn none_when_rows_fit_in_viewport() {
let t = vtree(5, 0);
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.is_none());
}
#[test]
fn none_when_rows_exactly_fill_viewport() {
let t = vtree(10, 0);
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.is_none());
}
#[test]
fn none_when_row_height_is_zero_or_negative() {
let t = vtree(50, 0);
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 0.0, 1.0)
.is_none());
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), -1.0, 1.0)
.is_none());
}
#[test]
fn none_when_track_width_is_zero_or_negative() {
let t = vtree(50, 0);
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 0.0)
.is_none());
assert!(t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, -1.0)
.is_none());
}
#[test]
fn track_spans_rightmost_column() {
let t = vtree(20, 0);
let sb = t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.x, 19.0);
assert_eq!(sb.track.y, 0.0);
assert_eq!(sb.track.width, 1.0);
assert_eq!(sb.track.height, 10.0);
assert_eq!(sb.thumb_start, 0.0);
assert!(sb.thumb_len > 0.0 && sb.thumb_len < sb.track.height);
}
#[test]
fn thumb_advances_with_scroll_offset() {
let t = vtree(20, 10);
let sb = t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.expect("overflow should yield a scrollbar");
assert!(
sb.thumb_start > 0.0,
"thumb should have travelled from the top once scrolled"
);
}
#[test]
fn pixel_backend_track_is_10px_wide_independent_of_row_pitch() {
let t = vtree(50, 0);
let sb = t
.vscrollbar_with_track_width(
Rect::new(0.0, 0.0, 300.0, 200.0),
20.0,
crate::primitives::layout_metrics::pixel::TREE_VSCROLLBAR_WIDTH,
)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.width, 10.0);
assert_eq!(sb.track.x, 300.0 - 10.0);
assert!(sb.thumb_len >= 20.0);
}
#[test]
fn tui_track_is_one_cell_wide() {
let t = vtree(50, 0);
let sb = t
.vscrollbar_with_track_width(Rect::new(0.0, 0.0, 20.0, 10.0), 1.0, 1.0)
.expect("overflow should yield a scrollbar");
assert_eq!(sb.track.width, 1.0);
}
}
}