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::row::LEAD;
20use super::row_menu::{self, RowAnchor, RowMenuItems};
21use super::rows::{self, RowScroll, Step};
22use super::{ContextItem, IndexMessage};
23use layout::Placed;
24pub use model::{Column, ColumnWidth, SortDirection, TableCell, TableRow};
25use paint::RowPaint;
26
27const COLUMN_GAP: u16 = 2;
29
30const MARK: u16 = 2;
32
33type SortMessage<Msg> = Box<dyn Fn(usize, SortDirection) -> Msg>;
35
36pub struct Table<Msg> {
61 columns: Vec<Column>,
62 rows: Arc<[TableRow]>,
63 selected: Option<usize>,
64 checked: Option<Vec<bool>>,
65 sort: Option<(usize, SortDirection)>,
66 empty: String,
67 on_select: Option<IndexMessage<Msg>>,
68 on_activate: Option<IndexMessage<Msg>>,
69 on_toggle: Option<IndexMessage<Msg>>,
70 on_sort: Option<SortMessage<Msg>>,
71 menu: Option<RowMenuItems<Msg>>,
72 menu_on_activate: bool,
73}
74
75#[derive(Debug, Default)]
76struct TableMemory {
77 fit: Option<(Arc<[TableRow]>, Vec<u16>)>,
79 column_offset: usize,
81 max_column_offset: usize,
83 more: bool,
85 placed: Vec<Placed>,
86}
87
88impl<Msg: 'static> Table<Msg> {
89 #[must_use]
91 pub fn new(columns: impl IntoIterator<Item = Column>, rows: impl Into<Arc<[TableRow]>>) -> Self {
92 Self {
93 columns: columns.into_iter().collect(),
94 rows: rows.into(),
95 selected: None,
96 checked: None,
97 sort: None,
98 empty: String::new(),
99 on_select: None,
100 on_activate: None,
101 on_toggle: None,
102 on_sort: None,
103 menu: None,
104 menu_on_activate: false,
105 }
106 }
107
108 #[must_use]
110 pub fn selected(mut self, index: Option<usize>) -> Self {
111 self.selected = index;
112 self
113 }
114
115 #[must_use]
117 pub fn checked(mut self, checked: Vec<bool>) -> Self {
118 self.checked = Some(checked);
119 self
120 }
121
122 #[must_use]
125 pub fn sort(mut self, column: usize, direction: SortDirection) -> Self {
126 self.sort = Some((column, direction));
127 self
128 }
129
130 #[must_use]
132 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
133 self.empty = text.into();
134 self
135 }
136
137 #[must_use]
139 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
140 self.on_select = Some(Box::new(message));
141 self
142 }
143
144 #[must_use]
146 pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
147 self.on_activate = Some(Box::new(message));
148 self
149 }
150
151 #[must_use]
153 pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
154 self.on_toggle = Some(Box::new(message));
155 self
156 }
157
158 #[must_use]
161 pub fn on_sort(mut self, message: impl Fn(usize, SortDirection) -> Msg + 'static) -> Self {
162 self.on_sort = Some(Box::new(message));
163 self
164 }
165
166 #[must_use]
175 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
176 self.menu = Some(Box::new(items));
177 self
178 }
179
180 #[must_use]
189 pub fn menu_on_activate(mut self, on: bool) -> Self {
190 self.menu_on_activate = on;
191 self
192 }
193
194 fn rows_width(area: Rect, overflows: bool) -> u16 {
196 area.width.saturating_sub(u16::from(overflows))
197 }
198
199 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
203 let area = cx.area();
204 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
205 let total = self.rows.len();
206 let visible = usize::from(body.height);
207 let overflows = total > visible;
208 row_menu::event(
209 cx,
210 event,
211 self.menu.as_ref(),
212 total,
213 |cx, x, y| {
214 if y < body.y || x >= area.x + i32::from(Self::rows_width(area, overflows)) {
215 return None;
216 }
217 let offset = cx.memory::<RowScroll>().offset;
218 let row = usize::try_from(y - body.y).ok().map(|row| offset + row).filter(|row| *row < total)?;
219 let checked = self.checked.as_ref().is_some_and(|checked| checked.get(row).copied().unwrap_or(false));
220 if !checked {
221 self.select(cx, row);
222 }
223 Some(RowAnchor { row, at: Rect::new(x, y, 1, 1), keyboard: false })
224 },
225 |cx| self.selected_anchor(cx),
226 )
227 }
228
229 fn selected_anchor(&self, cx: &mut EventCx<'_, Msg>) -> Option<RowAnchor> {
232 let area = cx.area();
233 let body_y = area.y + 1;
234 let total = self.rows.len();
235 let visible = usize::from(area.height.saturating_sub(1));
236 let overflows = total > visible;
237 let row = self.selected.filter(|row| *row < total)?;
238 let memory = cx.memory::<RowScroll>();
239 if row < memory.offset {
240 memory.offset = row;
241 } else if visible > 0 && row >= memory.offset + visible {
242 memory.offset = row + 1 - visible;
243 }
244 let y = body_y + i32::try_from(row - memory.offset).unwrap_or(0);
245 let at = Rect::new(area.x, y, Self::rows_width(area, overflows), 1);
246 Some(RowAnchor { row, at, keyboard: true })
247 }
248
249 fn activation_is_menu(&self) -> bool {
251 self.menu_on_activate && self.menu.is_some()
252 }
253
254 fn lead(&self) -> u16 {
255 LEAD + if self.checked.is_some() { MARK } else { 0 }
256 }
257
258 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
259 if Some(index) != self.selected
260 && let Some(message) = &self.on_select
261 {
262 cx.emit(message(index));
263 }
264 }
265
266 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
267 let Some(message) = &self.on_activate else {
268 return false;
269 };
270 cx.memory::<RowScroll>().flashed = Some(index);
271 cx.flash();
272 cx.emit(message(index));
273 true
274 }
275
276 fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
277 match (&self.checked, &self.on_toggle) {
278 (Some(_), Some(message)) => {
279 cx.emit(message(index));
280 true
281 }
282 _ => false,
283 }
284 }
285
286 fn request_sort(&self, cx: &mut EventCx<'_, Msg>, column: usize, direction: SortDirection) -> bool {
287 match &self.on_sort {
288 Some(message) if self.columns.get(column).is_some_and(|c| c.sortable) => {
289 cx.emit(message(column, direction));
290 true
291 }
292 _ => false,
293 }
294 }
295
296 fn click_sort(&self, column: usize) -> SortDirection {
298 match self.sort {
299 Some((sorted, direction)) if sorted == column => direction.reversed(),
300 _ => SortDirection::Ascending,
301 }
302 }
303
304 fn scroll_columns(cx: &mut EventCx<'_, Msg>, forward: bool) -> bool {
307 let memory = cx.memory::<TableMemory>();
308 if memory.max_column_offset == 0 {
309 return false;
310 }
311 memory.column_offset = if forward {
312 (memory.column_offset + 1).min(memory.max_column_offset)
313 } else {
314 memory.column_offset.saturating_sub(1)
315 };
316 true
317 }
318
319 fn scroll_arrow_at(cx: &mut EventCx<'_, Msg>, area: Rect, x: i32) -> Option<bool> {
323 let memory = cx.memory::<TableMemory>();
324 if x == area.x && memory.column_offset > 0 {
325 Some(false)
326 } else if x == area.right() - 1 && memory.more {
327 Some(true)
328 } else {
329 None
330 }
331 }
332}
333
334impl<Msg: 'static> Widget<Msg> for Table<Msg> {
335 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
336 let rows = self.rows.len().max(1) + 1;
337 let widths =
338 self.columns.iter().fold(0u16, |sum, c| sum.saturating_add(c.title_width()).saturating_add(COLUMN_GAP));
339 Size::new(widths.saturating_add(self.lead() + 1), clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)))
340 .min(available)
341 }
342
343 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
344 if area.is_empty() {
345 return;
346 }
347 cx.register_hit(area);
348 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
349 let total = self.rows.len();
350 let visible = usize::from(body.height);
351 let overflows = total > visible;
352 let lead = self.lead();
353 let room = area.width.saturating_sub(lead + u16::from(overflows));
354
355 let (placed, column_offset, more) = {
356 let memory = cx.memory::<TableMemory>();
357 let widest = if self.columns.iter().any(|c| c.width == ColumnWidth::Fit) {
358 self.widest_cells(memory)
359 } else {
360 vec![0; self.columns.len()]
361 };
362 let (widths, overflow) = self.widths(&widest, room);
363 let arrow = if overflow { 2 - u16::from(overflows) } else { 0 };
367 let room = room.saturating_sub(arrow);
368 let max_offset = if overflow { Self::max_offset(&widths, room) } else { 0 };
369 memory.max_column_offset = max_offset;
370 memory.column_offset = memory.column_offset.min(max_offset);
371 let placed = Self::place(&widths, memory.column_offset, area.x + i32::from(lead), room);
372 let more =
373 placed.last().is_some_and(|last| last.column + 1 < widths.len() || last.width < widths[last.column]);
374 memory.placed.clone_from(&placed);
375 memory.more = more;
376 (placed, memory.column_offset, more)
377 };
378 self.paint_header(cx, area, &placed, column_offset, more);
379
380 if total == 0 {
381 let faint = cx.style("list-header", None, &[]).text();
382 let budget = area.width.saturating_sub(LEAD);
383 cx.text(area.x + i32::from(LEAD), body.y, &self.empty, faint, budget);
384 return;
385 }
386 let focused = cx.is_focused();
387 let pressed = cx.is_pressed();
388 let menu_row = row_menu::open_row(cx, self.menu.as_ref());
391 if menu_row.is_some() {
392 cx.request_overlay(area);
393 }
394 let offset = cx.memory::<RowScroll>().follow(self.selected, total, visible);
395 let row_width = Self::rows_width(area, overflows);
396 for (row, index) in (offset..total).take(visible).enumerate() {
397 let rect = Rect::new(area.x, body.y + i32::try_from(row).unwrap_or(0), row_width, 1);
398 self.paint_row(cx, rect, index, &placed, RowPaint { focused, pressed, menu_row });
399 }
400 rows::paint_scrollbar(cx, body, total, offset, None);
401 }
402
403 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
404 row_menu::paint(cx, self.menu.as_ref(), anchor);
405 }
406
407 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
408 if self.menu_event(cx, event) {
409 return true;
410 }
411 let area = cx.area();
412 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
413 let total = self.rows.len();
414 match event {
415 Event::Key(key) => {
416 if let Some(step) = Step::from_key(key) {
417 let Some(target) = step.apply(self.selected, total, usize::from(body.height)) else {
418 return false;
419 };
420 self.select(cx, target);
421 return true;
422 }
423 if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
424 return Self::scroll_columns(cx, key.is_plain(Key::Right));
425 }
426 if key.is_plain(Key::Enter) {
427 if self.activation_is_menu() {
428 return self
429 .selected_anchor(cx)
430 .is_some_and(|anchor| row_menu::open_as_action(cx, self.menu.as_ref(), &anchor));
431 }
432 return self.selected.is_some_and(|index| self.activate(cx, index));
433 }
434 if key.is_plain(Key::Space) {
435 let Some(index) = self.selected else { return false };
436 return self.toggle(cx, index) || self.activate(cx, index);
437 }
438 let shift_s = KeyChord { key: Key::Char('s'), mods: Modifiers { shift: true, ..Modifiers::default() } };
439 if self.on_sort.is_some() && (key.is_plain(Key::Char('s')) || key.chord == shift_s) {
440 return match (key.chord == shift_s, self.sort) {
441 (true, Some((column, direction))) => self.request_sort(cx, column, direction.reversed()),
442 (true, None) => false,
443 (false, current) => {
444 let start = current.map_or(0, |(column, _)| column + 1);
445 let count = self.columns.len();
446 let next = (0..count)
447 .map(|step| (start + step) % count.max(1))
448 .find(|i| self.columns[*i].sortable);
449 next.is_some_and(|column| self.request_sort(cx, column, SortDirection::Ascending))
450 }
451 };
452 }
453 false
454 }
455 Event::Mouse(mouse) => {
456 if rows::scroll_mouse(cx, mouse, body, total) {
457 return true;
458 }
459 if mouse.kind != MouseKind::Down(MouseButton::Left) {
460 return false;
461 }
462 if mouse.y == area.y {
463 if let Some(forward) = Self::scroll_arrow_at(cx, area, mouse.x) {
464 return Self::scroll_columns(cx, forward);
465 }
466 let placed = cx.memory::<TableMemory>().placed.clone();
467 let Some(place) = placed.iter().find(|place| Self::spans(place, mouse.x)) else {
468 return false;
469 };
470 return self.request_sort(cx, place.column, self.click_sort(place.column));
471 }
472 let offset = cx.memory::<RowScroll>().offset;
473 let Some(index) = usize::try_from(mouse.y - body.y).ok().map(|row| offset + row).filter(|i| *i < total)
474 else {
475 return false;
476 };
477 if self.checked.is_some() && mouse.x < area.x + i32::from(LEAD + MARK) && self.toggle(cx, index) {
478 return true;
479 }
480 self.select(cx, index);
481 if self.activation_is_menu() {
482 let anchor = RowAnchor { row: index, at: Rect::new(mouse.x, mouse.y, 1, 1), keyboard: false };
483 row_menu::open_as_action(cx, self.menu.as_ref(), &anchor);
484 } else {
485 self.activate(cx, index);
486 }
487 true
488 }
489 _ => false,
490 }
491 }
492
493 fn focusable(&self) -> bool {
494 !self.rows.is_empty()
495 }
496}