use crate::event::{Key, NamedKey, Point, Rect};
use crate::primitives::completions::{
CompletionItem, CompletionItemMeasure, CompletionKind, Completions, CompletionsHit,
CompletionsLayout,
};
use crate::types::{Modifiers, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ComboBox {
pub id: WidgetId,
pub options: Vec<String>,
#[serde(default)]
pub text: String,
#[serde(default)]
pub committed: String,
#[serde(default)]
pub open: bool,
#[serde(default)]
pub highlighted: Option<usize>,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub focused: bool,
}
impl ComboBox {
pub fn new(id: WidgetId, options: Vec<String>) -> Self {
Self {
id,
options,
text: String::new(),
committed: String::new(),
open: false,
highlighted: None,
disabled: false,
focused: false,
}
}
pub fn filtered_indices(&self) -> Vec<usize> {
if self.text.is_empty() {
return (0..self.options.len()).collect();
}
let needle = self.text.to_lowercase();
self.options
.iter()
.enumerate()
.filter(|(_, option)| option.to_lowercase().contains(&needle))
.map(|(i, _)| i)
.collect()
}
pub fn layout<F>(
&self,
field_bounds: Rect,
viewport: Rect,
popup_width: f32,
max_popup_height: f32,
measure_item: F,
) -> ComboBoxLayout
where
F: Fn(usize) -> CompletionItemMeasure,
{
let filtered = self.filtered_indices();
let dropdown = if self.open && !filtered.is_empty() {
let completions = Completions {
id: self.id,
items: filtered
.iter()
.map(|&i| CompletionItem {
label: StyledText::plain(self.options[i].clone()),
detail: None,
documentation: None,
kind: CompletionKind::Text,
icon: None,
})
.collect(),
selected_idx: self.highlighted.unwrap_or(0),
scroll_offset: 0,
has_focus: self.focused,
};
Some(completions.layout(
field_bounds.x,
field_bounds.y,
field_bounds.height,
viewport,
popup_width,
max_popup_height,
measure_item,
))
} else {
None
};
ComboBoxLayout {
field_bounds,
dropdown,
}
}
fn move_highlight(&self, delta: isize) -> Option<usize> {
let len = self.filtered_indices().len();
if len == 0 {
return None;
}
let next = match self.highlighted {
None if delta > 0 => 0,
None => len - 1,
Some(i) => (i as isize + delta).rem_euclid(len as isize) as usize,
};
Some(next)
}
pub fn handle_key(&self, key: &Key, _modifiers: &Modifiers) -> Option<ComboBoxEvent> {
if self.disabled || !self.focused {
return None;
}
match key {
Key::Named(NamedKey::Down) => {
let index = self.move_highlight(1)?;
Some(ComboBoxEvent::HighlightChanged { id: self.id, index })
}
Key::Named(NamedKey::Up) => {
let index = self.move_highlight(-1)?;
Some(ComboBoxEvent::HighlightChanged { id: self.id, index })
}
Key::Named(NamedKey::Enter) => Some(self.commit()),
Key::Named(NamedKey::Escape) => self
.open
.then_some(ComboBoxEvent::Cancelled { id: self.id }),
Key::Named(NamedKey::Backspace) => {
let mut text = self.text.clone();
text.pop();
Some(ComboBoxEvent::TextChanged { id: self.id, text })
}
Key::Char(c) => {
let mut text = self.text.clone();
text.push(*c);
Some(ComboBoxEvent::TextChanged { id: self.id, text })
}
_ => None,
}
}
fn commit(&self) -> ComboBoxEvent {
let filtered = self.filtered_indices();
let value = match self.highlighted.and_then(|i| filtered.get(i)) {
Some(&option_idx) if self.open => self.options[option_idx].clone(),
_ => self.text.clone(),
};
ComboBoxEvent::Committed { id: self.id, value }
}
pub fn click(&self, hit: ComboBoxHit) -> Option<ComboBoxEvent> {
if self.disabled {
return None;
}
match hit {
ComboBoxHit::DropdownItem(index) => {
let filtered = self.filtered_indices();
let option_idx = *filtered.get(index)?;
Some(ComboBoxEvent::Committed {
id: self.id,
value: self.options[option_idx].clone(),
})
}
ComboBoxHit::Field | ComboBoxHit::Outside => None,
}
}
}
impl Default for ComboBox {
fn default() -> Self {
Self::new(WidgetId::new(""), Vec::new())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComboBoxLayout {
pub field_bounds: Rect,
pub dropdown: Option<CompletionsLayout>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComboBoxHit {
Field,
DropdownItem(usize),
Outside,
}
impl ComboBoxLayout {
pub fn hit_test(&self, p: Point) -> ComboBoxHit {
if let Some(dropdown) = &self.dropdown {
if let CompletionsHit::Item(idx) = dropdown.hit_test(p.x, p.y) {
return ComboBoxHit::DropdownItem(idx);
}
}
if self.field_bounds.contains(p) {
ComboBoxHit::Field
} else {
ComboBoxHit::Outside
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ComboBoxEvent {
TextChanged { id: WidgetId, text: String },
HighlightChanged { id: WidgetId, index: usize },
Committed { id: WidgetId, value: String },
Cancelled { id: WidgetId },
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ComboBox, ComboBoxLayout};
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
pub(crate) fn paint(
combo_box: &ComboBox,
layout: &ComboBoxLayout,
surface: &mut dyn PaintSurface,
theme: &Theme,
) {
let field = layout.field_bounds;
if field.width > 0.0 && field.height > 0.0 {
let border = if combo_box.disabled {
theme.border_fg
} else if combo_box.focused {
theme.accent_fg
} else {
theme.border_fg
};
let fg = if combo_box.disabled {
theme.muted_fg
} else {
theme.surface_fg
};
surface.surface_fill_rect(field, theme.input_bg);
surface.surface_stroke_rect(field, border, 1.0);
if !combo_box.text.is_empty() {
surface.surface_draw_text_run(field, &combo_box.text, fg);
}
}
let Some(dropdown) = &layout.dropdown else {
return;
};
if dropdown.bounds.width <= 0.0 || dropdown.bounds.height <= 0.0 {
return;
}
surface.surface_fill_rect(dropdown.bounds, theme.completion_bg);
surface.surface_stroke_rect(dropdown.bounds, theme.completion_border, 1.0);
let filtered = combo_box.filtered_indices();
for visible in &dropdown.visible_items {
if combo_box.highlighted == Some(visible.item_idx) {
surface.surface_fill_rect(visible.bounds, theme.completion_selected_bg);
}
if let Some(&option_idx) = filtered.get(visible.item_idx) {
surface.surface_draw_text_run(
visible.bounds,
&combo_box.options[option_idx],
theme.completion_fg,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::{Point, Rect, Viewport};
use crate::primitives::completions::CompletionItemMeasure;
use crate::types::{Color, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
strokes: Vec<(Rect, Color)>,
text_runs: Vec<(Rect, String, Color)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, _stroke_width: f32) {
self.strokes.push((rect, color));
}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
fn surface_draw_line(
&mut self,
_from: Point,
_to: Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, _rect: Rect) {}
fn surface_pop_clip(&mut self) {}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn options() -> Vec<String> {
vec!["Apple".into(), "Apricot".into(), "Banana".into()]
}
fn field_bounds() -> Rect {
Rect::new(0.0, 0.0, 20.0, 1.0)
}
fn viewport() -> Rect {
Rect::new(0.0, 0.0, 80.0, 24.0)
}
#[test]
fn paints_field_background_border_and_text() {
let mut c = ComboBox::new(WidgetId::new("form:fruit"), options());
c.text = "Cher".into();
let layout = c.layout(field_bounds(), viewport(), 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&c, &layout, &mut surface, &theme);
assert_eq!(surface.fills[0], (field_bounds(), theme.input_bg));
assert!(surface.strokes.contains(&(field_bounds(), theme.border_fg)));
assert_eq!(surface.text_runs[0].1, "Cher");
}
#[test]
fn closed_dropdown_paints_no_popup_chrome() {
let c = ComboBox::new(WidgetId::new("form:fruit"), options());
let layout = c.layout(field_bounds(), viewport(), 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&c, &layout, &mut surface, &theme);
assert!(!surface
.fills
.iter()
.any(|(_, color)| *color == theme.completion_bg));
}
#[test]
fn open_dropdown_paints_every_filtered_option_label() {
let mut c = ComboBox::new(WidgetId::new("form:fruit"), options());
c.open = true;
c.text = "ap".into(); let layout = c.layout(field_bounds(), viewport(), 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&c, &layout, &mut surface, &theme);
assert!(surface
.fills
.iter()
.any(|(_, color)| *color == theme.completion_bg));
let labels: Vec<&str> = surface
.text_runs
.iter()
.map(|(_, text, _)| text.as_str())
.collect();
assert!(labels.contains(&"Apple"));
assert!(labels.contains(&"Apricot"));
assert!(!labels.contains(&"Banana"));
}
#[test]
fn highlighted_item_gets_a_selection_fill() {
let mut c = ComboBox::new(WidgetId::new("form:fruit"), options());
c.open = true;
c.text = "ap".into(); c.highlighted = Some(1);
let layout = c.layout(field_bounds(), viewport(), 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&c, &layout, &mut surface, &theme);
let highlighted_bounds = layout.dropdown.as_ref().unwrap().visible_items[1].bounds;
assert!(surface
.fills
.contains(&(highlighted_bounds, theme.completion_selected_bg)));
let unhighlighted_bounds = layout.dropdown.as_ref().unwrap().visible_items[0].bounds;
assert!(!surface
.fills
.contains(&(unhighlighted_bounds, theme.completion_selected_bg)));
}
#[test]
fn zero_size_field_paints_nothing_for_the_field() {
let c = ComboBox::new(WidgetId::new("form:fruit"), options());
let layout = c.layout(
Rect::new(0.0, 0.0, 0.0, 0.0),
viewport(),
20.0,
10.0,
|_| CompletionItemMeasure::new(1.0),
);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(&c, &layout, &mut surface, &theme);
assert!(surface.fills.is_empty());
assert!(surface.strokes.is_empty());
assert!(surface.text_runs.is_empty());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options() -> Vec<String> {
vec!["Apple".into(), "Apricot".into(), "Banana".into()]
}
fn combo() -> ComboBox {
ComboBox::new(WidgetId::new("form:fruit"), options())
}
#[test]
fn new_starts_empty_closed_enabled_unfocused() {
let c = combo();
assert_eq!(c.text, "");
assert_eq!(c.committed, "");
assert!(!c.open);
assert_eq!(c.highlighted, None);
assert!(!c.disabled);
assert!(!c.focused);
}
#[test]
fn filtered_indices_matches_case_insensitive_substring() {
let mut c = combo();
assert_eq!(c.filtered_indices(), vec![0, 1, 2]);
c.text = "ap".into();
assert_eq!(c.filtered_indices(), vec![0, 1]);
c.text = "AP".into();
assert_eq!(c.filtered_indices(), vec![0, 1]);
c.text = "nana".into();
assert_eq!(c.filtered_indices(), vec![2]);
c.text = "zzz".into();
assert_eq!(c.filtered_indices(), Vec::<usize>::new());
}
#[test]
fn typed_chars_accumulate_in_text_when_focused() {
let mut c = combo();
c.focused = true;
let e1 = c.handle_key(&Key::Char('a'), &Modifiers::default());
assert_eq!(
e1,
Some(ComboBoxEvent::TextChanged {
id: c.id.clone(),
text: "a".into()
})
);
c.text = "a".into();
let e2 = c.handle_key(&Key::Char('p'), &Modifiers::default());
assert_eq!(
e2,
Some(ComboBoxEvent::TextChanged {
id: c.id.clone(),
text: "ap".into()
})
);
}
#[test]
fn backspace_pops_the_last_character() {
let mut c = combo();
c.focused = true;
c.text = "ap".into();
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Backspace), &Modifiers::default()),
Some(ComboBoxEvent::TextChanged {
id: c.id.clone(),
text: "a".into()
})
);
}
#[test]
fn down_and_up_move_the_highlight_through_the_filtered_list_with_wraparound() {
let mut c = combo();
c.focused = true;
c.text = "ap".into();
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
Some(ComboBoxEvent::HighlightChanged {
id: c.id.clone(),
index: 0
})
);
c.highlighted = Some(0);
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
Some(ComboBoxEvent::HighlightChanged {
id: c.id.clone(),
index: 1
})
);
c.highlighted = Some(1);
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
Some(ComboBoxEvent::HighlightChanged {
id: c.id.clone(),
index: 0
})
);
c.highlighted = Some(0);
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Up), &Modifiers::default()),
Some(ComboBoxEvent::HighlightChanged {
id: c.id.clone(),
index: 1
})
);
}
#[test]
fn navigation_is_inert_when_the_filtered_list_is_empty() {
let mut c = combo();
c.focused = true;
c.text = "zzz".into();
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Down), &Modifiers::default()),
None
);
}
#[test]
fn enter_commits_the_highlighted_option_when_open() {
let mut c = combo();
c.focused = true;
c.open = true;
c.text = "ap".into();
c.highlighted = Some(1); assert_eq!(
c.handle_key(&Key::Named(NamedKey::Enter), &Modifiers::default()),
Some(ComboBoxEvent::Committed {
id: c.id.clone(),
value: "Apricot".into()
})
);
}
#[test]
fn enter_commits_typed_text_verbatim_when_nothing_is_highlighted() {
let mut c = combo();
c.focused = true;
c.text = "Cherry".into();
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Enter), &Modifiers::default()),
Some(ComboBoxEvent::Committed {
id: c.id.clone(),
value: "Cherry".into()
})
);
}
#[test]
fn escape_cancels_an_open_edit_and_is_inert_otherwise() {
let mut c = combo();
c.focused = true;
c.open = true;
c.text = "ap".into();
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Escape), &Modifiers::default()),
Some(ComboBoxEvent::Cancelled { id: c.id.clone() })
);
c.open = false;
assert_eq!(
c.handle_key(&Key::Named(NamedKey::Escape), &Modifiers::default()),
None
);
}
#[test]
fn handle_key_is_inert_when_not_focused_or_disabled() {
let c = combo();
assert_eq!(c.handle_key(&Key::Char('a'), &Modifiers::default()), None);
let mut c = combo();
c.focused = true;
c.disabled = true;
assert_eq!(c.handle_key(&Key::Char('a'), &Modifiers::default()), None);
}
#[test]
fn click_on_dropdown_item_commits_without_requiring_focus() {
let mut c = combo();
c.text = "ap".into(); assert_eq!(
c.click(ComboBoxHit::DropdownItem(1)),
Some(ComboBoxEvent::Committed {
id: c.id.clone(),
value: "Apricot".into()
})
);
assert_eq!(c.click(ComboBoxHit::Field), None);
assert_eq!(c.click(ComboBoxHit::Outside), None);
}
#[test]
fn click_is_inert_when_disabled() {
let mut c = combo();
c.disabled = true;
assert_eq!(c.click(ComboBoxHit::DropdownItem(0)), None);
}
#[test]
fn layout_has_no_dropdown_when_closed_and_a_dropdown_when_open() {
let c = combo();
let field_bounds = Rect::new(0.0, 0.0, 20.0, 1.0);
let viewport = Rect::new(0.0, 0.0, 80.0, 24.0);
let closed = c.layout(field_bounds, viewport, 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
assert!(closed.dropdown.is_none());
assert_eq!(closed.hit_test(Point::new(5.0, 0.0)), ComboBoxHit::Field);
let mut open = c;
open.open = true;
let layout = open.layout(field_bounds, viewport, 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
assert!(layout.dropdown.is_some());
assert_eq!(
layout.hit_test(Point::new(1.0, 1.0)),
ComboBoxHit::DropdownItem(0)
);
}
#[test]
fn layout_has_no_dropdown_when_open_but_no_matches() {
let mut c = combo();
c.open = true;
c.text = "zzz".into();
let field_bounds = Rect::new(0.0, 0.0, 20.0, 1.0);
let viewport = Rect::new(0.0, 0.0, 80.0, 24.0);
let layout = c.layout(field_bounds, viewport, 20.0, 10.0, |_| {
CompletionItemMeasure::new(1.0)
});
assert!(layout.dropdown.is_none());
}
#[test]
fn serde_round_trip() {
let mut c = combo();
c.text = "ap".into();
c.open = true;
c.highlighted = Some(1);
c.focused = true;
let json = serde_json::to_string(&c).unwrap();
let back: ComboBox = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn serde_defaults_optional_fields_when_omitted() {
let json = r#"{"id":"form:fruit","options":["Apple","Banana"]}"#;
let c: ComboBox = serde_json::from_str(json).unwrap();
assert_eq!(c.text, "");
assert_eq!(c.committed, "");
assert!(!c.open);
assert_eq!(c.highlighted, None);
assert!(!c.disabled);
assert!(!c.focused);
}
}