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;