use crate::a11y::{A11y, Announce};
#[cfg(test)]
use crate::elements::listbox::option_a11y;
use crate::elements::listbox::{Listbox, ListboxFocus, LISTBOX_GAP};
use crate::theme::{focus_ring, ActiveTheme, ControlSize, Themeable};
use crate::traits::accessible::Accessible;
use crate::traits::control_sized::ControlSized;
use crate::traits::disableable::Disableable;
use gpui::{
anchored, deferred, div, point, prelude::*, px, App, Context, DismissEvent, ElementId, Entity,
EventEmitter, IntoElement, ParentElement, Rems, Render, Role, SharedString, Styled, Window,
};
use crate::icons::Icons;
use std::rc::Rc;
pub use crate::elements::listbox::{
ChooseHighlighted, DismissListbox, HighlightFirst, HighlightLast, HighlightNext,
HighlightPrevious,
};
pub const LISTBOX_CONTEXT: &str = crate::elements::listbox::LISTBOX_CONTEXT;
pub fn bind_select_keys(cx: &mut App) {
crate::elements::listbox::bind_listbox_keys(cx);
}
const MIN_TRIGGER_WIDTH: Rems = Rems(6.25);
pub struct SelectChanged;
pub struct Select<T: Clone + PartialEq + 'static> {
id: ElementId,
label: SharedString,
options: Vec<(T, SharedString)>,
selected: Option<T>,
placeholder: SharedString,
on_change: Option<Rc<dyn Fn(T, &mut Window, &mut App)>>,
full_width: bool,
disabled: bool,
size: ControlSize,
}
pub fn select<T: Clone + PartialEq + 'static>(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
options: Vec<(T, impl Into<SharedString>)>,
) -> Select<T> {
Select::new(id, label, options)
}
impl<T: Clone + PartialEq + 'static> Select<T> {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
options: Vec<(T, impl Into<SharedString>)>,
) -> Self {
Self {
id: id.into(),
label: label.into(),
options: options
.into_iter()
.map(|(value, label)| (value, label.into()))
.collect(),
selected: None,
placeholder: "Select...".into(),
on_change: None,
full_width: false,
disabled: false,
size: ControlSize::default(),
}
}
pub fn selected(mut self, value: T) -> Self {
self.selected = Some(value);
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn on_change(mut self, handler: impl Fn(T, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn full_width(mut self, full_width: bool) -> Self {
self.full_width = full_width;
self
}
}
impl<T: Clone + PartialEq + 'static> Disableable for Select<T> {
fn is_disabled(&self) -> bool {
self.disabled
}
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl<T: Clone + PartialEq + 'static> ControlSized for Select<T> {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
pub struct SelectState<T: Clone + PartialEq + 'static> {
id: ElementId,
label: SharedString,
options: Vec<(T, SharedString)>,
pub selected: Option<T>,
placeholder: SharedString,
listbox: Option<Entity<Listbox>>,
on_change: Option<Rc<dyn Fn(T, &mut Window, &mut App)>>,
full_width: bool,
disabled: bool,
size: ControlSize,
}
impl<T: Clone + PartialEq + 'static> EventEmitter<SelectChanged> for SelectState<T> {}
impl<T: Clone + PartialEq + 'static> SelectState<T> {
pub fn new(select: Select<T>) -> Self {
Self {
id: select.id,
label: select.label,
options: select.options,
selected: select.selected,
placeholder: select.placeholder,
listbox: None,
on_change: select.on_change,
full_width: select.full_width,
disabled: select.disabled,
size: select.size,
}
}
fn display_label(&self) -> (SharedString, bool) {
match &self.selected {
Some(selected) => {
let label = self
.options
.iter()
.find(|(v, _)| v == selected)
.map(|(_, label)| label.clone())
.unwrap_or_else(|| self.placeholder.clone());
(label, false)
}
None => (self.placeholder.clone(), true),
}
}
fn selected_index(&self) -> Option<usize> {
self.selected
.as_ref()
.and_then(|selected| self.options.iter().position(|(v, _)| v == selected))
}
pub fn set_selected(&mut self, value: Option<T>, cx: &mut Context<Self>) {
self.selected = value;
cx.emit(SelectChanged);
cx.notify();
}
pub fn is_open(&self) -> bool {
self.listbox.is_some()
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled;
if disabled && self.listbox.is_some() {
self.listbox = None;
}
cx.notify();
}
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.selected = None;
cx.emit(SelectChanged);
cx.notify();
}
fn toggle_listbox(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if self.listbox.is_some() {
self.listbox = None;
cx.notify();
return;
}
let options: Vec<SharedString> = self
.options
.iter()
.map(|(_, label)| label.clone())
.collect();
let selected_index = self.selected_index();
let values: Vec<T> = self.options.iter().map(|(v, _)| v.clone()).collect();
let on_change = self.on_change.clone();
let entity = cx.entity().downgrade();
let listbox = Listbox::build(
self.label.clone(),
options,
selected_index,
self.size,
ListboxFocus::Popup,
move |index, window, cx| {
if let Some(value) = values.get(index).cloned() {
if let Some(on_change) = &on_change {
on_change(value.clone(), window, cx);
}
if let Some(entity) = entity.upgrade() {
entity.update(cx, |state, cx| {
state.selected = Some(value);
cx.emit(SelectChanged);
cx.notify();
});
}
}
},
window,
cx,
);
cx.subscribe_in(
&listbox,
window,
|this, _, _event: &DismissEvent, _window, cx| {
this.listbox = None;
cx.notify();
},
)
.detach();
self.listbox = Some(listbox);
cx.notify();
}
fn selected_label(&self) -> Option<SharedString> {
self.selected_index()
.and_then(|index| self.options.get(index))
.map(|(_, label)| label.clone())
}
pub fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_open = self.listbox.is_some();
let a11y = self.a11y();
let (label, is_placeholder) = self.display_label();
let full_width = self.full_width;
let disabled = self.disabled;
let theme = cx.theme();
let metrics = theme.control(self.size);
let border_color = if disabled {
theme.border_subtle()
} else if is_open {
theme.input_border_focused()
} else {
theme.input_border()
};
let text_color = if disabled {
theme.fg_disabled()
} else if is_placeholder {
theme.input_placeholder()
} else {
theme.fg()
};
let trigger = div()
.id(self.id.clone())
.announce(a11y)
.focus_visible(|style| style.shadow(focus_ring(theme.accent())))
.flex()
.items_center()
.justify_between()
.h(metrics.height)
.gap(metrics.gap)
.px(metrics.padding_x)
.min_w(MIN_TRIGGER_WIDTH)
.when(full_width, |this| this.w_full())
.bg(theme.input_bg())
.border_1()
.border_color(border_color)
.rounded(metrics.radius)
.text_size(metrics.text_size)
.line_height(metrics.line_height)
.text_color(text_color)
.when(disabled, |this| this.cursor_not_allowed().opacity(0.65))
.when(!disabled, |this| {
this.cursor_pointer()
.hover(|style| style.border_color(theme.input_border_hover()))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_listbox(window, cx);
}))
})
.child(label)
.child(
div().flex().items_center().justify_center().child(
Icons::chevron_down()
.size(metrics.text_size)
.text_color(theme.fg_muted()),
),
);
#[cfg(test)]
let trigger = trigger.debug_selector(|| "gpuikit-select-trigger".into());
let gap = LISTBOX_GAP.to_pixels(window.rem_size());
div()
.relative()
.when(full_width, |this| this.w_full())
.child(trigger)
.when_some(self.listbox.clone(), |this, listbox| {
let popup = div().occlude().child(listbox);
#[cfg(test)]
let popup = popup.debug_selector(|| "gpuikit-select-popup".into());
this.child(
deferred(anchored().offset(point(px(0.), gap)).child(popup)).with_priority(1),
)
})
}
}
impl<T: Clone + PartialEq + 'static> Accessible for SelectState<T> {
fn a11y(&self) -> A11y {
let a11y = A11y::new(Role::ComboBox)
.name(self.label.clone())
.expanded(self.listbox.is_some());
let a11y = match self.selected_label() {
Some(label) => a11y.text_value(label),
None => a11y,
};
if self.disabled {
a11y.not_focusable("a disabled select has nothing for a keyboard to choose between")
} else {
a11y.focusable()
}
}
}
impl<T: Clone + PartialEq + 'static> Render for SelectState<T> {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.render(window, cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{
px, size, Bounds, KeyUpEvent, Keystroke, Pixels, PlatformInput, Render, TestAppContext,
VisualTestContext,
};
use std::ops::Deref;
struct TestView {
select: Entity<SelectState<usize>>,
top: Pixels,
}
impl Render for TestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.flex()
.flex_col()
.size_full()
.child(div().h(self.top).flex_shrink_0())
.child(self.select.clone())
}
}
struct Opened {
select: Entity<SelectState<usize>>,
cx: &'static mut VisualTestContext,
trigger: Bounds<Pixels>,
popup: Bounds<Pixels>,
}
fn open_select(
cx: &mut TestAppContext,
window_size: gpui::Size<Pixels>,
top: Pixels,
options: usize,
selected: Option<usize>,
) -> Opened {
let labels: Vec<SharedString> = (0..options)
.map(|index| SharedString::from(format!("Option {index}")))
.collect();
open_labelled(cx, window_size, top, labels, selected)
}
fn open_labelled(
cx: &mut TestAppContext,
window_size: gpui::Size<Pixels>,
top: Pixels,
labels: Vec<SharedString>,
selected: Option<usize>,
) -> Opened {
cx.update(crate::init);
let window = cx.open_window(window_size, move |_window, cx| {
let select = cx.new(|_cx| {
let options: Vec<(usize, SharedString)> =
labels.iter().cloned().enumerate().collect();
let mut builder = super::select("test-select", "Test select", options);
if let Some(selected) = selected {
builder = builder.selected(selected);
}
SelectState::new(builder)
});
TestView { select, top }
});
let select = window
.read_with(cx, |view, _cx| view.select.clone())
.expect("the window's root view is the test view");
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
cx.run_until_parked();
let trigger = cx
.debug_bounds("gpuikit-select-trigger")
.expect("the trigger should have been laid out");
cx.simulate_click(trigger.center(), gpui::Modifiers::default());
cx.run_until_parked();
let popup = cx
.debug_bounds("gpuikit-select-popup")
.expect("the popup should have been laid out");
Opened {
select,
cx,
trigger,
popup,
}
}
fn press(cx: &mut VisualTestContext, key: &str) {
cx.simulate_keystrokes(key);
let keystroke = Keystroke::parse(key).expect("a parseable keystroke");
cx.update(|window, cx| {
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke }), cx);
});
cx.run_until_parked();
}
fn highlighted_row(
select: &Entity<SelectState<usize>>,
cx: &VisualTestContext,
) -> Option<usize> {
select.read_with(cx, |state, cx| {
state
.listbox
.as_ref()
.expect("the popup should still be open")
.read(cx)
.highlighted
})
}
fn is_open(select: &Entity<SelectState<usize>>, cx: &VisualTestContext) -> bool {
select.read_with(cx, |state, _| state.is_open())
}
fn chosen(select: &Entity<SelectState<usize>>, cx: &VisualTestContext) -> Option<usize> {
select.read_with(cx, |state, _| state.selected)
}
fn row_bounds(cx: &mut VisualTestContext, index: usize) -> Bounds<Pixels> {
let selector: &'static str =
Box::leak(format!("gpuikit-select-option-{index}").into_boxed_str());
cx.debug_bounds(selector)
.unwrap_or_else(|| panic!("row {index} should have been laid out"))
}
#[gpui::test]
fn the_highlight_starts_on_the_chosen_row_or_the_first_one(cx: &mut TestAppContext) {
let window = size(px(320.), px(800.));
let opened = open_select(cx, window, px(40.), 4, Some(2));
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(2),
"an open popup starts the keyboard on the value the control holds"
);
let opened = open_select(cx, window, px(40.), 4, None);
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(0),
"with nothing chosen there is still somewhere for the keyboard to be"
);
}
#[gpui::test]
fn an_arrow_key_moves_the_highlight_and_chooses_nothing(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 4, Some(1));
press(opened.cx, "down");
assert_eq!(highlighted_row(&opened.select, opened.cx), Some(2));
press(opened.cx, "up");
press(opened.cx, "up");
assert_eq!(highlighted_row(&opened.select, opened.cx), Some(0));
assert!(is_open(&opened.select, opened.cx), "arrows do not close");
assert_eq!(
chosen(&opened.select, opened.cx),
Some(1),
"moving the highlight changed the control's value"
);
}
#[gpui::test]
fn the_highlight_wraps_at_both_ends(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(0));
press(opened.cx, "up");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(2),
"up from the first row wraps to the last"
);
press(opened.cx, "down");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(0),
"down from the last row wraps to the first"
);
}
#[gpui::test]
fn home_and_end_jump_to_the_ends(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 5, Some(2));
press(opened.cx, "end");
assert_eq!(highlighted_row(&opened.select, opened.cx), Some(4));
press(opened.cx, "home");
assert_eq!(highlighted_row(&opened.select, opened.cx), Some(0));
assert!(is_open(&opened.select, opened.cx));
}
#[gpui::test]
fn enter_chooses_the_highlighted_row_and_closes(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 4, Some(0));
press(opened.cx, "down");
press(opened.cx, "down");
press(opened.cx, "enter");
assert_eq!(chosen(&opened.select, opened.cx), Some(2));
assert!(
!is_open(&opened.select, opened.cx),
"Enter closes the popup"
);
}
#[gpui::test]
fn escape_closes_without_choosing_and_gives_the_trigger_its_focus_back(
cx: &mut TestAppContext,
) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(1));
press(opened.cx, "down");
press(opened.cx, "escape");
assert!(!is_open(&opened.select, opened.cx), "Escape closes");
assert_eq!(
chosen(&opened.select, opened.cx),
Some(1),
"Escape left the highlight where it was and the value alone"
);
press(opened.cx, "enter");
assert!(
is_open(&opened.select, opened.cx),
"Enter reopened nothing, so Escape did not give the trigger its focus back"
);
}
#[gpui::test]
fn tab_closes_the_popup_on_its_way_past(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(1));
press(opened.cx, "tab");
assert!(
!is_open(&opened.select, opened.cx),
"Tab moved focus and left an orphaned popup behind it"
);
assert_eq!(
chosen(&opened.select, opened.cx),
Some(1),
"Tab is not a way of choosing"
);
}
#[gpui::test]
fn a_printable_character_jumps_to_the_next_option_that_starts_with_it(cx: &mut TestAppContext) {
let labels: Vec<SharedString> = ["Apricot", "Banana", "Blueberry", "Cherry"]
.into_iter()
.map(SharedString::from)
.collect();
let opened = open_labelled(cx, size(px(320.), px(800.)), px(40.), labels, None);
press(opened.cx, "b");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(1),
"Banana"
);
press(opened.cx, "b");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(2),
"the same letter again walks to Blueberry rather than sticking on Banana"
);
press(opened.cx, "b");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(1),
"and wraps back round to Banana"
);
press(opened.cx, "c");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(3),
"Cherry"
);
press(opened.cx, "a");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(0),
"a search that runs off the end wraps to the front"
);
assert!(
is_open(&opened.select, opened.cx),
"type-ahead moves the highlight; it does not choose"
);
}
#[gpui::test]
fn hovering_a_row_moves_the_highlight(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 4, Some(0));
let row = row_bounds(opened.cx, 2);
opened
.cx
.simulate_mouse_move(row.center(), None, gpui::Modifiers::default());
opened.cx.run_until_parked();
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(2),
"the pointer moved over the third row and the highlight stayed put"
);
assert_eq!(
chosen(&opened.select, opened.cx),
Some(0),
"hovering is not choosing"
);
press(opened.cx, "down");
assert_eq!(
highlighted_row(&opened.select, opened.cx),
Some(3),
"the keyboard carries on from where the pointer left the highlight"
);
}
#[gpui::test]
fn exactly_one_row_claims_the_active_descendant(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 4, Some(1));
press(opened.cx, "down");
let rows: Vec<A11y> = opened.select.read_with(opened.cx, |state, cx| {
let listbox = state
.listbox
.as_ref()
.expect("the popup should be open")
.read(cx);
(0..listbox.options.len())
.map(|index| listbox.row_a11y(index))
.collect()
});
let claiming: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, a11y)| a11y.is_active_descendant())
.map(|(index, _)| index)
.collect();
assert_eq!(
claiming,
vec![2],
"the highlighted row, and only it, is the active descendant"
);
let selected: Vec<usize> = (0..rows.len())
.filter(|index| node_for(rows[*index].clone()).is_selected() == Some(true))
.collect();
assert_eq!(
selected,
vec![1],
"the choice stayed on row 1 while the highlight moved to row 2"
);
}
#[gpui::test]
fn an_empty_listbox_answers_every_key_by_doing_nothing(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 0, None);
for key in ["down", "up", "home", "end", "enter", "space", "a"] {
press(opened.cx, key);
assert!(
is_open(&opened.select, opened.cx),
"{key} closed a popup that had nothing to choose"
);
assert_eq!(highlighted_row(&opened.select, opened.cx), None);
assert_eq!(chosen(&opened.select, opened.cx), None);
}
press(opened.cx, "escape");
assert!(
!is_open(&opened.select, opened.cx),
"Escape closes an empty popup — there is always a way out"
);
}
fn node_for(a11y: crate::a11y::A11y) -> gpui::accesskit::Node {
crate::a11y::test_support::announced_element(div().id("node").announce(a11y))
.node
.expect("an id and a role make a node")
}
#[gpui::test]
fn the_trigger_announces_a_named_combo_box_valued_by_its_choice(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(1));
let a11y = opened.select.read_with(opened.cx, |state, _| state.a11y());
assert_eq!(a11y.role(), Role::ComboBox);
assert_eq!(a11y.accessible_name(), Some(&"Test select".into()));
let node = node_for(a11y);
assert_eq!(node.label(), Some("Test select"));
assert_eq!(
node.value(),
Some("Option 1"),
"a select's visible text is its value, not its name"
);
assert_eq!(node.is_expanded(), Some(true), "the popup is open");
}
#[gpui::test]
fn an_unchosen_select_reports_no_value(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let state = cx.update(|cx| {
cx.new(|_cx| {
SelectState::new(super::select(
"test-select",
"Test select",
vec![(0usize, "Option 0"), (1usize, "Option 1")],
))
})
});
let a11y = state.read_with(cx, |state, _| state.a11y());
let node = node_for(a11y);
assert_eq!(node.label(), Some("Test select"));
assert_eq!(node.value(), None);
assert_eq!(node.is_expanded(), Some(false));
}
#[gpui::test]
fn the_trigger_takes_keyboard_focus(cx: &mut TestAppContext) {
cx.update(crate::theme::init);
let state = cx.update(|cx| {
cx.new(|_cx| {
SelectState::new(super::select(
"test-select",
"Test select",
vec![(0usize, "Option 0")],
))
})
});
let live = state.read_with(cx, |state, _| state.a11y());
assert!(live.is_focusable());
assert!(!live.is_missing_a_focus_decision());
state.update(cx, |state, cx| state.set_disabled(true, cx));
let off = state.read_with(cx, |state, _| state.a11y());
assert!(!off.is_focusable());
assert!(
off.focus_declined_because().is_some(),
"a disabled select leaves the tab order for the same reason a disabled button does, \
and says so"
);
}
#[gpui::test]
fn the_popup_is_a_listbox_named_after_its_control(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(0));
let a11y = opened.select.read_with(opened.cx, |state, cx| {
state
.listbox
.as_ref()
.expect("the trigger was clicked")
.read(cx)
.a11y()
});
assert_eq!(a11y.role(), Role::ListBox);
assert_eq!(node_for(a11y).label(), Some("Test select"));
}
#[test]
fn a_row_announces_its_place_in_the_set() {
let node = node_for(option_a11y("Option 1".into(), true, false, true, 1, 3));
assert_eq!(node.label(), Some("Option 1"));
assert_eq!(node.is_selected(), Some(true));
assert_eq!(node.position_in_set(), Some(2), "counted from 1");
assert_eq!(node.size_of_set(), Some(3));
let unchosen = node_for(option_a11y("Option 2".into(), false, false, true, 2, 3));
assert_eq!(unchosen.is_selected(), Some(false));
assert_eq!(unchosen.position_in_set(), Some(3));
}
#[gpui::test]
fn a_popup_opened_at_the_bottom_of_the_window_stays_inside_it(cx: &mut TestAppContext) {
let window = size(px(320.), px(240.));
let opened = open_select(cx, window, px(120.), 8, Some(0));
let popup = opened.popup;
assert!(
popup.size.height < window.height,
"the popup is {:?} tall in a {:?}-tall window, so it could not fit however it was \
placed and this test measures nothing",
popup.size.height,
window.height,
);
assert!(
popup.bottom() <= window.height,
"the popup spans {:?} to {:?} in a {:?}-tall window",
popup.top(),
popup.bottom(),
window.height,
);
assert!(
popup.top() >= px(0.),
"the popup starts {:?} above the window",
-popup.top()
);
}
#[gpui::test]
fn a_popup_hangs_one_gap_below_its_trigger(cx: &mut TestAppContext) {
let opened = open_select(cx, size(px(320.), px(800.)), px(40.), 3, Some(0));
let gap = opened.popup.top() - opened.trigger.bottom();
let expected = LISTBOX_GAP.to_pixels(px(16.));
assert!(
(gap - expected).abs() <= px(1.),
"the popup hangs {gap:?} below the trigger, expected {expected:?}"
);
}
#[gpui::test]
fn an_unselected_select_marks_no_row_and_a_selected_one_marks_its_own(cx: &mut TestAppContext) {
let window = size(px(320.), px(800.));
let opened = open_select(cx, window, px(40.), 3, None);
assert_eq!(
marked_row(&opened.select, opened.cx),
None,
"a select with no value marked a row anyway"
);
let opened = open_select(cx, window, px(40.), 3, Some(2));
assert_eq!(
marked_row(&opened.select, opened.cx),
Some(2),
"a select holding the third option marked the wrong row"
);
}
fn marked_row(select: &Entity<SelectState<usize>>, cx: &VisualTestContext) -> Option<usize> {
select.read_with(cx, |state, cx| {
state
.listbox
.as_ref()
.expect("clicking the trigger should have opened the listbox")
.read(cx)
.selected_index
})
}
}