use super::{
BridgeView, events, identity::NodeIdentity, render_pass::RenderPass, style::apply_div_style,
};
use crate::ir::{DivStyle, PopoverAnchor, PopoverAnchorFit, SelectNode, SelectOption};
use gpui::{
AnyElement, Context, Corner, InteractiveElement, IntoElement, KeyDownEvent, ParentElement,
SharedString, StatefulInteractiveElement, Styled, Window, anchored, deferred, div, point, px,
rgb,
};
pub(crate) fn render(
pass: &mut RenderPass<'_>,
path: &str,
node: &SelectNode,
window: &mut Window,
cx: &mut Context<BridgeView>,
) -> AnyElement {
let view_id = pass.view_id();
let node_id = NodeIdentity::new(view_id, path, node.id.as_deref());
let node_key = node_id.to_string();
let focus_handle = if node.disabled {
None
} else {
Some(pass.ensure_focus_handle(&node_key, cx, Some(true), node.tab_index))
};
if let Some(handle) = focus_handle.as_ref() {
pass.register_focus_callbacks(
&node_key,
handle,
node.focus.as_deref(),
node.blur.as_deref(),
window,
cx,
);
}
let mut trigger_base = div()
.id(node_id.to_shared_string())
.flex()
.flex_row()
.items_center()
.justify_between()
.gap_2()
.min_w(px(180.0))
.px_2()
.py_2()
.rounded_md()
.border_1()
.border_color(if node.open {
rgb(0x60a5fa)
} else {
rgb(0x475569)
})
.bg(if node.disabled {
rgb(0x334155)
} else {
rgb(0x0f172a)
})
.text_color(if node.disabled {
rgb(0x94a3b8)
} else {
rgb(0xf8fafc)
})
.child(selected_label(node))
.child(if node.open { "⌃" } else { "⌄" });
if !node.disabled {
trigger_base = trigger_base.cursor_pointer();
}
let mut trigger = apply_div_style(trigger_base, &node.style);
if let Some(handle) = focus_handle.as_ref() {
let handle = handle.clone();
trigger =
trigger
.track_focus(&handle)
.focusable()
.on_any_mouse_down(move |_, window, _| {
handle.focus(window);
});
}
if !node.disabled {
if let Some(callback_id) = node.click.as_ref() {
let callback_id = callback_id.clone();
let click_node_id = node_key.clone();
trigger = trigger.on_click(move |_, _, cx| {
events::emit_click(view_id, &click_node_id, &callback_id);
cx.stop_propagation();
});
}
trigger = attach_keyboard_navigation(trigger, view_id, &node_key, node);
}
if node.open {
let list = render_option_list(view_id, &node_key, node);
trigger = trigger.child(deferred(build_anchored_select(node).child(list)).priority(2));
}
trigger.into_any_element()
}
fn build_anchored_select(node: &SelectNode) -> gpui::Anchored {
let (offset_x, offset_y) = node.anchor_offset.unwrap_or((0.0, 32.0));
let anchored = anchored()
.anchor(to_gpui_corner(node.anchor))
.position_mode(gpui::AnchoredPositionMode::Local)
.offset(point(px(offset_x), px(offset_y)));
match node.anchor_fit {
PopoverAnchorFit::SwitchAnchor => anchored,
PopoverAnchorFit::SnapToWindow => anchored.snap_to_window(),
PopoverAnchorFit::SnapToWindowWithMargin => {
anchored.snap_to_window_with_margin(px(node.snap_margin))
}
}
}
fn to_gpui_corner(anchor: PopoverAnchor) -> Corner {
match anchor {
PopoverAnchor::TopLeft => Corner::TopLeft,
PopoverAnchor::TopRight => Corner::TopRight,
PopoverAnchor::BottomLeft => Corner::BottomLeft,
PopoverAnchor::BottomRight => Corner::BottomRight,
}
}
fn selected_label(node: &SelectNode) -> String {
node.value
.as_ref()
.and_then(|value| node.options.iter().find(|option| &option.value == value))
.map(|option| option.label.clone())
.unwrap_or_else(|| node.placeholder.clone())
}
fn attach_keyboard_navigation(
mut trigger: gpui::Stateful<gpui::Div>,
view_id: u64,
node_key: &str,
node: &SelectNode,
) -> gpui::Stateful<gpui::Div> {
let click = node.click.clone();
let change = node.change.clone();
let close = node.close.clone();
let options = node.options.clone();
let value = node.value.clone();
let open = node.open;
let key_node_id = node_key.to_owned();
if click.is_some() || change.is_some() || close.is_some() {
trigger = trigger.on_key_down(move |event: &KeyDownEvent, _, cx| {
let Some(action) = select_keyboard_action(event, open, &options, value.as_deref())
else {
return;
};
let emitted = match action {
SelectKeyboardAction::Toggle => {
if let Some(callback_id) = click.as_ref() {
events::emit_click(view_id, &key_node_id, callback_id);
true
} else {
false
}
}
SelectKeyboardAction::Close => {
if let Some(callback_id) = close.as_ref() {
events::emit_close(view_id, &key_node_id, callback_id);
true
} else {
false
}
}
SelectKeyboardAction::Change(value) => {
if let Some(callback_id) = change.as_ref() {
events::emit_change(view_id, &key_node_id, callback_id, &value);
true
} else {
false
}
}
};
if emitted {
cx.stop_propagation();
}
});
}
trigger
}
fn render_option_list(view_id: u64, node_key: &str, node: &SelectNode) -> AnyElement {
let mut list_base = div()
.id(SharedString::from(format!("{node_key}.list")))
.flex()
.flex_col()
.min_w(px(180.0))
.p_1()
.rounded_md()
.border_1()
.border_color(rgb(0x334155))
.shadow_lg()
.bg(rgb(0x0f172a))
.text_color(rgb(0xf8fafc));
if node.options.len() > 8 {
list_base = list_base.max_h(px(260.0)).overflow_y_scroll();
}
let mut list = apply_div_style(list_base, &node.list_style);
if !node.disabled
&& let Some(callback_id) = node.close.as_ref()
{
let close_node_id = format!("{node_key}.list");
let callback_id = callback_id.clone();
list = list.on_mouse_down_out(move |_, _, _| {
events::emit_close(view_id, &close_node_id, &callback_id);
});
}
list.children(node.options.iter().map(|option| {
render_option(
view_id,
node_key,
option,
&node.option_style,
node.change.as_deref(),
node.value.as_deref() == Some(option.value.as_str()),
)
}))
.into_any_element()
}
fn render_option(
view_id: u64,
node_key: &str,
option: &SelectOption,
option_style: &DivStyle,
change: Option<&str>,
selected: bool,
) -> AnyElement {
let option_key = format!("{node_key}.{}", option.value);
let mut row_base = div()
.id(SharedString::from(option_key.clone()))
.flex()
.flex_row()
.items_center()
.justify_between()
.gap_2()
.w_full()
.px_2()
.py_2()
.rounded_sm()
.text_color(if option.disabled {
rgb(0x64748b)
} else if selected {
rgb(0xffffff)
} else {
rgb(0xe2e8f0)
})
.child(option.label.clone());
if selected {
row_base = row_base.bg(rgb(0x1d4ed8)).child("✓");
}
if !option.disabled {
row_base = row_base.cursor_pointer().hover(move |style| {
style.bg(if selected {
rgb(0x2563eb)
} else {
rgb(0x1e293b)
})
});
}
let mut row = apply_div_style(row_base, option_style);
if !option.disabled
&& let Some(callback_id) = change
{
let callback_id = callback_id.to_owned();
let event_node_id = node_key.to_owned();
let value = option.value.clone();
row = row.on_click(move |_, _, cx| {
events::emit_change(view_id, &event_node_id, &callback_id, &value);
cx.stop_propagation();
});
}
row.into_any_element()
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SelectKeyboardAction {
Toggle,
Close,
Change(String),
}
#[derive(Clone, Copy)]
enum SelectNavigationDirection {
Previous,
Next,
}
fn is_select_toggle_key(event: &KeyDownEvent) -> bool {
matches!(event.keystroke.key.as_str(), "space" | "enter")
}
fn select_navigation_direction(event: &KeyDownEvent) -> Option<SelectNavigationDirection> {
match event.keystroke.key.as_str() {
"up" => Some(SelectNavigationDirection::Previous),
"down" => Some(SelectNavigationDirection::Next),
_ => None,
}
}
fn select_keyboard_action(
event: &KeyDownEvent,
open: bool,
options: &[SelectOption],
value: Option<&str>,
) -> Option<SelectKeyboardAction> {
if is_select_toggle_key(event) {
// Held key repeat must not spam open/close toggles.
if event.is_held {
return None;
}
return Some(SelectKeyboardAction::Toggle);
}
match event.keystroke.key.as_str() {
"escape" if open && !event.is_held => Some(SelectKeyboardAction::Close),
"home" => first_enabled_option(options)
.map(|option| SelectKeyboardAction::Change(option.value.clone())),
"end" => last_enabled_option(options)
.map(|option| SelectKeyboardAction::Change(option.value.clone())),
_ => select_navigation_direction(event)
.and_then(|direction| adjacent_enabled_option(options, value, direction))
.or_else(|| typeahead_enabled_option(options, value, &event.keystroke.key))
.map(|option| SelectKeyboardAction::Change(option.value.clone())),
}
}
fn first_enabled_option(options: &[SelectOption]) -> Option<&SelectOption> {
options.iter().find(|option| !option.disabled)
}
fn last_enabled_option(options: &[SelectOption]) -> Option<&SelectOption> {
options.iter().rev().find(|option| !option.disabled)
}
fn typeahead_enabled_option<'a>(
options: &'a [SelectOption],
value: Option<&str>,
key: &str,
) -> Option<&'a SelectOption> {
if key.chars().count() != 1 {
return None;
}
let key = key.to_lowercase();
let enabled = options
.iter()
.filter(|option| !option.disabled)
.collect::<Vec<_>>();
if enabled.is_empty() {
return None;
}
let current = value
.and_then(|value| enabled.iter().position(|option| option.value == value))
.unwrap_or(enabled.len() - 1);
(1..=enabled.len())
.map(|offset| enabled[(current + offset) % enabled.len()])
.find(|option| option.label.to_lowercase().starts_with(&key))
}
fn adjacent_enabled_option<'a>(
options: &'a [SelectOption],
value: Option<&str>,
direction: SelectNavigationDirection,
) -> Option<&'a SelectOption> {
let enabled_count = options.iter().filter(|option| !option.disabled).count();
if enabled_count == 0 {
return None;
}
let current = value
.and_then(|value| {
options
.iter()
.filter(|option| !option.disabled)
.position(|option| option.value == value)
})
.unwrap_or(0);
let next = match direction {
SelectNavigationDirection::Previous => current.saturating_sub(1),
SelectNavigationDirection::Next => (current + 1).min(enabled_count - 1),
};
options.iter().filter(|option| !option.disabled).nth(next)
}
#[cfg(test)]
mod tests {
use super::{
SelectKeyboardAction, SelectNavigationDirection, adjacent_enabled_option,
is_select_toggle_key, select_keyboard_action, select_navigation_direction,
};
use crate::ir::SelectOption;
use gpui::{KeyDownEvent, Keystroke};
#[test]
fn select_keyboard_helpers_match_toggle_and_arrow_keys() {
assert!(is_select_toggle_key(&key_event("space")));
assert!(is_select_toggle_key(&key_event("enter")));
assert!(matches!(
select_navigation_direction(&key_event("up")),
Some(SelectNavigationDirection::Previous)
));
assert!(matches!(
select_navigation_direction(&key_event("down")),
Some(SelectNavigationDirection::Next)
));
assert!(select_navigation_direction(&key_event("tab")).is_none());
}
#[test]
fn adjacent_enabled_option_skips_disabled_options() {
let options = vec![
option("todo", "Todo", false),
option("blocked", "Blocked", true),
option("done", "Done", false),
];
assert_eq!(
adjacent_enabled_option(&options, Some("todo"), SelectNavigationDirection::Next)
.map(|option| option.value.as_str()),
Some("done")
);
assert_eq!(
adjacent_enabled_option(&options, Some("done"), SelectNavigationDirection::Previous)
.map(|option| option.value.as_str()),
Some("todo")
);
assert_eq!(
adjacent_enabled_option(&options, Some("missing"), SelectNavigationDirection::Next)
.map(|option| option.value.as_str()),
Some("done")
);
}
#[test]
fn select_keyboard_action_supports_escape_home_end_and_typeahead() {
let options = vec![
option("alpha", "Alpha", false),
option("archived", "Archived", false),
option("blocked", "Blocked", true),
option("done", "Done", false),
];
assert_eq!(
select_keyboard_action(&key_event("escape"), true, &options, Some("alpha")),
Some(SelectKeyboardAction::Close)
);
assert_eq!(
select_keyboard_action(&key_event("home"), true, &options, Some("done")),
Some(SelectKeyboardAction::Change("alpha".into()))
);
assert_eq!(
select_keyboard_action(&key_event("end"), true, &options, Some("alpha")),
Some(SelectKeyboardAction::Change("done".into()))
);
assert_eq!(
select_keyboard_action(&key_event("a"), true, &options, Some("alpha")),
Some(SelectKeyboardAction::Change("archived".into()))
);
assert_eq!(
select_keyboard_action(&key_event("b"), true, &options, Some("alpha")),
None
);
assert_eq!(
select_keyboard_action(&key_event("escape"), false, &options, Some("alpha")),
None
);
}
fn key_event(key: &str) -> KeyDownEvent {
KeyDownEvent {
keystroke: Keystroke::parse(key).unwrap(),
is_held: false,
}
}
fn held_key_event(key: &str) -> KeyDownEvent {
KeyDownEvent {
keystroke: Keystroke::parse(key).unwrap(),
is_held: true,
}
}
#[test]
fn held_keys_do_not_toggle_or_close_selects_but_still_navigate() {
let options = vec![
option("alpha", "Alpha", false),
option("beta", "Beta", false),
];
assert!(select_keyboard_action(&held_key_event("space"), false, &options, None).is_none());
assert!(select_keyboard_action(&held_key_event("enter"), true, &options, None).is_none());
assert!(select_keyboard_action(&held_key_event("escape"), true, &options, None).is_none());
assert!(matches!(
select_keyboard_action(&held_key_event("down"), true, &options, Some("alpha")),
Some(SelectKeyboardAction::Change(value)) if value == "beta"
));
}
fn option(value: &str, label: &str, disabled: bool) -> SelectOption {
SelectOption {
value: value.into(),
label: label.into(),
disabled,
}
}
}