use std::cell::Cell;
use std::rc::Rc;
use teksilo_i18n::lit;
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::build_context::BuildContext;
use teksilo_core::event::{EventResponse, Key, WidgetEvent};
use teksilo_core::overlay::OverlayPlacement;
use teksilo_core::signal::Signal;
use teksilo_core::styles::{PopoverStyleConfig, PopoverVariant};
use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use crate::list_view::ListView;
use crate::primitives::{FixedSize, HStack, Padding, VStack};
use crate::scroll_bar::{ScrollBar, ScrollBarOrientation};
use super::item::DropdownItem;
use super::state::ItemSource;
use teksilo_i18n::LocalizedString;
pub(super) type OnSelect<T> = Option<Rc<dyn Fn(&T, &mut EventContext)>>;
pub(super) fn build_static_item_list<T: Clone + PartialEq + 'static>(
ctx: &mut BuildContext,
source: &ItemSource<T>,
selected: &Signal<Option<T>>,
item_label: &Rc<dyn Fn(&T) -> LocalizedString>,
render_item: &Option<Rc<dyn Fn(&T, bool) -> Box<dyn Widget>>>,
on_select: &OnSelect<T>,
max_visible_items: usize,
) -> WidgetId {
let total = source.len();
if total <= max_visible_items {
let mut vstack = VStack::new();
for i in 0..total {
if let Some(value) = source.get(i) {
let label = (item_label)(&value).resolve_now();
vstack = vstack.child(DropdownItem {
value,
label,
position: i + 1,
total,
selected_signal: selected.clone(),
render: render_item.clone(),
on_select: on_select.clone(),
root_child_id: None,
});
}
}
let vstack_id = ctx.add(vstack);
return ctx.add(Padding::uniform(4.0).child_id(vstack_id));
}
build_virtualized_list(
ctx,
source,
selected,
item_label,
render_item,
on_select,
max_visible_items,
None,
)
}
fn build_virtualized_list<T: Clone + PartialEq + 'static>(
ctx: &mut BuildContext,
source: &ItemSource<T>,
selected: &Signal<Option<T>>,
item_label: &Rc<dyn Fn(&T) -> LocalizedString>,
render_item: &Option<Rc<dyn Fn(&T, bool) -> Box<dyn Widget>>>,
on_select: &OnSelect<T>,
max_visible_items: usize,
filtered_indices: Option<Vec<usize>>,
) -> WidgetId {
use crate::styles::recipe_menu_item_style as menu;
let item_height = menu::MENU_ITEM_HEIGHT;
let viewport_height = max_visible_items as f32 * item_height;
let filtered_indices: Option<Rc<Vec<usize>>> = filtered_indices.map(Rc::new);
let visible_count = match &filtered_indices {
Some(indices) => indices.len(),
None => source.len(),
};
let list_source = match &filtered_indices {
None => source.to_list_source(),
Some(indices) => {
let src = source.clone();
let indices = indices.clone();
let len_fn: Rc<dyn Fn() -> usize> = Rc::new({
let indices = indices.clone();
move || indices.len()
});
let item_at: Rc<dyn Fn(usize) -> Option<T>> = Rc::new({
let src = src.clone();
let indices = indices.clone();
move |pos| indices.get(pos).and_then(|&i| src.get(i))
});
let observe_fn = src.observe.clone();
crate::list_source::ListSource::from_cloning_accessors(len_fn, item_at, observe_fn)
}
};
let selected_for_delegate = selected.clone();
let item_label_for_delegate = item_label.clone();
let render_item_for_delegate = render_item.clone();
let on_select_for_delegate = on_select.clone();
let delegate = move |index: usize, value: &T, _selected: bool| -> Box<dyn Widget> {
let label = (item_label_for_delegate)(value).resolve_now();
Box::new(DropdownItem {
value: value.clone(),
label,
position: index + 1,
total: visible_count,
selected_signal: selected_for_delegate.clone(),
render: render_item_for_delegate.clone(),
on_select: on_select_for_delegate.clone(),
root_child_id: None,
})
};
let list_view = ListView::from_list_source(list_source, delegate)
.item_height(item_height)
.show_scrollbar(false);
let scroll_y = list_view.scroll_y_signal().clone();
let max_scroll_y = list_view.max_scroll_y_signal().clone();
let viewport_ratio_y = list_view.viewport_ratio_y_signal().clone();
let list_view_id = ctx.add(list_view);
let external_scrollbar = ScrollBar::new(
ScrollBarOrientation::Vertical,
scroll_y.clone(),
max_scroll_y,
viewport_ratio_y,
);
let scrollbar_id = ctx.add(external_scrollbar);
let row_id = ctx.add(
HStack::new()
.spacing(0.0)
.add_child(list_view_id)
.add_child(scrollbar_id),
);
let padded_id = ctx.add(Padding::uniform(4.0).child_id(row_id));
let outer_height = viewport_height + 8.0;
let sized_id = ctx.add(FixedSize::new().height(outer_height).child_id(padded_id));
register_scroll_into_view(
ctx,
source.clone(),
selected.clone(),
scroll_y,
filtered_indices,
item_height,
viewport_height,
);
sized_id
}
fn register_scroll_into_view<T: Clone + PartialEq + 'static>(
ctx: &mut BuildContext,
source: ItemSource<T>,
selected: Signal<Option<T>>,
scroll_y: Signal<f32>,
filtered_indices: Option<Rc<Vec<usize>>>,
item_height: f32,
viewport_height: f32,
) {
let scroll_into_view = {
let source = source.clone();
let scroll_y = scroll_y.clone();
move |sel: &Option<T>| {
let Some(v) = sel.as_ref() else { return };
let pos = match &filtered_indices {
None => match super::state::index_of(&source, v) {
Some(i) => i,
None => return,
},
Some(indices) => match indices
.iter()
.position(|&i| source.get(i).as_ref() == Some(v))
{
Some(p) => p,
None => return,
},
};
let item_top = pos as f32 * item_height;
let item_bot = item_top + item_height;
let cur_scroll = scroll_y.get();
let cur_bot = cur_scroll + viewport_height;
if item_top < cur_scroll {
scroll_y.set(item_top);
} else if item_bot > cur_bot {
scroll_y.set(item_bot - viewport_height);
}
}
};
scroll_into_view(&selected.get());
ctx.effect(&selected, move |sel| scroll_into_view(sel));
}
pub(super) struct DropdownPanel<T: Clone + PartialEq + 'static> {
pub(super) source: ItemSource<T>,
pub(super) selected: Signal<Option<T>>,
pub(super) item_label: Rc<dyn Fn(&T) -> LocalizedString>,
pub(super) render_item: Option<Rc<dyn Fn(&T, bool) -> Box<dyn Widget>>>,
pub(super) on_select: OnSelect<T>,
pub(super) max_visible_items: usize,
pub(super) version: Signal<u64>,
pub(super) search_query: Option<Signal<String>>,
pub(super) filter: Option<Rc<dyn Fn(&str, &T) -> bool>>,
pub(super) search_input_slot: Rc<Cell<Option<WidgetId>>>,
pub(super) visible_count_slot: Rc<Cell<usize>>,
pub(super) root_child_id: Option<WidgetId>,
}
struct FilteredItemList<T: Clone + PartialEq + 'static> {
source: ItemSource<T>,
selected: Signal<Option<T>>,
item_label: Rc<dyn Fn(&T) -> LocalizedString>,
render_item: Option<Rc<dyn Fn(&T, bool) -> Box<dyn Widget>>>,
on_select: OnSelect<T>,
max_visible_items: usize,
version: Signal<u64>,
search_query: Signal<String>,
filter: Option<Rc<dyn Fn(&str, &T) -> bool>>,
visible_count_slot: Rc<Cell<usize>>,
root_child_id: Option<WidgetId>,
}
impl<T: Clone + PartialEq + 'static> std::fmt::Debug for FilteredItemList<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FilteredItemList")
.field("item_count", &self.source.len())
.finish()
}
}
impl<T: Clone + PartialEq + 'static> Widget for FilteredItemList<T> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
use teksilo_core::binding::BindingLevel;
self.version
.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
self.search_query
.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
let total = self.source.len();
let q = self.search_query.get();
let visible_indices: Vec<usize> = if q.is_empty() {
(0..total).collect()
} else {
let q_lower = q.to_lowercase();
let mut keep = Vec::new();
for i in 0..total {
if let Some(value) = self.source.get(i) {
let matches = match &self.filter {
Some(f) => f(&q, &value),
None => (self.item_label)(&value)
.resolve_now()
.to_lowercase()
.contains(&q_lower),
};
if matches {
keep.push(i);
}
}
}
keep
};
let visible_count = visible_indices.len();
self.visible_count_slot.set(visible_count);
let root_id = if visible_count > self.max_visible_items {
build_virtualized_list(
ctx,
&self.source,
&self.selected,
&self.item_label,
&self.render_item,
&self.on_select,
self.max_visible_items,
Some(visible_indices),
)
} else {
let mut vstack = VStack::new();
for (pos, &i) in visible_indices.iter().enumerate() {
if let Some(value) = self.source.get(i) {
let label = (self.item_label)(&value).resolve_now();
vstack = vstack.child(DropdownItem {
value,
label,
position: pos + 1,
total: visible_count,
selected_signal: self.selected.clone(),
render: self.render_item.clone(),
on_select: self.on_select.clone(),
root_child_id: None,
});
}
}
let vstack_id = ctx.add(vstack);
ctx.add(Padding::uniform(4.0).child_id(vstack_id))
};
self.root_child_id = Some(root_id);
vec![root_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
match self.root_child_id {
Some(id) => ctx
.child_size(id, proposal)
.unwrap_or_else(|| proposal.resolve(120.0, 0.0)),
None => proposal.resolve(120.0, 0.0),
}
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
impl<T: Clone + PartialEq + 'static> std::fmt::Debug for DropdownPanel<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DropdownPanel")
.field("item_count", &self.source.len())
.finish()
}
}
impl<T: Clone + PartialEq + 'static> Widget for DropdownPanel<T> {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let _theme_signal = ctx.theme_signal();
let searchable = self.search_query.is_some();
if !searchable {
use teksilo_core::binding::BindingLevel;
self.version
.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
}
let list_id = if let Some(query) = &self.search_query {
ctx.add(FilteredItemList {
source: self.source.clone(),
selected: self.selected.clone(),
item_label: self.item_label.clone(),
render_item: self.render_item.clone(),
on_select: self.on_select.clone(),
max_visible_items: self.max_visible_items,
version: self.version.clone(),
search_query: query.clone(),
filter: self.filter.clone(),
visible_count_slot: self.visible_count_slot.clone(),
root_child_id: None,
})
} else {
self.visible_count_slot.set(self.source.len());
build_static_item_list(
ctx,
&self.source,
&self.selected,
&self.item_label,
&self.render_item,
&self.on_select,
self.max_visible_items,
)
};
let content_id = if let Some(query) = &self.search_query {
let search_input = crate::text_input::TextInput::new(query.clone())
.placeholder(lit!("Search…"))
.show_clear_button(true)
.on_submit_fn(|ctx| ctx.dismiss_top_overlay());
let search_id = ctx.add(search_input);
self.search_input_slot.set(Some(search_id));
let search_wrapped = ctx.add(Padding::new(4.0, 4.0, 0.0, 4.0).child_id(search_id));
let col = VStack::new()
.spacing(0.0)
.add_child(search_wrapped)
.add_child(list_id);
ctx.add(col)
} else {
list_id
};
let popover_style: teksilo_core::styles::SharedPopoverStyle = ctx
.theme()
.style_slots
.popover
.clone()
.unwrap_or_else(|| Rc::new(crate::styles::RecipePopoverStyle::default()));
let surface_cfg = PopoverStyleConfig {
content: content_id,
variant: PopoverVariant::Menu,
name: String::new(),
placement: OverlayPlacement::BelowPreferred,
show_caret: false,
caret_size: 0.0,
};
let root_id = popover_style.make_body(&surface_cfg, ctx);
self.root_child_id = Some(root_id);
let source_for_nav = self.source.clone();
let selected_for_nav = self.selected.clone();
let item_label_for_nav = self.item_label.clone();
let search_query_for_nav = self.search_query.clone();
let filter_for_nav = self.filter.clone();
let panel_handlers = HandlerSet::new().on_key(move |event, _ctx| {
let nav_key = match event {
WidgetEvent::KeyDown {
key: k @ (Key::ArrowDown | Key::ArrowUp),
..
} => *k,
_ => return EventResponse::Ignored,
};
let total = source_for_nav.len();
let filtered: Vec<usize> = if let Some(query) = &search_query_for_nav {
let q = query.get();
if q.is_empty() {
(0..total).collect()
} else {
let q_lower = q.to_lowercase();
(0..total)
.filter(|&i| {
source_for_nav
.get(i)
.map(|v| match &filter_for_nav {
Some(f) => f(&q, &v),
None => (item_label_for_nav)(&v)
.resolve_now()
.to_lowercase()
.contains(&q_lower),
})
.unwrap_or(false)
})
.collect()
}
} else {
(0..total).collect()
};
let n = filtered.len();
if n == 0 {
return EventResponse::Handled;
}
let current = selected_for_nav.get();
let current_in_filtered = current.as_ref().and_then(|v| {
filtered
.iter()
.position(|&i| source_for_nav.get(i).as_ref() == Some(v))
});
let next_idx = match nav_key {
Key::ArrowDown => match current_in_filtered {
None => 0,
Some(i) => (i + 1) % n,
},
Key::ArrowUp => match current_in_filtered {
None | Some(0) => n - 1,
Some(i) => i - 1,
},
_ => return EventResponse::Handled,
};
if let Some(v) = source_for_nav.get(filtered[next_idx]) {
selected_for_nav.set(Some(v));
}
EventResponse::Handled
});
ctx.apply_self_handlers(panel_handlers);
vec![root_id]
}
fn layout_response(
&self,
proposal: SizeProposal,
ctx: &LayoutContext,
) -> teksilo_core::widget::LayoutResponse {
match self.root_child_id {
Some(id) => ctx
.child_size(id, proposal)
.unwrap_or_else(|| proposal.resolve(120.0, 0.0)),
None => proposal.resolve(120.0, 0.0),
}
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(teksilo_core::accesskit::Role::ListBox);
let count = self.visible_count_slot.get();
if count > 0 {
builder.set_size_of_set(count);
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}