1mod layout;
7mod model;
8mod paint;
9#[cfg(test)]
10mod tests;
11
12use std::sync::Arc;
13
14use crate::event::{Event, MouseButton, MouseKind};
15use crate::geometry::{Rect, Size, clamp_u16};
16use crate::keymap::{Key, KeyChord, Modifiers};
17use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
18
19use super::click::Click;
20use super::row::LEAD;
21use super::row_menu::{self, RowAnchor, RowMenuItems};
22use super::row_pointer::{self, PickedRows, Picking, RowDrop, Spot};
23use super::rows::{self, RowScroll, Step};
24use super::select_box;
25use super::{ContextItem, IndexMessage};
26use layout::Placed;
27pub use model::{Column, ColumnWidth, SortDirection, TableCell, TableRow};
28use paint::RowPaint;
29
30const COLUMN_GAP: u16 = 2;
32
33const MARK: u16 = 2;
35
36type SortMessage<Msg> = Box<dyn Fn(usize, SortDirection) -> Msg>;
38
39pub struct Table<Msg> {
78 columns: Vec<Column>,
79 rows: Arc<[TableRow]>,
80 selected: Option<usize>,
81 checked: Option<Vec<bool>>,
82 sort: Option<(usize, SortDirection)>,
83 empty: String,
84 on_select: Option<IndexMessage<Msg>>,
85 on_activate: Option<IndexMessage<Msg>>,
86 on_toggle: Option<IndexMessage<Msg>>,
87 on_sort: Option<SortMessage<Msg>>,
88 menu: Option<RowMenuItems<Msg>>,
89 menu_on_activate: bool,
90 picking: Picking<Msg>,
91 wrap: bool,
92}
93
94#[derive(Debug, Default)]
95struct TableMemory {
96 fit: Option<(Arc<[TableRow]>, Vec<u16>)>,
98 column_offset: usize,
100 max_column_offset: usize,
102 more: bool,
104 placed: Vec<Placed>,
105}
106
107impl<Msg: 'static> Table<Msg> {
108 #[must_use]
110 pub fn new(columns: impl IntoIterator<Item = Column>, rows: impl Into<Arc<[TableRow]>>) -> Self {
111 Self {
112 columns: columns.into_iter().collect(),
113 rows: rows.into(),
114 selected: None,
115 checked: None,
116 sort: None,
117 empty: String::new(),
118 on_select: None,
119 on_activate: None,
120 on_toggle: None,
121 on_sort: None,
122 menu: None,
123 menu_on_activate: false,
124 picking: Picking::default(),
125 wrap: false,
126 }
127 }
128
129 #[must_use]
133 pub fn wrap(mut self, wrap: bool) -> Self {
134 self.wrap = wrap;
135 self
136 }
137
138 #[must_use]
140 pub fn selected(mut self, index: Option<usize>) -> Self {
141 self.selected = index;
142 self
143 }
144
145 #[must_use]
147 pub fn checked(mut self, checked: Vec<bool>) -> Self {
148 self.checked = Some(checked);
149 self
150 }
151
152 #[must_use]
155 pub fn sort(mut self, column: usize, direction: SortDirection) -> Self {
156 self.sort = Some((column, direction));
157 self
158 }
159
160 #[must_use]
162 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
163 self.empty = text.into();
164 self
165 }
166
167 #[must_use]
169 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
170 self.on_select = Some(Box::new(message));
171 self
172 }
173
174 #[must_use]
176 pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
177 self.on_activate = Some(Box::new(message));
178 self
179 }
180
181 #[must_use]
183 pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
184 self.on_toggle = Some(Box::new(message));
185 self
186 }
187
188 #[must_use]
191 pub fn on_sort(mut self, message: impl Fn(usize, SortDirection) -> Msg + 'static) -> Self {
192 self.on_sort = Some(Box::new(message));
193 self
194 }
195
196 #[must_use]
205 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
206 self.menu = Some(Box::new(items));
207 self
208 }
209
210 #[must_use]
219 pub fn menu_on_activate(mut self, on: bool) -> Self {
220 self.menu_on_activate = on;
221 self
222 }
223
224 #[must_use]
228 pub fn activate_on(mut self, click: Click) -> Self {
229 self.picking.activate_on = click;
230 self
231 }
232
233 #[must_use]
244 pub fn multi_select(mut self, selected: &[usize], message: impl Fn(Vec<usize>) -> Msg + 'static) -> Self {
245 self.picking.chosen = selected.to_vec();
246 self.picking.on_choose = Some(Box::new(message));
247 self
248 }
249
250 #[must_use]
255 pub fn box_select(mut self, on: bool) -> Self {
256 self.picking.box_select = on;
257 self
258 }
259
260 #[must_use]
270 pub fn droppable(
271 mut self,
272 message: impl Fn(RowDrop) -> Msg + 'static,
273 accepts: impl Fn(usize) -> bool + 'static,
274 ) -> Self {
275 self.picking.dropping = Some((Box::new(message), Box::new(accepts)));
276 self
277 }
278
279 #[must_use]
283 pub fn on_copy_drop(mut self, message: impl Fn(RowDrop) -> Msg + 'static) -> Self {
284 self.picking.copy_drop = Some(Box::new(message));
285 self
286 }
287
288 fn rows_width(area: Rect, overflows: bool) -> u16 {
290 area.width.saturating_sub(u16::from(overflows))
291 }
292
293 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
297 let area = cx.area();
298 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
299 let total = self.rows.len();
300 let visible = usize::from(body.height);
301 let overflows = total > visible;
302 row_menu::event(
303 cx,
304 event,
305 self.menu.as_ref(),
306 total,
307 |cx, x, y| {
308 if y < body.y || x >= area.x + i32::from(Self::rows_width(area, overflows)) {
309 return None;
310 }
311 let offset = cx.memory::<RowScroll>().offset;
312 let row = usize::try_from(y - body.y).ok().map(|row| offset + row).filter(|row| *row < total)?;
313 let checked = self.checked.as_ref().is_some_and(|checked| checked.get(row).copied().unwrap_or(false));
314 if self.picking.is_multi() && !self.picking.is_chosen(row) {
315 self.picking.select_one(cx, self, row);
316 } else if !checked && !self.picking.is_multi() {
317 self.select(cx, row);
318 }
319 Some(RowAnchor { row, at: Rect::new(x, y, 1, 1), keyboard: false })
320 },
321 |cx| self.selected_anchor(cx),
322 )
323 }
324
325 fn selected_anchor(&self, cx: &mut EventCx<'_, Msg>) -> Option<RowAnchor> {
328 let area = cx.area();
329 let body_y = area.y + 1;
330 let total = self.rows.len();
331 let visible = usize::from(area.height.saturating_sub(1));
332 let overflows = total > visible;
333 let row = self.selected.filter(|row| *row < total)?;
334 let memory = cx.memory::<RowScroll>();
335 if row < memory.offset {
336 memory.offset = row;
337 } else if visible > 0 && row >= memory.offset + visible {
338 memory.offset = row + 1 - visible;
339 }
340 let y = body_y + i32::try_from(row - memory.offset).unwrap_or(0);
341 let at = Rect::new(area.x, y, Self::rows_width(area, overflows), 1);
342 Some(RowAnchor { row, at, keyboard: true })
343 }
344
345 fn activation_is_menu(&self) -> bool {
347 self.menu_on_activate && self.menu.is_some()
348 }
349
350 fn lead(&self) -> u16 {
351 LEAD + if self.checked.is_some() { MARK } else { 0 }
352 }
353
354 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
355 if Some(index) != self.selected
356 && let Some(message) = &self.on_select
357 {
358 cx.emit(message(index));
359 }
360 }
361
362 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
363 let Some(message) = &self.on_activate else {
364 return false;
365 };
366 cx.memory::<RowScroll>().flashed = Some(index);
367 cx.flash();
368 cx.emit(message(index));
369 true
370 }
371
372 fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
373 match (&self.checked, &self.on_toggle) {
374 (Some(_), Some(message)) => {
375 cx.emit(message(index));
376 true
377 }
378 _ => false,
379 }
380 }
381
382 fn request_sort(&self, cx: &mut EventCx<'_, Msg>, column: usize, direction: SortDirection) -> bool {
383 match &self.on_sort {
384 Some(message) if self.columns.get(column).is_some_and(|c| c.sortable) => {
385 cx.emit(message(column, direction));
386 true
387 }
388 _ => false,
389 }
390 }
391
392 fn click_sort(&self, column: usize) -> SortDirection {
394 match self.sort {
395 Some((sorted, direction)) if sorted == column => direction.reversed(),
396 _ => SortDirection::Ascending,
397 }
398 }
399
400 fn scroll_columns(cx: &mut EventCx<'_, Msg>, forward: bool) -> bool {
403 let memory = cx.memory::<TableMemory>();
404 if memory.max_column_offset == 0 {
405 return false;
406 }
407 memory.column_offset = if forward {
408 (memory.column_offset + 1).min(memory.max_column_offset)
409 } else {
410 memory.column_offset.saturating_sub(1)
411 };
412 true
413 }
414
415 fn scroll_arrow_at(cx: &mut EventCx<'_, Msg>, area: Rect, x: i32) -> Option<bool> {
419 let memory = cx.memory::<TableMemory>();
420 if x == area.x && memory.column_offset > 0 {
421 Some(false)
422 } else if x == area.right() - 1 && memory.more {
423 Some(true)
424 } else {
425 None
426 }
427 }
428}
429
430impl<Msg: 'static> Widget<Msg> for Table<Msg> {
431 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
432 let rows = self.rows.len().max(1) + 1;
433 let widths =
434 self.columns.iter().fold(0u16, |sum, c| sum.saturating_add(c.title_width()).saturating_add(COLUMN_GAP));
435 Size::new(widths.saturating_add(self.lead() + 1), clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)))
436 .min(available)
437 }
438
439 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
440 if area.is_empty() {
441 return;
442 }
443 cx.register_hit(area);
444 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
445 let total = self.rows.len();
446 let visible = usize::from(body.height);
447 let overflows = total > visible;
448 let lead = self.lead();
449 let room = area.width.saturating_sub(lead + u16::from(overflows));
450
451 let (placed, column_offset, more) = {
452 let memory = cx.memory::<TableMemory>();
453 let widest = if self.columns.iter().any(|c| c.width == ColumnWidth::Fit) {
454 self.widest_cells(memory)
455 } else {
456 vec![0; self.columns.len()]
457 };
458 let (widths, overflow) = self.widths(&widest, room);
459 let arrow = if overflow { 2 - u16::from(overflows) } else { 0 };
463 let room = room.saturating_sub(arrow);
464 let max_offset = if overflow { Self::max_offset(&widths, room) } else { 0 };
465 memory.max_column_offset = max_offset;
466 memory.column_offset = memory.column_offset.min(max_offset);
467 let placed = Self::place(&widths, memory.column_offset, area.x + i32::from(lead), room);
468 let more =
469 placed.last().is_some_and(|last| last.column + 1 < widths.len() || last.width < widths[last.column]);
470 memory.placed.clone_from(&placed);
471 memory.more = more;
472 (placed, memory.column_offset, more)
473 };
474 self.paint_header(cx, area, &placed, column_offset, more);
475
476 if total == 0 {
477 let faint = cx.style("list-header", None, &[]).text();
478 let budget = area.width.saturating_sub(LEAD);
479 cx.text(area.x + i32::from(LEAD), body.y, &self.empty, faint, budget);
480 return;
481 }
482 let focused = cx.is_focused();
483 let pressed = cx.is_pressed();
484 let menu_row = row_menu::open_row(cx, self.menu.as_ref());
487 if menu_row.is_some() {
488 cx.request_overlay(area);
489 }
490 let offset = cx.memory::<RowScroll>().follow(self.selected, total, visible);
491 let row_width = Self::rows_width(area, overflows);
492 let rows_rect = Rect::new(area.x, body.y, row_width, body.height);
493 let target = row_pointer::dragged(cx).and_then(|((x, y), carried)| {
495 let index = offset + usize::try_from(y - body.y).ok()?;
496 (rows_rect.contains(x, y) && index < total && self.picking.takes_drop(&carried, index)).then_some(index)
497 });
498 for (row, index) in (offset..total).take(visible).enumerate() {
499 let rect = Rect::new(area.x, body.y + i32::try_from(row).unwrap_or(0), row_width, 1);
500 self.paint_row(cx, rect, index, &placed, RowPaint { focused, pressed, menu_row, target });
501 }
502 if let Some(drawn) = row_pointer::drawn_box(cx) {
503 select_box::paint(cx, drawn, rows_rect);
504 }
505 rows::paint_scrollbar(cx, body, total, offset, None);
506 }
507
508 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
509 row_menu::paint(cx, self.menu.as_ref(), anchor);
510 }
511
512 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
513 if self.menu_event(cx, event) {
514 return true;
515 }
516 let area = cx.area();
517 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
518 let total = self.rows.len();
519 match event {
520 Event::Key(key) => {
521 let page = usize::from(body.height);
522 let extend = |_: &mut EventCx<'_, Msg>, plain: &crate::event::KeyEvent| {
523 rows::SHIFT_STEPS
524 .contains(&plain.chord.key)
525 .then(|| Step::from_key(plain).and_then(|step| step.apply(self.selected, total, page)))
526 };
527 if self.picking.selection_key(cx, key, self, total, extend) {
528 return true;
529 }
530 if let Some(step) = Step::from_key(key) {
531 let Some(target) = step.apply_wrapping(self.selected, total, page, self.wrap) else {
532 return false;
533 };
534 if self.picking.is_multi() {
535 self.picking.select_one(cx, self, target);
536 } else {
537 self.select(cx, target);
538 }
539 return true;
540 }
541 if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
542 return Self::scroll_columns(cx, key.is_plain(Key::Right));
543 }
544 if key.is_plain(Key::Enter) {
545 if self.activation_is_menu() {
546 return self
547 .selected_anchor(cx)
548 .is_some_and(|anchor| row_menu::open_as_action(cx, self.menu.as_ref(), &anchor));
549 }
550 return self.selected.is_some_and(|index| self.activate(cx, index));
551 }
552 if key.is_plain(Key::Space) {
553 let Some(index) = self.selected else { return false };
554 return self.picking.toggle(cx, self, index) || self.toggle(cx, index) || self.activate(cx, index);
555 }
556 let shift_s = KeyChord { key: Key::Char('s'), mods: Modifiers { shift: true, ..Modifiers::default() } };
557 if self.on_sort.is_some() && (key.is_plain(Key::Char('s')) || key.chord == shift_s) {
558 return match (key.chord == shift_s, self.sort) {
559 (true, Some((column, direction))) => self.request_sort(cx, column, direction.reversed()),
560 (true, None) => false,
561 (false, current) => {
562 let start = current.map_or(0, |(column, _)| column + 1);
563 let count = self.columns.len();
564 let next = (0..count)
565 .map(|step| (start + step) % count.max(1))
566 .find(|i| self.columns[*i].sortable);
567 next.is_some_and(|column| self.request_sort(cx, column, SortDirection::Ascending))
568 }
569 };
570 }
571 false
572 }
573 Event::Mouse(mouse) => {
574 if rows::scroll_mouse(cx, mouse, body, total) {
575 return true;
576 }
577 if mouse.kind == MouseKind::Down(MouseButton::Left) {
578 if mouse.y == area.y {
579 if let Some(forward) = Self::scroll_arrow_at(cx, area, mouse.x) {
580 return Self::scroll_columns(cx, forward);
581 }
582 let placed = cx.memory::<TableMemory>().placed.clone();
583 let Some(place) = placed.iter().find(|place| Self::spans(place, mouse.x)) else {
584 return false;
585 };
586 return self.request_sort(cx, place.column, self.click_sort(place.column));
587 }
588 if self.checked.is_some()
589 && mouse.x < area.x + i32::from(LEAD + MARK)
590 && let Spot::Row(index) = self.spot(cx, mouse.x, mouse.y)
591 && self.toggle(cx, index)
592 {
593 return true;
594 }
595 }
596 self.picking.mouse(cx, mouse, self).unwrap_or(false)
597 }
598 _ => false,
599 }
600 }
601
602 fn focusable(&self) -> bool {
603 !self.rows.is_empty()
604 }
605}
606
607impl<Msg: 'static> PickedRows<Msg> for Table<Msg> {
608 fn spot(&self, cx: &mut EventCx<'_, Msg>, x: i32, y: i32) -> Spot {
609 let area = cx.area();
610 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
611 let total = self.rows.len();
612 let overflows = total > usize::from(body.height);
613 let rows = Rect::new(area.x, body.y, Self::rows_width(area, overflows), body.height);
614 if !rows.contains(x, y) {
615 return Spot::Outside;
616 }
617 let index = cx.memory::<RowScroll>().offset + usize::try_from(y - body.y).unwrap_or(0);
618 if index < total { Spot::Row(index) } else { Spot::Free }
619 }
620
621 fn covered(&self, cx: &mut EventCx<'_, Msg>, rect: Rect) -> Vec<usize> {
622 let area = cx.area();
623 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
624 let offset = cx.memory::<RowScroll>().offset;
625 let (top, bottom) = (rect.y.max(body.y), rect.bottom().min(body.bottom()));
626 (top..bottom)
627 .filter_map(|y| usize::try_from(y - body.y).ok())
628 .map(|row| offset + row)
629 .filter(|index| *index < self.rows.len())
630 .collect()
631 }
632
633 fn cursor(&self) -> Option<usize> {
634 self.selected
635 }
636
637 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
638 Table::select(self, cx, index);
639 }
640
641 fn open(&self, cx: &mut EventCx<'_, Msg>, index: usize, (x, y): (i32, i32)) {
642 if self.activation_is_menu() {
643 let anchor = RowAnchor { row: index, at: Rect::new(x, y, 1, 1), keyboard: false };
644 row_menu::open_as_action(cx, self.menu.as_ref(), &anchor);
645 } else {
646 self.activate(cx, index);
647 }
648 }
649}