facett-app/src/dropdown.rs
//! **`Dropdown` — a themed, headless select/combobox.** A first-class nav-input
//! primitive. Unlike `egui::ComboBox` (which hides its open/closed state in egui
//! memory, violating the component contract's FC-1), this keeps **all** state —
//! including `open` — in the struct, so it is headless-addressable (`select`,
//! `set_open`) and fully observable (`state_json`). Theme-aware; stable id.
use serde_json::{Value, json};
/// A themed select. State lives here (FC-1), never in egui memory.
pub struct Dropdown {
id_salt: String,
/// `(id, label)` options.
options: Vec<(String, String)>,
selected: Option<String>,
placeholder: String,
/// Popup open/closed — observable state (FC-1), not hidden in egui memory.
open: bool,
}
impl Dropdown {
pub fn new(id_salt: impl Into<String>) -> Self {
Self { id_salt: id_salt.into(), options: Vec::new(), selected: None, placeholder: "Select…".into(), open: false }
}
/// Builder: set the `(id, label)` options.
pub fn options(mut self, opts: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
self.options = opts.into_iter().map(|(i, l)| (i.into(), l.into())).collect();
self
}
pub fn with_selected(mut self, id: impl Into<String>) -> Self {
let id = id.into();
if self.options.iter().any(|(o, _)| *o == id) {
self.selected = Some(id);
}
self
}
pub fn placeholder(mut self, text: impl Into<String>) -> Self {
self.placeholder = text.into();
self
}
/// **Headless** select by id (CLI/robot/test parity). No-op (false) for an
/// unknown id; closes the popup on success.
pub fn select(&mut self, id: &str) -> bool {
if self.options.iter().any(|(o, _)| o == id) {
self.selected = Some(id.to_string());
self.open = false;
true
} else {
false
}
}
pub fn selected(&self) -> Option<&str> {
self.selected.as_deref()
}
pub fn selected_label(&self) -> Option<&str> {
let sel = self.selected.as_deref()?;
self.options.iter().find(|(o, _)| o == sel).map(|(_, l)| l.as_str())
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
/// Draw the dropdown. Returns `Some(id)` if a NEW option was picked this frame.
pub fn show(&mut self, ui: &mut egui::Ui) -> Option<String> {
let text = self.selected_label().unwrap_or(&self.placeholder).to_string();
let arrow = if self.open { "▴" } else { "▾" };
let btn = ui.button(format!("{text} {arrow}"));
if btn.clicked() {
self.open = !self.open;
}
let mut chosen: Option<String> = None;
if self.open {
let area_id = egui::Id::new((&self.id_salt, "dropdown_popup"));
let opts = self.options.clone();
let sel = self.selected.clone();
egui::Area::new(area_id)
.order(egui::Order::Foreground)
.fixed_pos(btn.rect.left_bottom())
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(btn.rect.width().max(120.0));
for (id, label) in &opts {
let is_sel = sel.as_deref() == Some(id.as_str());
if ui.add(egui::Button::selectable(is_sel, label)).clicked() {
chosen = Some(id.clone());
}
}
});
});
// Close on Escape (a non-pointer path, FC-2-friendly).
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
self.open = false;
}
}
if let Some(id) = &chosen {
self.selected = Some(id.clone());
self.open = false;
}
chosen
}
pub fn state_json(&self) -> Value {
json!({
"selected": self.selected,
"selected_label": self.selected_label(),
"open": self.open,
"options": self.options.iter().map(|(i, l)| json!({"id": i, "label": l})).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod robot_tests {
use super::*;
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
/// **F6 — the `open` toggle + popup pick have a real-click test.** Only the inherent
/// `select()` path was covered before. Click the button and assert the observable
/// `open` flips true; click an option in the popup and assert it is selected and the
/// popup closes.
#[test]
fn clicking_the_button_opens_the_popup_and_an_option_selects() {
let dd = Dropdown::new("theme")
.options([("dark", "Dark"), ("light", "Light")])
.placeholder("pick a theme");
let mut h = Harness::builder()
.with_size(egui::vec2(300.0, 240.0))
.build_ui_state(|ui, d: &mut Dropdown| { d.show(ui); }, dd);
h.run();
assert!(!h.state().is_open(), "starts closed");
assert_eq!(h.state().selected(), None, "nothing selected yet");
// Click the button → the popup opens (the observable `open` flips true).
h.get_by_label_contains("pick a theme").click();
h.run();
assert!(h.state().is_open(), "a real click on the button opened the popup");
// Click an option in the popup → it is selected and the popup closes.
h.get_by_label("Light").click();
h.run();
assert_eq!(h.state().selected(), Some("light"), "clicking the option selected it");
assert!(!h.state().is_open(), "picking an option closed the popup");
}
}