use iced::widget::{combo_box, container, text_input};
use iced::{Element, Theme, widget};
use serde_json::Value;
use crate::PlushieRenderer;
use crate::a11y::A11yOverrides;
use crate::iced_convert;
use crate::message::Message;
use crate::protocol::TreeNode;
use crate::registry::PlushieWidget;
use crate::render_ctx::RenderCtx;
use crate::widget::helpers::*;
use plushie_core::types::{
A11y, Ellipsis, Font, HasPopup, Length, LineHeight, Padding, PlushieType, Shaping,
Style as CoreStyle,
};
struct ComboBoxProps {
selected: Option<String>,
placeholder: Option<String>,
width: Option<Length>,
padding: Option<Padding>,
size: Option<f32>,
font: Option<Font>,
line_height: Option<LineHeight>,
menu_height: Option<f32>,
shaping: Option<Shaping>,
ellipsis: Option<Ellipsis>,
style: Option<CoreStyle>,
}
impl ComboBoxProps {
fn from_node(node: &TreeNode) -> Self {
let p = &node.props;
Self {
selected: String::extract(p, "selected"),
placeholder: String::extract(p, "placeholder"),
width: Length::extract(p, "width"),
padding: Padding::extract(p, "padding"),
size: f32::extract(p, "size"),
font: Font::extract(p, "font"),
line_height: LineHeight::extract(p, "line_height"),
menu_height: f32::extract(p, "menu_height"),
shaping: Shaping::extract(p, "shaping"),
ellipsis: Ellipsis::extract(p, "ellipsis"),
style: CoreStyle::extract(p, "style"),
}
}
}
pub(crate) struct ComboBoxWidget {
states: std::collections::HashMap<(String, String), combo_box::State<String>>,
options: std::collections::HashMap<(String, String), Vec<String>>,
}
impl ComboBoxWidget {
pub(crate) fn new() -> Self {
Self {
states: std::collections::HashMap::new(),
options: std::collections::HashMap::new(),
}
}
}
impl<R: PlushieRenderer> PlushieWidget<R> for ComboBoxWidget {
fn type_names(&self) -> &[&str] {
&["combo_box"]
}
fn prepare(&mut self, node: &TreeNode, window_id: &str, _theme: &iced::Theme) {
let key = (window_id.to_string(), node.id.clone());
let props = &node.props;
let new_options: Vec<String> = prop_str_array(props, "options").unwrap_or_default();
let options_changed = self
.options
.get(&key)
.is_none_or(|cached| *cached != new_options);
if options_changed {
self.states
.insert(key.clone(), combo_box::State::new(new_options.clone()));
self.options.insert(key, new_options);
}
}
fn render<'a>(
&'a self,
node: &'a TreeNode,
ctx: &RenderCtx<'a, R>,
) -> Element<'a, Message, Theme, R> {
let key = (ctx.window_id.to_string(), node.id.clone());
match self.states.get(&key) {
Some(state) => render_combo_box_with_state(node, *ctx, state),
None => {
log::warn!("combo_box factory cache miss for id={}", node.id);
iced::widget::text("(combo_box: cache miss)").into()
}
}
}
fn infer_a11y(&self, node: &TreeNode) -> Option<A11yOverrides> {
let props = &node.props;
let mut a11y = A11y::new().has_popup(HasPopup::Listbox);
if let Some(desc) = crate::prop_helpers::prop_str(props, "placeholder") {
a11y = a11y.description(desc);
}
Some(A11yOverrides::from_core(&a11y))
}
fn prune_stale(&mut self, live_ids: &std::collections::HashSet<(String, String)>) {
self.states.retain(|k, _| live_ids.contains(k));
self.options.retain(|k, _| live_ids.contains(k));
}
fn fresh_for_session(&self) -> Box<dyn PlushieWidget<R>> {
Box::new(ComboBoxWidget::new())
}
}
fn render_combo_box_with_state<'a, R: PlushieRenderer>(
node: &'a TreeNode,
ctx: RenderCtx<'a, R>,
state: &'a combo_box::State<String>,
) -> Element<'a, Message, Theme, R> {
let cp = ComboBoxProps::from_node(node);
let placeholder = cp.placeholder.unwrap_or_default();
let id = node.id.clone();
let input_id = node.id.clone();
let window_id = ctx.window_id.to_string();
let input_window_id = window_id.clone();
let width = cp
.width
.as_ref()
.map(iced_convert::length)
.unwrap_or(iced::Length::Fill);
let mut cb = combo_box(state, &placeholder, cp.selected.as_ref(), move |selected| {
Message::Event {
window_id: window_id.clone(),
id: id.clone(),
value: Value::String(selected),
family: "select".into(),
}
})
.width(width);
if let Some(ref p) = cp.padding {
cb = cb.padding(iced_convert::padding(p));
}
cb = cb.on_input(move |v| Message::Event {
window_id: input_window_id.clone(),
id: input_id.clone(),
value: Value::String(v),
family: "input".into(),
});
if let Some(sz) = cp.size.or(ctx.default_text_size) {
cb = cb.size(sz);
}
let font = cp.font.map(|f| iced_convert::font(&f)).or(ctx.default_font);
if let Some(f) = font {
cb = cb.font(f);
}
if let Some(lh) = cp.line_height {
cb = cb.line_height(iced_convert::line_height(lh));
}
if let Some(s) = cp.shaping {
cb = cb.shaping(iced_convert::shaping(s));
}
if let Some(mh) = cp.menu_height {
cb = cb.menu_height(mh);
}
if let Some(icon) = node
.props
.get_value("icon")
.as_ref()
.and_then(parse_text_input_icon)
{
cb = cb.icon(icon);
}
if let Some(e) = cp.ellipsis {
cb = cb.ellipsis(iced_convert::ellipsis(e));
}
if let Some(ms) = parse_menu_style(&node.props) {
cb = cb.menu_style(move |theme: &iced::Theme| {
use iced::overlay::menu;
let mut style = menu::default(theme);
apply_menu_style_overrides(&mut style, &ms);
style
});
}
if prop_bool_default(&node.props, "on_option_hovered", false) {
let hover_id = node.id.clone();
let hover_window_id = ctx.window_id.to_string();
cb = cb.on_option_hovered(move |val| Message::Event {
window_id: hover_window_id.clone(),
id: hover_id.clone(),
value: Value::String(val),
family: "option_hovered".into(),
});
}
if prop_bool_default(&node.props, "on_open", false) {
let open_id = node.id.clone();
cb = cb.on_open(Message::Event {
window_id: ctx.window_id.to_string(),
id: open_id,
value: Value::Null,
family: "open".into(),
});
}
if prop_bool_default(&node.props, "on_close", false) {
let close_id = node.id.clone();
cb = cb.on_close(Message::Event {
window_id: ctx.window_id.to_string(),
id: close_id,
value: Value::Null,
family: "close".into(),
});
}
let cursor_color = ctx.theme_chrome.cursor_color;
match &cp.style {
Some(CoreStyle::Preset(name)) => {
cb = match name.as_str() {
"default" => {
if cursor_color.is_some() {
cb.input_style(move |theme: &iced::Theme, status| {
let mut style = text_input::default(theme, status);
apply_text_input_cursor_chrome(&mut style, status, cursor_color);
style
})
} else {
cb.input_style(text_input::default)
}
}
_ => {
log::warn!(
"unknown style {:?} for widget type {:?}, using default",
name,
"combo_box"
);
cb
}
};
}
Some(CoreStyle::Custom(style_map)) => {
let ov = style_overrides_from_style_map(&node.id, style_map, ctx.caches);
cb = cb.input_style(move |theme: &iced::Theme, status| {
let base_fn: fn(&iced::Theme, text_input::Status) -> text_input::Style =
match ov.preset_base.as_deref() {
Some("default") => text_input::default,
_ => text_input::default,
};
let mut style = base_fn(theme, status);
apply_text_input_cursor_chrome(&mut style, status, cursor_color);
apply_text_input_fields(&mut style, &ov.base);
match status {
text_input::Status::Focused { .. } => {
if let Some(ref f) = ov.focused {
apply_text_input_fields(&mut style, f);
}
}
text_input::Status::Hovered => {
if let Some(ref f) = ov.hovered {
apply_text_input_fields(&mut style, f);
} else {
style.background = deviate_background(style.background, 0.1);
}
}
text_input::Status::Disabled => {
if let Some(ref f) = ov.disabled {
apply_text_input_fields(&mut style, f);
} else {
style.background = match style.background {
iced::Background::Color(c) => {
iced::Background::Color(alpha_color(c, 0.5))
}
iced::Background::Gradient(g) => {
iced::Background::Gradient(alpha_gradient(g, 0.5))
}
};
style.value = alpha_color(style.value, 0.5);
style.border = auto_derive_disabled_border(style.border);
}
}
_ => {}
}
style
});
}
None => {
if cursor_color.is_some() {
cb = cb.input_style(move |theme: &iced::Theme, status| {
let mut style = text_input::default(theme, status);
apply_text_input_cursor_chrome(&mut style, status, cursor_color);
style
});
}
}
}
container(cb).id(widget::Id::from(node.id.clone())).into()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn infer(props: serde_json::Value) -> Option<A11yOverrides> {
let node = crate::testing::node_with_props("cb", "combo_box", props);
let widget = ComboBoxWidget::new();
<ComboBoxWidget as PlushieWidget<iced::Renderer>>::infer_a11y(&widget, &node)
}
#[test]
fn has_popup_listbox_always_present() {
let o = infer(json!({})).expect("combo_box should always infer has_popup");
assert_eq!(o.core().has_popup, Some(HasPopup::Listbox));
}
#[test]
fn placeholder_flows_to_description() {
let o = infer(json!({"placeholder": "Pick one"})).expect("placeholder should infer");
assert_eq!(o.core().description.as_deref(), Some("Pick one"));
assert_eq!(o.core().has_popup, Some(HasPopup::Listbox));
}
}