use ribir_core::prelude::*;
use crate::prelude::*;
#[declare]
pub struct List {
#[declare(default)]
select_mode: ListSelectMode,
#[declare(skip)]
active_item: Option<usize>,
#[declare(skip)]
items: Vec<Stateful<ListItem>>,
#[declare(skip)]
subscriptions: Vec<BoxedSubscription>,
}
class_names! {
LIST,
LIST_ITEM_SELECTED,
LIST_ITEM_UNSELECTED,
LIST_ITEM_INTERACTIVE,
LIST_ITEM,
LIST_ITEM_CONTENT,
LIST_ITEM_HEADLINE,
LIST_ITEM_SUPPORTING,
LIST_ITEM_TRAILING_SUPPORTING,
LIST_ITEM_IMG,
LIST_ITEM_THUMBNAIL,
LIST_ITEM_LEADING,
LIST_ITEM_TRAILING
}
#[declare]
pub struct ListItem {
#[declare(default = true)]
interactive: bool,
#[declare(default)]
selected: bool,
#[declare(skip)]
wid: TrackId,
}
#[repr(transparent)]
pub struct ListCustomItem(Stateful<ListItem>);
#[derive(Template)]
pub enum ListChild<'c> {
StandardItem(PairOf<'c, ListItem>),
CustomItem(PairOf<'c, ListCustomItem>),
Divider(FatObj<Stateful<Divider>>),
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum ListSelectMode {
#[default]
None,
Single,
Multi,
}
#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub struct ListItemAlignItems(pub Align);
#[derive(Template)]
pub struct ListItemHeadline(TextValue);
#[derive(Template)]
pub struct ListItemSupporting {
#[template(field = 1usize)]
lines: PipeValue<usize>,
text: TextValue,
}
#[derive(Template)]
pub struct ListItemTrailingSupporting(TextValue);
#[declare(simple)]
pub struct ListItemImg;
#[declare(simple)]
pub struct ListItemThumbnail;
#[derive(Template)]
pub struct ListItemChildren<'w> {
leading: Option<Widget<'w>>,
headline: ListItemHeadline,
supporting: Option<ListItemSupporting>,
trailing_supporting: Option<ListItemTrailingSupporting>,
trailing: Option<Trailing<Widget<'w>>>,
}
pub struct ListItemStructInfo {
pub supporting: bool,
pub trailing_supporting: bool,
pub leading: bool,
pub trailing: bool,
}
impl ListItem {
pub fn is_selected(&self) -> bool { self.selected }
pub fn select(&mut self) { self.selected = true; }
pub fn deselect(&mut self) { self.selected = false; }
pub fn toggle(&mut self) { self.selected = !self.selected; }
pub fn is_interactive(&self) -> bool { self.interactive }
pub fn set_interactive(&mut self, interactive: bool) {
if self.interactive != interactive {
self.interactive = interactive;
}
}
fn select_action(mut this: WriteRef<Self>, mode: ListSelectMode) {
if this.interactive {
match mode {
ListSelectMode::None => {}
ListSelectMode::Single => this.toggle(),
ListSelectMode::Multi => this.select(),
}
}
}
fn item_classes(item: &impl StateWatcher<Value = Self>) -> Pipe<ClassList> {
distinct_pipe! {
let item = $read(item);
let mut classes = ClassList::new();
if item.is_selected() {
classes.push(LIST_ITEM_SELECTED);
} else {
classes.push(LIST_ITEM_UNSELECTED);
}
if item.is_interactive() {
classes.push(LIST_ITEM_INTERACTIVE);
}
classes.push(LIST_ITEM);
classes
}
}
}
pub struct ListCustomItemDeclarer(ListItemDeclarer);
impl<'c> ComposeChild<'c> for List {
type Child = Vec<ListChild<'c>>;
fn compose_child(this: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'c> {
List::collect_items(&this, &child);
let select_mode = this.read().select_mode;
self::column! {
class: LIST,
align_items: Align::Stretch,
on_disposed: move |_| $write(this).clear(),
on_key_down: move |e| {
if select_mode != ListSelectMode::None {
match e.key() {
VirtualKey::Named(NamedKey::ArrowUp) => $write(this).focus_prev_item(&e.window()),
VirtualKey::Named(NamedKey::ArrowDown) => $write(this).focus_next_item(&e.window()),
_ => {}
}
}
},
@ {
child.into_iter().map(move |item| match item {
ListChild::StandardItem(pair) => {
let item = pair.parent().clone_writer();
$read(this).item_select_actions(item, pair.into_fat_widget())
},
ListChild::CustomItem(pair) => {
let item = pair.parent().read().0.clone_writer();
$read(this).item_select_actions(item, pair.into_fat_widget())
},
ListChild::Divider(divider) => divider.into_widget(),
})
}
}
.into_widget()
}
}
impl<'c> ComposeChild<'c> for ListItem {
type Child = ListItemChildren<'c>;
fn compose_child(this: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'c> {
let item_struct_info = child.struct_info();
let item_classes = ListItem::item_classes(&this);
providers! {
providers: [Provider::new(item_struct_info)],
@Class {
class: item_classes,
@{ child.compose_sections() }
}
}
.into_widget()
}
}
impl<'c> ComposeChild<'c> for ListCustomItem {
type Child = Widget<'c>;
fn compose_child(this: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'c> {
class! {
class: ListItem::item_classes(&this.read().0),
@unconstrained_box! {
clamp_dim: ClampDim::Max,
dir: UnconstrainedDir::Y,
@Row {
align_items: ListItemAlignItems::get_align(BuildCtx::get()),
@ { child }
}
}
}
.into_widget()
}
}
impl ListItemSupporting {
fn into_widget(self) -> Widget<'static> {
let Self { lines, text } = self;
text_clamp! {
class: LIST_ITEM_SUPPORTING,
rows: lines.map(|v| { Some(v as f32) }),
@Text { text }
}
.into_widget()
}
}
impl<'c> ComposeChild<'c> for ListItemImg {
type Child = Widget<'c>;
fn compose_child(_: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'c> {
class! { class: LIST_ITEM_IMG, @ { child } }.into_widget()
}
}
impl<'c> ComposeChild<'c> for ListItemThumbnail {
type Child = Widget<'c>;
fn compose_child(_: impl StateWriter<Value = Self>, child: Self::Child) -> Widget<'c> {
class! { class: LIST_ITEM_THUMBNAIL, @ { child } }.into_widget()
}
}
impl ListItemAlignItems {
pub fn get_align(ctx: &BuildCtx) -> PipeValue<Align> {
Variant::<Self>::new_or_default(ctx)
.map(|v| v.0)
.r_into()
}
}
impl<'w> ListItemChildren<'w> {
pub fn compose_sections(self) -> Widget<'w> {
let ListItemChildren { headline, supporting, trailing_supporting, leading, trailing } = self;
let content = content_section(headline, supporting.map(ListItemSupporting::into_widget));
let trailing_supporting = trailing_supporting.map(|s| {
text! { class: LIST_ITEM_TRAILING_SUPPORTING, text: s.0 }
});
let leading_widget = leading.map(|l| {
class! { class: LIST_ITEM_LEADING, @ { l } }
});
let trailing_widget = trailing.map(|t| {
class! { class: LIST_ITEM_TRAILING, @ { t.unwrap() } }
});
flex! {
align_items: ListItemAlignItems::get_align(BuildCtx::get()),
@ { leading_widget }
@Expanded { @ { content } }
@ { trailing_supporting }
@ { trailing_widget }
}
.into_widget()
}
fn struct_info(&self) -> ListItemStructInfo {
ListItemStructInfo {
supporting: self.supporting.is_some(),
trailing_supporting: self.trailing_supporting.is_some(),
leading: self.leading.is_some(),
trailing: self.trailing.is_some(),
}
}
}
impl List {
pub fn selected_items(&self) -> impl DoubleEndedIterator<Item = (usize, &Stateful<ListItem>)> {
self
.items
.iter()
.enumerate()
.filter(|(_, item)| item.read().is_selected())
}
pub fn active_selected_idx(&self) -> Option<usize> {
self.active_selected().and(self.active_item)
}
pub fn active_selected(&self) -> Option<Stateful<ListItem>> {
self
.active_item()
.filter(|item| item.read().is_selected())
}
pub fn active_item_idx(&self) -> Option<usize> { self.active_item }
pub fn active_item(&self) -> Option<Stateful<ListItem>> {
self
.active_item
.and_then(|i| self.items.get(i))
.map(Stateful::clone_writer)
}
pub fn deselect_all(&mut self) {
self
.items
.iter()
.for_each(|item| item.write().deselect());
self.active_item = None;
}
pub fn select_all(&mut self) -> usize {
let take_count = match self.select_mode {
ListSelectMode::Single => 1,
ListSelectMode::Multi => self.items.len(),
ListSelectMode::None => return 0,
};
self
.items
.iter()
.take(take_count)
.for_each(|item| item.write().select());
self.active_item = Some(0);
take_count
}
fn focus_next_item(&mut self, wnd: &Window) {
let len = self.items.len();
let start = self.active_item.map_or(0, |idx| idx + 1);
for i in 0..len {
let idx = (start + i) % len;
let Some(id) = self.items[idx].read().wid.get() else { break };
if wnd
.request_focus(id, FocusReason::Keyboard)
.is_some()
{
self.active_item = Some(idx);
break;
}
}
}
fn focus_prev_item(&mut self, wnd: &Window) {
let len = self.items.len();
let start = self.active_item.map_or(0, |idx| idx + len - 1);
for i in 0..len {
let idx = (start + len - i) % len;
let Some(id) = self.items[idx].read().wid.get() else { break };
if wnd
.request_focus(id, FocusReason::Keyboard)
.is_some()
{
self.active_item = Some(idx);
break;
}
}
}
fn clear(&mut self) {
self
.items
.iter()
.for_each(|item| item.write().deselect());
self.active_item = None;
self
.subscriptions
.drain(..)
.for_each(|u| u.unsubscribe());
}
fn collect_items<'c>(this: &impl StateWriter<Value = Self>, children: &Vec<ListChild<'c>>) {
let mut list = this.write();
let List { items, subscriptions, .. } = &mut *list;
children.iter().for_each(|child| match child {
ListChild::StandardItem(pair) => {
let item = pair.parent().clone_writer();
items.push(item.clone_writer());
}
ListChild::CustomItem(pair) => {
let item = pair.parent().read().0.clone_writer();
items.push(item.clone_writer());
}
ListChild::Divider(_) => {}
});
items.iter().enumerate().for_each(|(idx, item)| {
let item = item.clone_writer();
let this = this.clone_writer();
let u = watch!($read(item).is_selected())
.distinct_until_changed()
.filter(|selected| *selected)
.subscribe(move |_| this.write().on_item_select(idx));
subscriptions.push(BoxedSubscription::new(u));
});
}
fn on_item_select(&mut self, idx: usize) {
self.active_item = Some(idx);
if self.select_mode == ListSelectMode::Single {
for (i, item) in self.items.iter().enumerate() {
if i != idx && item.read().is_selected() {
item.write().deselect();
}
}
}
}
fn item_select_actions<'c>(
&self, item: Stateful<ListItem>, mut list_item: FatObj<Widget<'c>>,
) -> Widget<'c> {
item.silent().wid = list_item.track_id();
let mode = self.select_mode;
if mode == ListSelectMode::None {
list_item.into_widget()
} else {
rdl! {
@(list_item) {
on_tap: move |_| ListItem::select_action($write(item), mode),
on_key_down: move |e| {
if matches!(e.key(), VirtualKey::Named(NamedKey::Enter)
| VirtualKey::Named(NamedKey::Space)) {
ListItem::select_action($write(item), mode)
}
}
}.into_widget()
}
}
}
}
fn content_section(headline: ListItemHeadline, supporting: Option<Widget>) -> Widget {
let headline = text! { class: LIST_ITEM_HEADLINE, text: headline.0 };
if let Some(supporting) = supporting {
self::column! {
class: LIST_ITEM_CONTENT,
align_items: Align::Stretch,
@ { headline }
@ { supporting }
}
.into_widget()
} else {
class! {
class: LIST_ITEM_CONTENT,
@ { headline }
}
.into_widget()
}
}
impl Declare for ListCustomItem {
type Builder = ListCustomItemDeclarer;
#[inline]
fn declarer() -> Self::Builder { ListCustomItemDeclarer(ListItem::declarer()) }
}
impl ObjDeclarer for ListCustomItemDeclarer {
type Target = FatObj<ListCustomItem>;
fn finish(self) -> Self::Target {
let item = self.0.finish();
item.map(|item| ListCustomItem(item.clone_writer()))
}
}
impl std::ops::Deref for ListCustomItemDeclarer {
type Target = ListItemDeclarer;
fn deref(&self) -> &Self::Target { &self.0 }
}
impl std::ops::DerefMut for ListCustomItemDeclarer {
fn deref_mut(&mut self) -> &mut Self::Target { &mut self.0 }
}
#[cfg(test)]
mod tests {
use ribir_core::{prelude::*, test_helper::*};
use ribir_dev_helper::widget_image_tests;
use super::*;
widget_image_tests! {
list,
WidgetTester::new(list!{
@ListItem {
@Icon { @svg_registry::default_svg() }
@ListItemHeadline { @ { "Icon"} }
@ListItemSupporting { @ { "description"} }
@ListItemTrailingSupporting { @ { "100+"} }
}
@ListItem {
disabled: true,
@Icon { @svg_registry::default_svg() }
@ListItemHeadline { @ { "Only Headline"} }
@Trailing { @Icon { @svg_registry::default_svg() } }
}
@ListCustomItem { @Text { text: "Custom Item" } }
@ListItem {
@Icon { @svg_registry::default_svg() }
@ListItemHeadline { @ { "Only Headline"} }
@ListItemTrailingSupporting { @ { "100+"} }
@Trailing { @Icon { @svg_registry::default_svg() } }
}
@ListItem {
@Avatar { @ { "A" } }
@ListItemHeadline { @ { "Avatar"} }
@ListItemSupporting { @ { "description"} }
@Trailing { @Icon { @svg_registry::default_svg() } }
}
@ListItem {
@ListItemImg {
@Container { size: Size::new(100., 100.), background: Color::PINK }
}
@ListItemHeadline { @ { "Image Item"} }
@ListItemSupporting { @ { "description"} }
}
@ListItem {
@ListItemThumbnail {
@Container { size: Size::new(160., 90.), background: Color::GREEN }
}
@ListItemHeadline { @ { "Counter"} }
@ListItemSupporting {
lines: 2usize,
@ { "there is supporting lines, many lines, wrap to multiple lines, xxhadkasda"}
}
@ListItemTrailingSupporting { @ { "100+" } }
@Trailing { @Icon { @svg_registry::default_svg() } }
}
@ListItem {
@ListItemHeadline { @ { "Counter"} }
@Trailing {
@ListItemThumbnail {
@Container { size: Size::new(160., 90.), background: Color::GREEN }
}
}
}
}).with_wnd_size(Size::new(320., 640.))
.with_comparison(0.00005)
}
}