use std::cell::RefCell;
use std::rc::Rc;
use crate::base::{Rect, Size};
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::render::Style;
use crate::theme::TokenSet;
use crate::ui::{Element, EventCtx, Key, Mods, MouseButton, MouseKind, Phase, Role, UiEvent};
use super::super::anchored::{DismissReason, PanelAnchor, PanelWidth, Popup};
use super::super::overlays::Overlays;
use super::super::viewport::use_viewport;
use super::core::{
first_enabled, last_enabled, option_rows_view, page_highlight, resolve_overlays,
step_highlight, trigger_view, OptionRows, TriggerLabel, DEFAULT_MAX_VISIBLE,
};
use super::SelectOption;
struct MultiSession {
popup: Option<Popup>,
}
fn canonical(options: &[SelectOption], keys: &[String]) -> Vec<String> {
options
.iter()
.filter(|o| keys.contains(&o.key))
.map(|o| o.key.clone())
.collect()
}
pub struct MultiSelect {
options: Vec<SelectOption>,
values: Option<Signal<Vec<String>>>,
placeholder: String,
disabled: bool,
max_visible: usize,
layout: Option<LayoutStyle>,
overlays: Option<Overlays>,
on_change: Option<Box<dyn FnMut(Vec<String>)>>,
}
impl MultiSelect {
pub fn new(options: Vec<SelectOption>) -> MultiSelect {
MultiSelect {
options,
values: None,
placeholder: String::from("choose…"),
disabled: false,
max_visible: DEFAULT_MAX_VISIBLE,
layout: None,
overlays: None,
on_change: None,
}
}
pub fn values(mut self, values: Signal<Vec<String>>) -> MultiSelect {
self.values = Some(values);
self
}
pub fn placeholder(mut self, text: impl Into<String>) -> MultiSelect {
self.placeholder = text.into();
self
}
pub fn disabled(mut self, disabled: bool) -> MultiSelect {
self.disabled = disabled;
self
}
pub fn max_visible(mut self, n: usize) -> MultiSelect {
self.max_visible = n.max(1);
self
}
pub fn layout(mut self, layout: LayoutStyle) -> MultiSelect {
self.layout = Some(layout);
self
}
pub fn overlays(mut self, overlays: &Overlays) -> MultiSelect {
self.overlays = Some(overlays.clone());
self
}
pub fn on_change(mut self, f: impl FnMut(Vec<String>) + 'static) -> MultiSelect {
self.on_change = Some(Box::new(f));
self
}
pub fn view(self, cx: Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(cx, &t).build()
}
pub fn element(self, cx: Scope, t: &TokenSet) -> Element {
let tokens = *t;
let options: Rc<Vec<SelectOption>> = Rc::new(self.options);
let values = self.values.unwrap_or_else(|| cx.signal(Vec::new()));
let placeholder = self.placeholder;
let disabled = self.disabled;
let max_visible = self.max_visible;
let overlays = resolve_overlays(cx, self.overlays);
let viewport = use_viewport(cx);
let on_change: crate::widgets::SharedCallback<Vec<String>> =
Rc::new(RefCell::new(self.on_change));
let focused = cx.signal(false);
let hovered = cx.signal(false);
let display: Signal<Vec<usize>> = cx.signal((0..options.len()).collect());
let highlight: Signal<usize> = cx.signal(0);
let working: Signal<Vec<String>> = cx.signal(Vec::new());
let session: Rc<RefCell<MultiSession>> =
Rc::new(RefCell::new(MultiSession { popup: None }));
let toggle = Rc::new({
let options = options.clone();
move |pos: usize| {
let disp = display.get_untracked();
let Some(&idx) = disp.get(pos) else { return };
let Some(opt) = options.get(idx) else { return };
if opt.disabled {
return;
}
let key = opt.key.clone();
working.update(|w| {
if let Some(i) = w.iter().position(|k| *k == key) {
w.remove(i);
} else {
w.push(key);
}
});
}
});
let commit_and_close = Rc::new({
let session = session.clone();
let options = options.clone();
let on_change = on_change.clone();
move || {
let set = canonical(&options, &working.get_untracked());
if values.get_untracked() != set {
values.set(set.clone());
if let Some(f) = on_change.borrow_mut().as_mut() {
f(set);
}
}
let popup = session.borrow().popup.clone();
if let Some(popup) = popup {
popup.dismiss(DismissReason::Commit);
}
}
});
let open = Rc::new({
let session = session.clone();
let options = options.clone();
let commit_and_close = commit_and_close.clone();
let toggle = toggle.clone();
let overlays = overlays.clone();
move |anchor: Rect| {
if session.borrow().popup.is_some() {
return;
}
let Some(overlays) = overlays.clone() else {
debug_assert!(
false,
"MultiSelect: no Overlays available — build inside an App (context) \
or pass .overlays(..) explicitly"
);
return;
};
let disp = display.get_untracked();
if disp.is_empty() {
return;
}
working.set(values.get_untracked());
highlight.set(first_enabled(&options, &disp).unwrap_or(0));
let rows = disp.len().min(max_visible) as i32;
let build = multi_popup_content(
tokens,
options.clone(),
display,
highlight,
working,
max_visible,
toggle.clone(),
commit_and_close.clone(),
);
let popup = Popup::open(
&overlays,
cx,
viewport.get_untracked(),
PanelAnchor { rect: anchor },
PanelWidth::MatchAnchor,
Size::new(anchor.w, rows),
build,
);
let Some(popup) = popup else { return };
popup.on_dismiss({
let session = session.clone();
move |_reason| {
session.borrow_mut().popup = None;
}
});
session.borrow_mut().popup = Some(popup);
}
});
let access_options = options.clone();
let mut el = Element::new()
.style(self.layout.unwrap_or_else(|| {
LayoutStyle::default()
.height(Dimension::Cells(1))
.grow(1.0)
.shrink(0.0)
}))
.role(Role::Button)
.access_label(placeholder.clone())
.access_value(move || {
let keys = values.get_untracked();
let labels: Vec<&str> = access_options
.iter()
.filter(|o| keys.contains(&o.key))
.map(|o| o.label.as_str())
.collect();
labels.join(", ")
})
.hover_signal(hovered)
.focus_signal(focused);
if !disabled {
let open = open.clone();
el = el.focusable().on(Phase::Bubble, move |ctx, ev| match ev {
UiEvent::Key(k)
if (k.key == Key::Enter || k.key == Key::Char(' ')) && k.mods == Mods::NONE =>
{
if focused.get_untracked() {
open(ctx.current_rect());
ctx.stop_propagation();
}
}
UiEvent::Mouse(m) if matches!(m.kind, MouseKind::Down(MouseButton::Left)) => {
open(ctx.current_rect());
ctx.stop_propagation();
}
_ => {}
});
}
let label_options = options.clone();
let label_placeholder = placeholder.clone();
el.child(trigger_view(
t,
focused,
hovered,
disabled,
Rc::new(move || {
let keys = values.get();
let picked: Vec<&str> = label_options
.iter()
.filter(|o| keys.contains(&o.key))
.map(|o| o.label.as_str())
.collect();
if picked.is_empty() {
TriggerLabel {
text: label_placeholder.clone(),
short: None,
placeholder: true,
}
} else {
TriggerLabel {
text: picked.join(", "),
short: Some(format!("{} selected", picked.len())),
placeholder: false,
}
}
}),
))
}
}
#[allow(clippy::too_many_arguments)]
fn multi_popup_content(
tokens: TokenSet,
options: Rc<Vec<SelectOption>>,
display: Signal<Vec<usize>>,
highlight: Signal<usize>,
working: Signal<Vec<String>>,
max_visible: usize,
toggle: Rc<dyn Fn(usize)>,
commit_and_close: Rc<dyn Fn()>,
) -> impl FnOnce(Scope, bool) -> crate::ui::View {
move |_pcx: Scope, _flipped: bool| {
let ground = tokens.surface_raised;
let ink = tokens.text;
let key_handler = {
let options = options.clone();
let toggle = toggle.clone();
let commit = commit_and_close.clone();
move |ctx: &mut EventCtx, ev: &UiEvent| {
let UiEvent::Key(k) = ev else { return };
if k.mods != Mods::NONE {
return;
}
let disp = display.get_untracked();
let h = highlight.get_untracked().min(disp.len().saturating_sub(1));
match k.key {
Key::Down => highlight.set(step_highlight(&options, &disp, h, 1)),
Key::Up => highlight.set(step_highlight(&options, &disp, h, -1)),
Key::Home => {
if let Some(p) = first_enabled(&options, &disp) {
highlight.set(p);
}
}
Key::End => {
if let Some(p) = last_enabled(&options, &disp) {
highlight.set(p);
}
}
Key::PageDown => {
highlight.set(page_highlight(&options, &disp, h, 1, max_visible))
}
Key::PageUp => {
highlight.set(page_highlight(&options, &disp, h, -1, max_visible))
}
Key::Char(' ') => toggle(h),
Key::Enter => commit(),
_ => return, }
ctx.stop_propagation();
}
};
Element::new()
.style(
LayoutStyle::column()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.role(Role::Menu)
.access_label("options")
.on(Phase::Bubble, key_handler)
.draw(move |canvas, rect| {
canvas.fill_styled(rect, ' ', &Style::new().fg(ink).bg(ground));
})
.child(option_rows_view(
&tokens,
OptionRows {
options,
display,
highlight,
checks: Some(working),
max_visible,
on_activate: toggle,
},
))
.build()
}
}