Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}