use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::style::EdgeOffsets;
use crate::undo::{TextSnapshotCommand, UndoStack};
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::cell::RefCell;
use std::rc::Rc;
const INDICATOR_LEADING_GAP: u32 = dimensions::TEXT_FIELD_PADDING_H / 2;
#[derive(Debug, Clone, Copy, PartialEq)]
struct EditGeometry {
box_rect: Rect,
text_box: Rect,
}
impl EditGeometry {
fn for_band(band: Rect, line_height: u32) -> Self {
let indicator_width = dimensions::BUTTON_ICON_SIZE.min(band.width);
let height = line_height.min(band.height);
let box_rect = Rect::new(
band.x
+ band.width.saturating_sub(indicator_width + dimensions::TEXT_FIELD_PADDING_H)
as i32,
band.y + (band.height.saturating_sub(height) / 2) as i32,
indicator_width,
height,
);
let text_right = box_rect.x.saturating_sub(INDICATOR_LEADING_GAP as i32);
let text_left = (band.x + dimensions::TEXT_FIELD_PADDING_H as i32).min(text_right);
let text_box =
Rect::new(text_left, band.y, text_right.saturating_sub(text_left) as u32, band.height);
Self { box_rect, text_box }
}
}
pub struct EditableComboBox {
base: BaseWidget,
text: String,
items: Vec<String>,
expanded: bool,
selected_index: Option<usize>,
pub text_changed: Signal1<String>,
pub item_selected: Signal1<usize>,
undo_stack: UndoStack,
history_target: Rc<RefCell<String>>,
restoring_history: bool,
}
impl EditableComboBox {
fn field_band(&self) -> Rect {
ControlMetrics::full_width_band(self.geometry(), dimensions::TEXT_FIELD_MIN_HEIGHT)
}
fn indicator_geometry(&self, line_height: u32) -> EditGeometry {
EditGeometry::for_band(self.field_band(), line_height)
}
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::EditableComboBox, geometry, "EditableComboBox"),
text: String::new(),
items: Vec::new(),
expanded: false,
selected_index: None,
text_changed: Signal1::new(),
item_selected: Signal1::new(),
undo_stack: UndoStack::new(),
history_target: Rc::new(RefCell::new(String::new())),
restoring_history: false,
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
let t = text.into();
if self.text != t {
let before = self.text.clone();
self.text = t.clone();
if !self.restoring_history {
*self.history_target.borrow_mut() = self.text.clone();
self.undo_stack.push(Box::new(TextSnapshotCommand::new(
self.history_target.clone(),
before,
self.text.clone(),
"editable_combo_text",
)));
}
self.text_changed.emit(self.text.clone());
self.base.request_redraw();
}
}
pub fn add_item(&mut self, item: impl Into<String>) {
self.items.push(item.into());
self.base.request_redraw();
}
pub fn remove_item(&mut self, index: usize) -> bool {
if index < self.items.len() {
self.items.remove(index);
if self.selected_index == Some(index) {
self.selected_index = None;
} else if let Some(ref mut sel) = self.selected_index {
if index < *sel {
*sel -= 1;
}
}
self.base.request_redraw();
true
} else {
false
}
}
pub fn clear_items(&mut self) {
self.items.clear();
self.selected_index = None;
self.expanded = false;
self.base.request_redraw();
}
pub fn items(&self) -> &[String] {
&self.items
}
pub fn item_count(&self) -> usize {
self.items.len()
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
pub fn expand(&mut self) {
if !self.expanded {
self.expanded = true;
self.base.request_redraw();
}
}
pub fn collapse(&mut self) {
if self.expanded {
self.expanded = false;
self.base.request_redraw();
}
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
self.base.request_redraw();
}
pub fn selected_index(&self) -> Option<usize> {
self.selected_index
}
pub fn undo(&mut self) -> bool {
if self.undo_stack.undo().is_err() {
return false;
}
self.restoring_history = true;
let text = self.history_target.borrow().clone();
self.set_text(&text);
self.restoring_history = false;
true
}
pub fn redo(&mut self) -> bool {
if self.undo_stack.redo().is_err() {
return false;
}
self.restoring_history = true;
let text = self.history_target.borrow().clone();
self.set_text(&text);
self.restoring_history = false;
true
}
pub fn can_undo(&self) -> bool {
self.undo_stack.can_undo()
}
pub fn can_redo(&self) -> bool {
self.undo_stack.can_redo()
}
pub fn select_index(&mut self, index: usize) -> bool {
if index < self.items.len() && self.selected_index != Some(index) {
self.selected_index = Some(index);
self.text = self.items[index].clone();
self.text_changed.emit(self.text.clone());
self.item_selected.emit(index);
self.expanded = false;
self.base.request_redraw();
return true;
}
false
}
}
impl Widget for EditableComboBox {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
let side_air = (dimensions::TEXT_FIELD_MIN_HEIGHT / 2).saturating_sub(8);
let trailing =
dimensions::TEXT_FIELD_PADDING_H + dimensions::BUTTON_ICON_SIZE + INDICATOR_LEADING_GAP;
let padding = EdgeOffsets {
top: side_air,
right: trailing,
bottom: side_air,
left: dimensions::TEXT_FIELD_PADDING_H,
};
let content = Size::new(self.text.len() as u32 * 8, 0);
let floor = Size::new(
dimensions::TEXT_FIELD_MIN_HEIGHT + trailing,
dimensions::TEXT_FIELD_MIN_HEIGHT,
);
let hint = ControlMetrics::implicit_size(content, padding, floor);
Size::new(hint.width, self.field_band().height.max(floor.height))
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for EditableComboBox {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"text" => Ok(CapabilityValue::String(self.text().to_string())),
"item_count" => Ok(CapabilityValue::UInt(self.item_count() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"text" => {
self.set_text(expect_string(value)?);
Ok(())
}
"item_count" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["text", "item_count", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_text" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl Draw for EditableComboBox {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.field_band();
if rect.width == 0 || rect.height == 0 {
return;
}
let is_enabled = self.base.is_enabled();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("editable_combo_box");
let themed_ink = theme.as_ref().and_then(|t| t.text_color);
let themed_border = theme.as_ref().and_then(|t| t.border_color);
let ink = style.text_color.or(themed_ink).unwrap_or(Color::BLACK);
let field = style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::WHITE);
let border = style
.border_color
.or(themed_border)
.filter(|resolved| *resolved != field)
.unwrap_or_else(|| field.blend(&ink, 0.3));
let bg_color = if is_enabled { field } else { field.blend(&ink, 0.08) };
context.fill_rounded_rect(rect, 4, bg_color);
let border_color = border;
context.draw_rounded_rect_stroke(rect, 4, border_color, 1);
let font = Font::simple("sans-serif", 13.0);
let line = context.text_line(rect, &font);
let geometry = self.indicator_geometry(line.height);
let display_text = if self.text.is_empty() && !is_enabled { "" } else { &self.text };
let text_color = if is_enabled { ink } else { ink.blend(&bg_color, 0.6) };
let value_line = context.text_line(geometry.text_box, &font);
context.draw_text_fitted(
Rect::new(
geometry.text_box.x,
value_line.y,
geometry.text_box.width,
value_line.height,
),
display_text,
&font,
text_color,
HorizontalAlignment::Left,
);
let arrow_text = if self.expanded { "▲" } else { "▼" };
let arrow_color = if is_enabled {
ink.legible_on(bg_color, 4.5).blend(&bg_color, 0.15)
} else {
ink.blend(&bg_color, 0.7)
};
context.draw_text_fitted(
geometry.box_rect,
arrow_text,
&font,
arrow_color,
HorizontalAlignment::Center,
);
if !self.expanded || self.items.is_empty() {
return;
}
let drop_down_y = rect.y + rect.height as i32;
let item_height = 28u32;
let drop_down_height = item_height * self.items.len() as u32;
let drop_rect = Rect::new(rect.x, drop_down_y, rect.width, drop_down_height);
context.fill_rounded_rect(drop_rect, 2, field);
context.draw_rounded_rect_stroke(drop_rect, 2, border, 1);
let highlight = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.primary.with_alpha(40))
.unwrap_or_else(|| ink.blend(&field, 0.85));
for (i, item) in self.items.iter().enumerate() {
let item_rect = Rect::new(
rect.x + 1,
drop_down_y + (i as i32) * (item_height as i32),
rect.width.saturating_sub(2),
item_height,
);
if self.selected_index == Some(i) {
context.fill_rounded_rect(item_rect, 2, highlight);
}
let item_text_x = item_rect.x + 8;
let item_text_y = item_rect.y + 18;
let item_color = if is_enabled { ink } else { ink.blend(&field, 0.6) };
context.draw_text(
Point::new(item_text_x, item_text_y),
item,
&font,
item_color,
HorizontalAlignment::Left,
);
}
}
}
impl EventHandler for EditableComboBox {
fn handle_event(&mut self, event: &Event) {
if !self.base.is_enabled() {
return;
}
match event {
Event::KeyPress { key, modifiers } => {
if *modifiers == 2 && *key == 90 {
let _ = self.undo();
return;
}
if *modifiers == 2 && *key == 89 {
let _ = self.redo();
return;
}
match *key {
8 => {
let mut new_text = self.text.clone();
new_text.pop();
self.set_text(new_text);
return;
}
13 => {
if self.expanded && !self.items.is_empty() {
if let Some(idx) = self.selected_index {
self.select_index(idx);
} else if let Some(idx) =
self.items.iter().position(|item| item == &self.text)
{
self.select_index(idx);
}
}
self.expanded = false;
self.base.request_redraw();
return;
}
27 => {
self.expanded = false;
self.base.request_redraw();
return;
}
38 => {
if self.expanded && !self.items.is_empty() {
let prev = match self.selected_index {
Some(0) | None => 0,
Some(idx) => idx - 1,
};
self.selected_index = Some(prev);
self.base.request_redraw();
}
return;
}
40 => {
if !self.expanded {
self.expanded = true;
}
if !self.items.is_empty() {
let next = match self.selected_index {
Some(idx) => (idx + 1).min(self.items.len() - 1),
None => 0,
};
self.selected_index = Some(next);
self.base.request_redraw();
}
return;
}
_ => {
if let Some(ch) = char::from_u32(*key) {
if ch.is_ascii_graphic() || ch == ' ' {
let mut new_text = self.text.clone();
new_text.push(ch);
self.set_text(new_text);
return;
}
}
}
}
self.base.handle_event(event);
}
Event::MousePress { pos, button } if *button == 1 => {
let rect = self.geometry();
if rect.contains_point(*pos) {
let arrow_zone_x = rect.x + rect.width as i32 - 24;
if pos.x >= arrow_zone_x {
self.toggle();
} else {
if !self.expanded && !self.items.is_empty() {
self.expand();
} else {
self.toggle();
}
}
return;
}
if self.expanded {
let item_height = 28i32;
let drop_down_y = rect.y + rect.height as i32;
let drop_down_height = item_height * self.items.len() as i32;
let drop_rect =
Rect::new(rect.x, drop_down_y, rect.width, drop_down_height as u32);
if drop_rect.contains_point(*pos) {
let rel_y = pos.y - drop_down_y;
let idx = (rel_y / item_height) as usize;
if idx < self.items.len() {
self.select_index(idx);
}
return;
}
if !rect.contains_point(*pos) {
self.collapse();
}
}
}
_ => {
self.base.handle_event(event);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::svg::render_to_svg;
#[test]
fn editable_combo_box_default_creation() {
let cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
assert_eq!(cb.kind(), WidgetKind::EditableComboBox);
assert_eq!(cb.text(), "");
assert_eq!(cb.item_count(), 0);
assert!(!cb.is_expanded());
assert!(cb.selected_index().is_none());
}
#[test]
fn editable_combo_box_add_and_remove_items() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
cb.add_item("Option A");
cb.add_item("Option B");
cb.add_item("Option C");
assert_eq!(cb.item_count(), 3);
assert!(cb.remove_item(1));
assert_eq!(cb.item_count(), 2);
assert_eq!(cb.items()[0], "Option A");
assert_eq!(cb.items()[1], "Option C");
assert!(!cb.remove_item(5));
assert_eq!(cb.item_count(), 2);
}
#[test]
fn editable_combo_box_text_operations() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
assert_eq!(cb.text(), "");
cb.set_text("Hello");
assert_eq!(cb.text(), "Hello");
cb.set_text("World");
assert_eq!(cb.text(), "World");
cb.clear_items();
assert_eq!(cb.text(), "World");
}
#[test]
fn editable_combo_box_expand_collapse() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
assert!(!cb.is_expanded());
cb.expand();
assert!(cb.is_expanded());
cb.collapse();
assert!(!cb.is_expanded());
cb.toggle();
assert!(cb.is_expanded());
cb.toggle();
assert!(!cb.is_expanded());
}
#[test]
fn editable_combo_box_select_index() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
cb.add_item("Alpha");
cb.add_item("Beta");
cb.add_item("Gamma");
assert!(cb.select_index(1));
assert_eq!(cb.selected_index(), Some(1));
assert_eq!(cb.text(), "Beta");
assert!(!cb.is_expanded());
assert!(!cb.select_index(1));
assert!(!cb.select_index(10));
}
#[test]
fn editable_combo_box_svg_output() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
cb.add_item("Item 1");
cb.add_item("Item 2");
cb.set_text("Hello");
let svg = render_to_svg(&mut cb);
assert!(svg.starts_with("<svg"));
assert!(svg.ends_with("</svg>"));
}
#[test]
fn editable_combo_box_text_changed_signal() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
let captured = std::sync::Arc::new(std::sync::Mutex::new(None::<String>));
let c = captured.clone();
cb.text_changed.connect(move |val: std::sync::Arc<String>| {
*c.lock().unwrap() = Some(val.to_string());
});
cb.set_text("CustomValue");
assert_eq!(captured.lock().unwrap().as_deref(), Some("CustomValue"));
}
#[test]
fn editable_combo_box_item_selected_signal() {
let mut cb = EditableComboBox::new(Rect::new(0, 0, 200, 30));
cb.add_item("A");
cb.add_item("B");
let captured = std::sync::Arc::new(std::sync::Mutex::new(None::<usize>));
let c = captured.clone();
cb.item_selected.connect(move |val: std::sync::Arc<usize>| {
*c.lock().unwrap() = Some(*val);
});
cb.select_index(1);
assert_eq!(*captured.lock().unwrap(), Some(1));
}
#[test]
fn the_value_box_ends_where_the_indicator_begins() {
let trailing =
dimensions::TEXT_FIELD_PADDING_H + dimensions::BUTTON_ICON_SIZE + INDICATOR_LEADING_GAP;
for width in [0u32, 20, 64, 240, 400] {
let cb = EditableComboBox::new(Rect::new(0, 0, width, 120));
let geometry = cb.indicator_geometry(14);
let band = cb.field_band();
assert_eq!(
geometry.text_box.x + geometry.text_box.width as i32 + INDICATOR_LEADING_GAP as i32,
geometry.box_rect.x,
"the value must stop one gap short of the indicator at width {width}"
);
assert!(
geometry.box_rect.x + geometry.box_rect.width as i32 <= band.x + band.width as i32,
"the indicator must stay inside the band at width {width}"
);
if width < trailing {
assert_eq!(
geometry.text_box.width, 0,
"a band too narrow for its own chrome holds no value at width {width}"
);
} else {
assert!(
geometry.text_box.width > 0,
"a band with room must give the value some of it at width {width}"
);
}
}
}
#[test]
fn the_reported_height_is_the_band_that_is_painted() {
let cb = EditableComboBox::new(Rect::new(0, 0, 240, 120));
let band = cb.field_band();
assert_eq!(band.height, dimensions::TEXT_FIELD_MIN_HEIGHT);
assert_eq!(cb.size_hint().height, band.height);
assert_eq!(band.y, (120 - dimensions::TEXT_FIELD_MIN_HEIGHT as i32) / 2);
}
}