use std::cell::{Cell, RefCell};
use std::cmp::Ordering;
use std::rc::Rc;
use crate::anim::{Easing, Transition};
use crate::core::{EventCtx, Widget};
use crate::event::{Event, Key, PointerKind};
use crate::geometry::{Color, Rect, Size};
use crate::render::{Canvas, Paint};
use crate::signal::Signal;
use crate::spec::{Align, Dimension};
use crate::style::{Role, Style};
use crate::text::TextEngine;
use super::{Element, SortOrder, Truncate, TABLE_CELL_PAD_X, TABLE_CELL_PAD_Y, TABLE_HEADER_PAD_Y};
pub(super) type SortState = Signal<Option<(usize, SortOrder)>>;
#[derive(Clone)]
pub(super) struct ActionCol {
title: String,
weight: f32,
build: Rc<dyn Fn(usize) -> Element>,
}
#[derive(Clone, Default)]
pub struct SortStyle {
pub asc: Option<String>,
pub desc: Option<String>,
pub size: Option<f32>,
pub color: Option<Color>,
pub slot: Option<i32>,
pub gap: Option<i32>,
pub leading: Option<bool>,
}
struct ResolvedSort {
asc: String,
desc: String,
size: f32,
color: Option<Color>,
slot: i32,
gap: i32,
leading: bool,
}
fn resolve_sort_style(ov: &SortStyle) -> ResolvedSort {
let t = crate::theme::current();
let tt = &t.table;
ResolvedSort {
asc: ov.asc.clone().unwrap_or_else(|| tt.sort_asc().to_string()),
desc: ov
.desc
.clone()
.unwrap_or_else(|| tt.sort_desc().to_string()),
size: ov.size.unwrap_or_else(|| tt.sort_size()),
color: ov.color.or(tt.sort_color),
slot: ov.slot.unwrap_or_else(|| tt.sort_slot()),
gap: ov.gap.unwrap_or_else(|| tt.sort_gap()),
leading: ov.leading.unwrap_or_else(|| tt.sort_leading()),
}
}
pub(super) type OnSort = Rc<RefCell<dyn FnMut(&mut EventCtx, Option<(usize, SortOrder)>)>>;
fn cmp_cells(a: &str, b: &str) -> Ordering {
match (a.trim().parse::<f64>(), b.trim().parse::<f64>()) {
(Ok(x), Ok(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
_ => a.cmp(b),
}
}
pub(super) fn sorted_order(rows: &[Vec<String>], sort: Option<(usize, SortOrder)>) -> Vec<usize> {
let mut order: Vec<usize> = (0..rows.len()).collect();
if let Some((col, ord)) = sort {
order.sort_by(|&a, &b| {
let va = rows[a].get(col).map(String::as_str).unwrap_or("");
let vb = rows[b].get(col).map(String::as_str).unwrap_or("");
let c = cmp_cells(va, vb);
match ord {
SortOrder::Asc => c,
SortOrder::Desc => c.reverse(),
}
});
}
order
}
pub(super) fn next_sort(
current: Option<(usize, SortOrder)>,
ci: usize,
) -> Option<(usize, SortOrder)> {
match current {
Some((c, SortOrder::Asc)) if c == ci => Some((ci, SortOrder::Desc)),
Some((c, SortOrder::Desc)) if c == ci => None,
_ => Some((ci, SortOrder::Asc)),
}
}
fn header_cell(
ci: usize,
title: &str,
weight: f32,
sort: SortState,
on_sort: Option<OnSort>,
rs: &ResolvedSort,
) -> Element {
let glyph = match sort.get() {
Some((c, SortOrder::Asc)) if c == ci => rs.asc.clone(),
Some((c, SortOrder::Desc)) if c == ci => rs.desc.clone(),
_ => String::new(),
};
let mut arrow = Element::label(glyph)
.font_size(rs.size)
.width(rs.slot)
.height(18);
arrow = match rs.color {
Some(c) => arrow.fg(c),
None => arrow.fg_role(Role::TextMuted),
};
let title_el = Element::label(title.to_string())
.font_size(13.0)
.font_weight(600)
.fg_role(Role::TextMuted)
.max_lines(1)
.truncate(Truncate::End)
.weight(1.0)
.height(18);
let mut inner = Element::row()
.width_match()
.cross(Align::Center)
.spacing(rs.gap);
inner = if rs.leading {
inner.child(arrow).child(title_el)
} else {
inner.child(title_el).child(arrow)
};
Element::stack()
.weight(weight)
.clickable()
.on_click(move |ctx| {
let next = next_sort(sort.get(), ci);
sort.set(next);
if let Some(cb) = &on_sort {
(cb.borrow_mut())(ctx, next);
}
})
.padding_xy(TABLE_CELL_PAD_X, TABLE_HEADER_PAD_Y)
.child(inner)
}
const ROW_HOVER_A: f32 = 0.05;
pub(super) struct HoverRow {
hover: bool,
overlay: Cell<Transition<f32>>,
primed: Cell<bool>,
}
impl HoverRow {
pub(super) fn new() -> Self {
Self {
hover: false,
overlay: Cell::new(Transition::new(0.0)),
primed: Cell::new(false),
}
}
}
impl Widget for HoverRow {
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let target = if enabled && self.hover {
ROW_HOVER_A
} else {
0.0
};
let mut ov = self.overlay.get();
if !self.primed.get() {
ov = Transition::new(target);
self.primed.set(true);
} else if ov.target() != target {
ov.retarget(target, th.anim.fast(), Easing::EaseOut);
}
let a = ov.animate();
self.overlay.set(ov);
if a > 0.001 {
canvas.fill_round_rect(
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
style.corner_radius,
&Paint::fill(th.palette.text.scale_alpha(a)),
);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
if let Event::Pointer(p) = ev {
match p.kind {
PointerKind::Enter => {
if !self.hover {
self.hover = true;
ctx.mark_dirty();
}
true
}
PointerKind::Leave => {
if self.hover {
self.hover = false;
ctx.mark_dirty();
}
true
}
_ => false,
}
} else {
false
}
}
fn reset_interaction(&mut self) {
self.hover = false;
self.primed.set(false); }
}
fn action_cell(content: Element, weight: f32) -> Element {
Element::row()
.weight(weight)
.cross(Align::Center)
.padding_xy(TABLE_CELL_PAD_X, TABLE_CELL_PAD_Y)
.child(content)
}
fn action_header_cell(title: &str, weight: f32) -> Element {
Element::stack()
.weight(weight)
.padding_xy(TABLE_CELL_PAD_X, TABLE_HEADER_PAD_Y)
.child(
Element::label(title.to_string())
.font_size(13.0)
.font_weight(600)
.fg_role(Role::TextMuted)
.max_lines(1)
.truncate(Truncate::End)
.width_match()
.height(18),
)
}
pub(super) fn body_row(
disp: usize,
orig: usize,
cells: &[String],
weights: &[f32],
actions: Option<&ActionCol>,
) -> Element {
let mut tr = Element::row().width_match().cross(Align::Stretch);
if disp % 2 == 1 {
tr = tr.bg_role(Role::SurfaceAlt);
}
for (ci, cell) in cells.iter().enumerate() {
let w = weights.get(ci).copied().unwrap_or(1.0);
tr = tr
.child(Element::table_cell_pad(Element::label(cell.clone()).font_size(13.0)).weight(w));
}
if let Some(a) = actions {
tr = tr.child(action_cell((a.build)(orig), a.weight));
}
tr.widget = Box::new(HoverRow::new());
Element::col()
.width_match()
.child(tr)
.child(Element::divider())
}
fn clear_children(tree: &mut crate::core::Tree, id: crate::core::NodeId) {
let old: Vec<_> = tree.get(id).map(|n| n.children.clone()).unwrap_or_default();
for c in old {
tree.remove(c);
}
if let Some(n) = tree.get_mut(id) {
n.children.clear();
}
}
pub(super) struct SortableHeader {
columns: Vec<(String, f32)>,
sort: SortState,
on_sort: Option<OnSort>,
style: SortStyle,
actions: Option<ActionCol>,
built: bool,
last_version: u64,
}
impl SortableHeader {
pub(super) fn new(
columns: Vec<(String, f32)>,
sort: SortState,
on_sort: Option<OnSort>,
) -> Self {
Self {
last_version: sort.version(),
columns,
sort,
on_sort,
style: SortStyle::default(),
actions: None,
built: false,
}
}
pub(super) fn set_style(&mut self, style: SortStyle) {
self.style = style;
}
pub(super) fn set_actions(&mut self, actions: ActionCol) {
self.actions = Some(actions);
self.built = false;
}
}
impl Widget for SortableHeader {
fn on_update(&mut self, ctx: &mut EventCtx) {
let ver = self.sort.version();
if self.built && ver == self.last_version {
return;
}
self.built = true;
self.last_version = ver;
let rs = resolve_sort_style(&self.style);
let self_id = ctx.id();
let tree = ctx.tree_mut();
clear_children(tree, self_id);
for (ci, (title, w)) in self.columns.iter().enumerate() {
let mut el = header_cell(ci, title, *w, self.sort, self.on_sort.clone(), &rs);
el.width = Dimension::Weight(*w);
let id = el.build(tree);
tree.add_child(self_id, id);
}
if let Some(a) = &self.actions {
let mut el = action_header_cell(&a.title, a.weight);
el.width = Dimension::Weight(a.weight);
let id = el.build(tree);
tree.add_child(self_id, id);
}
}
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::ZERO
}
fn paint(
&self,
_bounds: Rect,
_content: Rect,
_focused: bool,
_enabled: bool,
_canvas: &mut dyn Canvas,
_style: &Style,
) {
}
fn on_event(&mut self, _ctx: &mut EventCtx, _ev: &Event) -> bool {
false
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub(super) struct SortableBody {
rows: Vec<Vec<String>>,
weights: Vec<f32>,
sort: SortState,
actions: Option<ActionCol>,
force: bool,
last_version: u64,
}
impl SortableBody {
pub(super) fn new(rows: Vec<Vec<String>>, weights: Vec<f32>, sort: SortState) -> Self {
Self {
last_version: sort.version(),
rows,
weights,
sort,
actions: None,
force: false,
}
}
pub(super) fn set_actions(&mut self, actions: ActionCol) {
self.actions = Some(actions);
self.force = true;
}
}
impl Widget for SortableBody {
fn on_update(&mut self, ctx: &mut EventCtx) {
let ver = self.sort.version();
if !self.force && ver == self.last_version {
return;
}
self.force = false;
self.last_version = ver;
let self_id = ctx.id();
let tree = ctx.tree_mut();
clear_children(tree, self_id);
let order = sorted_order(&self.rows, self.sort.get());
for (disp, &ri) in order.iter().enumerate() {
let el = body_row(
disp,
ri,
&self.rows[ri],
&self.weights,
self.actions.as_ref(),
);
let id = el.build(tree);
tree.add_child(self_id, id);
}
}
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::ZERO
}
fn paint(
&self,
_bounds: Rect,
_content: Rect,
_focused: bool,
_enabled: bool,
_canvas: &mut dyn Canvas,
_style: &Style,
) {
}
fn on_event(&mut self, _ctx: &mut EventCtx, _ev: &Event) -> bool {
false
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub(super) struct PagedBody {
rows: Signal<Vec<Vec<String>>>,
weights: Vec<f32>,
actions: Option<ActionCol>,
force: bool,
last_version: u64,
}
impl PagedBody {
pub(super) fn new(rows: Signal<Vec<Vec<String>>>, weights: Vec<f32>) -> Self {
Self {
last_version: rows.version(),
rows,
weights,
actions: None,
force: false,
}
}
pub(super) fn set_actions(&mut self, actions: ActionCol) {
self.actions = Some(actions);
self.force = true;
}
}
impl Widget for PagedBody {
fn on_update(&mut self, ctx: &mut EventCtx) {
let ver = self.rows.version();
if !self.force && ver == self.last_version {
return;
}
self.force = false;
self.last_version = ver;
let self_id = ctx.id();
let tree = ctx.tree_mut();
clear_children(tree, self_id);
let data = self.rows.get();
for (disp, row) in data.iter().enumerate() {
let el = body_row(disp, disp, row, &self.weights, self.actions.as_ref());
let id = el.build(tree);
tree.add_child(self_id, id);
}
}
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::ZERO
}
fn paint(
&self,
_bounds: Rect,
_content: Rect,
_focused: bool,
_enabled: bool,
_canvas: &mut dyn Canvas,
_style: &Style,
) {
}
fn on_event(&mut self, _ctx: &mut EventCtx, _ev: &Event) -> bool {
false
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
const SELECT_COL_W: i32 = 40;
const SEL_BOX: i32 = 18;
const ROW_SELECTED_A: f32 = 0.12;
fn select_cell(row_sel: Signal<bool>) -> Element {
let pad = (SELECT_COL_W - SEL_BOX) / 2;
Element::row()
.width(SELECT_COL_W)
.cross(Align::Center)
.padding_xy(pad, 0)
.child(Element::checkbox("", row_sel).width(SEL_BOX))
}
pub(super) fn select_body_row(
disp: usize,
orig: usize,
cells: &[String],
weights: &[f32],
row_sel: Signal<bool>,
actions: Option<&ActionCol>,
) -> Element {
let mut tr = Element::row().width_match().cross(Align::Stretch);
if disp % 2 == 1 {
tr = tr.bg_role(Role::SurfaceAlt);
}
tr = tr.child(select_cell(row_sel));
for (ci, cell) in cells.iter().enumerate() {
let w = weights.get(ci).copied().unwrap_or(1.0);
tr = tr
.child(Element::table_cell_pad(Element::label(cell.clone()).font_size(13.0)).weight(w));
}
if let Some(a) = actions {
tr = tr.child(action_cell((a.build)(orig), a.weight));
}
tr.widget = Box::new(SelectableRow::new(row_sel));
Element::col()
.width_match()
.child(tr)
.child(Element::divider())
}
pub(super) struct SelectableRow {
sel: Signal<bool>,
hover: bool,
overlay: Cell<Transition<f32>>,
primed: Cell<bool>,
}
impl SelectableRow {
pub(super) fn new(sel: Signal<bool>) -> Self {
Self {
sel,
hover: false,
overlay: Cell::new(Transition::new(0.0)),
primed: Cell::new(false),
}
}
}
impl Widget for SelectableRow {
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
if enabled && self.sel.get() {
canvas.fill_round_rect(
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
style.corner_radius,
&Paint::fill(th.palette.accent.scale_alpha(ROW_SELECTED_A)),
);
}
let target = if enabled && self.hover {
ROW_HOVER_A
} else {
0.0
};
let mut ov = self.overlay.get();
if !self.primed.get() {
ov = Transition::new(target);
self.primed.set(true);
} else if ov.target() != target {
ov.retarget(target, th.anim.fast(), Easing::EaseOut);
}
let a = ov.animate();
self.overlay.set(ov);
if a > 0.001 {
canvas.fill_round_rect(
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
style.corner_radius,
&Paint::fill(th.palette.text.scale_alpha(a)),
);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
if let Event::Pointer(p) = ev {
match p.kind {
PointerKind::Enter => {
if !self.hover {
self.hover = true;
ctx.mark_dirty();
}
true
}
PointerKind::Leave => {
if self.hover {
self.hover = false;
ctx.mark_dirty();
}
true
}
_ => false,
}
} else {
false
}
}
fn reset_interaction(&mut self) {
self.hover = false;
self.primed.set(false);
}
}
pub(super) struct SelectAllCheck {
sel: Vec<Signal<bool>>,
}
impl SelectAllCheck {
pub(super) fn new(sel: Vec<Signal<bool>>) -> Self {
Self { sel }
}
fn all(&self) -> bool {
!self.sel.is_empty() && self.sel.iter().all(|s| s.get())
}
fn toggle_all(&self, ctx: &mut EventCtx) {
let target = !self.all();
for s in &self.sel {
s.set(target);
}
ctx.mark_dirty_all(); }
}
impl Widget for SelectAllCheck {
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
_style: &Style,
) {
let th = crate::theme::current();
let (p, tg) = (&th.palette, &th.toggle);
let n = self.sel.iter().filter(|s| s.get()).count();
let all = n > 0 && n == self.sel.len();
let some = n > 0 && !all;
let box_x = bounds.x as f32 + ((SELECT_COL_W - SEL_BOX) / 2) as f32;
let box_y = bounds.y as f32 + ((bounds.h - SEL_BOX) / 2) as f32;
let sz = SEL_BOX as f32;
let radius = 4.0;
let accent = if enabled { tg.accent(p) } else { p.track };
let filled = all || some;
canvas.fill_round_rect(
box_x,
box_y,
sz,
sz,
radius,
&Paint::fill(if filled { accent } else { tg.knob(p) }),
);
if !filled {
canvas.stroke_round_rect(box_x, box_y, sz, sz, radius, 1.5, &Paint::fill(tg.track(p)));
}
let mark = Paint::fill(p.on_accent);
let s = sz / 18.0; if all {
canvas.draw_line(
box_x + 4.0 * s,
box_y + 9.0 * s,
box_x + 8.0 * s,
box_y + 13.0 * s,
2.0,
&mark,
);
canvas.draw_line(
box_x + 8.0 * s,
box_y + 13.0 * s,
box_x + 14.0 * s,
box_y + 5.0 * s,
2.0,
&mark,
);
} else if some {
canvas.draw_line(
box_x + 4.5 * s,
box_y + 9.0 * s,
box_x + 13.5 * s,
box_y + 9.0 * s,
2.0,
&mark,
);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) if p.kind == PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.toggle_all(ctx);
}
true
}
Event::Pointer(p) if p.kind == PointerKind::Down => {
ctx.request_focus();
true
}
Event::Key(k) if k.pressed && (k.key == Key::Space || k.key == Key::Enter) => {
self.toggle_all(ctx);
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
}
pub(super) struct SelectableBody {
rows: Vec<Vec<String>>,
weights: Vec<f32>,
sel: Vec<Signal<bool>>,
sort: SortState,
actions: Option<ActionCol>,
built: bool,
last_version: u64,
}
impl SelectableBody {
pub(super) fn new(
rows: Vec<Vec<String>>,
weights: Vec<f32>,
sel: Vec<Signal<bool>>,
sort: SortState,
) -> Self {
Self {
last_version: sort.version(),
rows,
weights,
sel,
sort,
actions: None,
built: false,
}
}
pub(super) fn set_actions(&mut self, actions: ActionCol) {
self.actions = Some(actions);
self.built = false;
}
}
impl Widget for SelectableBody {
fn on_update(&mut self, ctx: &mut EventCtx) {
let ver = self.sort.version();
if self.built && ver == self.last_version {
return;
}
self.built = true;
self.last_version = ver;
let self_id = ctx.id();
let tree = ctx.tree_mut();
clear_children(tree, self_id);
let order = sorted_order(&self.rows, self.sort.get());
for (disp, &ri) in order.iter().enumerate() {
let Some(&row_sel) = self.sel.get(ri) else {
continue;
};
let el = select_body_row(
disp,
ri,
&self.rows[ri],
&self.weights,
row_sel,
self.actions.as_ref(),
);
let id = el.build(tree);
tree.add_child(self_id, id);
}
}
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::ZERO
}
fn paint(
&self,
_bounds: Rect,
_content: Rect,
_focused: bool,
_enabled: bool,
_canvas: &mut dyn Canvas,
_style: &Style,
) {
}
fn on_event(&mut self, _ctx: &mut EventCtx, _ev: &Event) -> bool {
false
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub(super) const fn select_col_w() -> i32 {
SELECT_COL_W
}
pub(super) fn action_col(
title: String,
weight: f32,
build: impl Fn(usize) -> Element + 'static,
) -> ActionCol {
ActionCol {
title,
weight,
build: Rc::new(build),
}
}
pub(super) fn set_header_actions(el: &mut Element, ac: &ActionCol) -> bool {
if let Some(a) = el.widget.as_any_mut() {
if let Some(h) = a.downcast_mut::<SortableHeader>() {
h.set_actions(ac.clone());
return true;
}
}
false
}
pub(super) fn set_body_actions(el: &mut Element, ac: &ActionCol) -> bool {
let Some(a) = el.widget.as_any_mut() else {
return false;
};
if let Some(b) = a.downcast_mut::<SortableBody>() {
b.set_actions(ac.clone());
return true;
}
if let Some(b) = a.downcast_mut::<PagedBody>() {
b.set_actions(ac.clone());
return true;
}
if let Some(b) = a.downcast_mut::<SelectableBody>() {
b.set_actions(ac.clone());
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Tree;
use crate::event::{MouseButton, PointerEvent, PointerKind};
use crate::geometry::Point;
use crate::signal::signal;
fn rows(data: &[&[&str]]) -> Vec<Vec<String>> {
data.iter()
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect()
}
fn col_values(rows: &[Vec<String>], order: &[usize], col: usize) -> Vec<String> {
order.iter().map(|&i| rows[i][col].clone()).collect()
}
#[test]
fn sorted_order_numeric_column_compares_as_numbers() {
let r = rows(&[&["a", "1280"], &["b", "3"], &["c", "20480"], &["d", "12"]]);
let asc = sorted_order(&r, Some((1, SortOrder::Asc)));
assert_eq!(col_values(&r, &asc, 1), ["3", "12", "1280", "20480"]);
let desc = sorted_order(&r, Some((1, SortOrder::Desc)));
assert_eq!(col_values(&r, &desc, 1), ["20480", "1280", "12", "3"]);
}
#[test]
fn sorted_order_string_column_lexicographic() {
let r = rows(&[&["banana"], &["apple"], &["cherry"]]);
let asc = sorted_order(&r, Some((0, SortOrder::Asc)));
assert_eq!(col_values(&r, &asc, 0), ["apple", "banana", "cherry"]);
}
#[test]
fn sorted_order_none_keeps_original_and_is_stable() {
let r = rows(&[&["x", "5"], &["y", "5"], &["z", "5"]]);
assert_eq!(sorted_order(&r, None), [0, 1, 2]);
assert_eq!(sorted_order(&r, Some((1, SortOrder::Asc))), [0, 1, 2]);
}
#[test]
fn next_sort_cycles_none_asc_desc_none() {
assert_eq!(next_sort(None, 0), Some((0, SortOrder::Asc)));
assert_eq!(
next_sort(Some((0, SortOrder::Asc)), 0),
Some((0, SortOrder::Desc))
);
assert_eq!(next_sort(Some((0, SortOrder::Desc)), 0), None);
assert_eq!(
next_sort(Some((0, SortOrder::Desc)), 1),
Some((1, SortOrder::Asc))
);
}
fn layout(el: Element) -> Tree {
let mut tree = Tree::new();
let root = el.build(&mut tree);
tree.root = Some(root);
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
tree
}
fn click(tree: &mut Tree, at: Point) {
let mut hover = None;
let mut capture = None;
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Down, at, MouseButton::Left),
&mut hover,
&mut capture,
);
tree.dispatch_pointer(
PointerEvent::single(PointerKind::Up, at, MouseButton::Left),
&mut hover,
&mut capture,
);
}
#[test]
fn clicking_header_advances_sort_signal() {
let sort = signal(None);
let mut tree = layout(
Element::table_sortable(
vec![("名称", 2.0), ("大小", 1.0)],
vec![vec!["a", "2"], vec!["b", "1"]],
sort,
)
.width(400)
.height(300),
);
click(&mut tree, Point::new(40, 18));
assert_eq!(sort.get(), Some((0, SortOrder::Asc)), "首次点击→升序");
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
click(&mut tree, Point::new(40, 18));
assert_eq!(sort.get(), Some((0, SortOrder::Desc)), "再点同列→降序");
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
click(&mut tree, Point::new(40, 18));
assert_eq!(sort.get(), None, "三点同列→取消排序");
}
#[test]
fn body_rebuilds_row_count_after_sort_change() {
let sort = signal(Some((0usize, SortOrder::Asc)));
let mut tree = layout(
Element::table_sortable(
vec![("名称", 1.0)],
vec![vec!["c"], vec!["a"], vec!["b"]],
sort,
)
.width(400)
.height(300),
);
sort.set(Some((0, SortOrder::Desc)));
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
}
#[test]
fn resolve_sort_style_falls_back_to_theme_defaults() {
let rs = resolve_sort_style(&SortStyle::default());
assert_eq!(rs.asc, "\u{25B2}");
assert_eq!(rs.desc, "\u{25BC}");
assert_eq!(rs.size, 10.0);
assert_eq!(rs.slot, 14);
assert_eq!(rs.gap, 2);
assert!(!rs.leading);
assert!(
rs.color.is_none(),
"默认颜色应为 None(用 TextMuted 角色随主题)"
);
}
#[test]
fn resolve_sort_style_instance_override_wins() {
let ov = SortStyle {
asc: Some("↑".into()),
desc: Some("↓".into()),
size: Some(14.0),
slot: Some(20),
gap: Some(6),
leading: Some(true),
color: Some(Color::hex(0xFF0000)),
};
let rs = resolve_sort_style(&ov);
assert_eq!(rs.asc, "↑");
assert_eq!(rs.desc, "↓");
assert_eq!(rs.size, 14.0);
assert_eq!(rs.slot, 20);
assert_eq!(rs.gap, 6);
assert!(rs.leading);
assert_eq!(rs.color, Some(Color::hex(0xFF0000)));
}
#[test]
fn sort_indicator_builder_reaches_header_widget() {
let sort = signal(Some((0usize, SortOrder::Asc)));
let el = Element::table_sortable(
vec![("名称", 1.0), ("大小", 1.0)],
vec![vec!["a", "2"], vec!["b", "1"]],
sort,
)
.sort_indicator(SortStyle {
asc: Some("↑".into()),
leading: Some(true),
..Default::default()
})
.width(400)
.height(300);
let _tree = layout(el); }
#[test]
fn reactive_table_scrolls_on_wheel() {
let sort = signal(None);
let rows: Vec<Vec<String>> = (0..20).map(|i| vec![format!("r{i}")]).collect();
let mut tree = layout(
Element::table_sortable(vec![("列", 1.0)], rows, sort)
.width(400)
.height(200),
);
let root = tree.root.unwrap();
let scroll = *tree.get(root).unwrap().children.last().unwrap();
assert!(
tree.scroll_range(scroll).is_some_and(|(_, max)| max > 0),
"20 行应溢出,正文应可滚"
);
let mut h = None;
let mut cap = None;
tree.dispatch_pointer(
PointerEvent::single(
PointerKind::Wheel(-120),
Point::new(80, 120),
MouseButton::Left,
),
&mut h,
&mut cap,
);
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
assert!(
tree.get(scroll).unwrap().scroll_y > 0,
"滚轮应滚动响应式表格正文(ScrollWidget 未被替换)"
);
}
#[test]
fn clicking_row_checkbox_toggles_that_rows_selection() {
let sort = signal(None);
let sel: Vec<Signal<bool>> = (0..2).map(|_| signal(false)).collect();
let mut tree = layout(
Element::table_selectable(
vec![("名称", 2.0), ("大小", 1.0)],
vec![vec!["a", "2"], vec!["b", "1"]],
sel.clone(),
sort,
)
.width(400)
.height(300),
);
assert!(!sel[0].get());
click(&mut tree, Point::new(20, 58));
assert!(sel[0].get(), "点击首行复选框应选中原始行 0");
assert!(!sel[1].get(), "不应影响其它行");
}
#[test]
fn select_all_selects_then_clears() {
let sort = signal(None);
let sel: Vec<Signal<bool>> = (0..3).map(|_| signal(false)).collect();
let mut tree = layout(
Element::table_selectable(
vec![("名称", 1.0)],
vec![vec!["a"], vec!["b"], vec!["c"]],
sel.clone(),
sort,
)
.width(400)
.height(300),
);
click(&mut tree, Point::new(20, 19));
assert!(sel.iter().all(|s| s.get()), "点全选应选中所有行");
click(&mut tree, Point::new(20, 19));
assert!(sel.iter().all(|s| !s.get()), "再点全选应清空");
}
#[test]
fn selection_tracks_original_row_across_sort() {
let sort = signal(None);
let sel: Vec<Signal<bool>> = (0..3).map(|_| signal(false)).collect();
let mut tree = layout(
Element::table_selectable(
vec![("v", 1.0)],
vec![vec!["3"], vec!["1"], vec!["2"]], sel.clone(),
sort,
)
.width(400)
.height(300),
);
sel[0].set(true); sort.set(Some((0, SortOrder::Asc))); tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
assert!(
sel[0].get(),
"排序后原始行 0 仍选中(选择按身份跟随,不随位置)"
);
assert!(!sel[1].get() && !sel[2].get());
}
#[test]
fn hovering_body_row_requests_repaint() {
let sort = signal(Some((0usize, SortOrder::Asc)));
let mut tree = layout(
Element::table_sortable(
vec![("名称", 2.0), ("大小", 1.0)],
vec![vec!["a", "2"], vec!["b", "1"]],
sort,
)
.width(400)
.height(300),
);
let mut hover = None;
let mut capture = None;
let res = tree.dispatch_pointer(
PointerEvent::single(PointerKind::Move, Point::new(80, 70), MouseButton::Left),
&mut hover,
&mut capture,
);
assert!(res.repaint, "悬停正文行应触发重绘(整行高亮淡入)");
assert!(hover.is_some(), "应有命中的悬停节点");
}
#[test]
fn header_cells_keep_weighted_width_after_rebuild() {
let sort = signal(None);
let mut tree = layout(
Element::table_sortable(
vec![("A", 2.0), ("B", 1.0), ("C", 1.5)],
vec![vec!["a", "b", "c"]],
sort,
)
.width(400)
.height(300),
);
click(&mut tree, Point::new(40, 18)); tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
let root = tree.root.unwrap();
let header_id = tree.get(root).unwrap().children[0];
let cells = tree.get(header_id).unwrap().children.clone();
assert_eq!(cells.len(), 3, "重建后表头应仍有 3 个单元格");
let widths: Vec<Dimension> = cells.iter().map(|&c| tree.get(c).unwrap().width).collect();
assert_eq!(
widths,
vec![
Dimension::Weight(2.0),
Dimension::Weight(1.0),
Dimension::Weight(1.5)
],
"重建后各表头单元格应保持比例宽度"
);
}
fn abs_center(tree: &Tree, target: crate::core::NodeId) -> Option<Point> {
fn walk(
tree: &Tree,
id: crate::core::NodeId,
ox: i32,
oy: i32,
target: crate::core::NodeId,
) -> Option<Point> {
let n = tree.get(id)?;
let (x, y) = (ox + n.bounds.x, oy + n.bounds.y);
if id == target {
return Some(Point::new(x + n.bounds.w / 2, y + n.bounds.h / 2));
}
for &c in &n.children {
if let Some(p) = walk(tree, c, x, y, target) {
return Some(p);
}
}
None
}
walk(tree, tree.root?, 0, 0, target)
}
#[test]
fn actions_column_adds_header_and_body_cells() {
let sort = signal(None);
let tree = layout(
Element::table_sortable(
vec![("A", 1.0), ("B", 1.0)],
vec![vec!["a", "b"], vec!["c", "d"]],
sort,
)
.actions("操作", 1.0, |_row| Element::label("·"))
.width(400)
.height(300),
);
let root = tree.root.unwrap();
let header = tree.get(root).unwrap().children[0];
assert_eq!(
tree.get(header).unwrap().children.len(),
3,
"表头应为 2 数据列 + 1 操作列"
);
let scroll = *tree.get(root).unwrap().children.last().unwrap();
let body = tree.get(scroll).unwrap().children[0]; let first_row = tree.get(body).unwrap().children[0]; let tr = tree.get(first_row).unwrap().children[0];
assert_eq!(
tree.get(tr).unwrap().children.len(),
3,
"正文行应为 2 数据单元格 + 1 操作单元格"
);
}
#[test]
fn action_button_reports_original_row_after_sort() {
use std::cell::Cell as StdCell;
use std::rc::Rc;
let sort = signal(Some((0usize, SortOrder::Asc)));
let seen: Rc<StdCell<Option<usize>>> = Rc::new(StdCell::new(None));
let seen_c = seen.clone();
let mut tree = layout(
Element::table_sortable(
vec![("v", 1.0)],
vec![vec!["3"], vec!["1"], vec!["2"]], sort,
)
.actions("op", 1.0, move |row| {
let s = seen_c.clone();
Element::row()
.width(120)
.height(24)
.clickable()
.on_click(move |_| s.set(Some(row)))
.child(Element::label("x"))
})
.width(400)
.height(300),
);
let root = tree.root.unwrap();
let scroll = *tree.get(root).unwrap().children.last().unwrap();
let body = tree.get(scroll).unwrap().children[0];
let first_row = tree.get(body).unwrap().children[0];
let tr = tree.get(first_row).unwrap().children[0];
let action = *tree.get(tr).unwrap().children.last().unwrap();
let clickable = tree.get(action).unwrap().children[0];
let at = abs_center(&tree, clickable).unwrap();
click(&mut tree, at);
assert_eq!(
seen.get(),
Some(1),
"点首个显示行的操作按钮应回报其原始行下标 1"
);
}
#[test]
fn server_mode_click_updates_sort_and_fires_callback() {
use std::cell::Cell as StdCell;
use std::rc::Rc;
let sort = signal(None);
let rows = signal(vec![vec!["a".to_string(), "2".to_string()]]);
let seen: Rc<StdCell<Option<(usize, SortOrder)>>> = Rc::new(StdCell::new(None));
let fired = Rc::new(StdCell::new(0u32));
let (seen_c, fired_c) = (seen.clone(), fired.clone());
let mut tree = layout(
Element::table_sortable_server(
vec![("名称", 2.0), ("大小", 1.0)],
rows,
sort,
move |_ctx, new_sort| {
seen_c.set(new_sort);
fired_c.set(fired_c.get() + 1);
},
)
.width(400)
.height(300),
);
click(&mut tree, Point::new(40, 18));
assert_eq!(sort.get(), Some((0, SortOrder::Asc)), "sort 信号更新");
assert_eq!(fired.get(), 1, "on_sort 回调被触发一次");
assert_eq!(seen.get(), Some((0, SortOrder::Asc)), "回调收到新排序意图");
}
#[test]
fn server_mode_body_renders_backend_order_without_internal_sort() {
let sort = signal(Some((0usize, SortOrder::Asc)));
let rows = signal(vec![
vec!["c".to_string()],
vec!["b".to_string()],
vec!["a".to_string()],
]);
let mut tree = layout(
Element::table_sortable_server(vec![("名称", 1.0)], rows, sort, |_, _| {})
.width(400)
.height(300),
);
rows.set(vec![vec!["z".to_string()], vec!["y".to_string()]]);
tree.layout_root(Size::new(400, 300), &mut crate::text::NullTextEngine);
}
}