combo_box.rs raw

//! A combo box: a field you can type in that also opens a list.
//!
//! It is a [`TextInput`] that hosts a popup — an adapter, not a second text
//! editor. Everything about editing (selection, the clipboard, IME, the caret,
//! scrolling it into view) is the text input's, because a combo box that
//! reimplemented any of that would be a second implementation of the hardest
//! widget in the framework.
//!
//! **It holds no candidates.** The rows are ordinary deferred content, so the
//! list is a `for` over application state:
//!
//! ```lisp
//! (combo-box :text draft :on-change (filter payload)
//!   (for candidate matches :key candidate
//!     (menu-item :label candidate :on-select (choose candidate))))
//! ```
//!
//! Filtering is the application's, and that is a finding rather than an
//! omission: the plan expected a *completion query wire* here, on the shape of
//! `:get-image`. A query exists for a value the widget discovers itself and
//! the application never sees — a markdown image's `src`. A combo box's text
//! is already delivered by `:on-change`, so the application can compute
//! matches into state and let `for` do the rest. Pulling what has just been
//! pushed would be a second path to the same value.

use guiduck_scene::Fragment;
use guiduck_scene::geom::{Point, Rect, Size};

use super::menu::Popup;
use super::{CursorShape, TextInput, Widget};
use crate::content::ContentBuilder;
use crate::dirty::Dirt;
use crate::event::{EventData, EventKind, Key, KeyInput, PointerEvent};
use crate::style::ComputedStyle;
use crate::text::TextContext;

#[derive(Default)]
pub struct ComboBox {
    field: TextInput,
    content: ContentBuilder,
    /// Set while the popup is up, so the same key that opened it closes it.
    open: bool,
    extra: Vec<EventData>,
}

impl ComboBox {
    pub fn new(font_size: f32) -> Self {
        Self {
            field: TextInput::new(font_size),
            ..Default::default()
        }
    }

    pub fn set_text(&mut self, text: impl Into<String>) {
        self.field.set_content(text);
    }

    pub fn text(&self) -> String {
        self.field.content()
    }

    pub fn set_font_size(&mut self, size: f32) {
        self.field.set_font_size(size);
    }

    pub fn set_family(&mut self, family: impl Into<String>) {
        self.field.set_family(family);
    }

    /// The tree's half of opening: it tells the field whether its list is up,
    /// so the arrow key toggles rather than re-opening.
    pub fn set_menu_open(&mut self, open: bool) {
        self.open = open;
    }

    fn ask_to_open(&mut self) {
        self.extra.push(EventData::MenuOpen);
    }
}

impl Widget for ComboBox {
    fn popup(&self) -> Option<Popup> {
        Some(Popup {
            content: self.content.clone(),
            beside: false,
            // The list belongs to the field, so it is the field's width.
            match_width: true,
        })
    }

    fn defers_content(&self) -> bool {
        true
    }

    fn set_content(&mut self, content: ContentBuilder) {
        self.content = content;
    }

    fn measure(
        &mut self,
        text: &mut TextContext,
        known: taffy::Size<Option<f32>>,
        available: taffy::Size<taffy::AvailableSpace>,
    ) -> taffy::Size<f32> {
        self.field.measure(text, known, available)
    }

    fn finalize_layout(&mut self, text: &mut TextContext, size: Size, content: Size) {
        self.field.finalize_layout(text, size, content);
    }

    fn paint(&mut self, fragment: &mut Fragment, size: Size) {
        self.field.paint(fragment, size);
    }

    fn focusable(&self) -> bool {
        true
    }

    fn cursor(&self, local: Point) -> CursorShape {
        self.field.cursor(local)
    }

    fn on_focus_changed(&mut self, focused: bool) {
        self.field.on_focus_changed(focused);
    }

    fn take_wake(&mut self) -> Option<std::time::Duration> {
        self.field.take_wake()
    }

    fn on_timer(&mut self) {
        self.field.on_timer();
    }

    fn on_key(
        &mut self,
        key: &KeyInput,
        text: &mut TextContext,
        clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        // Down opens the list — the one key a combo box takes that a plain
        // field does not. It is checked *before* delegating so the field
        // cannot consume it as caret movement, and only when the list is
        // closed, so the open list's own navigation still gets it.
        if key.key == Key::Down && !self.open {
            self.ask_to_open();
            return true;
        }
        self.field.on_key(key, text, clipboard)
    }

    fn on_ime(&mut self, ime: &crate::event::ImeInput, text: &mut TextContext) {
        self.field.on_ime(ime, text);
    }

    fn on_pointer(
        &mut self,
        kind: EventKind,
        event: &PointerEvent,
        text: &mut TextContext,
        clipboard: &mut dyn crate::clipboard::Clipboard,
    ) -> bool {
        self.field.on_pointer(kind, event, text, clipboard)
    }

    fn ime_cursor_area(&self) -> Option<Rect> {
        self.field.ime_cursor_area()
    }

    fn reveal_rect(&self) -> Option<Rect> {
        self.field.reveal_rect()
    }

    fn role(&self) -> accesskit::Role {
        accesskit::Role::ComboBox
    }

    fn accessibility(&self, node: &mut accesskit::Node) {
        self.field.accessibility(node);
        node.set_expanded(self.open);
    }

    fn accessibility_extended(
        &mut self,
        node: &mut accesskit::Node,
        update: &mut accesskit::TreeUpdate,
        next_id: &mut dyn FnMut() -> accesskit::NodeId,
        origin: Point,
        text: &mut TextContext,
    ) {
        self.field
            .accessibility_extended(node, update, next_id, origin, text);
        node.set_expanded(self.open);
    }

    fn take_dirt(&mut self) -> Dirt {
        self.field.take_dirt()
    }

    fn take_emitted(&mut self) -> Vec<EventData> {
        let mut emitted = std::mem::take(&mut self.extra);
        emitted.extend(self.field.take_emitted());
        emitted
    }

    fn type_name(&self) -> &'static str {
        "combo-box"
    }

    fn apply_style(&mut self, style: &ComputedStyle) {
        self.field.apply_style(style);
    }
}

#[cfg(test)]
mod tests;