use super::{
events,
identity::NodeIdentity,
render_pass::RenderPass,
roving,
style::{apply_div_style, apply_semantic_focus_visible_affordance},
};
use crate::{
bridge_view::BridgeView,
ir::{DivStyle, TreeItem, TreeNode},
};
use gpui::{
AnyElement, Context, FocusHandle, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, div, list,
};
use std::collections::HashMap;
const SELECT_EVENT: i32 = 1;
const TOGGLE_EVENT: i32 = 2;
#[derive(Clone, Debug, PartialEq)]
struct VisibleTreeItem {
id: String,
label: String,
depth: usize,
expanded: bool,
has_children: bool,
parent_id: Option<String>,
style: DivStyle,
}
pub(crate) fn render(
pass: &mut RenderPass<'_>,
path: &str,
tree: &TreeNode,
_window: &mut Window,
cx: &mut Context<BridgeView>,
) -> AnyElement {
let view_id = pass.view_id();
let node_id = NodeIdentity::new(view_id, path, tree.id.as_deref());
let tree_id = node_id.to_string();
let visible_items = flatten_visible_tree_items(&tree.nodes);
let state = pass.retain_list_state(&format!("{tree_id}.rows"), visible_items.len());
let focus_visible = pass.focus_visible();
let row_focus_handles = prepare_row_focus_handles(
pass,
&tree_id,
&visible_items,
tree.select.is_some() || tree.toggle.is_some() || tree.context_menu.is_some(),
cx,
);
let row_style = tree.row_style.clone();
let select = tree.select.clone();
let toggle = tree.toggle.clone();
let context_menu = tree.context_menu.clone();
let tree_id_for_rows = tree_id.clone();
let rows = list(state, move |index, window, _cx| {
visible_items
.get(index)
.map(|item| {
let targets =
roving::vertical_neighbors(&visible_items, index, &row_focus_handles, |item| {
&item.id
});
let parent_focus = item
.parent_id
.as_ref()
.and_then(|parent_id| row_focus_handles.get(parent_id));
let first_child_focus = visible_items.get(index + 1).and_then(|next_item| {
if next_item.parent_id.as_deref() == Some(item.id.as_str()) {
row_focus_handles.get(&next_item.id)
} else {
None
}
});
render_row(
view_id,
&tree_id_for_rows,
item,
&row_style,
select.as_deref(),
toggle.as_deref(),
context_menu.as_deref(),
row_focus_handles.get(&item.id),
focus_visible,
window,
targets,
parent_focus,
first_child_focus,
)
})
.unwrap_or_else(|| div().into_any_element())
})
.size_full();
apply_div_style(
div()
.id(node_id.to_shared_string())
.flex()
.flex_col()
.size_full()
.child(rows),
&tree.style,
)
.into_any_element()
}
fn prepare_row_focus_handles(
pass: &mut RenderPass<'_>,
tree_id: &str,
visible_items: &[VisibleTreeItem],
keyboard_enabled: bool,
cx: &mut Context<BridgeView>,
) -> HashMap<String, FocusHandle> {
roving::prepare_focus_handles(
pass,
cx,
keyboard_enabled,
visible_items
.iter()
.map(|item| (item.id.clone(), tree_row_id(tree_id, &item.id)))
.collect::<Vec<_>>(),
)
}
fn flatten_visible_tree_items(items: &[TreeItem]) -> Vec<VisibleTreeItem> {
let mut visible = Vec::new();
collect_visible_tree_items(items, 0, None, &mut visible);
visible
}
fn collect_visible_tree_items(
items: &[TreeItem],
depth: usize,
parent_id: Option<&str>,
visible: &mut Vec<VisibleTreeItem>,
) {
for item in items {
let has_children = !item.children.is_empty();
visible.push(VisibleTreeItem {
id: item.id.clone(),
label: item.label.clone(),
depth,
expanded: item.expanded,
has_children,
parent_id: parent_id.map(str::to_owned),
style: item.style.clone(),
});
if item.expanded {
collect_visible_tree_items(&item.children, depth + 1, Some(&item.id), visible);
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_row(
view_id: u64,
tree_id: &str,
item: &VisibleTreeItem,
row_style: &crate::ir::DivStyle,
select: Option<&str>,
toggle: Option<&str>,
context_menu: Option<&str>,
focus_handle: Option<&FocusHandle>,
focus_visible: bool,
window: &Window,
targets: roving::NavigationTargets,
parent_focus: Option<&FocusHandle>,
first_child_focus: Option<&FocusHandle>,
) -> AnyElement {
let row_id = tree_row_id(tree_id, &item.id);
let disclosure_id = format!("{row_id}.toggle");
let label_id = format!("{row_id}.label");
let indent = " ".repeat(item.depth);
let marker = if item.has_children {
if item.expanded { "▾" } else { "▸" }
} else {
"•"
};
let mut disclosure = div()
.id(SharedString::from(disclosure_id.clone()))
.p_2()
.child(format!("{indent}{marker}"));
if item.has_children
&& let Some(callback_id) = toggle
{
let callback_id = callback_id.to_owned();
let tree_id = tree_id.to_owned();
let item_id = item.id.clone();
disclosure = disclosure.on_click(move |_, _, _| {
events::emit_tree_event(
view_id,
TOGGLE_EVENT,
&disclosure_id,
&callback_id,
&tree_id,
&item_id,
);
});
}
let mut label = div()
.id(SharedString::from(label_id.clone()))
.p_2()
.flex_1()
.child(item.label.clone());
if let Some(callback_id) = select {
let callback_id = callback_id.to_owned();
let tree_id = tree_id.to_owned();
let item_id = item.id.clone();
label = label.on_click(move |_, _, _| {
events::emit_tree_event(
view_id,
SELECT_EVENT,
&label_id,
&callback_id,
&tree_id,
&item_id,
);
});
}
let mut row = div()
.id(SharedString::from(row_id.clone()))
.flex()
.flex_row()
.children([disclosure.into_any_element(), label.into_any_element()]);
let show_focus_visible =
focus_visible && focus_handle.is_some_and(|handle| handle.is_focused(window));
if let Some(handle) = focus_handle {
let focus_handle = handle.clone();
row = row
.track_focus(&focus_handle)
.focusable()
.on_any_mouse_down(move |_, window, _| {
focus_handle.focus(window);
});
}
if select.is_some() || (item.has_children && toggle.is_some()) || context_menu.is_some() {
let select_callback = select.map(str::to_owned);
let toggle_callback = toggle.map(str::to_owned);
let context_menu_callback = context_menu.map(str::to_owned);
let key_tree_id = tree_id.to_owned();
let key_item_id = item.id.clone();
let key_row_id = row_id.clone();
let item = item.clone();
// Left moves to the parent unless the row is an expanded branch, and
// right descends into the first child of an expanded branch; the
// complementary cases fall through to tree_keyboard_action toggles.
let targets = {
let mut targets = targets;
if !(item.has_children && item.expanded) {
targets.left = parent_focus.cloned();
}
if item.has_children && item.expanded {
targets.right = first_child_focus.cloned();
}
targets
};
row = row.on_key_down(move |event: &KeyDownEvent, window, cx| {
if targets.handle(event, window, cx) {
return;
}
if let Some(callback_id) = context_menu_callback.as_deref()
&& events::is_context_menu_key(event)
{
events::emit_tree_keyboard_context_menu(
view_id,
&key_row_id,
callback_id,
&key_tree_id,
&key_item_id,
event,
);
cx.stop_propagation();
return;
}
match tree_keyboard_action(
event,
&item,
select_callback.as_deref(),
toggle_callback.as_deref(),
) {
Some(TreeKeyboardAction::Select(callback_id)) => {
events::emit_tree_event(
view_id,
SELECT_EVENT,
&key_row_id,
callback_id,
&key_tree_id,
&key_item_id,
);
cx.stop_propagation();
}
Some(TreeKeyboardAction::Toggle(callback_id)) => {
events::emit_tree_event(
view_id,
TOGGLE_EVENT,
&key_row_id,
callback_id,
&key_tree_id,
&key_item_id,
);
cx.stop_propagation();
}
None => {}
}
});
}
if let Some(callback_id) = context_menu {
let callback_id = callback_id.to_owned();
let tree_id = tree_id.to_owned();
let item_id = item.id.clone();
row = row.on_mouse_down(MouseButton::Right, move |event, _, _| {
events::emit_tree_context_menu(
view_id,
&row_id,
&callback_id,
&tree_id,
&item_id,
event,
);
});
}
let row = apply_tree_row_styles(row, row_style, &item.style);
apply_semantic_focus_visible_affordance(row, show_focus_visible).into_any_element()
}
fn tree_row_id(tree_id: &str, item_id: &str) -> String {
format!("{tree_id}.row.{item_id}")
}
#[derive(Debug, PartialEq, Eq)]
enum TreeKeyboardAction<'a> {
Select(&'a str),
Toggle(&'a str),
}
fn tree_keyboard_action<'a>(
event: &KeyDownEvent,
item: &VisibleTreeItem,
select: Option<&'a str>,
toggle: Option<&'a str>,
) -> Option<TreeKeyboardAction<'a>> {
// Held key repeat must not spam select/toggle events; held arrows still
// move focus through the unguarded navigation handling above.
if event.is_held {
return None;
}
match event.keystroke.key.as_str() {
"enter" => select.map(TreeKeyboardAction::Select),
"space" if item.has_children => toggle.map(TreeKeyboardAction::Toggle),
"right" if item.has_children && !item.expanded => toggle.map(TreeKeyboardAction::Toggle),
"left" if item.has_children && item.expanded => toggle.map(TreeKeyboardAction::Toggle),
_ => None,
}
}
fn apply_tree_row_styles<E>(element: E, row_style: &DivStyle, item_style: &DivStyle) -> E
where
E: Styled + StatefulInteractiveElement,
{
let element = apply_div_style(element, row_style);
apply_div_style(element, item_style)
}
#[cfg(test)]
mod tests {
use super::{
SELECT_EVENT, TOGGLE_EVENT, TreeKeyboardAction, VisibleTreeItem, apply_tree_row_styles,
flatten_visible_tree_items, tree_keyboard_action,
};
use gpui::{KeyDownEvent, Keystroke};
fn tree_key_event(key: &str, is_held: bool) -> KeyDownEvent {
KeyDownEvent {
keystroke: Keystroke::parse(key).unwrap(),
is_held,
}
}
#[test]
fn held_keys_do_not_select_or_toggle_tree_rows() {
let item = VisibleTreeItem {
id: "node".into(),
label: "Node".into(),
depth: 0,
expanded: false,
has_children: true,
parent_id: None,
style: Vec::new().into(),
};
assert!(matches!(
tree_keyboard_action(&tree_key_event("enter", false), &item, Some("sel"), None),
Some(TreeKeyboardAction::Select("sel"))
));
assert!(
tree_keyboard_action(&tree_key_event("enter", true), &item, Some("sel"), None)
.is_none()
);
assert!(
tree_keyboard_action(&tree_key_event("space", true), &item, None, Some("tog"))
.is_none()
);
assert!(
tree_keyboard_action(&tree_key_event("right", true), &item, None, Some("tog"))
.is_none()
);
}
use crate::{
bridge_view::events,
ir::{StyleOp, TreeItem},
};
use gpui::{InteractiveElement, SharedString, Styled, div};
#[test]
fn flatten_visible_tree_items_carries_item_style_for_row_rendering() {
let visible = flatten_visible_tree_items(&[TreeItem {
id: "styled".into(),
label: "Styled".into(),
expanded: false,
style: vec![StyleOp::Opacity(0.75)].into(),
children: Vec::new().into(),
}]);
assert_eq!(visible[0].style.as_ref(), [StyleOp::Opacity(0.75)]);
}
#[test]
fn tree_row_styles_apply_row_style_then_item_style() {
let row_style = vec![StyleOp::Opacity(0.25)].into();
let item_style = vec![StyleOp::Opacity(0.75)].into();
let mut row = apply_tree_row_styles(
div().id(SharedString::from("tree.row.styled")),
&row_style,
&item_style,
);
assert_eq!(row.style().opacity, Some(0.75));
}
#[test]
fn flatten_visible_tree_items_only_includes_expanded_descendants() {
let visible = flatten_visible_tree_items(&[
TreeItem {
id: "open".into(),
label: "Open".into(),
expanded: true,
style: Vec::new().into(),
children: vec![TreeItem {
id: "child".into(),
label: "Child".into(),
expanded: false,
style: Vec::new().into(),
children: Vec::new().into(),
}]
.into(),
},
TreeItem {
id: "closed".into(),
label: "Closed".into(),
expanded: false,
style: Vec::new().into(),
children: vec![TreeItem {
id: "hidden".into(),
label: "Hidden".into(),
expanded: false,
style: Vec::new().into(),
children: Vec::new().into(),
}]
.into(),
},
]);
assert_eq!(
visible
.iter()
.map(|item| item.id.as_str())
.collect::<Vec<_>>(),
["open", "child", "closed"]
);
assert_eq!(visible[1].depth, 1);
assert_eq!(visible[1].parent_id.as_deref(), Some("open"));
assert!(visible[0].has_children);
}
#[test]
fn tree_events_include_semantic_identity() {
use gpui::{Modifiers, MouseButton, MouseDownEvent, point, px};
events::emit_tree_event(
9,
SELECT_EVENT,
"tree.row.child.label",
"select_node",
"tree",
"child",
);
let event = crate::take_semantic_event_snapshot_for_test().unwrap();
assert_eq!(event.event, "tree_select");
assert_eq!(event.view_id, 9);
assert_eq!(event.tree_id.as_deref(), Some("tree"));
assert_eq!(event.item_id.as_deref(), Some("child"));
events::emit_tree_event(
9,
TOGGLE_EVENT,
"tree.row.child.toggle",
"toggle_node",
"tree",
"child",
);
let event = crate::take_semantic_event_snapshot_for_test().unwrap();
assert_eq!(event.event, "tree_toggle");
assert_eq!(event.tree_id.as_deref(), Some("tree"));
assert_eq!(event.item_id.as_deref(), Some("child"));
events::emit_tree_context_menu(
9,
"tree.row.child",
"tree_context_menu",
"tree",
"child",
&MouseDownEvent {
position: point(px(12.0), px(8.0)),
modifiers: Modifiers::none(),
button: MouseButton::Right,
click_count: 1,
first_mouse: false,
},
);
let event = crate::take_semantic_event_snapshot_for_test().unwrap();
assert_eq!(event.event, "context_menu");
assert_eq!(event.tree_id.as_deref(), Some("tree"));
assert_eq!(event.item_id.as_deref(), Some("child"));
}
}