feat(gpui_widgets): add combo box with anchored+deferred popup menu

ComboBox renders a field showing the selection and a drop-down menu built
entirely with gpui's anchored + deferred elements (no platform primitives).
Menu dismissal is mouse-up-out based so an option click is never swallowed
by a capture-phase close that would redraw the menu away; the field toggles
from the open state recorded at mouse-down. Arrow keys cycle the selection
with wrap-around, escape closes. 48 tests pass.
This commit is contained in:
2026-08-09 05:02:04 +08:00
parent b65ccf1417
commit 23b1f610a2
2 changed files with 479 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
//! A combo box: a field showing the current selection with a drop-down menu.
//!
//! The menu is a pure-gpui popup built from `anchored` + `deferred` (no
//! platform primitives). Request-only: selecting an option emits
//! [`ComboBoxEvent::Selected`]; the host applies it and calls
//! [`ComboBox::set_selected`].
//!
//! Interactions: click the field to toggle the menu; click an option to pick
//! it; arrow keys (when the field is focused) cycle the selection; `escape`
//! or a click outside closes the menu.
use gpui::{
Anchor, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, KeyDownEvent,
Pixels, Point, Render, SharedString, Window, anchored, colors::DefaultColors, deferred, div,
point, prelude::*, px,
};
/// A selectable option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComboBoxOption {
/// The option's value (stable id).
pub value: usize,
/// The label shown in the field and the menu.
pub label: SharedString,
}
impl ComboBoxOption {
/// Create an option.
pub fn new(value: usize, label: impl Into<SharedString>) -> Self {
Self {
value,
label: label.into(),
}
}
}
/// A request emitted by a combo box.
#[derive(Debug, Clone, PartialEq)]
pub enum ComboBoxEvent {
/// The user picked an option (click or arrow key).
Selected {
/// The control's stable id.
control: usize,
/// The chosen option value.
value: usize,
},
/// The menu was opened.
MenuOpened {
/// The control's stable id.
control: usize,
},
/// The menu was closed.
MenuClosed {
/// The control's stable id.
control: usize,
},
}
/// Move `selected` by `delta` positions over a list of `len` options,
/// wrapping around. `None` moves to the first (or last) option.
pub fn cycle_selection(selected: Option<usize>, len: usize, delta: i32) -> Option<usize> {
if len == 0 {
return None;
}
let current = match selected {
Some(index) => index,
None if delta > 0 => len - 1,
None => 0,
};
Some(((current as i64 + delta as i64).rem_euclid(len as i64)) as usize)
}
/// A drop-down selection control.
pub struct ComboBox {
control: usize,
options: Vec<ComboBoxOption>,
selected: Option<usize>,
placeholder: Option<SharedString>,
open: bool,
/// Window position of the menu, recorded when the field is clicked.
popup_position: Point<Pixels>,
/// Whether the menu was open when the field was pressed, so the toggle on
/// mouse-up does not reopen a menu that an outside-click close already
/// dismissed on the same click.
was_open_at_down: bool,
focus_handle: FocusHandle,
}
impl ComboBox {
/// Create a combo box for `control` over `options`.
pub fn new(
control: usize,
options: Vec<ComboBoxOption>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
control,
options,
selected: None,
placeholder: None,
open: false,
popup_position: Point::default(),
was_open_at_down: false,
focus_handle: cx.focus_handle(),
}
}
/// Text shown when nothing is selected.
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
/// The currently selected option value, if any.
pub fn selected(&self) -> Option<usize> {
self.selected
}
/// Whether the menu is open.
pub fn is_open(&self) -> bool {
self.open
}
/// Apply the host's selection and repaint.
pub fn set_selected(&mut self, selected: Option<usize>, cx: &mut Context<Self>) {
if self.selected != selected {
self.selected = selected;
cx.notify();
}
}
fn selected_index(&self) -> Option<usize> {
self.selected
.and_then(|value| self.options.iter().position(|o| o.value == value))
}
fn open_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if !self.open {
self.open = true;
self.popup_position = position;
cx.emit(ComboBoxEvent::MenuOpened {
control: self.control,
});
cx.notify();
}
}
fn close_menu(&mut self, cx: &mut Context<Self>) {
if self.open {
self.open = false;
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
}
fn toggle_menu(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
if self.open {
self.close_menu(cx);
} else {
self.open_menu(position, cx);
}
}
fn pick(&mut self, value: usize, cx: &mut Context<Self>) {
self.selected = Some(value);
self.open = false;
cx.emit(ComboBoxEvent::Selected {
control: self.control,
value,
});
cx.emit(ComboBoxEvent::MenuClosed {
control: self.control,
});
cx.notify();
}
fn cycle(&mut self, delta: i32, cx: &mut Context<Self>) {
if let Some(index) = cycle_selection(self.selected_index(), self.options.len(), delta) {
let value = self.options[index].value;
self.pick(value, cx);
}
}
}
impl EventEmitter<ComboBoxEvent> for ComboBox {}
impl Focusable for ComboBox {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ComboBox {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let colors = cx.default_colors().clone();
let label = self
.selected
.and_then(|value| self.options.iter().find(|o| o.value == value))
.map(|o| o.label.clone())
.or_else(|| self.placeholder.clone())
.unwrap_or_else(|| SharedString::from(""));
let open = self.open;
let control = self.control;
let field = div()
.id(ElementId::named_usize("gpui-widgets-combo-field", control))
.min_w(px(140.0))
.h(px(24.0))
.rounded_md()
.border_1()
.border_color(if open { colors.selected } else { colors.border })
.bg(colors.background)
.px_2()
.flex()
.items_center()
.justify_between()
.gap(px(6.0))
.cursor_pointer()
.track_focus(&self.focus_handle)
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseDownEvent, _window, _cx| {
this.was_open_at_down = this.open;
}),
)
.on_click(cx.listener(|this, event: &ClickEvent, _window, cx| {
if this.was_open_at_down {
this.close_menu(cx);
} else {
this.open_menu(event.position(), cx);
}
cx.stop_propagation();
}))
.on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| {
match event.keystroke.key.as_str() {
"up" => this.cycle(-1, cx),
"down" => this.cycle(1, cx),
"escape" => this.close_menu(cx),
"enter" | "space" => {
this.toggle_menu(this.popup_position, cx);
}
_ => {}
}
}))
.child(div().text_color(colors.text).child(label))
.child(div().child(chevron(colors.border)));
let popup = if open {
// Build the option list inline so each option can carry its own
// view listener.
let mut menu = div()
.id(ElementId::named_usize("gpui-widgets-combo-menu", control))
.debug_selector(|| "combo-menu".into())
.min_w(px(140.0))
.rounded_md()
.border_1()
.border_color(colors.border)
.bg(colors.container)
.py_1()
.flex()
.flex_col()
.overflow_y_scroll()
.max_h(px(220.0))
.on_mouse_up_out(
gpui::MouseButton::Left,
cx.listener(|this, _event: &gpui::MouseUpEvent, _window, cx| {
this.close_menu(cx);
}),
);
for option in &self.options {
let is_selected = self.selected == Some(option.value);
let value = option.value;
let label = option.label.clone();
menu = menu.child(
div()
.id(ElementId::named_usize(
format!("gpui-widgets-combo-option-{control}"),
value,
))
.px_2()
.py_1()
.text_color(colors.text)
.hover(|style| style.bg(colors.selected))
.when(is_selected, |el| el.text_color(colors.selected_text))
.on_click(
cx.listener(move |this, _event: &ClickEvent, _window, cx| {
this.pick(value, cx);
cx.stop_propagation();
}),
)
.child(label),
);
}
deferred(
anchored()
.position(self.popup_position)
.anchor(Anchor::TopLeft)
.offset(point(px(0.0), px(26.0)))
.snap_to_window_with_margin(px(8.0))
.child(menu),
)
.with_priority(1)
} else {
deferred(div())
};
div()
.relative()
.child(field)
.child(popup)
}
}
/// A small down-chevron painted with a canvas.
fn chevron(color: gpui::Rgba) -> impl IntoElement {
use gpui::{canvas, point, px, Bounds, PathBuilder, Pixels};
canvas(
move |_bounds, _window, _cx| (),
move |bounds: Bounds<Pixels>, (), window, cx| {
let _ = cx;
let mut path = PathBuilder::fill();
path.move_to(point(bounds.left() + px(3.0), bounds.top() + px(2.0)));
path.line_to(point(bounds.center().x, bounds.bottom() - px(2.0)));
path.line_to(point(bounds.right() - px(3.0), bounds.top() + px(2.0)));
path.close();
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size};
#[test]
fn cycle_selection_wraps() {
assert_eq!(cycle_selection(None, 3, 1), Some(0));
assert_eq!(cycle_selection(None, 3, -1), Some(2));
assert_eq!(cycle_selection(Some(0), 3, -1), Some(2));
assert_eq!(cycle_selection(Some(2), 3, 1), Some(0));
assert_eq!(cycle_selection(Some(1), 3, 1), Some(2));
assert_eq!(cycle_selection(None, 0, 1), None);
}
fn options() -> Vec<ComboBoxOption> {
vec![
ComboBoxOption::new(1, "Frame"),
ComboBoxOption::new(2, "Timecode"),
ComboBoxOption::new(3, "Frames"),
]
}
#[gpui::test]
async fn click_opens_menu_and_selects(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Open the menu, then force a redraw so the popup lands in the frame.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
let menu_visible = cx.debug_bounds("combo-menu").is_some();
eprintln!("menu bounds: {:?}", cx.debug_bounds("combo-menu"));
assert!(menu_visible, "menu should be open after clicking the field");
// Pick the second option (roughly below the field).
// Pick the second option (menu at x=70, options from x=71; use a
// well-inside point).
cx.simulate_click(point(px(90.0), px(94.0)), Modifiers::none());
cx.run_until_parked();
let (selected, emitted, menu_closed) = cx.read(|app| {
let host = host.read(app);
(
host.combo.read(app).selected(),
host.events.iter().any(|e| {
matches!(e, ComboBoxEvent::Selected { control: 1, value: 2 })
}),
host.events
.iter()
.any(|e| matches!(e, ComboBoxEvent::MenuClosed { .. })),
)
});
assert_eq!(selected, Some(2));
assert!(emitted);
assert!(menu_closed);
}
#[gpui::test]
async fn arrow_keys_cycle_selection(cx: &mut TestAppContext) {
struct Host {
combo: Entity<ComboBox>,
events: Vec<ComboBoxEvent>,
}
impl Render for Host {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.combo.clone())
}
}
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(300.0), px(200.0)), |window, cx| {
let combo = cx.new(|cx| ComboBox::new(1, options(), window, cx));
let host = Host {
combo,
events: Vec::new(),
};
cx.subscribe(
&host.combo,
|host: &mut Host,
_c: Entity<ComboBox>,
event: &ComboBoxEvent,
_cx: &mut Context<Host>| {
host.events.push(event.clone());
},
)
.detach();
host
});
cx.run_until_parked();
let host = window.root(cx).unwrap();
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
// Focus the field, then press down twice: none -> first -> second.
cx.simulate_click(point(px(70.0), px(12.0)), Modifiers::none());
cx.run_until_parked();
cx.simulate_keystrokes("escape");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
cx.simulate_keystrokes("down");
cx.run_until_parked();
let selected = cx.read(|app| host.read(app).combo.read(app).selected());
assert_eq!(selected, Some(2));
}
}
+1
View File
@@ -18,6 +18,7 @@
//! covered by plain unit tests.
pub mod checkbox;
pub mod combo_box;
pub mod keyable;
pub mod radio_group;
pub mod slider;