use super::{
events::{self, RowControlEventContext},
identity::{NodeIdentity, RowControlKey},
render_checkbox, render_choice,
render_pass::RenderPass,
render_radio, render_text, roving,
style::{apply_div_style, apply_refinement_style, apply_semantic_focus_visible_affordance},
};
use crate::{
bridge_view::BridgeView,
ir::{CheckboxNode, DivNode, DivStyle, IrNode, ListItem, RadioNode},
};
use gpui::{
AnyElement, Context, FocusHandle, InteractiveElement, IntoElement, KeyDownEvent, ParentElement,
SharedString, StatefulInteractiveElement, Styled, Window, div, list, rgb,
};
use std::{collections::HashMap, sync::Arc};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct RowControlLookupKey {
row_id: String,
control_id: String,
}
#[derive(Clone)]
struct RowControlRenderState {
context: RowControlEventContext,
focus_handle: Option<FocusHandle>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render(
pass: &mut RenderPass<'_>,
path: &str,
id: Option<&str>,
items: &Arc<[ListItem]>,
style: &DivStyle,
item_style: &DivStyle,
click: Option<&str>,
context_menu: Option<&str>,
_window: &mut Window,
cx: &mut Context<BridgeView>,
) -> AnyElement {
let view_id = pass.view_id();
let node_id = NodeIdentity::new(view_id, path, id);
let list_key = node_id.to_string();
let state = pass.retain_list_state(&list_key, items.len());
let row_controls = prepare_row_control_states(pass, &list_key, items.as_ref(), cx);
let row_focus_handles = prepare_row_focus_handles(
pass,
&list_key,
items.as_ref(),
click.is_some() || context_menu.is_some(),
cx,
);
let focus_visible = pass.focus_visible();
let items = items.clone();
let item_style = item_style.clone();
let click = click.map(str::to_owned);
let context_menu = context_menu.map(str::to_owned);
let item_list_key = list_key.clone();
let list = list(state, move |index, window, _cx| {
items
.get(index)
.map(|item| {
let targets =
roving::vertical_neighbors(&items, index, &row_focus_handles, |item| &item.id);
render_item(
view_id,
&item_list_key,
item,
&item_style,
click.as_deref(),
context_menu.as_deref(),
&row_controls,
row_focus_handles.get(&item.id),
targets,
focus_visible,
window,
)
})
.unwrap_or_else(|| div().into_any_element())
})
.size_full();
apply_div_style(
div()
.id(SharedString::from(format!("{list_key}.wrapper")))
.child(list),
style,
)
.into_any_element()
}
fn prepare_row_focus_handles(
pass: &mut RenderPass<'_>,
list_key: &str,
items: &[ListItem],
keyboard_enabled: bool,
cx: &mut Context<BridgeView>,
) -> HashMap<String, FocusHandle> {
roving::prepare_focus_handles(
pass,
cx,
keyboard_enabled,
items
.iter()
.map(|item| (item.id.clone(), list_row_id(list_key, &item.id)))
.collect::<Vec<_>>(),
)
}
fn prepare_row_control_states(
pass: &mut RenderPass<'_>,
list_key: &str,
items: &[ListItem],
cx: &mut Context<BridgeView>,
) -> HashMap<RowControlLookupKey, RowControlRenderState> {
let mut states = HashMap::new();
for item in items {
collect_row_control_states(pass, list_key, &item.id, &item.children, cx, &mut states);
}
states
}
fn collect_row_control_states(
pass: &mut RenderPass<'_>,
list_key: &str,
row_id: &str,
children: &[IrNode],
cx: &mut Context<BridgeView>,
states: &mut HashMap<RowControlLookupKey, RowControlRenderState>,
) {
for child in children {
match child {
IrNode::Button(node) => {
if let Some(control_id) = node.id.as_deref() {
insert_row_control_state(
pass,
list_key,
row_id,
control_id,
node.disabled,
node.tab_index,
cx,
states,
);
}
}
IrNode::Checkbox(node) => {
if let Some(control_id) = node.id.as_deref() {
insert_row_control_state(
pass,
list_key,
row_id,
control_id,
node.disabled,
node.tab_index,
cx,
states,
);
}
}
IrNode::Radio(node) => {
if let Some(control_id) = node.id.as_deref() {
insert_row_control_state(
pass,
list_key,
row_id,
control_id,
node.disabled,
node.tab_index,
cx,
states,
);
}
}
IrNode::Div(node) => {
collect_row_control_states(pass, list_key, row_id, &node.children, cx, states);
}
_ => {}
}
}
}
#[allow(clippy::too_many_arguments)]
fn insert_row_control_state(
pass: &mut RenderPass<'_>,
list_key: &str,
row_id: &str,
control_id: &str,
disabled: bool,
tab_index: Option<isize>,
cx: &mut Context<BridgeView>,
states: &mut HashMap<RowControlLookupKey, RowControlRenderState>,
) {
let key = RowControlKey::new(pass.view_id(), list_key, row_id, control_id).to_string();
let focus_handle = if disabled {
None
} else {
Some(pass.ensure_focus_handle(&key, cx, Some(true), tab_index))
};
states.insert(
row_control_lookup_key(row_id, control_id),
RowControlRenderState {
context: RowControlEventContext {
node_id: key,
list_id: list_key.to_owned(),
row_id: row_id.to_owned(),
control_id: control_id.to_owned(),
},
focus_handle,
},
);
}
#[allow(clippy::too_many_arguments)]
fn render_item(
view_id: u64,
list_key: &str,
item: &ListItem,
item_style: &DivStyle,
click: Option<&str>,
context_menu: Option<&str>,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
row_focus_handle: Option<&FocusHandle>,
targets: roving::NavigationTargets,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
let item_key = list_row_id(list_key, &item.id);
let children = item.children.iter().enumerate().map(|(index, child)| {
render_static_node(
view_id,
list_key,
&item.id,
&format!("{item_key}.{index}"),
child,
row_controls,
focus_visible,
window,
)
});
let mut row = apply_div_style(
div()
.id(SharedString::from(item_key.clone()))
.w_full()
.children(children),
item_style,
);
let show_focus_visible =
focus_visible && row_focus_handle.is_some_and(|handle| handle.is_focused(window));
if let Some(handle) = row_focus_handle {
let focus_handle = handle.clone();
row = row
.track_focus(&focus_handle)
.focusable()
.on_any_mouse_down(move |_, window, _| {
focus_handle.focus(window);
});
}
row = roving::attach_row_interactions(
row,
roving::RowInteractionSpec {
view_id,
row_key: &item_key,
click,
context_menu,
targets,
},
);
apply_semantic_focus_visible_affordance(row, show_focus_visible).into_any_element()
}
fn list_row_id(list_key: &str, item_id: &str) -> String {
format!("{list_key}.{item_id}")
}
#[allow(clippy::too_many_arguments)]
fn render_static_node(
view_id: u64,
list_key: &str,
row_id: &str,
path: &str,
ir: &IrNode,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
match ir {
IrNode::Text {
id,
content,
runs,
style,
click,
} => render_text::render_with_view_id(
view_id,
path,
id.as_deref(),
content,
runs,
style,
click.as_deref(),
),
IrNode::Button(node) => render_row_button(
view_id,
row_id,
path,
node,
row_controls,
focus_visible,
window,
),
IrNode::Checkbox(node) => {
render_row_checkbox(view_id, row_id, node, row_controls, focus_visible, window)
}
IrNode::Radio(node) => {
render_row_radio(view_id, row_id, node, row_controls, focus_visible, window)
}
IrNode::Div(node) => render_static_div(
view_id,
list_key,
row_id,
path,
node,
row_controls,
focus_visible,
window,
),
IrNode::Spacer { id, style } => {
let node_id = NodeIdentity::new(view_id, path, id.as_deref());
apply_div_style(div().id(node_id.to_shared_string()), style).into_any_element()
}
_ => div()
.id(SharedString::from(format!(
"guppy-{view_id}-{path}-unsupported"
)))
.child("Unsupported list row child")
.into_any_element(),
}
}
#[allow(clippy::too_many_arguments)]
fn render_static_div(
view_id: u64,
list_key: &str,
row_id: &str,
path: &str,
node: &DivNode,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
let node_id = NodeIdentity::new(view_id, path, node.id.as_deref());
let node_key = node_id.to_string();
let children = node.children.iter().enumerate().map(|(index, child)| {
render_static_node(
view_id,
list_key,
row_id,
&format!("{path}.{index}"),
child,
row_controls,
focus_visible,
window,
)
});
let mut element = apply_div_style(
div().id(node_id.to_shared_string()).children(children),
&node.style,
);
if !node.disabled
&& let Some(callback_id) = node.click.as_ref()
{
let callback_id = callback_id.clone();
element = element.on_click(move |_, _, _| {
events::emit_click(view_id, &node_key, &callback_id);
});
}
element.into_any_element()
}
fn render_row_button(
view_id: u64,
row_id: &str,
path: &str,
node: &DivNode,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
let Some(control_id) = node.id.as_deref() else {
return unsupported_row_control(view_id, path, "button missing id");
};
let Some(state) = row_controls.get(&row_control_lookup_key(row_id, control_id)) else {
return unsupported_row_control(view_id, path, "button state missing");
};
let children = node
.children
.iter()
.enumerate()
.map(|(index, child)| render_static_text_child(view_id, path, index, child));
let mut button = apply_div_style(
div()
.id(SharedString::from(state.context.node_id.clone()))
.children(children),
&node.style,
);
button = attach_row_control_focus(button, state.focus_handle.as_ref());
button = apply_row_control_styles(
button,
RowControlStyleSpec {
disabled: node.disabled,
hover_style: &node.hover_style,
focus_style: &node.focus_style,
focus_visible_style: &node.focus_visible_style,
in_focus_style: &node.in_focus_style,
active_style: &node.active_style,
disabled_style: &node.disabled_style,
},
state.focus_handle.as_ref(),
focus_visible,
window,
);
if !node.disabled
&& let Some(callback_id) = node.click.as_ref()
{
let context = state.context.clone();
let callback_id = callback_id.clone();
button = button.on_click(move |_, _, _| {
events::emit_row_control_click(view_id, &context, &callback_id);
});
}
button.into_any_element()
}
fn render_static_text_child(view_id: u64, path: &str, index: usize, child: &IrNode) -> AnyElement {
match child {
IrNode::Text {
id,
content,
runs,
style,
click,
} => render_text::render_with_view_id(
view_id,
&format!("{path}.{index}"),
id.as_deref(),
content,
runs,
style,
click.as_deref(),
),
_ => div().into_any_element(),
}
}
fn render_row_checkbox(
view_id: u64,
row_id: &str,
node: &CheckboxNode,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
let Some(control_id) = node.id.as_deref() else {
return unsupported_row_control(view_id, row_id, "checkbox missing id");
};
let Some(state) = row_controls.get(&row_control_lookup_key(row_id, control_id)) else {
return unsupported_row_control(view_id, row_id, "checkbox state missing");
};
let mut checkbox = apply_div_style(
div()
.id(SharedString::from(state.context.node_id.clone()))
.flex()
.flex_row()
.items_center()
.gap_2()
.text_color(rgb(render_choice::default_text_color(node.disabled)))
.child(render_checkbox::checkbox_indicator(
node.checked,
node.disabled,
))
.child(render_choice::choice_label(&node.label)),
&node.style,
);
checkbox = attach_row_control_focus(checkbox, state.focus_handle.as_ref());
checkbox = apply_row_control_styles(
checkbox,
RowControlStyleSpec {
disabled: node.disabled,
hover_style: &node.hover_style,
focus_style: &node.focus_style,
focus_visible_style: &node.focus_visible_style,
in_focus_style: &node.in_focus_style,
active_style: &node.active_style,
disabled_style: &node.disabled_style,
},
state.focus_handle.as_ref(),
focus_visible,
window,
);
if !node.disabled
&& let Some(callback_id) = node.change.as_ref()
{
let next_checked = !node.checked;
let click_context = state.context.clone();
let click_callback_id = callback_id.clone();
checkbox = checkbox.on_click(move |_, _, _| {
events::emit_row_control_checkbox_change(
view_id,
&click_context,
&click_callback_id,
next_checked,
);
});
let key_context = state.context.clone();
let key_callback_id = callback_id.clone();
checkbox = checkbox.on_key_down(move |event: &KeyDownEvent, _, cx| {
if render_choice::choice_toggle_triggered(event) {
events::emit_row_control_checkbox_change(
view_id,
&key_context,
&key_callback_id,
next_checked,
);
cx.stop_propagation();
}
});
}
checkbox.into_any_element()
}
fn render_row_radio(
view_id: u64,
row_id: &str,
node: &RadioNode,
row_controls: &HashMap<RowControlLookupKey, RowControlRenderState>,
focus_visible: bool,
window: &mut Window,
) -> AnyElement {
let Some(control_id) = node.id.as_deref() else {
return unsupported_row_control(view_id, row_id, "radio missing id");
};
let Some(state) = row_controls.get(&row_control_lookup_key(row_id, control_id)) else {
return unsupported_row_control(view_id, row_id, "radio state missing");
};
let mut radio = apply_div_style(
div()
.id(SharedString::from(state.context.node_id.clone()))
.flex()
.flex_row()
.items_center()
.gap_2()
.text_color(rgb(render_choice::default_text_color(node.disabled)))
.child(render_radio::radio_indicator(node.checked, node.disabled))
.child(render_choice::choice_label(&node.label)),
&node.style,
);
radio = attach_row_control_focus(radio, state.focus_handle.as_ref());
radio = apply_row_control_styles(
radio,
RowControlStyleSpec {
disabled: node.disabled,
hover_style: &node.hover_style,
focus_style: &node.focus_style,
focus_visible_style: &node.focus_visible_style,
in_focus_style: &node.in_focus_style,
active_style: &node.active_style,
disabled_style: &node.disabled_style,
},
state.focus_handle.as_ref(),
focus_visible,
window,
);
if !node.disabled
&& let Some(callback_id) = node.change.as_ref()
{
let click_context = state.context.clone();
let click_callback_id = callback_id.clone();
let click_value = node.value.clone();
radio = radio.on_click(move |_, _, _| {
events::emit_row_control_change(
view_id,
&click_context,
&click_callback_id,
&click_value,
);
});
let key_context = state.context.clone();
let key_callback_id = callback_id.clone();
let key_value = node.value.clone();
radio = radio.on_key_down(move |event: &KeyDownEvent, _, cx| {
if render_choice::choice_toggle_triggered(event) {
events::emit_row_control_change(
view_id,
&key_context,
&key_callback_id,
&key_value,
);
cx.stop_propagation();
}
});
}
radio.into_any_element()
}
fn attach_row_control_focus(
mut element: gpui::Stateful<gpui::Div>,
focus_handle: Option<&FocusHandle>,
) -> gpui::Stateful<gpui::Div> {
if let Some(handle) = focus_handle {
let handle = handle.clone();
element =
element
.track_focus(&handle)
.focusable()
.on_any_mouse_down(move |_, window, _| {
handle.focus(window);
});
}
element
}
struct RowControlStyleSpec<'a> {
disabled: bool,
hover_style: &'a DivStyle,
focus_style: &'a DivStyle,
focus_visible_style: &'a DivStyle,
in_focus_style: &'a DivStyle,
active_style: &'a DivStyle,
disabled_style: &'a DivStyle,
}
fn apply_row_control_styles(
mut element: gpui::Stateful<gpui::Div>,
spec: RowControlStyleSpec<'_>,
focus_handle: Option<&FocusHandle>,
focus_visible: bool,
window: &mut Window,
) -> gpui::Stateful<gpui::Div> {
if !spec.disabled && !spec.hover_style.is_empty() {
let hover_ops = spec.hover_style.clone();
element = element.hover(move |style| apply_refinement_style(style, &hover_ops));
}
if !spec.disabled
&& !spec.focus_visible_style.is_empty()
&& focus_visible
&& focus_handle.is_some_and(|handle| handle.is_focused(window))
{
element = apply_div_style(element, spec.focus_visible_style);
}
if !spec.disabled && !spec.focus_style.is_empty() {
let focus_ops = spec.focus_style.clone();
element = element.focus(move |style| apply_refinement_style(style, &focus_ops));
}
if !spec.disabled && !spec.in_focus_style.is_empty() {
let in_focus_ops = spec.in_focus_style.clone();
element = element.in_focus(move |style| apply_refinement_style(style, &in_focus_ops));
}
if !spec.disabled && !spec.active_style.is_empty() {
let active_ops = spec.active_style.clone();
element = element.active(move |style| apply_refinement_style(style, &active_ops));
}
if spec.disabled && !spec.disabled_style.is_empty() {
element = apply_div_style(element, spec.disabled_style);
}
element
}
fn unsupported_row_control(view_id: u64, path: &str, message: &str) -> AnyElement {
div()
.id(SharedString::from(format!(
"guppy-{view_id}-{path}-unsupported"
)))
.child(message.to_owned())
.into_any_element()
}
fn row_control_lookup_key(row_id: &str, control_id: &str) -> RowControlLookupKey {
RowControlLookupKey {
row_id: row_id.to_owned(),
control_id: control_id.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::row_control_lookup_key;
#[test]
fn row_control_lookup_keys_are_row_scoped() {
assert_ne!(
row_control_lookup_key("row_1", "done"),
row_control_lookup_key("row_2", "done")
);
}
#[test]
fn row_control_lookup_keys_do_not_collide_on_delimiter_chars() {
assert_ne!(
row_control_lookup_key("row\0nested", "done"),
row_control_lookup_key("row", "nested\0done")
);
}
}