use crate::primitives::scrollbar::{scroll_by_clamped, wheel_row_delta, PressZone, ThumbDrag};
use crate::text_util::{next_char_boundary, prev_char_boundary, snap_to_char_boundary};
use crate::{
Backend, ButtonMask, Key, Modifiers, MouseButton, NamedKey, Point, Rect, Scrollbar,
SelectionMode, TreePath, TreeRow, TreeRowEditState, TreeView, TreeViewHit, UiEvent, WidgetId,
};
#[derive(Debug, Clone, PartialEq)]
pub enum TreeControllerEvent {
RowSelected { path: TreePath },
RowActivated { path: TreePath },
RowToggleExpand { path: TreePath },
ScrollChanged,
Consumed,
Ignored,
EditConfirmed { path: TreePath, new_text: String },
EditCancelled { path: TreePath },
EditChanged { path: TreePath, text: String },
ContextMenuRequested { path: TreePath, position: Point },
}
struct EditingState {
path: TreePath,
text: String,
cursor: usize,
selection_anchor: Option<usize>,
placeholder: Option<String>,
}
pub struct TreeController {
id: WidgetId,
rows: Vec<TreeRow>,
selected_path: Option<TreePath>,
scroll_offset: usize,
has_focus: bool,
scroll_drag: Option<ThumbDrag>,
vim_keys: bool,
editing: Option<EditingState>,
show_scrollbar: bool,
scrollbar_width: Option<f32>,
empty_space_context_menu: bool,
}
impl TreeController {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: WidgetId::new(id),
rows: Vec::new(),
selected_path: None,
scroll_offset: 0,
has_focus: true,
scroll_drag: None,
vim_keys: true,
editing: None,
show_scrollbar: true,
scrollbar_width: None,
empty_space_context_menu: false,
}
}
pub fn set_rows(&mut self, rows: Vec<TreeRow>) {
self.rows = rows;
}
pub fn selected_path(&self) -> Option<&TreePath> {
self.selected_path.as_ref()
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn has_focus(&self) -> bool {
self.has_focus
}
pub fn rows(&self) -> &[TreeRow] {
&self.rows
}
pub fn set_selected_path(&mut self, path: Option<TreePath>) {
self.selected_path = path;
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll_offset = offset;
}
pub fn set_has_focus(&mut self, has_focus: bool) {
self.has_focus = has_focus;
}
pub fn vim_keys(&self) -> bool {
self.vim_keys
}
pub fn set_vim_keys(&mut self, enabled: bool) {
self.vim_keys = enabled;
}
pub fn show_scrollbar(&self) -> bool {
self.show_scrollbar
}
pub fn set_show_scrollbar(&mut self, show: bool) {
self.show_scrollbar = show;
}
pub fn set_scrollbar_width(&mut self, width: Option<f32>) {
self.scrollbar_width = width;
}
pub fn empty_space_context_menu(&self) -> bool {
self.empty_space_context_menu
}
pub fn set_empty_space_context_menu(&mut self, enabled: bool) {
self.empty_space_context_menu = enabled;
}
pub fn start_editing(
&mut self,
path: TreePath,
initial_text: String,
cursor: usize,
selection_anchor: Option<usize>,
placeholder: Option<String>,
) {
self.editing = Some(EditingState {
path: path.clone(),
text: initial_text,
cursor,
selection_anchor,
placeholder,
});
self.selected_path = Some(path);
}
pub fn cancel_editing(&mut self) {
self.editing = None;
}
pub fn is_editing(&self) -> bool {
self.editing.is_some()
}
pub fn editing_path(&self) -> Option<&TreePath> {
self.editing.as_ref().map(|e| &e.path)
}
pub fn editing_text(&self) -> Option<&str> {
self.editing.as_ref().map(|e| e.text.as_str())
}
pub fn editing_cursor(&self) -> usize {
self.editing.as_ref().map(|e| e.cursor).unwrap_or(0)
}
pub fn editing_selection_anchor(&self) -> Option<usize> {
self.editing.as_ref().and_then(|e| e.selection_anchor)
}
pub fn editing_placeholder(&self) -> Option<&str> {
self.editing.as_ref().and_then(|e| e.placeholder.as_deref())
}
pub fn edit_insert_char_via(&mut self, ch: char) -> TreeControllerEvent {
if self.editing.is_none() {
return TreeControllerEvent::Ignored;
}
self.edit_insert_char(ch)
}
pub fn edit_insert_str_via(&mut self, s: &str) -> TreeControllerEvent {
if self.editing.is_none() {
return TreeControllerEvent::Ignored;
}
self.edit_insert_str(s)
}
pub fn handle_edit_key_via(&mut self, key: &Key, modifiers: &Modifiers) -> TreeControllerEvent {
if self.editing.is_none() {
return TreeControllerEvent::Ignored;
}
self.handle_edit_key(key, modifiers)
}
pub fn render(&self, backend: &mut dyn Backend, rect: Rect) {
let (tree_rect, sb) = self.split_rect(backend, rect);
let tree = self.build_tree_view(tree_rect);
backend.draw_tree(tree_rect, &tree);
if !backend.tree_paints_own_scrollbar() {
if let Some(sb) = sb {
backend.draw_scrollbar(sb.track, &sb);
}
}
}
pub fn handle(
&mut self,
event: &UiEvent,
backend: &mut dyn Backend,
rect: Rect,
) -> TreeControllerEvent {
match event {
UiEvent::CharTyped(ch) if self.editing.is_some() => self.edit_insert_char(*ch),
UiEvent::ClipboardPaste(text) if self.editing.is_some() => self.edit_insert_str(text),
UiEvent::MouseDown {
button: MouseButton::Left,
position,
..
} => self.click(backend, rect, position.x, position.y),
UiEvent::MouseDown {
button: MouseButton::Right,
position,
..
} => self.right_click(backend, rect, *position),
UiEvent::DoubleClick { position, .. } => {
self.double_click(backend, rect, position.x, position.y)
}
UiEvent::MouseMoved {
position,
buttons:
ButtonMask {
left: true,
middle: _,
right: _,
},
} => self.drag_to(position.y),
UiEvent::MouseUp {
button: MouseButton::Left,
..
} => {
self.scroll_drag = None;
TreeControllerEvent::Ignored
}
UiEvent::Scroll { delta, .. } => {
let vr = self.viewport_rows(backend, rect);
let rows = wheel_row_delta(delta.y, 1.0);
self.scroll_by(rows, vr);
TreeControllerEvent::Consumed
}
UiEvent::KeyPressed { key, modifiers, .. } => {
self.handle_key(key, modifiers, backend, rect)
}
_ => TreeControllerEvent::Ignored,
}
}
pub fn move_selection_by(&mut self, delta: isize, viewport_rows: usize) -> TreeControllerEvent {
if self.rows.is_empty() {
return TreeControllerEvent::Ignored;
}
let count = self.rows.len();
let current = self.selected_row_index();
let new = if let Some(cur) = current {
let target = cur as isize + delta;
target.max(0).min(count as isize - 1) as usize
} else if delta > 0 {
0
} else {
count - 1
};
if current == Some(new) {
return TreeControllerEvent::Consumed;
}
let path = self.rows[new].path.clone();
self.selected_path = Some(path.clone());
self.scroll_to_visible(new, viewport_rows);
TreeControllerEvent::RowSelected { path }
}
pub fn jump_to_edge(&mut self, to_start: bool, viewport_rows: usize) -> TreeControllerEvent {
if self.rows.is_empty() {
return TreeControllerEvent::Ignored;
}
let row = if to_start { 0 } else { self.rows.len() - 1 };
let path = self.rows[row].path.clone();
self.selected_path = Some(path.clone());
self.scroll_to_visible(row, viewport_rows);
TreeControllerEvent::RowSelected { path }
}
pub fn activate_selection(&self) -> TreeControllerEvent {
match &self.selected_path {
Some(path) => TreeControllerEvent::RowActivated { path: path.clone() },
None => TreeControllerEvent::Ignored,
}
}
pub fn toggle_expand_selected(&self) -> TreeControllerEvent {
let Some(sel_path) = &self.selected_path else {
return TreeControllerEvent::Ignored;
};
let Some(row) = self.rows.iter().find(|r| &r.path == sel_path) else {
return TreeControllerEvent::Ignored;
};
if row.is_expanded.is_some() {
TreeControllerEvent::RowToggleExpand {
path: row.path.clone(),
}
} else {
TreeControllerEvent::Ignored
}
}
pub fn scroll_to_visible(&mut self, row: usize, viewport_rows: usize) {
if viewport_rows == 0 {
return;
}
if row < self.scroll_offset {
self.scroll_offset = row;
} else if row >= self.scroll_offset + viewport_rows {
self.scroll_offset = row.saturating_sub(viewport_rows.saturating_sub(1));
}
}
pub fn page_scroll(&mut self, delta: isize, viewport_rows: usize) {
let max = self.rows.len().saturating_sub(viewport_rows);
self.scroll_offset = scroll_by_clamped(self.scroll_offset, delta, max);
}
fn handle_key(
&mut self,
key: &Key,
modifiers: &Modifiers,
backend: &mut dyn Backend,
rect: Rect,
) -> TreeControllerEvent {
if self.editing.is_some() {
return self.handle_edit_key(key, modifiers);
}
let vim_up = self.vim_keys && matches!(key, Key::Char('k'));
let vim_down = self.vim_keys && matches!(key, Key::Char('j'));
match key {
Key::Named(NamedKey::Up) => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by(-1, vr)
}
_ if vim_up => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by(-1, vr)
}
Key::Named(NamedKey::Down) => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by(1, vr)
}
_ if vim_down => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by(1, vr)
}
Key::Named(NamedKey::Home) => {
let vr = self.viewport_rows(backend, rect);
self.jump_to_edge(true, vr)
}
Key::Named(NamedKey::End) => {
let vr = self.viewport_rows(backend, rect);
self.jump_to_edge(false, vr)
}
Key::Named(NamedKey::PageUp) => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by(-((vr.max(1) - 1).max(1) as isize), vr)
}
Key::Named(NamedKey::PageDown) => {
let vr = self.viewport_rows(backend, rect);
self.move_selection_by((vr.max(1) - 1).max(1) as isize, vr)
}
Key::Named(NamedKey::Enter) => self.activate_selection(),
Key::Char(' ') => self.toggle_expand_selected(),
_ => TreeControllerEvent::Ignored,
}
}
fn handle_edit_key(&mut self, key: &Key, modifiers: &Modifiers) -> TreeControllerEvent {
match key {
Key::Named(NamedKey::Enter) => {
let Some(e) = self.editing.take() else {
return TreeControllerEvent::Ignored;
};
TreeControllerEvent::EditConfirmed {
path: e.path,
new_text: e.text,
}
}
Key::Named(NamedKey::Escape) => {
let Some(e) = self.editing.take() else {
return TreeControllerEvent::Ignored;
};
TreeControllerEvent::EditCancelled { path: e.path }
}
Key::Named(NamedKey::Backspace) => self.edit_backspace(),
Key::Named(NamedKey::Delete) => self.edit_delete(),
Key::Named(NamedKey::Left) => {
self.edit_move_cursor_left(modifiers.shift);
TreeControllerEvent::Consumed
}
Key::Named(NamedKey::Right) => {
self.edit_move_cursor_right(modifiers.shift);
TreeControllerEvent::Consumed
}
Key::Named(NamedKey::Home) => {
self.edit_move_home(modifiers.shift);
TreeControllerEvent::Consumed
}
Key::Named(NamedKey::End) => {
self.edit_move_end(modifiers.shift);
TreeControllerEvent::Consumed
}
Key::Char('a') if modifiers.ctrl => {
self.edit_select_all();
TreeControllerEvent::Consumed
}
Key::Char(c) => self.edit_insert_char(*c),
_ => TreeControllerEvent::Consumed,
}
}
fn edit_delete_selection(&mut self) -> bool {
let Some(e) = self.editing.as_mut() else {
return false;
};
if let Some(anchor) = e.selection_anchor {
if anchor != e.cursor {
let lo = anchor.min(e.cursor);
let hi = anchor.max(e.cursor);
let lo = snap_to_char_boundary(&e.text, lo);
let hi = snap_to_char_boundary(&e.text, hi);
e.text.replace_range(lo..hi, "");
e.cursor = lo;
e.selection_anchor = None;
return true;
}
}
false
}
fn edit_insert_char(&mut self, c: char) -> TreeControllerEvent {
self.edit_delete_selection();
let Some(e) = self.editing.as_mut() else {
return TreeControllerEvent::Ignored;
};
let cursor = snap_to_char_boundary(&e.text, e.cursor);
e.text.insert(cursor, c);
e.cursor = cursor + c.len_utf8();
e.selection_anchor = None;
self.emit_edit_changed()
}
fn edit_insert_str(&mut self, s: &str) -> TreeControllerEvent {
self.edit_delete_selection();
let Some(e) = self.editing.as_mut() else {
return TreeControllerEvent::Ignored;
};
let cursor = snap_to_char_boundary(&e.text, e.cursor);
e.text.insert_str(cursor, s);
e.cursor = cursor + s.len();
e.selection_anchor = None;
self.emit_edit_changed()
}
fn edit_backspace(&mut self) -> TreeControllerEvent {
if self.edit_delete_selection() {
return self.emit_edit_changed();
}
let Some(e) = self.editing.as_mut() else {
return TreeControllerEvent::Ignored;
};
if e.cursor == 0 {
return TreeControllerEvent::Consumed;
}
let prev = prev_char_boundary(&e.text, e.cursor);
e.text.replace_range(prev..e.cursor, "");
e.cursor = prev;
e.selection_anchor = None;
self.emit_edit_changed()
}
fn edit_delete(&mut self) -> TreeControllerEvent {
if self.edit_delete_selection() {
return self.emit_edit_changed();
}
let Some(e) = self.editing.as_mut() else {
return TreeControllerEvent::Ignored;
};
if e.cursor >= e.text.len() {
return TreeControllerEvent::Consumed;
}
let next = next_char_boundary(&e.text, e.cursor);
e.text.replace_range(e.cursor..next, "");
self.emit_edit_changed()
}
fn edit_move_cursor_left(&mut self, extend_selection: bool) {
let Some(e) = self.editing.as_mut() else {
return;
};
if e.cursor == 0 {
if !extend_selection {
e.selection_anchor = None;
}
return;
}
if extend_selection && e.selection_anchor.is_none() {
e.selection_anchor = Some(e.cursor);
}
e.cursor = prev_char_boundary(&e.text, e.cursor);
if !extend_selection {
e.selection_anchor = None;
}
}
fn edit_move_cursor_right(&mut self, extend_selection: bool) {
let Some(e) = self.editing.as_mut() else {
return;
};
if e.cursor >= e.text.len() {
if !extend_selection {
e.selection_anchor = None;
}
return;
}
if extend_selection && e.selection_anchor.is_none() {
e.selection_anchor = Some(e.cursor);
}
e.cursor = next_char_boundary(&e.text, e.cursor);
if !extend_selection {
e.selection_anchor = None;
}
}
fn edit_move_home(&mut self, extend_selection: bool) {
let Some(e) = self.editing.as_mut() else {
return;
};
if extend_selection && e.selection_anchor.is_none() {
e.selection_anchor = Some(e.cursor);
}
e.cursor = 0;
if !extend_selection {
e.selection_anchor = None;
}
}
fn edit_move_end(&mut self, extend_selection: bool) {
let Some(e) = self.editing.as_mut() else {
return;
};
if extend_selection && e.selection_anchor.is_none() {
e.selection_anchor = Some(e.cursor);
}
e.cursor = e.text.len();
if !extend_selection {
e.selection_anchor = None;
}
}
fn edit_select_all(&mut self) {
let Some(e) = self.editing.as_mut() else {
return;
};
e.selection_anchor = Some(0);
e.cursor = e.text.len();
}
fn emit_edit_changed(&self) -> TreeControllerEvent {
let Some(e) = self.editing.as_ref() else {
return TreeControllerEvent::Ignored;
};
TreeControllerEvent::EditChanged {
path: e.path.clone(),
text: e.text.clone(),
}
}
fn click(
&mut self,
backend: &mut dyn Backend,
rect: Rect,
x: f32,
y: f32,
) -> TreeControllerEvent {
if !rect.contains(Point::new(x, y)) {
return TreeControllerEvent::Ignored;
}
let (tree_rect, sb) = self.split_rect(backend, rect);
if let Some(sb) = &sb {
if sb.track.contains(Point::new(x, y)) {
return self.click_scrollbar(backend, tree_rect, sb, x, y);
}
}
if tree_rect.contains(Point::new(x, y)) {
let tree = self.build_tree_view(tree_rect);
let layout = backend.tree_layout(tree_rect, &tree);
match layout.hit_test(x - tree_rect.x, y - tree_rect.y) {
TreeViewHit::Row(idx) => {
let path = self.rows[idx].path.clone();
self.selected_path = Some(path.clone());
TreeControllerEvent::RowSelected { path }
}
TreeViewHit::Chevron(idx) => {
let path = self.rows[idx].path.clone();
self.selected_path = Some(path.clone());
TreeControllerEvent::RowToggleExpand { path }
}
TreeViewHit::Empty => TreeControllerEvent::Consumed,
}
} else {
TreeControllerEvent::Ignored
}
}
fn double_click(
&mut self,
backend: &mut dyn Backend,
rect: Rect,
x: f32,
y: f32,
) -> TreeControllerEvent {
let (tree_rect, _) = self.split_rect(backend, rect);
if !tree_rect.contains(Point::new(x, y)) {
return TreeControllerEvent::Ignored;
}
let tree = self.build_tree_view(tree_rect);
let layout = backend.tree_layout(tree_rect, &tree);
match layout.hit_test(x - tree_rect.x, y - tree_rect.y) {
TreeViewHit::Row(idx) | TreeViewHit::Chevron(idx) => {
let path = self.rows[idx].path.clone();
self.selected_path = Some(path.clone());
TreeControllerEvent::RowActivated { path }
}
TreeViewHit::Empty => TreeControllerEvent::Consumed,
}
}
fn right_click(
&mut self,
backend: &mut dyn Backend,
rect: Rect,
position: Point,
) -> TreeControllerEvent {
let (tree_rect, _) = self.split_rect(backend, rect);
if !tree_rect.contains(position) {
return TreeControllerEvent::Ignored;
}
let tree = self.build_tree_view(tree_rect);
let layout = backend.tree_layout(tree_rect, &tree);
match layout.hit_test(position.x - tree_rect.x, position.y - tree_rect.y) {
TreeViewHit::Row(idx) | TreeViewHit::Chevron(idx) => {
let path = self.rows[idx].path.clone();
self.selected_path = Some(path.clone());
TreeControllerEvent::ContextMenuRequested { path, position }
}
TreeViewHit::Empty if self.empty_space_context_menu => {
TreeControllerEvent::ContextMenuRequested {
path: TreePath::new(),
position,
}
}
TreeViewHit::Empty => TreeControllerEvent::Consumed,
}
}
fn click_scrollbar(
&mut self,
backend: &mut dyn Backend,
tree_rect: Rect,
sb: &Scrollbar,
_x: f32,
y: f32,
) -> TreeControllerEvent {
let viewport_rows = self.viewport_rows(backend, tree_rect);
let max_offset = self.rows.len().saturating_sub(viewport_rows);
if max_offset == 0 {
return TreeControllerEvent::Ignored;
}
match sb.press_zone(y) {
PressZone::Thumb => {
self.scroll_drag = Some(ThumbDrag::begin(
y,
self.scroll_offset as f32,
sb.travel(),
max_offset as f32,
));
TreeControllerEvent::ScrollChanged
}
PressZone::Before => {
self.page_scroll(-(viewport_rows as isize), viewport_rows);
TreeControllerEvent::ScrollChanged
}
PressZone::After => {
self.page_scroll(viewport_rows as isize, viewport_rows);
TreeControllerEvent::ScrollChanged
}
}
}
fn drag_to(&mut self, y: f32) -> TreeControllerEvent {
let Some(drag) = &self.scroll_drag else {
return TreeControllerEvent::Ignored;
};
let Some(new) = drag.offset_at(y) else {
return TreeControllerEvent::Ignored;
};
let new = new.round() as usize;
if new == self.scroll_offset {
return TreeControllerEvent::Ignored;
}
self.scroll_offset = new;
TreeControllerEvent::Consumed
}
pub fn scroll_by(&mut self, delta: isize, viewport_rows: usize) {
let max = self.rows.len().saturating_sub(viewport_rows);
self.scroll_offset = scroll_by_clamped(self.scroll_offset, delta, max);
}
fn viewport_rows(&self, backend: &dyn Backend, rect: Rect) -> usize {
let tree = self.build_tree_view(rect);
let mut shadow = tree;
shadow.scroll_offset = 0;
let layout = backend.tree_layout(rect, &shadow);
layout.visible_rows.len()
}
pub fn selected_row_index(&self) -> Option<usize> {
let sel = self.selected_path.as_ref()?;
self.rows.iter().position(|r| &r.path == sel)
}
fn build_tree_view(&self, _rect: Rect) -> TreeView {
let mut rows = self.rows.clone();
if let Some(ref editing) = self.editing {
if let Some(row) = rows.iter_mut().find(|r| r.path == editing.path) {
row.edit = Some(TreeRowEditState {
text: editing.text.clone(),
cursor: editing.cursor,
selection_anchor: editing.selection_anchor,
placeholder: editing.placeholder.clone(),
});
}
}
TreeView {
id: self.id,
rows,
selection_mode: SelectionMode::Single,
selected_path: self.selected_path.clone(),
scroll_offset: self.scroll_offset,
style: Default::default(),
has_focus: self.has_focus,
}
}
fn scrollbar_track_width(&self, backend: &dyn Backend) -> f32 {
self.scrollbar_width
.unwrap_or_else(|| backend.line_height())
}
fn needs_scrollbar(&self, backend: &dyn Backend, tree_rect: Rect) -> bool {
self.rows.len() > self.viewport_rows(backend, tree_rect)
}
fn split_rect(&self, backend: &dyn Backend, rect: Rect) -> (Rect, Option<Scrollbar>) {
if !self.show_scrollbar {
return (rect, None);
}
if backend.tree_paints_own_scrollbar() {
let tree = self.build_tree_view(rect);
let sb = backend.tree_vscrollbar(rect, &tree).map(|mut sb| {
sb.dragging = self.scroll_drag.is_some();
sb
});
return (rect, sb);
}
let track_w = self.scrollbar_track_width(backend);
if rect.width <= track_w {
return (rect, None);
}
let tree_rect = Rect::new(rect.x, rect.y, rect.width - track_w, rect.height);
if !self.needs_scrollbar(backend, tree_rect) {
return (rect, None);
}
let sb_rect = Rect::new(rect.x + rect.width - track_w, rect.y, track_w, rect.height);
let sb = self.build_scrollbar(backend, sb_rect);
(tree_rect, Some(sb))
}
fn build_scrollbar(&self, backend: &dyn Backend, sb_rect: Rect) -> Scrollbar {
let total = self.rows.len() as f32;
let lh = backend.line_height();
let visible = if lh > 0.0 {
(sb_rect.height / lh).floor()
} else {
total
};
let track_w = self.scrollbar_track_width(backend);
let min_thumb = track_w.max(1.0);
let is_dragging = self.scroll_drag.is_some();
let mut sb = Scrollbar::vertical(
format!("{}-scrollbar", self.id),
sb_rect,
self.scroll_offset as f32,
total,
visible,
min_thumb,
);
sb.dragging = is_dragging;
sb
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::RecordingBackend;
use crate::{Decoration, StyledText};
fn fake_rows(prefix: &str, n: usize) -> Vec<TreeRow> {
(0..n)
.map(|i| TreeRow {
path: vec![i as u16],
indent: 0,
icon: None,
text: StyledText::plain(format!("{prefix}{i}")),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
})
.collect()
}
fn test_controller() -> TreeController {
let mut tc = TreeController::new("test-tree");
tc.set_rows(fake_rows("item", 5));
tc
}
#[test]
fn new_starts_empty() {
let tc = TreeController::new("t");
assert_eq!(tc.selected_path(), None);
assert_eq!(tc.scroll_offset(), 0);
assert!(tc.has_focus());
assert!(tc.rows().is_empty());
}
#[test]
fn set_rows_updates_data() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 3));
assert_eq!(tc.rows().len(), 3);
}
#[test]
fn set_selected_path_and_read_back() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![2]));
assert_eq!(tc.selected_path(), Some(&vec![2]));
tc.set_selected_path(None);
assert_eq!(tc.selected_path(), None);
}
#[test]
fn set_has_focus() {
let mut tc = test_controller();
tc.set_has_focus(false);
assert!(!tc.has_focus());
}
#[test]
fn down_selects_first_row_when_none_selected() {
let mut tc = test_controller();
let ev = tc.move_selection_by(1, 10);
assert_eq!(tc.selected_path(), Some(&vec![0]));
assert!(matches!(ev, TreeControllerEvent::RowSelected { .. }));
}
#[test]
fn down_advances_to_next_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![0]));
let ev = tc.move_selection_by(1, 10);
assert_eq!(tc.selected_path(), Some(&vec![1]));
assert!(matches!(ev, TreeControllerEvent::RowSelected { .. }));
}
#[test]
fn up_moves_to_previous_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![2]));
tc.move_selection_by(-1, 10);
assert_eq!(tc.selected_path(), Some(&vec![1]));
}
#[test]
fn clamps_at_first_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![0]));
let ev = tc.move_selection_by(-1, 10);
assert_eq!(tc.selected_path(), Some(&vec![0]));
assert_eq!(ev, TreeControllerEvent::Consumed);
}
#[test]
fn clamps_at_last_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![4]));
let ev = tc.move_selection_by(1, 10);
assert_eq!(tc.selected_path(), Some(&vec![4]));
assert_eq!(ev, TreeControllerEvent::Consumed);
}
#[test]
fn up_with_no_selection_selects_last_row() {
let mut tc = test_controller();
tc.move_selection_by(-1, 10);
assert_eq!(tc.selected_path(), Some(&vec![4]));
}
#[test]
fn home_jumps_to_first_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![3]));
tc.jump_to_edge(true, 10);
assert_eq!(tc.selected_path(), Some(&vec![0]));
}
#[test]
fn end_jumps_to_last_row() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![0]));
tc.jump_to_edge(false, 10);
assert_eq!(tc.selected_path(), Some(&vec![4]));
}
#[test]
fn page_down_jumps_by_viewport() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![0]));
let jump = (3_usize.max(1) - 1).max(1) as isize; tc.move_selection_by(jump, 3);
assert_eq!(tc.selected_path(), Some(&vec![2]));
}
#[test]
fn enter_emits_row_activated() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![2]));
let ev = tc.activate_selection();
assert_eq!(ev, TreeControllerEvent::RowActivated { path: vec![2] });
}
#[test]
fn enter_with_no_selection_returns_ignored() {
let tc = test_controller();
let ev = tc.activate_selection();
assert_eq!(ev, TreeControllerEvent::Ignored);
}
#[test]
fn scroll_follows_when_row_below_viewport() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![0]));
for _ in 0..4 {
tc.move_selection_by(1, 3);
}
assert_eq!(tc.selected_path(), Some(&vec![4]));
assert!(tc.scroll_offset() >= 2, "offset={}", tc.scroll_offset());
assert!(tc.scroll_offset() + 3 > 4);
}
#[test]
fn scroll_follows_when_row_above_viewport() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![4]));
tc.set_scroll_offset(2);
for _ in 0..4 {
tc.move_selection_by(-1, 3);
}
assert_eq!(tc.selected_path(), Some(&vec![0]));
assert_eq!(tc.scroll_offset(), 0);
}
#[test]
fn wheel_scroll_consumes_the_delta_magnitude_not_just_its_sign() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("row", 40));
let rect = Rect::new(0.0, 0.0, 40.0, 5.0);
let ev = UiEvent::Scroll {
widget: None,
delta: crate::ScrollDelta::new(0.0, -4.0),
position: crate::Point::new(5.0, 5.0),
};
assert_eq!(
tc.handle(&ev, &mut RecordingBackend::new(), rect),
TreeControllerEvent::Consumed
);
assert_eq!(
tc.scroll_offset(),
4,
"a four-notch gesture moves four rows"
);
}
#[test]
fn wheel_scroll_still_moves_one_row_for_a_sub_row_gesture() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("row", 40));
let rect = Rect::new(0.0, 0.0, 40.0, 5.0);
let ev = UiEvent::Scroll {
widget: None,
delta: crate::ScrollDelta::new(0.0, -0.2),
position: crate::Point::new(5.0, 5.0),
};
tc.handle(&ev, &mut RecordingBackend::new(), rect);
assert_eq!(tc.scroll_offset(), 1);
}
#[cfg(feature = "gtk")]
#[test]
fn gtk_wheel_down_scrolls_tree_toward_later_rows_traditional() {
use crate::gtk::events::gdk_scroll_to_uievent_with_direction;
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("row", 20));
let rect = Rect::new(0.0, 0.0, 40.0, 3.0);
let ev = gdk_scroll_to_uievent_with_direction(0.0, 1.0, 5.0, 5.0, false);
let outcome = tc.handle(&ev, &mut RecordingBackend::new(), rect);
assert_eq!(outcome, TreeControllerEvent::Consumed);
assert!(
tc.scroll_offset() > 0,
"wheel-down should move the view toward later rows, got offset={}",
tc.scroll_offset()
);
}
#[cfg(feature = "gtk")]
#[test]
fn gtk_wheel_down_scrolls_tree_toward_earlier_rows_when_natural() {
use crate::gtk::events::gdk_scroll_to_uievent_with_direction;
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("row", 20));
tc.set_scroll_offset(5);
let rect = Rect::new(0.0, 0.0, 40.0, 3.0);
let ev = gdk_scroll_to_uievent_with_direction(0.0, 1.0, 5.0, 5.0, true);
let outcome = tc.handle(&ev, &mut RecordingBackend::new(), rect);
assert_eq!(outcome, TreeControllerEvent::Consumed);
assert!(
tc.scroll_offset() < 5,
"natural_scroll should flip wheel-down toward earlier rows, got offset={}",
tc.scroll_offset()
);
}
#[test]
fn page_scroll_clamps_to_bounds() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 20));
tc.page_scroll(100, 5);
assert_eq!(tc.scroll_offset(), 15); tc.page_scroll(-100, 5);
assert_eq!(tc.scroll_offset(), 0);
}
#[test]
fn move_selection_on_empty_tree_returns_ignored() {
let mut tc = TreeController::new("t");
let ev = tc.move_selection_by(1, 10);
assert_eq!(ev, TreeControllerEvent::Ignored);
}
#[test]
fn jump_to_edge_on_empty_tree_returns_ignored() {
let mut tc = TreeController::new("t");
let ev = tc.jump_to_edge(true, 10);
assert_eq!(ev, TreeControllerEvent::Ignored);
}
#[test]
fn scroll_by_positive_advances_offset() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 20));
tc.scroll_by(3, 5);
assert_eq!(tc.scroll_offset(), 3);
}
#[test]
fn scroll_by_negative_retreats_offset() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 20));
tc.set_scroll_offset(5);
tc.scroll_by(-2, 5);
assert_eq!(tc.scroll_offset(), 3);
}
#[test]
fn scroll_by_clamps_to_bounds() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 10));
tc.scroll_by(100, 5);
assert_eq!(tc.scroll_offset(), 5); tc.scroll_by(-100, 5);
assert_eq!(tc.scroll_offset(), 0);
}
#[test]
fn start_editing_initializes_state() {
let mut tc = test_controller();
tc.start_editing(vec![2], "hello.rs".into(), 8, Some(0), None);
assert!(tc.is_editing());
assert_eq!(tc.editing_path(), Some(&vec![2]));
assert_eq!(tc.editing_text(), Some("hello.rs"));
assert_eq!(tc.selected_path(), Some(&vec![2]));
}
#[test]
fn cancel_editing_clears_state() {
let mut tc = test_controller();
tc.start_editing(vec![0], "a.rs".into(), 4, Some(0), None);
tc.cancel_editing();
assert!(!tc.is_editing());
assert_eq!(tc.editing_path(), None);
}
#[test]
fn char_key_inserts_during_editing() {
let mut tc = test_controller();
tc.start_editing(vec![0], String::new(), 0, None, None);
let ev = tc.handle_edit_key_via(&Key::Char('j'), &Modifiers::default());
assert!(matches!(ev, TreeControllerEvent::EditChanged { .. }));
assert_eq!(tc.editing_text(), Some("j"));
}
#[test]
fn enter_confirms_editing() {
let mut tc = test_controller();
tc.start_editing(vec![1], "new.txt".into(), 7, Some(0), None);
tc.handle_edit_key_via(&Key::Char('x'), &Modifiers::default());
let ev = tc.handle_edit_key_via(&Key::Named(NamedKey::Enter), &Modifiers::default());
assert_eq!(
ev,
TreeControllerEvent::EditConfirmed {
path: vec![1],
new_text: "x".into()
}
);
assert!(!tc.is_editing());
}
#[test]
fn escape_cancels_editing() {
let mut tc = test_controller();
tc.start_editing(vec![0], "old.txt".into(), 7, Some(0), None);
let ev = tc.handle_edit_key_via(&Key::Named(NamedKey::Escape), &Modifiers::default());
assert_eq!(ev, TreeControllerEvent::EditCancelled { path: vec![0] });
assert!(!tc.is_editing());
}
#[test]
fn backspace_deletes_char() {
let mut tc = test_controller();
tc.start_editing(vec![0], "abc".into(), 3, Some(0), None);
tc.handle_edit_key_via(&Key::Named(NamedKey::End), &Modifiers::default());
tc.handle_edit_key_via(&Key::Named(NamedKey::Backspace), &Modifiers::default());
assert_eq!(tc.editing_text(), Some("ab"));
}
#[test]
fn left_right_move_cursor() {
let mut tc = test_controller();
tc.start_editing(vec![0], "ab".into(), 2, Some(0), None);
tc.handle_edit_key_via(&Key::Named(NamedKey::End), &Modifiers::default());
tc.handle_edit_key_via(&Key::Named(NamedKey::Left), &Modifiers::default());
tc.handle_edit_key_via(&Key::Char('X'), &Modifiers::default());
assert_eq!(tc.editing_text(), Some("aXb"));
}
#[test]
fn shift_right_extends_selection_and_backspace_deletes_range() {
let mut tc = test_controller();
tc.start_editing(vec![0], "abcd".into(), 4, Some(0), None);
tc.handle_edit_key_via(&Key::Named(NamedKey::Home), &Modifiers::default());
let shift = Modifiers {
shift: true,
..Default::default()
};
tc.handle_edit_key_via(&Key::Named(NamedKey::Right), &shift);
tc.handle_edit_key_via(&Key::Named(NamedKey::Right), &shift);
tc.handle_edit_key_via(&Key::Named(NamedKey::Backspace), &Modifiers::default());
assert_eq!(tc.editing_text(), Some("cd"));
}
#[test]
fn build_tree_view_stamps_edit_state() {
let mut tc = test_controller();
tc.start_editing(vec![2], "renamed".into(), 7, Some(0), None);
let tree = tc.build_tree_view(Rect::new(0.0, 0.0, 40.0, 10.0));
let row = &tree.rows[2];
assert!(row.edit.is_some());
let edit = row.edit.as_ref().unwrap();
assert_eq!(edit.text, "renamed");
}
#[test]
fn nav_keys_suppressed_during_editing() {
let mut tc = test_controller();
tc.set_selected_path(Some(vec![2]));
tc.start_editing(vec![2], "test".into(), 4, Some(0), None);
let ev = tc.handle_edit_key_via(&Key::Named(NamedKey::Down), &Modifiers::default());
assert_eq!(ev, TreeControllerEvent::Consumed);
assert_eq!(tc.selected_path(), Some(&vec![2]));
}
#[test]
fn select_all_then_type_replaces() {
let mut tc = test_controller();
tc.start_editing(vec![0], "old".into(), 3, Some(0), None);
tc.handle_edit_key_via(&Key::Char('n'), &Modifiers::default());
assert_eq!(tc.editing_text(), Some("n"));
}
#[test]
fn partial_selection_preserves_unselected_text() {
let mut tc = test_controller();
tc.start_editing(vec![0], "main.rs".into(), 4, Some(0), None);
tc.handle_edit_key_via(&Key::Char('a'), &Modifiers::default());
assert_eq!(tc.editing_text(), Some("a.rs"));
}
#[test]
fn show_scrollbar_defaults_to_true() {
let tc = TreeController::new("t");
assert!(tc.show_scrollbar());
}
#[test]
fn set_show_scrollbar_false_suppresses_scrollbar() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
let (tree_rect, sb_rect) = tc.split_rect(&RecordingBackend::new(), rect);
assert!(sb_rect.is_none());
assert_eq!(tree_rect.width, 80.0);
}
#[test]
fn set_scrollbar_width_overrides_track() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
tc.set_scrollbar_width(Some(8.0));
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
let (tree_rect, sb) = tc.split_rect(&RecordingBackend::new(), rect);
let sb = sb.expect("scrollbar should be present");
assert_eq!(sb.track.width, 8.0);
assert_eq!(tree_rect.width, 72.0);
}
#[test]
fn default_scrollbar_width_uses_line_height() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
let (tree_rect, sb) = tc.split_rect(&RecordingBackend::new(), rect);
let sb = sb.expect("scrollbar should be present");
assert_eq!(sb.track.width, 1.0);
assert_eq!(tree_rect.width, 79.0);
}
#[test]
fn render_skips_own_paint_when_backend_paints_tree_scrollbar() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
let _overlay = crate::testing::TreePaintsOwnScrollbar::set(true);
let mut backend = RecordingBackend::new();
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
tc.render(&mut backend, rect);
assert_eq!(backend.calls, vec!["draw_tree"]);
}
#[test]
fn render_paints_its_own_scrollbar_when_backend_does_not() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
let mut backend = RecordingBackend::new();
assert!(!crate::Backend::tree_paints_own_scrollbar(&backend));
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
tc.render(&mut backend, rect);
assert_eq!(backend.calls, vec!["draw_tree", "draw_scrollbar"]);
}
#[test]
fn split_rect_reserves_no_column_when_backend_paints_own_scrollbar() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("r", 100));
let _overlay = crate::testing::TreePaintsOwnScrollbar::set(true);
let backend = RecordingBackend::new();
let rect = Rect::new(0.0, 0.0, 80.0, 10.0);
let (tree_rect, sb) = tc.split_rect(&backend, rect);
assert_eq!(tree_rect, rect);
assert!(sb.is_some(), "geometry is still returned for hit-testing");
}
fn branch_rows() -> Vec<crate::TreeRow> {
vec![
crate::TreeRow {
path: vec![0],
indent: 0,
icon: None,
text: StyledText::plain("src"),
badge: None,
is_expanded: Some(true),
decoration: Decoration::Normal,
edit: None,
},
crate::TreeRow {
path: vec![1],
indent: 0,
icon: None,
text: StyledText::plain("tests"),
badge: None,
is_expanded: Some(false),
decoration: Decoration::Normal,
edit: None,
},
crate::TreeRow {
path: vec![2],
indent: 1,
icon: None,
text: StyledText::plain("main.rs"),
badge: None,
is_expanded: None,
decoration: Decoration::Normal,
edit: None,
},
]
}
#[test]
fn space_on_branch_row_emits_row_toggle_expand() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
tc.set_selected_path(Some(vec![0]));
let ev = tc.toggle_expand_selected();
assert_eq!(ev, TreeControllerEvent::RowToggleExpand { path: vec![0] });
}
#[test]
fn space_on_collapsed_branch_emits_row_toggle_expand() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
tc.set_selected_path(Some(vec![1]));
let ev = tc.toggle_expand_selected();
assert_eq!(ev, TreeControllerEvent::RowToggleExpand { path: vec![1] });
}
#[test]
fn space_on_leaf_row_returns_ignored() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
tc.set_selected_path(Some(vec![2]));
let ev = tc.toggle_expand_selected();
assert_eq!(ev, TreeControllerEvent::Ignored);
}
#[test]
fn space_with_no_selection_returns_ignored() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
let ev = tc.toggle_expand_selected();
assert_eq!(ev, TreeControllerEvent::Ignored);
}
#[test]
fn chevron_click_emits_row_toggle_expand() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Left,
position: Point::new(0.5, 0.5),
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::RowToggleExpand { path: vec![0] },
"chevron click should emit RowToggleExpand"
);
assert_eq!(tc.selected_path(), Some(&vec![0]));
}
#[test]
fn body_click_on_branch_emits_row_selected() {
let mut tc = TreeController::new("t");
tc.set_rows(branch_rows());
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Left,
position: Point::new(10.0, 0.5),
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::RowSelected { path: vec![0] },
"body click on branch should emit RowSelected"
);
}
#[test]
fn header_branch_chevron_click_emits_row_toggle_expand() {
let mut tc = TreeController::new("t");
tc.set_rows(vec![crate::TreeRow {
path: vec![0],
indent: 0,
icon: None,
text: StyledText::plain("CHANGES"),
badge: None,
is_expanded: Some(true),
decoration: Decoration::Header,
edit: None,
}]);
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Left,
position: Point::new(0.5, 0.5),
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::RowToggleExpand { path: vec![0] },
"chevron click on Header branch should emit RowToggleExpand"
);
}
#[test]
fn right_click_on_row_emits_context_menu_requested_with_that_rows_path() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("item", 5));
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Right,
position: Point::new(10.0, 0.5),
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::ContextMenuRequested {
path: vec![0],
position: Point::new(10.0, 0.5),
},
);
assert_eq!(tc.selected_path(), Some(&vec![0]));
}
#[test]
fn right_click_on_empty_space_below_last_row_is_consumed_by_default() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("item", 5));
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let pos = Point::new(10.0, 10.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Right,
position: pos,
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::Consumed,
"right-click on empty space should stay Consumed unless opted in"
);
assert_eq!(tc.selected_path(), None);
}
#[test]
fn right_click_on_empty_space_emits_context_menu_requested_when_opted_in() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("item", 5));
tc.set_show_scrollbar(false);
tc.set_empty_space_context_menu(true);
assert!(tc.empty_space_context_menu());
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let pos = Point::new(10.0, 10.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Right,
position: pos,
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(
ev,
TreeControllerEvent::ContextMenuRequested {
path: Vec::new(),
position: pos,
},
"opted-in empty-space right-click should offer a context menu"
);
assert_eq!(tc.selected_path(), None);
}
#[test]
fn right_click_outside_rect_is_ignored() {
let mut tc = TreeController::new("t");
tc.set_rows(fake_rows("item", 5));
tc.set_show_scrollbar(false);
let rect = Rect::new(0.0, 0.0, 80.0, 24.0);
let ev = tc.handle(
&UiEvent::MouseDown {
button: MouseButton::Right,
position: Point::new(200.0, 200.0),
modifiers: Modifiers::default(),
widget: None,
},
&mut RecordingBackend::new(),
rect,
);
assert_eq!(ev, TreeControllerEvent::Ignored);
}
}