use std::cell::RefCell;
use std::rc::Rc;
use crate::base::Point;
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, MouseButton, MouseKind, Phase, UiEvent};
use crate::widgets::richtext::draw_rich_lines;
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)>>>;
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
},
))
}
fn ensure_visible(offset: Signal<i32>, prefix: &[i32], idx: usize, view_h: i32, total_rows: i32) {
let top = prefix[idx];
let bottom = prefix[idx + 1];
offset.update(|o| {
if top < *o {
*o = top;
}
if view_h > 0 && bottom > *o + view_h {
*o = bottom - view_h;
}
*o = (*o).clamp(0, (total_rows - view_h.max(1)).max(0));
});
}
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)>>,
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,
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 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({
let mut out = Vec::with_capacity(len + 1);
let mut acc = 0i32;
out.push(0);
for (i, item) in items.iter().enumerate() {
let h = self
.heights
.as_ref()
.map(|f| f(i, item).max(1))
.unwrap_or(1);
acc += h;
out.push(acc);
}
out
});
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<_>>(),
)
});
if len > 0 {
let by_key = self
.selection_key
.zip(keys.as_ref())
.and_then(|(sig, keys)| {
let wanted = sig.get_untracked();
keys.iter().position(|k| *k == wanted)
});
let idx = by_key.unwrap_or_else(|| selection.get_untracked().min(len - 1));
selection.set_if_changed(idx);
if let (Some(sig), Some(keys)) = (self.selection_key, keys.as_ref()) {
sig.set_if_changed(keys[idx].clone());
}
}
let selection_key = self.selection_key;
let keys_for_select = keys.clone();
let offset = self.offset_y.unwrap_or_else(|| cx.signal(0i32));
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 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 prefix_for_select = prefix.clone();
let select = {
let on_select = on_select.clone();
move |target: usize, view_h: i32| {
if len == 0 {
return; }
let target = target.min(len - 1);
let changed = selection.get_untracked() != target;
if changed {
selection.set(target);
if let (Some(key_sig), Some(keys)) = (selection_key, keys_for_select.as_ref()) {
if let Some(k) = keys.get(target) {
key_sig.set(k.clone());
}
}
}
ensure_visible(offset, &prefix_for_select, target, view_h, total_rows);
if changed {
if let Some(f) = on_select.borrow_mut().as_mut() {
f(target);
}
}
}
};
if let Some(request) = self.scroll_to {
let prefix_for_scroll = prefix.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; }
let idx = idx.min(len - 1);
ensure_visible(offset, &prefix_for_scroll, idx, vh, total_rows);
request.set(None); });
}
let prefix_for_handler = prefix.clone();
let activate = on_activate;
let accessory_click = on_accessory_click;
let row_double_click = on_row_double_click;
let accessory_w_handler = accessory_w;
let cells_handler = accessory_cells.clone();
let hover_handler = hover;
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 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 page = (h as usize).max(1);
let target = match k.key {
Key::Up => cur.saturating_sub(1),
Key::Down => cur + 1,
Key::PageUp => cur.saturating_sub(page),
Key::PageDown => cur + page,
Key::Home => 0,
Key::End => len.saturating_sub(1),
_ => 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::Down(MouseButton::Left) => {
match hit_at(m.pos) {
Some(ListHit::Scrollbar) | 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();
}
_ => {}
},
_ => {}
}
};
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(view_box))
.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 {
draw_scrollbar(
canvas,
rect,
first_row,
total,
track,
if bar_hot { accent_draw } else { thumb },
ground,
);
}
})
.build()
},
))
}
}
pub(crate) fn draw_scrollbar(
canvas: &mut dyn crate::ui::StyledCanvas,
rect: crate::base::Rect,
first: i32,
total: i32,
track: crate::base::Rgba,
thumb: crate::base::Rgba,
ground: crate::base::Rgba,
) {
let x = rect.right() - 1;
let h = rect.h.max(1);
let track_style = Style::new().fg(track).bg(ground);
for y in rect.y..rect.bottom() {
canvas.print_styled(crate::base::Point::new(x, y), "│", &track_style);
}
let thumb_h = ((h * h) / total.max(1)).clamp(1, h);
let denom = (total - h).max(1);
let thumb_y = rect.y + ((first.min(denom) * (h - thumb_h)) / denom).max(0);
let thumb_style = Style::new().fg(thumb).bg(ground);
for y in thumb_y..(thumb_y + thumb_h).min(rect.bottom()) {
canvas.print_styled(crate::base::Point::new(x, y), "┃", &thumb_style);
}
}
#[cfg(test)]
#[path = "list_tests.rs"]
mod tests;