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}
73
74#[derive(Debug, Default)]
75struct TableMemory {
76 fit: Option<(Arc<[TableRow]>, Vec<u16>)>,
78 column_offset: usize,
80 max_column_offset: usize,
82 more: bool,
84 placed: Vec<Placed>,
85}
86
87impl<Msg: 'static> Table<Msg> {
88 #[must_use]
90 pub fn new(columns: impl IntoIterator<Item = Column>, rows: impl Into<Arc<[TableRow]>>) -> Self {
91 Self {
92 columns: columns.into_iter().collect(),
93 rows: rows.into(),
94 selected: None,
95 checked: None,
96 sort: None,
97 empty: String::new(),
98 on_select: None,
99 on_activate: None,
100 on_toggle: None,
101 on_sort: None,
102 menu: None,
103 }
104 }
105
106 #[must_use]
108 pub fn selected(mut self, index: Option<usize>) -> Self {
109 self.selected = index;
110 self
111 }
112
113 #[must_use]
115 pub fn checked(mut self, checked: Vec<bool>) -> Self {
116 self.checked = Some(checked);
117 self
118 }
119
120 #[must_use]
123 pub fn sort(mut self, column: usize, direction: SortDirection) -> Self {
124 self.sort = Some((column, direction));
125 self
126 }
127
128 #[must_use]
130 pub fn empty_text(mut self, text: impl Into<String>) -> Self {
131 self.empty = text.into();
132 self
133 }
134
135 #[must_use]
137 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
138 self.on_select = Some(Box::new(message));
139 self
140 }
141
142 #[must_use]
144 pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
145 self.on_activate = Some(Box::new(message));
146 self
147 }
148
149 #[must_use]
151 pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
152 self.on_toggle = Some(Box::new(message));
153 self
154 }
155
156 #[must_use]
159 pub fn on_sort(mut self, message: impl Fn(usize, SortDirection) -> Msg + 'static) -> Self {
160 self.on_sort = Some(Box::new(message));
161 self
162 }
163
164 #[must_use]
173 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
174 self.menu = Some(Box::new(items));
175 self
176 }
177
178 fn rows_width(area: Rect, overflows: bool) -> u16 {
180 area.width.saturating_sub(u16::from(overflows))
181 }
182
183 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
187 let area = cx.area();
188 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
189 let total = self.rows.len();
190 let visible = usize::from(body.height);
191 let overflows = total > visible;
192 row_menu::event(
193 cx,
194 event,
195 self.menu.as_ref(),
196 total,
197 |cx, x, y| {
198 if y < body.y || x >= area.x + i32::from(Self::rows_width(area, overflows)) {
199 return None;
200 }
201 let offset = cx.memory::<RowScroll>().offset;
202 let row = usize::try_from(y - body.y).ok().map(|row| offset + row).filter(|row| *row < total)?;
203 let checked = self.checked.as_ref().is_some_and(|checked| checked.get(row).copied().unwrap_or(false));
204 if !checked {
205 self.select(cx, row);
206 }
207 Some(RowAnchor { row, at: Rect::new(x, y, 1, 1), keyboard: false })
208 },
209 |cx| {
210 let row = self.selected.filter(|row| *row < total)?;
211 let memory = cx.memory::<RowScroll>();
212 if row < memory.offset {
213 memory.offset = row;
214 } else if visible > 0 && row >= memory.offset + visible {
215 memory.offset = row + 1 - visible;
216 }
217 let y = body.y + i32::try_from(row - memory.offset).unwrap_or(0);
218 let at = Rect::new(area.x, y, Self::rows_width(area, overflows), 1);
219 Some(RowAnchor { row, at, keyboard: true })
220 },
221 )
222 }
223
224 fn lead(&self) -> u16 {
225 LEAD + if self.checked.is_some() { MARK } else { 0 }
226 }
227
228 fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
229 if Some(index) != self.selected
230 && let Some(message) = &self.on_select
231 {
232 cx.emit(message(index));
233 }
234 }
235
236 fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
237 let Some(message) = &self.on_activate else {
238 return false;
239 };
240 cx.memory::<RowScroll>().flashed = Some(index);
241 cx.flash();
242 cx.emit(message(index));
243 true
244 }
245
246 fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
247 match (&self.checked, &self.on_toggle) {
248 (Some(_), Some(message)) => {
249 cx.emit(message(index));
250 true
251 }
252 _ => false,
253 }
254 }
255
256 fn request_sort(&self, cx: &mut EventCx<'_, Msg>, column: usize, direction: SortDirection) -> bool {
257 match &self.on_sort {
258 Some(message) if self.columns.get(column).is_some_and(|c| c.sortable) => {
259 cx.emit(message(column, direction));
260 true
261 }
262 _ => false,
263 }
264 }
265
266 fn click_sort(&self, column: usize) -> SortDirection {
268 match self.sort {
269 Some((sorted, direction)) if sorted == column => direction.reversed(),
270 _ => SortDirection::Ascending,
271 }
272 }
273
274 fn scroll_columns(cx: &mut EventCx<'_, Msg>, forward: bool) -> bool {
277 let memory = cx.memory::<TableMemory>();
278 if memory.max_column_offset == 0 {
279 return false;
280 }
281 memory.column_offset = if forward {
282 (memory.column_offset + 1).min(memory.max_column_offset)
283 } else {
284 memory.column_offset.saturating_sub(1)
285 };
286 true
287 }
288
289 fn scroll_arrow_at(cx: &mut EventCx<'_, Msg>, area: Rect, x: i32) -> Option<bool> {
293 let memory = cx.memory::<TableMemory>();
294 if x == area.x && memory.column_offset > 0 {
295 Some(false)
296 } else if x == area.right() - 1 && memory.more {
297 Some(true)
298 } else {
299 None
300 }
301 }
302}
303
304impl<Msg: 'static> Widget<Msg> for Table<Msg> {
305 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
306 let rows = self.rows.len().max(1) + 1;
307 let widths =
308 self.columns.iter().fold(0u16, |sum, c| sum.saturating_add(c.title_width()).saturating_add(COLUMN_GAP));
309 Size::new(widths.saturating_add(self.lead() + 1), clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)))
310 .min(available)
311 }
312
313 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
314 if area.is_empty() {
315 return;
316 }
317 cx.register_hit(area);
318 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
319 let total = self.rows.len();
320 let visible = usize::from(body.height);
321 let overflows = total > visible;
322 let lead = self.lead();
323 let room = area.width.saturating_sub(lead + u16::from(overflows));
324
325 let (placed, column_offset, more) = {
326 let memory = cx.memory::<TableMemory>();
327 let widest = if self.columns.iter().any(|c| c.width == ColumnWidth::Fit) {
328 self.widest_cells(memory)
329 } else {
330 vec![0; self.columns.len()]
331 };
332 let (widths, overflow) = self.widths(&widest, room);
333 let arrow = if overflow { 2 - u16::from(overflows) } else { 0 };
337 let room = room.saturating_sub(arrow);
338 let max_offset = if overflow { Self::max_offset(&widths, room) } else { 0 };
339 memory.max_column_offset = max_offset;
340 memory.column_offset = memory.column_offset.min(max_offset);
341 let placed = Self::place(&widths, memory.column_offset, area.x + i32::from(lead), room);
342 let more =
343 placed.last().is_some_and(|last| last.column + 1 < widths.len() || last.width < widths[last.column]);
344 memory.placed.clone_from(&placed);
345 memory.more = more;
346 (placed, memory.column_offset, more)
347 };
348 self.paint_header(cx, area, &placed, column_offset, more);
349
350 if total == 0 {
351 let faint = cx.style("list-header", None, &[]).text();
352 let budget = area.width.saturating_sub(LEAD);
353 cx.text(area.x + i32::from(LEAD), body.y, &self.empty, faint, budget);
354 return;
355 }
356 let focused = cx.is_focused();
357 let pressed = cx.is_pressed();
358 let menu_row = row_menu::open_row(cx, self.menu.as_ref());
361 if menu_row.is_some() {
362 cx.request_overlay(area);
363 }
364 let offset = cx.memory::<RowScroll>().follow(self.selected, total, visible);
365 let row_width = Self::rows_width(area, overflows);
366 for (row, index) in (offset..total).take(visible).enumerate() {
367 let rect = Rect::new(area.x, body.y + i32::try_from(row).unwrap_or(0), row_width, 1);
368 self.paint_row(cx, rect, index, &placed, RowPaint { focused, pressed, menu_row });
369 }
370 rows::paint_scrollbar(cx, body, total, offset, None);
371 }
372
373 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
374 row_menu::paint(cx, self.menu.as_ref(), anchor);
375 }
376
377 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
378 if self.menu_event(cx, event) {
379 return true;
380 }
381 let area = cx.area();
382 let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
383 let total = self.rows.len();
384 match event {
385 Event::Key(key) => {
386 if let Some(step) = Step::from_key(key) {
387 let Some(target) = step.apply(self.selected, total, usize::from(body.height)) else {
388 return false;
389 };
390 self.select(cx, target);
391 return true;
392 }
393 if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
394 return Self::scroll_columns(cx, key.is_plain(Key::Right));
395 }
396 if key.is_plain(Key::Enter) {
397 return self.selected.is_some_and(|index| self.activate(cx, index));
398 }
399 if key.is_plain(Key::Space) {
400 let Some(index) = self.selected else { return false };
401 return self.toggle(cx, index) || self.activate(cx, index);
402 }
403 let shift_s = KeyChord { key: Key::Char('s'), mods: Modifiers { shift: true, ..Modifiers::default() } };
404 if self.on_sort.is_some() && (key.is_plain(Key::Char('s')) || key.chord == shift_s) {
405 return match (key.chord == shift_s, self.sort) {
406 (true, Some((column, direction))) => self.request_sort(cx, column, direction.reversed()),
407 (true, None) => false,
408 (false, current) => {
409 let start = current.map_or(0, |(column, _)| column + 1);
410 let count = self.columns.len();
411 let next = (0..count)
412 .map(|step| (start + step) % count.max(1))
413 .find(|i| self.columns[*i].sortable);
414 next.is_some_and(|column| self.request_sort(cx, column, SortDirection::Ascending))
415 }
416 };
417 }
418 false
419 }
420 Event::Mouse(mouse) => {
421 if rows::scroll_mouse(cx, mouse, body, total) {
422 return true;
423 }
424 if mouse.kind != MouseKind::Down(MouseButton::Left) {
425 return false;
426 }
427 if mouse.y == area.y {
428 if let Some(forward) = Self::scroll_arrow_at(cx, area, mouse.x) {
429 return Self::scroll_columns(cx, forward);
430 }
431 let placed = cx.memory::<TableMemory>().placed.clone();
432 let Some(place) = placed.iter().find(|place| Self::spans(place, mouse.x)) else {
433 return false;
434 };
435 return self.request_sort(cx, place.column, self.click_sort(place.column));
436 }
437 let offset = cx.memory::<RowScroll>().offset;
438 let Some(index) = usize::try_from(mouse.y - body.y).ok().map(|row| offset + row).filter(|i| *i < total)
439 else {
440 return false;
441 };
442 if self.checked.is_some() && mouse.x < area.x + i32::from(LEAD + MARK) && self.toggle(cx, index) {
443 return true;
444 }
445 self.select(cx, index);
446 self.activate(cx, index);
447 true
448 }
449 _ => false,
450 }
451 }
452
453 fn focusable(&self) -> bool {
454 !self.rows.is_empty()
455 }
456}