use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::base::{Point, Rect};
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::render::rich::RichText;
use crate::render::{Attrs, Style};
use crate::theme::TokenSet;
use crate::ui::{dyn_view, Element, EventCtx, Key, Mods, MouseButton, MouseKind, Phase, UiEvent};
use crate::widgets::richtext::draw_rich_lines;
use crate::widgets::row_select;
use crate::widgets::scrollbar;
type HeightFn = Box<dyn Fn(usize, &str) -> i32>;
type KeyFn = Box<dyn Fn(usize, &str) -> String>;
type AccessoryFn = Box<dyn Fn(usize, &str) -> Option<String>>;
const REMOVE_GLYPH: &str = "✕";
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ListHitZone {
Body,
Accessory,
Scrollbar,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct ListColumns {
body_w: i32,
accessory_w: i32,
bar_w: i32,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum ListHit {
Row(usize, ListHitZone),
Scrollbar,
}
type AccessoryCells = Rc<Vec<Option<(String, i32)>>>;
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ListContext {
pub index: usize,
pub screen_position: Point,
pub screen_row: Rect,
pub mods: Mods,
}
#[allow(clippy::too_many_arguments)]
fn list_context(
index: usize,
position: Point,
rect: Rect,
cols: ListColumns,
offset: i32,
prefix: &[i32],
layer_origin: Point,
mods: Mods,
) -> ListContext {
let top = (rect.y + prefix[index] - offset).max(rect.y);
let bottom = (rect.y + prefix[index + 1] - offset).min(rect.bottom());
ListContext {
index,
screen_position: Point::new(position.x + layer_origin.x, position.y + layer_origin.y),
screen_row: Rect::new(
rect.x + layer_origin.x,
top + layer_origin.y,
cols.body_w + cols.accessory_w,
(bottom - top).max(0),
),
mods,
}
}
fn list_columns(viewport_w: i32, show_bar: bool, accessory_w: i32) -> ListColumns {
let bar_w = i32::from(show_bar);
let accessory_w = accessory_w.max(0);
let body_w = (viewport_w - bar_w - accessory_w).max(0);
ListColumns {
body_w,
accessory_w,
bar_w,
}
}
fn list_hit_zone(local_x: i32, cols: ListColumns) -> ListHitZone {
if cols.bar_w > 0 && local_x >= cols.body_w + cols.accessory_w {
ListHitZone::Scrollbar
} else if cols.accessory_w > 0 && local_x >= cols.body_w {
ListHitZone::Accessory
} else {
ListHitZone::Body
}
}
fn resolve_hit(
local: Point,
cols: ListColumns,
offset: i32,
prefix: &[i32],
len: usize,
cells: Option<&AccessoryCells>,
) -> Option<ListHit> {
let zone = list_hit_zone(local.x, cols);
if zone == ListHitZone::Scrollbar {
return Some(ListHit::Scrollbar);
}
let row = local.y + offset;
let total_rows = *prefix.last()?;
if row < 0 || row >= total_rows {
return None;
}
let idx = prefix.partition_point(|&p| p <= row).saturating_sub(1);
if idx >= len {
return None;
}
let on_accessory = zone == ListHitZone::Accessory
&& row == prefix[idx]
&& cells.is_some_and(|c| c[idx].is_some());
Some(ListHit::Row(
idx,
if on_accessory {
ListHitZone::Accessory
} else {
ListHitZone::Body
},
))
}
pub struct List {
items: Vec<String>,
selection: Option<Signal<usize>>,
selection_key: Option<Signal<String>>,
key_fn: Option<KeyFn>,
heights: Option<HeightFn>,
scroll_to: Option<Signal<Option<usize>>>,
offset_y: Option<Signal<i32>>,
focused: Option<Signal<bool>>,
layout: Option<LayoutStyle>,
on_select: Option<Box<dyn FnMut(usize)>>,
on_activate: Option<Box<dyn FnMut(usize)>>,
accessory_fn: Option<AccessoryFn>,
accessory_width: Option<i32>,
on_accessory_click: Option<Box<dyn FnMut(usize)>>,
on_remove: Option<Box<dyn FnMut(usize)>>,
on_row_double_click: Option<Box<dyn FnMut(usize)>>,
on_context_menu: Option<Box<dyn FnMut(ListContext)>>,
rich_items: Option<Vec<RichText>>,
}
impl List {
pub fn of<I, S>(items: I) -> List
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
List::new(items.into_iter().map(Into::into).collect())
}
pub fn new(items: Vec<String>) -> List {
List {
items,
selection: None,
selection_key: None,
key_fn: None,
heights: None,
scroll_to: None,
offset_y: None,
focused: None,
layout: None,
on_select: None,
on_activate: None,
accessory_fn: None,
accessory_width: None,
on_accessory_click: None,
on_remove: None,
on_row_double_click: None,
on_context_menu: None,
rich_items: None,
}
}
pub fn selection(mut self, selection: Signal<usize>) -> List {
self.selection = Some(selection);
self
}
pub fn key_fn(mut self, f: impl Fn(usize, &str) -> String + 'static) -> List {
self.key_fn = Some(Box::new(f));
self
}
pub fn selection_key(mut self, key: Signal<String>) -> List {
self.selection_key = Some(key);
self
}
pub fn item_heights(mut self, f: impl Fn(usize, &str) -> i32 + 'static) -> List {
self.heights = Some(Box::new(f));
self
}
pub fn scroll_to(mut self, request: Signal<Option<usize>>) -> List {
self.scroll_to = Some(request);
self
}
pub fn offset_y(mut self, offset: Signal<i32>) -> List {
self.offset_y = Some(offset);
self
}
pub fn focus_signal(mut self, focused: Signal<bool>) -> List {
self.focused = Some(focused);
self
}
pub fn layout(mut self, layout: LayoutStyle) -> List {
self.layout = Some(layout);
self
}
pub fn on_select(mut self, f: impl FnMut(usize) + 'static) -> List {
self.on_select = Some(Box::new(f));
self
}
pub fn on_activate(mut self, f: impl FnMut(usize) + 'static) -> List {
self.on_activate = Some(Box::new(f));
self
}
pub fn row_accessory(mut self, f: impl Fn(usize, &str) -> Option<String> + 'static) -> List {
self.accessory_fn = Some(Box::new(f));
self
}
pub fn accessory_width(mut self, cells: i32) -> List {
self.accessory_width = Some(cells.max(1));
self
}
pub fn on_accessory_click(mut self, f: impl FnMut(usize) + 'static) -> List {
self.on_accessory_click = Some(Box::new(f));
self
}
pub fn on_remove(mut self, f: impl FnMut(usize) + 'static) -> List {
self.on_remove = Some(Box::new(f));
self
}
pub fn on_row_double_click(mut self, f: impl FnMut(usize) + 'static) -> List {
self.on_row_double_click = Some(Box::new(f));
self
}
pub fn on_context_menu(mut self, f: impl FnMut(ListContext) + 'static) -> List {
self.on_context_menu = Some(Box::new(f));
self
}
pub fn rich_items(mut self, items: Vec<RichText>) -> List {
self.rich_items = Some(items);
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 text_fg = t.text;
let ground = t.surface;
let sel_bg = t.selection_bg;
let sel_fg = t.selection_fg;
let track = t.border;
let thumb = t.text_muted;
let accent = t.accent;
let items = Rc::new(self.items);
let len = items.len();
let prefix: Rc<Vec<i32>> = Rc::new(row_select::prefix_sums(len, |i| {
self.heights.as_ref().map(|f| f(i, &items[i])).unwrap_or(1)
}));
let total_rows = *prefix.last().unwrap_or(&0);
let selection = self.selection.unwrap_or_else(|| cx.signal(0usize));
let hover = cx.signal(None::<ListHit>);
let view_box = cx.signal((0i32, 0i32));
let keys: Option<Rc<Vec<String>>> = self.key_fn.map(|f| {
Rc::new(
items
.iter()
.enumerate()
.map(|(i, s)| f(i, s))
.collect::<Vec<_>>(),
)
});
let offset = self.offset_y.unwrap_or_else(|| cx.signal(0i32));
let model = row_select::SelectionModel {
len,
keys: keys.clone(),
prefix: prefix.clone(),
selection,
selection_key: self.selection_key,
offset,
};
model.settle();
if self.offset_y.is_some() {
offset.update(|o| *o = (*o).clamp(0, (total_rows - 1).max(0)));
}
let on_select: crate::widgets::SharedCallback<usize> =
Rc::new(RefCell::new(self.on_select));
let on_activate: crate::widgets::SharedCallback<usize> =
Rc::new(RefCell::new(self.on_activate));
let on_row_double_click: crate::widgets::SharedCallback<usize> =
Rc::new(RefCell::new(self.on_row_double_click));
let on_context_menu: crate::widgets::SharedCallback<ListContext> =
Rc::new(RefCell::new(self.on_context_menu));
let removable = self.on_remove.is_some();
let acc_hot_ink = if removable { t.error } else { t.accent };
let accessory_fn: Option<AccessoryFn> = self.accessory_fn.or_else(|| {
removable.then(|| {
Box::new(|_: usize, _: &str| Some(REMOVE_GLYPH.to_string())) as AccessoryFn
})
});
let on_accessory_click: crate::widgets::SharedCallback<usize> =
Rc::new(RefCell::new(self.on_accessory_click.or(self.on_remove)));
let accessory_w = accessory_fn.as_ref().map_or(0, |f| {
self.accessory_width.unwrap_or_else(|| {
items
.iter()
.enumerate()
.filter_map(|(i, s)| f(i, s))
.map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()) as i32 + 1)
.max()
.unwrap_or(0)
})
});
let accessory_cells: Option<AccessoryCells> = accessory_fn.map(|f| {
Rc::new(
items
.iter()
.enumerate()
.map(|(i, s)| {
f(i, s).map(|label| {
let text = crate::text::truncate_ellipsis(&label, accessory_w.max(0));
let w = unicode_width::UnicodeWidthStr::width(text.as_str()) as i32;
(text, w)
})
})
.collect::<Vec<_>>(),
)
});
let rich_items = self.rich_items.map(Rc::new);
let layout = self
.layout
.unwrap_or_else(|| LayoutStyle::default().grow(1.0));
let select = {
let on_select = on_select.clone();
let model = model.clone();
move |target: usize, view_h: i32| {
if model.select(target, view_h) {
if let Some(f) = on_select.borrow_mut().as_mut() {
f(target);
}
}
}
};
if let Some(request) = self.scroll_to {
let model = model.clone();
cx.effect_labeled("list-scroll-to", move || {
let Some(idx) = request.get() else {
return;
};
if len == 0 {
request.set(None);
return;
}
let vh = view_box.get().1;
if vh <= 0 {
return; }
model.ensure_visible(idx.min(len - 1), vh);
request.set(None); });
}
let prefix_for_handler = prefix.clone();
let bar_grab: Rc<Cell<Option<i32>>> = Rc::new(Cell::new(None));
let activate = on_activate;
let accessory_click = on_accessory_click;
let row_double_click = on_row_double_click;
let context_menu = on_context_menu;
let accessory_w_handler = accessory_w;
let cells_handler = accessory_cells.clone();
let hover_handler = hover;
let model_handler = model.clone();
let handler = move |ctx: &mut EventCtx, ev: &UiEvent| {
let rect = ctx.current_rect();
let h = rect.h.max(1);
let hit_at = |pos: Point| {
let cols = list_columns(rect.w, total_rows > h, accessory_w_handler);
resolve_hit(
Point::new(pos.x - rect.x, pos.y - rect.y),
cols,
offset.get_untracked(),
&prefix_for_handler,
len,
cells_handler.as_ref(),
)
};
match ev {
UiEvent::MouseLeave => {
hover_handler.set_if_changed(None);
}
UiEvent::Key(k) => {
if k.key == Key::F(10) && k.mods == Mods::SHIFT {
if len == 0 || context_menu.borrow().is_none() {
return;
}
let idx = selection.get_untracked().min(len - 1);
model_handler.ensure_visible(idx, h);
let cols = list_columns(rect.w, total_rows > h, accessory_w_handler);
let first = offset.get_untracked();
let y = (rect.y + prefix_for_handler[idx] - first)
.clamp(rect.y, rect.bottom().saturating_sub(1));
let event = list_context(
idx,
Point::new(rect.x, y),
rect,
cols,
first,
&prefix_for_handler,
ctx.layer_origin(),
k.mods,
);
ctx.stop_propagation();
if let Some(f) = context_menu.borrow_mut().as_mut() {
f(event);
}
return;
}
if matches!(k.key, Key::Enter | Key::Char(' ')) {
if len > 0 {
if let Some(f) = activate.borrow_mut().as_mut() {
f(selection.get_untracked().min(len - 1));
ctx.stop_propagation();
}
}
return;
}
let cur = selection.get_untracked();
let Some(target) = row_select::nav_target(k.key, cur, len, (h as usize).max(1))
else {
return;
};
select(target, h);
ctx.stop_propagation();
}
UiEvent::Mouse(m) => match m.kind {
MouseKind::Move => {
hover_handler.set_if_changed(hit_at(m.pos));
}
MouseKind::ScrollUp | MouseKind::ScrollDown => {
let delta = if m.kind == MouseKind::ScrollUp { -3 } else { 3 };
let max_off = (total_rows - h).max(0);
let to = (offset.get_untracked() + delta).clamp(0, max_off);
if offset.set_if_changed(to) {
hover_handler.set_if_changed(hit_at(m.pos));
ctx.stop_propagation();
}
}
MouseKind::Up(MouseButton::Left) => {
bar_grab.set(None);
}
MouseKind::Drag(MouseButton::Left) => {
let Some(dy) = bar_grab.get() else { return };
let bar = scrollbar::metrics(rect, 1, offset.get_untracked(), total_rows);
if bar.overflows() {
offset.set_if_changed(scrollbar::offset_at(&bar, m.pos.y, dy));
hover_handler.set_if_changed(hit_at(m.pos));
ctx.stop_propagation();
}
}
MouseKind::Down(MouseButton::Left) => {
match hit_at(m.pos) {
Some(ListHit::Scrollbar) => {
let bar =
scrollbar::metrics(rect, 1, offset.get_untracked(), total_rows);
if let Some(zone) = scrollbar::hit(&bar, m.pos) {
if bar.overflows() {
let dy = scrollbar::grab_for(&bar, zone);
bar_grab.set(Some(dy));
if matches!(zone, scrollbar::Zone::Track) {
offset.set_if_changed(scrollbar::offset_at(
&bar, m.pos.y, dy,
));
hover_handler.set_if_changed(hit_at(m.pos));
}
}
}
ctx.stop_propagation();
}
None => {}
Some(ListHit::Row(idx, ListHitZone::Accessory)) => {
if let Some(f) = accessory_click.borrow_mut().as_mut() {
f(idx);
}
}
Some(ListHit::Row(idx, _)) => {
let was_selected = selection.get_untracked() == idx;
select(idx, h);
if was_selected {
if ctx.click_count() >= 2 {
if let Some(f) = row_double_click.borrow_mut().as_mut() {
f(idx);
} else if let Some(f) = activate.borrow_mut().as_mut() {
f(idx);
}
} else if let Some(f) = activate.borrow_mut().as_mut() {
f(idx);
}
}
}
}
ctx.stop_propagation();
}
MouseKind::Down(MouseButton::Right) => {
let Some(ListHit::Row(idx, _)) = hit_at(m.pos) else {
return;
};
let mut slot = context_menu.borrow_mut();
let Some(f) = slot.as_mut() else { return };
let cols = list_columns(rect.w, total_rows > h, accessory_w_handler);
let event = list_context(
idx,
m.pos,
rect,
cols,
offset.get_untracked(),
&prefix_for_handler,
ctx.layer_origin(),
m.mods,
);
ctx.stop_propagation();
f(event);
}
_ => {}
},
_ => {}
}
};
let mut el = Element::new()
.style(layout)
.role(crate::ui::Role::List)
.access_value(move || {
if len == 0 {
return "0 items".into();
}
format!("{} items, selected {}", len, selection.get_untracked() + 1)
})
.draw(super::scroll::size_probe(cx, view_box))
.drag_zone(move |rect| {
let cols = list_columns(rect.w, total_rows > rect.h, accessory_w);
if cols.bar_w == 0 {
return None;
}
let strip = Rect::new(
rect.x + cols.body_w + cols.accessory_w,
rect.y,
cols.bar_w,
rect.h,
);
scrollbar::metrics(strip, cols.bar_w, 0, total_rows)
.overflows()
.then_some(strip)
})
.focusable();
if let Some(focused) = self.focused {
el = el.focus_signal(focused);
}
let prefix_for_draw = prefix;
let accessory_w_draw = accessory_w;
let cells_draw = accessory_cells;
let rich_items_draw = rich_items;
let hover_draw = hover;
let accent_draw = accent;
el.on(Phase::Bubble, handler).child(dyn_view(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
move || {
let sel = selection.get();
let first_row = offset.get().max(0);
let pointer = hover_draw.get();
let items = items.clone();
let prefix = prefix_for_draw.clone();
let cells_inner = cells_draw.clone();
let rich_items_inner = rich_items_draw.clone();
Element::new()
.style(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Percent(1.0)),
)
.draw(move |canvas, rect| {
if rect.is_empty() || items.is_empty() {
return;
}
let base = Style::new().fg(text_fg).bg(ground);
canvas.fill_styled(rect, ' ', &base);
let total = *prefix.last().unwrap_or(&0);
let show_bar = total > rect.h;
let cols = list_columns(rect.w, show_bar, accessory_w_draw);
let text_w = cols.body_w;
let bar_hot = pointer == Some(ListHit::Scrollbar);
let mut idx = prefix
.partition_point(|&p| p <= first_row)
.saturating_sub(1);
while idx < items.len() {
let top = prefix[idx] - first_row;
if top >= rect.h {
break;
}
let item_h = prefix[idx + 1] - prefix[idx];
let selected = idx == sel;
let hot_zone = match pointer {
Some(ListHit::Row(i, z)) if i == idx => Some(z),
_ => None,
};
let style = if selected {
Style::new().fg(sel_fg).bg(sel_bg)
} else if hot_zone.is_some() {
Style::new().fg(accent_draw).bg(ground)
} else {
base
};
let body_style = if hot_zone == Some(ListHitZone::Body) {
style.attrs(Attrs::BOLD)
} else {
style
};
if selected {
let row_w = cols.body_w + cols.accessory_w;
for r in 0..item_h {
let y = rect.y + top + r;
if y >= rect.y && y < rect.bottom() {
canvas.fill_styled(
crate::base::Rect::new(rect.x, y, row_w, 1),
' ',
&style,
);
}
}
}
let y = rect.y + top;
if y >= rect.y && y < rect.bottom() {
if let Some(rich) =
rich_items_inner.as_ref().and_then(|v| v.get(idx))
{
let shaped = rich.wrap(text_w.max(0));
draw_rich_lines(
canvas,
crate::base::Rect::new(rect.x, y, text_w, 1),
shaped.lines.iter().take(1),
if selected {
sel_fg
} else if hot_zone.is_some() {
accent_draw
} else {
text_fg
},
crate::render::rich::HAlign::Left,
);
} else {
let line =
crate::text::truncate_ellipsis(&items[idx], text_w.max(0));
canvas.print_styled(
crate::base::Point::new(rect.x, y),
&line,
&body_style,
);
}
}
if cols.accessory_w > 0 {
if let Some((acc_text, acc_w)) =
cells_inner.as_ref().and_then(|c| c[idx].as_ref())
{
let acc_hot = hot_zone == Some(ListHitZone::Accessory);
let acc_x =
rect.x + cols.body_w + (cols.accessory_w - acc_w).max(0);
let acc_style = if selected {
if acc_hot {
style.attrs(Attrs::BOLD)
} else {
style
}
} else if acc_hot {
Style::new().fg(acc_hot_ink).bg(ground).attrs(Attrs::BOLD)
} else {
style
};
if y >= rect.y && y < rect.bottom() {
canvas.print_styled(
crate::base::Point::new(acc_x, y),
acc_text,
&acc_style,
);
}
}
}
idx += 1;
}
if show_bar {
let bar = scrollbar::metrics(rect, cols.bar_w, first_row, total);
scrollbar::draw(
canvas,
&bar,
bar_hot,
track,
thumb,
accent_draw,
ground,
);
}
})
.build()
},
))
}
}
#[cfg(test)]
#[path = "list_tests.rs"]
mod tests;