1use crate::block::Block;
2use crate::undo_support::{TableUndoExt, UndoSupport, UndoWidgetId};
3use crate::{
4 MeasurableWidget, SizeConstraints, StatefulWidget, Widget, apply_style, set_style_area,
5};
6use ftui_core::geometry::{Rect, Size};
7use ftui_layout::{Constraint, Flex};
8use ftui_render::buffer::Buffer;
9use ftui_render::cell::Cell;
10use ftui_render::frame::{Frame, HitId, HitRegion};
11use ftui_style::{
12 Style, TableEffectResolver, TableEffectScope, TableEffectTarget, TableSection, TableTheme,
13};
14use ftui_text::Text;
15use std::any::Any;
16
17#[derive(Debug, Clone, Default)]
19pub struct Row {
20 cells: Vec<Text>,
21 height: u16,
22 style: Style,
23 bottom_margin: u16,
24}
25
26impl Row {
27 pub fn new(cells: impl IntoIterator<Item = impl Into<Text>>) -> Self {
29 Self {
30 cells: cells.into_iter().map(|c| c.into()).collect(),
31 height: 1,
32 style: Style::default(),
33 bottom_margin: 0,
34 }
35 }
36
37 pub fn height(mut self, height: u16) -> Self {
39 self.height = height;
40 self
41 }
42
43 pub fn style(mut self, style: Style) -> Self {
45 self.style = style;
46 self
47 }
48
49 pub fn bottom_margin(mut self, margin: u16) -> Self {
51 self.bottom_margin = margin;
52 self
53 }
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct Table<'a> {
59 rows: Vec<Row>,
60 widths: Vec<Constraint>,
61 intrinsic_col_widths: Vec<u16>,
62 header: Option<Row>,
63 block: Option<Block<'a>>,
64 style: Style,
65 highlight_style: Style,
66 theme: TableTheme,
67 theme_phase: f32,
68 column_spacing: u16,
69 hit_id: Option<HitId>,
72}
73
74impl<'a> Table<'a> {
75 pub fn new(
77 rows: impl IntoIterator<Item = Row>,
78 widths: impl IntoIterator<Item = Constraint>,
79 ) -> Self {
80 let rows: Vec<Row> = rows.into_iter().collect();
81 let widths: Vec<Constraint> = widths.into_iter().collect();
82 let col_count = widths.len();
83
84 let intrinsic_col_widths = if Self::requires_measurement(&widths) {
85 Self::compute_intrinsic_widths(&rows, None, col_count)
86 } else {
87 Vec::new()
88 };
89
90 Self {
91 rows,
92 widths,
93 intrinsic_col_widths,
94 header: None,
95 block: None,
96 style: Style::default(),
97 highlight_style: Style::default(),
98 theme: TableTheme::default(),
99 theme_phase: 0.0,
100 column_spacing: 1,
101 hit_id: None,
102 }
103 }
104
105 pub fn header(mut self, header: Row) -> Self {
107 self.header = Some(header);
108 self
109 }
110
111 pub fn block(mut self, block: Block<'a>) -> Self {
113 self.block = Some(block);
114 self
115 }
116
117 pub fn style(mut self, style: Style) -> Self {
119 self.style = style;
120 self
121 }
122
123 pub fn highlight_style(mut self, style: Style) -> Self {
125 self.highlight_style = style;
126 self
127 }
128
129 pub fn theme(mut self, theme: TableTheme) -> Self {
131 self.theme = theme;
132 self
133 }
134
135 pub fn theme_phase(mut self, phase: f32) -> Self {
139 self.theme_phase = phase;
140 self
141 }
142
143 pub fn column_spacing(mut self, spacing: u16) -> Self {
145 self.column_spacing = spacing;
146 self
147 }
148
149 pub fn hit_id(mut self, id: HitId) -> Self {
155 self.hit_id = Some(id);
156 self
157 }
158
159 fn requires_measurement(constraints: &[Constraint]) -> bool {
160 constraints.iter().any(|c| {
161 matches!(
162 c,
163 Constraint::FitContent | Constraint::FitContentBounded { .. } | Constraint::FitMin
164 )
165 })
166 }
167
168 fn compute_intrinsic_widths(rows: &[Row], header: Option<&Row>, col_count: usize) -> Vec<u16> {
169 if col_count == 0 {
170 return Vec::new();
171 }
172
173 let mut col_widths: Vec<u16> = vec![0; col_count];
174
175 if let Some(header) = header {
176 for (i, cell) in header.cells.iter().enumerate().take(col_count) {
177 let cell_width = cell.width().min(u16::MAX as usize) as u16;
178 col_widths[i] = col_widths[i].max(cell_width);
179 }
180 }
181
182 for row in rows {
183 for (i, cell) in row.cells.iter().enumerate().take(col_count) {
184 let cell_width = cell.width().min(u16::MAX as usize) as u16;
185 col_widths[i] = col_widths[i].max(cell_width);
186 }
187 }
188
189 col_widths
190 }
191}
192
193impl<'a> Widget for Table<'a> {
194 fn render(&self, area: Rect, frame: &mut Frame) {
195 let mut state = TableState::default();
196 StatefulWidget::render(self, area, frame, &mut state);
197 }
198}
199
200#[derive(Debug, Clone, Default)]
202pub struct TableState {
203 #[allow(dead_code)]
205 undo_id: UndoWidgetId,
206 pub selected: Option<usize>,
208 pub hovered: Option<usize>,
210 pub offset: usize,
212 persistence_id: Option<String>,
215 #[allow(dead_code)]
217 sort_column: Option<usize>,
218 #[allow(dead_code)]
220 sort_ascending: bool,
221 #[allow(dead_code)]
223 filter: String,
224}
225
226impl TableState {
227 pub fn select(&mut self, index: Option<usize>) {
229 self.selected = index;
230 if index.is_none() {
231 self.offset = 0;
232 }
233 }
234
235 #[must_use]
237 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
238 self.persistence_id = Some(id.into());
239 self
240 }
241
242 #[must_use]
244 pub fn persistence_id(&self) -> Option<&str> {
245 self.persistence_id.as_deref()
246 }
247}
248
249#[derive(Clone, Debug, Default, PartialEq)]
258#[cfg_attr(
259 feature = "state-persistence",
260 derive(serde::Serialize, serde::Deserialize)
261)]
262pub struct TablePersistState {
263 pub selected: Option<usize>,
265 pub offset: usize,
267 pub sort_column: Option<usize>,
269 pub sort_ascending: bool,
271 pub filter: String,
273}
274
275impl crate::stateful::Stateful for TableState {
276 type State = TablePersistState;
277
278 fn state_key(&self) -> crate::stateful::StateKey {
279 crate::stateful::StateKey::new("Table", self.persistence_id.as_deref().unwrap_or("default"))
280 }
281
282 fn save_state(&self) -> TablePersistState {
283 TablePersistState {
284 selected: self.selected,
285 offset: self.offset,
286 sort_column: self.sort_column,
287 sort_ascending: self.sort_ascending,
288 filter: self.filter.clone(),
289 }
290 }
291
292 fn restore_state(&mut self, state: TablePersistState) {
293 self.selected = state.selected;
295 self.offset = state.offset;
296 self.sort_column = state.sort_column;
297 self.sort_ascending = state.sort_ascending;
298 self.filter = state.filter;
299 }
300}
301
302#[derive(Debug, Clone)]
308pub struct TableStateSnapshot {
309 selected: Option<usize>,
310 offset: usize,
311 sort_column: Option<usize>,
312 sort_ascending: bool,
313 filter: String,
314}
315
316impl UndoSupport for TableState {
317 fn undo_widget_id(&self) -> UndoWidgetId {
318 self.undo_id
319 }
320
321 fn create_snapshot(&self) -> Box<dyn Any + Send> {
322 Box::new(TableStateSnapshot {
323 selected: self.selected,
324 offset: self.offset,
325 sort_column: self.sort_column,
326 sort_ascending: self.sort_ascending,
327 filter: self.filter.clone(),
328 })
329 }
330
331 fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool {
332 if let Some(snap) = snapshot.downcast_ref::<TableStateSnapshot>() {
333 self.selected = snap.selected;
334 self.offset = snap.offset;
335 self.sort_column = snap.sort_column;
336 self.sort_ascending = snap.sort_ascending;
337 self.filter = snap.filter.clone();
338 true
339 } else {
340 false
341 }
342 }
343}
344
345impl TableUndoExt for TableState {
346 fn sort_state(&self) -> (Option<usize>, bool) {
347 (self.sort_column, self.sort_ascending)
348 }
349
350 fn set_sort_state(&mut self, column: Option<usize>, ascending: bool) {
351 self.sort_column = column;
352 self.sort_ascending = ascending;
353 }
354
355 fn filter_text(&self) -> &str {
356 &self.filter
357 }
358
359 fn set_filter_text(&mut self, filter: &str) {
360 self.filter = filter.to_string();
361 }
362}
363
364impl TableState {
365 #[must_use]
369 pub fn undo_id(&self) -> UndoWidgetId {
370 self.undo_id
371 }
372
373 #[must_use]
375 pub fn sort_column(&self) -> Option<usize> {
376 self.sort_column
377 }
378
379 #[must_use]
381 pub fn sort_ascending(&self) -> bool {
382 self.sort_ascending
383 }
384
385 pub fn set_sort(&mut self, column: Option<usize>, ascending: bool) {
387 self.sort_column = column;
388 self.sort_ascending = ascending;
389 }
390
391 #[must_use]
393 pub fn filter(&self) -> &str {
394 &self.filter
395 }
396
397 pub fn set_filter(&mut self, filter: impl Into<String>) {
399 self.filter = filter.into();
400 }
401}
402
403impl<'a> StatefulWidget for Table<'a> {
404 type State = TableState;
405
406 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
407 #[cfg(feature = "tracing")]
408 let _span = tracing::debug_span!(
409 "widget_render",
410 widget = "Table",
411 x = area.x,
412 y = area.y,
413 w = area.width,
414 h = area.height
415 )
416 .entered();
417
418 if area.is_empty() {
419 return;
420 }
421
422 let apply_styling = frame.degradation.apply_styling();
423 let theme = &self.theme;
424 let effects_enabled = apply_styling && !theme.effects.is_empty();
425 let has_column_effects = effects_enabled && theme_has_column_effects(theme);
426 let effect_resolver = theme.effect_resolver();
427 let effects = if effects_enabled {
428 Some((&effect_resolver, self.theme_phase))
429 } else {
430 None
431 };
432
433 let table_area = match &self.block {
435 Some(b) => {
436 let mut block = b.clone();
437 if apply_styling {
438 block = block.border_style(theme.border);
439 }
440 block.render(area, frame);
441 block.inner(area)
442 }
443 None => area,
444 };
445
446 if table_area.is_empty() {
447 return;
448 }
449
450 frame.buffer.push_scissor(table_area);
453
454 if apply_styling {
456 let fill_style = self.style.merge(&theme.row);
457 set_style_area(&mut frame.buffer, table_area, fill_style);
458 }
459
460 let header_height = self
461 .header
462 .as_ref()
463 .map(|h| h.height.saturating_add(h.bottom_margin))
464 .unwrap_or(0);
465
466 if header_height > table_area.height {
467 frame.buffer.pop_scissor();
468 return;
469 }
470
471 let rows_top = table_area.y.saturating_add(header_height);
472 let rows_max_y = table_area.bottom();
473 let rows_height = rows_max_y.saturating_sub(rows_top);
474
475 if self.rows.is_empty() {
477 state.offset = 0;
478 } else {
479 state.offset = state.offset.min(self.rows.len().saturating_sub(1));
480 }
481
482 if let Some(selected) = state.selected {
483 if self.rows.is_empty() {
484 state.selected = None;
485 } else if selected >= self.rows.len() {
486 state.selected = Some(self.rows.len() - 1);
487 }
488 }
489
490 if let Some(selected) = state.selected {
492 if selected < state.offset {
493 state.offset = selected;
494 } else {
495 let mut current_y = rows_top;
498 let max_y = rows_max_y;
499 let mut last_visible = state.offset;
500
501 for (i, row) in self.rows.iter().enumerate().skip(state.offset) {
503 if row.height > max_y.saturating_sub(current_y) {
504 break;
505 }
506 current_y = current_y
507 .saturating_add(row.height)
508 .saturating_add(row.bottom_margin);
509 last_visible = i;
510 }
511
512 if selected > last_visible {
513 let mut new_offset = selected;
515 let mut accumulated_height = 0;
516 let available_height = rows_height;
517
518 for i in (0..=selected).rev() {
520 let row = &self.rows[i];
521 let total_row_height = if i == selected {
524 row.height
525 } else {
526 row.height.saturating_add(row.bottom_margin)
527 };
528
529 if total_row_height > available_height.saturating_sub(accumulated_height) {
530 if i == selected {
534 new_offset = selected;
535 } else {
536 new_offset = i + 1;
537 }
538 break;
539 }
540
541 accumulated_height = accumulated_height.saturating_add(total_row_height);
542 new_offset = i;
543 }
544 state.offset = new_offset;
545 }
546 }
547 }
548
549 let flex = Flex::horizontal()
551 .constraints(self.widths.clone())
552 .gap(self.column_spacing);
553
554 let column_rects = flex.split_with_measurer(
556 Rect::new(table_area.x, table_area.y, table_area.width, 1),
557 |idx, _| {
558 let row_width = self.intrinsic_col_widths.get(idx).copied().unwrap_or(0);
560 let header_width = self
561 .header
562 .as_ref()
563 .and_then(|h| h.cells.get(idx))
564 .map(|c| c.width().min(u16::MAX as usize) as u16)
565 .unwrap_or(0);
566 ftui_layout::LayoutSizeHint::exact(row_width.max(header_width))
567 },
568 );
569
570 let mut y = table_area.y;
571 let max_y = table_area.bottom();
572 let divider_char = divider_char(self.block.as_ref());
573
574 if let Some(header) = &self.header {
576 if header.height > max_y.saturating_sub(y) {
577 frame.buffer.pop_scissor();
578 return;
579 }
580 let row_area = Rect::new(table_area.x, y, table_area.width, header.height);
581 let header_style = if apply_styling {
582 let mut style = theme.header;
583 style = self.style.merge(&style);
584 header.style.merge(&style)
585 } else {
586 Style::default()
587 };
588
589 if apply_styling {
590 set_style_area(&mut frame.buffer, row_area, header_style);
591 if let Some((resolver, phase)) = effects {
592 for (col_idx, rect) in column_rects.iter().enumerate() {
593 let cell_area = Rect::new(rect.x, y, rect.width, header.height);
594 let scope = TableEffectScope {
595 section: TableSection::Header,
596 row: None,
597 column: Some(col_idx),
598 };
599 let style = resolver.resolve(header_style, scope, phase);
600 set_style_area(&mut frame.buffer, cell_area, style);
601 }
602 }
603 }
604
605 let divider_style = if apply_styling {
606 theme.divider.merge(&header_style)
607 } else {
608 Style::default()
609 };
610 draw_vertical_dividers(
611 &mut frame.buffer,
612 row_area,
613 &column_rects,
614 divider_char,
615 divider_style,
616 );
617
618 render_row(
619 header,
620 &column_rects,
621 frame,
622 y,
623 header_style,
624 TableSection::Header,
625 None,
626 effects,
627 effects.is_some(),
628 );
629 y = y
630 .saturating_add(header.height)
631 .saturating_add(header.bottom_margin);
632 }
633
634 if self.rows.is_empty() {
636 frame.buffer.pop_scissor();
637 return;
638 }
639
640 for (i, row) in self.rows.iter().enumerate().skip(state.offset) {
644 if y >= max_y {
645 break;
646 }
647
648 let is_selected = state.selected == Some(i);
649 let is_hovered = state.hovered == Some(i);
650 let row_area = Rect::new(table_area.x, y, table_area.width, row.height);
651 let row_style = if apply_styling {
652 let mut style = if i % 2 == 0 { theme.row } else { theme.row_alt };
653 if is_selected {
654 style = theme.row_selected.merge(&style);
655 }
656 if is_hovered {
657 style = theme.row_hover.merge(&style);
658 }
659 style = self.style.merge(&style);
660 style = row.style.merge(&style);
661 if is_selected {
662 style = self.highlight_style.merge(&style);
663 }
664 style
665 } else {
666 Style::default()
667 };
668
669 if apply_styling {
670 if let Some((resolver, phase)) = effects {
671 if has_column_effects {
672 set_style_area(&mut frame.buffer, row_area, row_style);
673 for (col_idx, rect) in column_rects.iter().enumerate() {
674 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
675 let scope = TableEffectScope {
676 section: TableSection::Body,
677 row: Some(i),
678 column: Some(col_idx),
679 };
680 let style = resolver.resolve(row_style, scope, phase);
681 set_style_area(&mut frame.buffer, cell_area, style);
682 }
683 } else {
684 let scope = TableEffectScope::row(TableSection::Body, i);
685 let style = resolver.resolve(row_style, scope, phase);
686 set_style_area(&mut frame.buffer, row_area, style);
687 }
688 } else {
689 set_style_area(&mut frame.buffer, row_area, row_style);
690 }
691 }
692
693 let divider_style = if apply_styling {
694 theme.divider.merge(&row_style)
695 } else {
696 Style::default()
697 };
698 draw_vertical_dividers(
699 &mut frame.buffer,
700 row_area,
701 &column_rects,
702 divider_char,
703 divider_style,
704 );
705
706 render_row(
707 row,
708 &column_rects,
709 frame,
710 y,
711 row_style,
712 TableSection::Body,
713 Some(i),
714 effects,
715 has_column_effects,
716 );
717
718 if let Some(id) = self.hit_id {
720 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
721 }
722
723 y = y
724 .saturating_add(row.height)
725 .saturating_add(row.bottom_margin);
726 }
727
728 frame.buffer.pop_scissor();
729 }
730}
731
732#[allow(clippy::too_many_arguments)]
733fn render_row(
734 row: &Row,
735 col_rects: &[Rect],
736 frame: &mut Frame,
737 y: u16,
738 base_style: Style,
739 section: TableSection,
740 row_idx: Option<usize>,
741 effects: Option<(&TableEffectResolver<'_>, f32)>,
742 column_effects: bool,
743) {
744 let apply_styling = frame.degradation.apply_styling();
745 let row_effect_base = if apply_styling {
746 if let Some((resolver, phase)) = effects {
747 if !column_effects {
748 let scope = TableEffectScope {
749 section,
750 row: row_idx,
751 column: None,
752 };
753 Some(resolver.resolve(base_style, scope, phase))
754 } else {
755 None
756 }
757 } else {
758 None
759 }
760 } else {
761 None
762 };
763
764 for (col_idx, cell_text) in row.cells.iter().enumerate() {
765 if col_idx >= col_rects.len() {
766 break;
767 }
768 let rect = col_rects[col_idx];
769 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
770 let scope = if effects.is_some() {
771 Some(TableEffectScope {
772 section,
773 row: row_idx,
774 column: if column_effects { Some(col_idx) } else { None },
775 })
776 } else {
777 None
778 };
779 let column_effect_base = if apply_styling && column_effects {
780 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
781 Some(resolver.resolve(base_style, scope, phase))
782 } else {
783 None
784 }
785 } else {
786 None
787 };
788
789 for (line_idx, line) in cell_text.lines().iter().enumerate() {
790 if line_idx as u16 >= row.height {
791 break;
792 }
793
794 let mut x = cell_area.x;
795 for span in line.spans() {
796 let mut span_style = if apply_styling {
798 match span.style {
799 Some(s) => s.merge(&base_style),
800 None => base_style,
801 }
802 } else {
803 Style::default()
804 };
805
806 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
807 if span.style.is_none() {
808 if let Some(base_effect) = column_effect_base.or(row_effect_base) {
809 span_style = base_effect;
810 } else {
811 span_style = resolver.resolve(span_style, scope, phase);
812 }
813 } else {
814 span_style = resolver.resolve(span_style, scope, phase);
815 }
816 }
817
818 x = crate::draw_text_span_with_link(
819 frame,
820 x,
821 cell_area.y.saturating_add(line_idx as u16),
822 &span.content,
823 span_style,
824 cell_area.right(),
825 span.link.as_deref(),
826 );
827 if x >= cell_area.right() {
828 break;
829 }
830 }
831 }
832 }
833}
834
835fn theme_has_column_effects(theme: &TableTheme) -> bool {
836 theme.effects.iter().any(|rule| {
837 matches!(
838 rule.target,
839 TableEffectTarget::Column(_) | TableEffectTarget::ColumnRange { .. }
840 )
841 })
842}
843
844fn divider_char(block: Option<&Block<'_>>) -> char {
845 block
846 .map(|b| b.border_set().vertical)
847 .unwrap_or(crate::borders::BorderSet::SQUARE.vertical)
848}
849
850fn draw_vertical_dividers(
851 buf: &mut Buffer,
852 row_area: Rect,
853 col_rects: &[Rect],
854 divider_char: char,
855 style: Style,
856) {
857 if col_rects.len() < 2 || row_area.is_empty() {
858 return;
859 }
860
861 for pair in col_rects.windows(2) {
862 let left = pair[0];
863 let right = pair[1];
864 let gap = right.x.saturating_sub(left.right());
865 if gap == 0 {
866 continue;
867 }
868 let x = left.right();
869 if x >= row_area.right() {
870 continue;
871 }
872 for y in row_area.y..row_area.bottom() {
873 let mut cell = Cell::from_char(divider_char);
874 apply_style(&mut cell, style);
875 buf.set(x, y, cell);
876 }
877 }
878}
879
880impl MeasurableWidget for Table<'_> {
881 fn measure(&self, _available: Size) -> SizeConstraints {
882 if self.rows.is_empty() && self.header.is_none() {
883 return SizeConstraints::ZERO;
884 }
885
886 let col_count = self.widths.len();
887 if col_count == 0 {
888 return SizeConstraints::ZERO;
889 }
890
891 let fallback;
892 let row_widths = if self.intrinsic_col_widths.len() == col_count {
893 &self.intrinsic_col_widths
894 } else {
895 fallback = Self::compute_intrinsic_widths(&self.rows, None, col_count);
897 &fallback
898 };
899
900 let separator_width = if col_count > 1 {
902 ((col_count - 1) as u16).saturating_mul(self.column_spacing)
903 } else {
904 0
905 };
906
907 let mut summed_col_width = 0u16;
908 for (i, &r_w) in row_widths.iter().enumerate() {
909 let h_w = self
910 .header
911 .as_ref()
912 .and_then(|h| h.cells.get(i))
913 .map(|c| c.width().min(u16::MAX as usize) as u16)
914 .unwrap_or(0);
915 summed_col_width = summed_col_width.saturating_add(r_w.max(h_w));
916 }
917
918 let content_width = summed_col_width.saturating_add(separator_width);
919
920 let header_height = self
923 .header
924 .as_ref()
925 .map(|h| h.height.saturating_add(h.bottom_margin))
926 .unwrap_or(0);
927
928 let rows_height: u16 = self.rows.iter().fold(0u16, |acc, r| {
929 acc.saturating_add(r.height.saturating_add(r.bottom_margin))
930 });
931
932 let content_height = header_height.saturating_add(rows_height);
933
934 let (block_width, block_height) = self
936 .block
937 .as_ref()
938 .map(|b| {
939 let inner = b.inner(Rect::new(0, 0, 100, 100));
940 let w_overhead = 100u16.saturating_sub(inner.width);
941 let h_overhead = 100u16.saturating_sub(inner.height);
942 (w_overhead, h_overhead)
943 })
944 .unwrap_or((0, 0));
945
946 let total_width = content_width.saturating_add(block_width);
947 let total_height = content_height.saturating_add(block_height);
948
949 SizeConstraints {
950 min: Size::new(col_count as u16, 1), preferred: Size::new(total_width, total_height),
952 max: Some(Size::new(total_width, total_height)), }
954 }
955
956 fn has_intrinsic_size(&self) -> bool {
957 !self.rows.is_empty() || self.header.is_some()
958 }
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964 use ftui_render::buffer::Buffer;
965 use ftui_render::cell::PackedRgba;
966 use ftui_render::grapheme_pool::GraphemePool;
967 use ftui_text::{Line, Span};
968
969 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
970 buf.get(x, y).and_then(|c| c.content.as_char())
971 }
972
973 fn cell_fg(buf: &Buffer, x: u16, y: u16) -> Option<PackedRgba> {
974 buf.get(x, y).map(|c| c.fg)
975 }
976
977 #[test]
980 fn row_new_from_strings() {
981 let row = Row::new(["A", "B", "C"]);
982 assert_eq!(row.cells.len(), 3);
983 assert_eq!(row.height, 1);
984 assert_eq!(row.bottom_margin, 0);
985 }
986
987 #[test]
988 fn row_builder_methods() {
989 let row = Row::new(["X"])
990 .height(3)
991 .bottom_margin(1)
992 .style(Style::new().bold());
993 assert_eq!(row.height, 3);
994 assert_eq!(row.bottom_margin, 1);
995 assert!(row.style.has_attr(ftui_style::StyleFlags::BOLD));
996 }
997
998 #[test]
1001 fn table_state_default() {
1002 let state = TableState::default();
1003 assert_eq!(state.selected, None);
1004 assert_eq!(state.offset, 0);
1005 }
1006
1007 #[test]
1008 fn table_state_select() {
1009 let mut state = TableState::default();
1010 state.select(Some(5));
1011 assert_eq!(state.selected, Some(5));
1012 assert_eq!(state.offset, 0);
1013 }
1014
1015 #[test]
1016 fn table_state_deselect_resets_offset() {
1017 let mut state = TableState {
1018 offset: 10,
1019 ..Default::default()
1020 };
1021 state.select(Some(3));
1022 assert_eq!(state.selected, Some(3));
1023 state.select(None);
1024 assert_eq!(state.selected, None);
1025 assert_eq!(state.offset, 0);
1026 }
1027
1028 #[test]
1031 fn render_zero_area() {
1032 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
1033 let area = Rect::new(0, 0, 0, 0);
1034 let mut pool = GraphemePool::new();
1035 let mut frame = Frame::new(1, 1, &mut pool);
1036 Widget::render(&table, area, &mut frame);
1037 }
1039
1040 #[test]
1041 fn render_empty_rows() {
1042 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1043 let area = Rect::new(0, 0, 10, 5);
1044 let mut pool = GraphemePool::new();
1045 let mut frame = Frame::new(10, 5, &mut pool);
1046 Widget::render(&table, area, &mut frame);
1047 }
1049
1050 #[test]
1051 fn render_single_row_single_column() {
1052 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1053 let area = Rect::new(0, 0, 10, 3);
1054 let mut pool = GraphemePool::new();
1055 let mut frame = Frame::new(10, 3, &mut pool);
1056 Widget::render(&table, area, &mut frame);
1057
1058 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
1059 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('e'));
1060 assert_eq!(cell_char(&frame.buffer, 4, 0), Some('o'));
1061 }
1062
1063 #[test]
1064 fn render_multiple_rows() {
1065 let table = Table::new(
1066 [Row::new(["AA", "BB"]), Row::new(["CC", "DD"])],
1067 [Constraint::Fixed(4), Constraint::Fixed(4)],
1068 );
1069 let area = Rect::new(0, 0, 10, 3);
1070 let mut pool = GraphemePool::new();
1071 let mut frame = Frame::new(10, 3, &mut pool);
1072 Widget::render(&table, area, &mut frame);
1073
1074 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1076 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('C'));
1078 }
1079
1080 #[test]
1081 fn render_with_header() {
1082 let header = Row::new(["Name", "Val"]);
1083 let table = Table::new(
1084 [Row::new(["foo", "42"])],
1085 [Constraint::Fixed(5), Constraint::Fixed(4)],
1086 )
1087 .header(header);
1088
1089 let area = Rect::new(0, 0, 10, 3);
1090 let mut pool = GraphemePool::new();
1091 let mut frame = Frame::new(10, 3, &mut pool);
1092 Widget::render(&table, area, &mut frame);
1093
1094 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('N'));
1096 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('f'));
1098 }
1099
1100 #[test]
1101 fn render_with_block() {
1102 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).block(Block::bordered());
1103
1104 let area = Rect::new(0, 0, 10, 5);
1105 let mut pool = GraphemePool::new();
1106 let mut frame = Frame::new(10, 5, &mut pool);
1107 Widget::render(&table, area, &mut frame);
1108
1109 assert_eq!(cell_char(&frame.buffer, 1, 1), Some('X'));
1112 }
1113
1114 #[test]
1115 fn stateful_render_with_selection() {
1116 let table = Table::new(
1117 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
1118 [Constraint::Fixed(5)],
1119 )
1120 .highlight_style(Style::new().bold());
1121
1122 let area = Rect::new(0, 0, 5, 3);
1123 let mut pool = GraphemePool::new();
1124 let mut frame = Frame::new(5, 3, &mut pool);
1125 let mut state = TableState::default();
1126 state.select(Some(1));
1127
1128 StatefulWidget::render(&table, area, &mut frame, &mut state);
1129 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1132 }
1133
1134 #[test]
1135 fn row_style_merge_precedence_and_span_override() {
1136 let base_fg = PackedRgba::rgb(10, 0, 0);
1137 let selected_fg = PackedRgba::rgb(20, 0, 0);
1138 let hovered_fg = PackedRgba::rgb(30, 0, 0);
1139 let table_fg = PackedRgba::rgb(40, 0, 0);
1140 let row_fg = PackedRgba::rgb(50, 0, 0);
1141 let highlight_fg = PackedRgba::rgb(60, 0, 0);
1142 let span_fg = PackedRgba::rgb(70, 0, 0);
1143
1144 let mut theme = TableTheme::default();
1145 theme.row = Style::new().fg(base_fg);
1146 theme.row_alt = theme.row;
1147 theme.row_selected = Style::new().fg(selected_fg);
1148 theme.row_hover = Style::new().fg(hovered_fg);
1149
1150 let text = Text::from_line(Line::from_spans([
1151 Span::raw("A"),
1152 Span::styled("B", Style::new().fg(span_fg)),
1153 ]));
1154
1155 let table = Table::new(
1156 [Row::new([text]).style(Style::new().fg(row_fg))],
1157 [Constraint::Fixed(2)],
1158 )
1159 .style(Style::new().fg(table_fg))
1160 .highlight_style(Style::new().fg(highlight_fg))
1161 .theme(theme);
1162
1163 let area = Rect::new(0, 0, 2, 1);
1164 let mut pool = GraphemePool::new();
1165 let mut frame = Frame::new(2, 1, &mut pool);
1166 let mut state = TableState {
1167 selected: Some(0),
1168 hovered: Some(0),
1169 ..Default::default()
1170 };
1171
1172 StatefulWidget::render(&table, area, &mut frame, &mut state);
1173
1174 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(highlight_fg));
1175 assert_eq!(cell_fg(&frame.buffer, 1, 0), Some(span_fg));
1176 }
1177
1178 #[test]
1179 fn selection_below_offset_adjusts_offset() {
1180 let mut state = TableState {
1181 offset: 5,
1182 selected: Some(2), persistence_id: None,
1184 ..Default::default()
1185 };
1186
1187 let table = Table::new(
1188 (0..10).map(|i| Row::new([format!("Row {i}")])),
1189 [Constraint::Fixed(10)],
1190 );
1191 let area = Rect::new(0, 0, 10, 3);
1192 let mut pool = GraphemePool::new();
1193 let mut frame = Frame::new(10, 3, &mut pool);
1194 StatefulWidget::render(&table, area, &mut frame, &mut state);
1195
1196 assert_eq!(state.offset, 2);
1198 }
1199
1200 #[test]
1201 fn selection_out_of_bounds_clamps_to_last_row() {
1202 let table = Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]);
1203 let area = Rect::new(0, 0, 5, 2);
1204 let mut pool = GraphemePool::new();
1205 let mut frame = Frame::new(5, 2, &mut pool);
1206 let mut state = TableState {
1207 offset: 0,
1208 selected: Some(99),
1209 persistence_id: None,
1210 ..Default::default()
1211 };
1212
1213 StatefulWidget::render(&table, area, &mut frame, &mut state);
1214 assert_eq!(state.selected, Some(1));
1215 }
1216
1217 #[test]
1218 fn selection_with_header_accounts_for_header_height() {
1219 let header = Row::new(["H"]);
1220 let table =
1221 Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]).header(header);
1222
1223 let area = Rect::new(0, 0, 5, 2);
1224 let mut pool = GraphemePool::new();
1225 let mut frame = Frame::new(5, 2, &mut pool);
1226 let mut state = TableState {
1227 offset: 0,
1228 selected: Some(1),
1229 persistence_id: None,
1230 ..Default::default()
1231 };
1232
1233 StatefulWidget::render(&table, area, &mut frame, &mut state);
1234 assert_eq!(state.offset, 1);
1235 }
1236
1237 #[test]
1238 fn rows_overflow_area_truncated() {
1239 let table = Table::new(
1240 (0..20).map(|i| Row::new([format!("R{i}")])),
1241 [Constraint::Fixed(5)],
1242 );
1243 let area = Rect::new(0, 0, 5, 3);
1244 let mut pool = GraphemePool::new();
1245 let mut frame = Frame::new(5, 3, &mut pool);
1246 Widget::render(&table, area, &mut frame);
1247
1248 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('R'));
1250 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('0'));
1251 assert_eq!(cell_char(&frame.buffer, 1, 2), Some('2'));
1252 }
1253
1254 #[test]
1255 fn column_spacing_applied() {
1256 let table = Table::new(
1257 [Row::new(["A", "B"])],
1258 [Constraint::Fixed(3), Constraint::Fixed(3)],
1259 )
1260 .column_spacing(2);
1261
1262 let area = Rect::new(0, 0, 10, 1);
1263 let mut pool = GraphemePool::new();
1264 let mut frame = Frame::new(10, 1, &mut pool);
1265 Widget::render(&table, area, &mut frame);
1266
1267 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1269 }
1270
1271 #[test]
1272 fn divider_style_overrides_row_style() {
1273 let row_fg = PackedRgba::rgb(120, 10, 10);
1274 let divider_fg = PackedRgba::rgb(0, 200, 0);
1275 let mut theme = TableTheme::default();
1276 theme.row = Style::new().fg(row_fg);
1277 theme.row_alt = theme.row;
1278 theme.divider = Style::new().fg(divider_fg);
1279
1280 let table = Table::new(
1281 [Row::new(["AA", "BB"])],
1282 [Constraint::Fixed(2), Constraint::Fixed(2)],
1283 )
1284 .theme(theme);
1285
1286 let area = Rect::new(0, 0, 5, 1);
1287 let mut pool = GraphemePool::new();
1288 let mut frame = Frame::new(5, 1, &mut pool);
1289 Widget::render(&table, area, &mut frame);
1290
1291 assert_eq!(cell_fg(&frame.buffer, 2, 0), Some(divider_fg));
1292 }
1293
1294 #[test]
1295 fn block_border_uses_theme_border_style() {
1296 let border_fg = PackedRgba::rgb(1, 2, 3);
1297 let theme = TableTheme {
1298 border: Style::new().fg(border_fg),
1299 ..Default::default()
1300 };
1301
1302 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(1)])
1303 .block(Block::bordered())
1304 .theme(theme);
1305
1306 let area = Rect::new(0, 0, 3, 3);
1307 let mut pool = GraphemePool::new();
1308 let mut frame = Frame::new(3, 3, &mut pool);
1309 Widget::render(&table, area, &mut frame);
1310
1311 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(border_fg));
1312 }
1313
1314 #[test]
1315 fn render_clips_long_cell_to_column_width() {
1316 let table = Table::new([Row::new(["ABCDE"])], [Constraint::Fixed(3)]);
1317 let area = Rect::new(0, 0, 3, 1);
1318 let mut pool = GraphemePool::new();
1319 let mut frame = Frame::new(4, 1, &mut pool);
1320 Widget::render(&table, area, &mut frame);
1321
1322 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1323 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('B'));
1324 assert_eq!(cell_char(&frame.buffer, 2, 0), Some('C'));
1325 assert_ne!(cell_char(&frame.buffer, 3, 0), Some('D'));
1326 }
1327
1328 #[test]
1329 fn render_multiline_cell_respects_row_height() {
1330 let table = Table::new([Row::new(["A\nB"]).height(1)], [Constraint::Fixed(3)]);
1331 let area = Rect::new(0, 0, 3, 2);
1332 let mut pool = GraphemePool::new();
1333 let mut frame = Frame::new(3, 2, &mut pool);
1334 Widget::render(&table, area, &mut frame);
1335
1336 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1337 assert_ne!(cell_char(&frame.buffer, 0, 1), Some('B'));
1338 }
1339
1340 #[test]
1341 fn render_multiline_cell_draws_second_line_when_height_allows() {
1342 let table = Table::new([Row::new(["A\nB"]).height(2)], [Constraint::Fixed(3)]);
1343 let area = Rect::new(0, 0, 3, 2);
1344 let mut pool = GraphemePool::new();
1345 let mut frame = Frame::new(3, 2, &mut pool);
1346 Widget::render(&table, area, &mut frame);
1347
1348 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1349 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1350 }
1351
1352 #[test]
1353 fn more_cells_than_columns_truncated() {
1354 let table = Table::new(
1355 [Row::new(["A", "B", "C", "D"])],
1356 [Constraint::Fixed(3), Constraint::Fixed(3)],
1357 );
1358 let area = Rect::new(0, 0, 8, 1);
1359 let mut pool = GraphemePool::new();
1360 let mut frame = Frame::new(8, 1, &mut pool);
1361 Widget::render(&table, area, &mut frame);
1362 }
1364
1365 #[test]
1366 fn header_too_tall_for_area() {
1367 let header = Row::new(["H"]).height(10);
1368 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).header(header);
1369
1370 let area = Rect::new(0, 0, 5, 3);
1371 let mut pool = GraphemePool::new();
1372 let mut frame = Frame::new(5, 3, &mut pool);
1373 Widget::render(&table, area, &mut frame);
1374 }
1376
1377 #[test]
1378 fn row_with_bottom_margin() {
1379 let table = Table::new(
1380 [Row::new(["A"]).bottom_margin(1), Row::new(["B"])],
1381 [Constraint::Fixed(5)],
1382 );
1383 let area = Rect::new(0, 0, 5, 4);
1384 let mut pool = GraphemePool::new();
1385 let mut frame = Frame::new(5, 4, &mut pool);
1386 Widget::render(&table, area, &mut frame);
1387
1388 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1390 assert_eq!(cell_char(&frame.buffer, 0, 2), Some('B'));
1391 }
1392
1393 #[test]
1394 fn table_registers_hit_regions() {
1395 let table = Table::new(
1396 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
1397 [Constraint::Fixed(5)],
1398 )
1399 .hit_id(HitId::new(99));
1400
1401 let area = Rect::new(0, 0, 5, 3);
1402 let mut pool = GraphemePool::new();
1403 let mut frame = Frame::with_hit_grid(5, 3, &mut pool);
1404 let mut state = TableState::default();
1405 StatefulWidget::render(&table, area, &mut frame, &mut state);
1406
1407 let hit0 = frame.hit_test(2, 0);
1409 let hit1 = frame.hit_test(2, 1);
1410 let hit2 = frame.hit_test(2, 2);
1411
1412 assert_eq!(hit0, Some((HitId::new(99), HitRegion::Content, 0)));
1413 assert_eq!(hit1, Some((HitId::new(99), HitRegion::Content, 1)));
1414 assert_eq!(hit2, Some((HitId::new(99), HitRegion::Content, 2)));
1415 }
1416
1417 #[test]
1418 fn table_no_hit_without_hit_id() {
1419 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
1420 let area = Rect::new(0, 0, 5, 1);
1421 let mut pool = GraphemePool::new();
1422 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
1423 let mut state = TableState::default();
1424 StatefulWidget::render(&table, area, &mut frame, &mut state);
1425
1426 assert!(frame.hit_test(2, 0).is_none());
1428 }
1429
1430 #[test]
1431 fn table_no_hit_without_hit_grid() {
1432 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]).hit_id(HitId::new(1));
1433 let area = Rect::new(0, 0, 5, 1);
1434 let mut pool = GraphemePool::new();
1435 let mut frame = Frame::new(5, 1, &mut pool); let mut state = TableState::default();
1437 StatefulWidget::render(&table, area, &mut frame, &mut state);
1438
1439 assert!(frame.hit_test(2, 0).is_none());
1441 }
1442
1443 #[test]
1446 fn measure_empty_table() {
1447 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1448 let c = table.measure(Size::MAX);
1449 assert_eq!(c, SizeConstraints::ZERO);
1450 }
1451
1452 #[test]
1453 fn measure_empty_columns() {
1454 let table = Table::new([Row::new(["A"])], Vec::<Constraint>::new());
1455 let c = table.measure(Size::MAX);
1456 assert_eq!(c, SizeConstraints::ZERO);
1457 }
1458
1459 #[test]
1460 fn measure_single_row() {
1461 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1462 let c = table.measure(Size::MAX);
1463
1464 assert_eq!(c.preferred.width, 5); assert_eq!(c.preferred.height, 1); assert!(table.has_intrinsic_size());
1467 }
1468
1469 #[test]
1470 fn measure_multiple_columns() {
1471 let table = Table::new(
1472 [Row::new(["A", "BB", "CCC"])],
1473 [
1474 Constraint::Fixed(5),
1475 Constraint::Fixed(5),
1476 Constraint::Fixed(5),
1477 ],
1478 )
1479 .column_spacing(2);
1480
1481 let c = table.measure(Size::MAX);
1482
1483 assert_eq!(c.preferred.width, 10);
1485 assert_eq!(c.preferred.height, 1);
1486 }
1487
1488 #[test]
1489 fn measure_respects_row_height_and_column_spacing() {
1490 let table = Table::new(
1491 [Row::new(["A", "BB"]).height(2)],
1492 [Constraint::FitContent, Constraint::FitContent],
1493 )
1494 .column_spacing(2);
1495
1496 let c = table.measure(Size::MAX);
1497
1498 assert_eq!(c.preferred.width, 5);
1499 assert_eq!(c.preferred.height, 2);
1500 }
1501
1502 #[test]
1503 fn measure_accounts_for_wide_glyphs() {
1504 let table = Table::new(
1505 [Row::new(["界", "A"])],
1506 [Constraint::FitContent, Constraint::FitContent],
1507 )
1508 .column_spacing(1);
1509
1510 let c = table.measure(Size::MAX);
1511
1512 assert_eq!(c.preferred.width, 4);
1513 assert_eq!(c.preferred.height, 1);
1514 }
1515
1516 #[test]
1517 fn measure_with_header() {
1518 let header = Row::new(["Name", "Value"]);
1519 let table = Table::new(
1520 [Row::new(["foo", "42"])],
1521 [Constraint::Fixed(5), Constraint::Fixed(5)],
1522 )
1523 .header(header);
1524
1525 let c = table.measure(Size::MAX);
1526
1527 assert_eq!(c.preferred.width, 10);
1530 assert_eq!(c.preferred.height, 2);
1532 }
1533
1534 #[test]
1535 fn measure_with_row_margins() {
1536 let table = Table::new(
1537 [
1538 Row::new(["A"]).bottom_margin(2),
1539 Row::new(["B"]).bottom_margin(1),
1540 ],
1541 [Constraint::Fixed(5)],
1542 );
1543
1544 let c = table.measure(Size::MAX);
1545
1546 assert_eq!(c.preferred.height, 5);
1548 }
1549
1550 #[test]
1551 fn measure_column_widths_from_max_cell() {
1552 let table = Table::new(
1553 [Row::new(["A", "BB"]), Row::new(["CCC", "D"])],
1554 [Constraint::Fixed(5), Constraint::Fixed(5)],
1555 )
1556 .column_spacing(1);
1557
1558 let c = table.measure(Size::MAX);
1559
1560 assert_eq!(c.preferred.width, 6);
1564 assert_eq!(c.preferred.height, 2);
1565 }
1566
1567 #[test]
1568 fn measure_min_is_column_count() {
1569 let table = Table::new(
1570 [Row::new(["A", "B", "C"])],
1571 [
1572 Constraint::Fixed(5),
1573 Constraint::Fixed(5),
1574 Constraint::Fixed(5),
1575 ],
1576 );
1577
1578 let c = table.measure(Size::MAX);
1579
1580 assert_eq!(c.min.width, 3);
1582 assert_eq!(c.min.height, 1);
1583 }
1584
1585 #[test]
1586 fn measure_has_intrinsic_size() {
1587 let empty = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1588 assert!(!empty.has_intrinsic_size());
1589
1590 let with_rows = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]);
1591 assert!(with_rows.has_intrinsic_size());
1592
1593 let header_only =
1594 Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]).header(Row::new(["Header"]));
1595 assert!(header_only.has_intrinsic_size());
1596 }
1597
1598 use crate::stateful::Stateful;
1601
1602 #[test]
1603 fn table_state_with_persistence_id() {
1604 let state = TableState::default().with_persistence_id("my-table");
1605 assert_eq!(state.persistence_id(), Some("my-table"));
1606 }
1607
1608 #[test]
1609 fn table_state_default_no_persistence_id() {
1610 let state = TableState::default();
1611 assert_eq!(state.persistence_id(), None);
1612 }
1613
1614 #[test]
1615 fn table_state_save_restore_round_trip() {
1616 let mut state = TableState::default().with_persistence_id("test");
1617 state.select(Some(5));
1618 state.offset = 3;
1619 state.set_sort(Some(2), true);
1620 state.set_filter("search term");
1621
1622 let saved = state.save_state();
1623 assert_eq!(saved.selected, Some(5));
1624 assert_eq!(saved.offset, 3);
1625 assert_eq!(saved.sort_column, Some(2));
1626 assert!(saved.sort_ascending);
1627 assert_eq!(saved.filter, "search term");
1628
1629 state.select(None);
1631 state.offset = 0;
1632 state.set_sort(None, false);
1633 state.set_filter("");
1634 assert_eq!(state.selected, None);
1635 assert_eq!(state.offset, 0);
1636 assert_eq!(state.sort_column(), None);
1637 assert!(!state.sort_ascending());
1638 assert!(state.filter().is_empty());
1639
1640 state.restore_state(saved);
1642 assert_eq!(state.selected, Some(5));
1643 assert_eq!(state.offset, 3);
1644 assert_eq!(state.sort_column(), Some(2));
1645 assert!(state.sort_ascending());
1646 assert_eq!(state.filter(), "search term");
1647 }
1648
1649 #[test]
1650 fn table_state_key_uses_persistence_id() {
1651 let state = TableState::default().with_persistence_id("main-data-table");
1652 let key = state.state_key();
1653 assert_eq!(key.widget_type, "Table");
1654 assert_eq!(key.instance_id, "main-data-table");
1655 }
1656
1657 #[test]
1658 fn table_state_key_default_when_no_id() {
1659 let state = TableState::default();
1660 let key = state.state_key();
1661 assert_eq!(key.widget_type, "Table");
1662 assert_eq!(key.instance_id, "default");
1663 }
1664
1665 #[test]
1666 fn table_persist_state_default() {
1667 let persist = TablePersistState::default();
1668 assert_eq!(persist.selected, None);
1669 assert_eq!(persist.offset, 0);
1670 assert_eq!(persist.sort_column, None);
1671 assert!(!persist.sort_ascending);
1672 assert!(persist.filter.is_empty());
1673 }
1674
1675 #[test]
1680 fn table_state_undo_widget_id_unique() {
1681 let state1 = TableState::default();
1682 let state2 = TableState::default();
1683 assert_ne!(state1.undo_id(), state2.undo_id());
1684 }
1685
1686 #[test]
1687 fn table_state_undo_snapshot_and_restore() {
1688 let mut state = TableState::default();
1689 state.select(Some(5));
1690 state.offset = 2;
1691 state.set_sort(Some(1), false);
1692 state.set_filter("test filter");
1693
1694 let snapshot = state.create_snapshot();
1696
1697 state.select(Some(10));
1699 state.offset = 7;
1700 state.set_sort(Some(3), true);
1701 state.set_filter("new filter");
1702
1703 assert_eq!(state.selected, Some(10));
1704 assert_eq!(state.offset, 7);
1705 assert_eq!(state.sort_column(), Some(3));
1706 assert!(state.sort_ascending());
1707 assert_eq!(state.filter(), "new filter");
1708
1709 assert!(state.restore_snapshot(&*snapshot));
1711
1712 assert_eq!(state.selected, Some(5));
1714 assert_eq!(state.offset, 2);
1715 assert_eq!(state.sort_column(), Some(1));
1716 assert!(!state.sort_ascending());
1717 assert_eq!(state.filter(), "test filter");
1718 }
1719
1720 #[test]
1721 fn table_state_undo_ext_sort() {
1722 let mut state = TableState::default();
1723
1724 assert_eq!(state.sort_state(), (None, false));
1726
1727 state.set_sort_state(Some(2), true);
1729 assert_eq!(state.sort_state(), (Some(2), true));
1730
1731 state.set_sort_state(Some(0), false);
1733 assert_eq!(state.sort_state(), (Some(0), false));
1734 }
1735
1736 #[test]
1737 fn table_state_undo_ext_filter() {
1738 let mut state = TableState::default();
1739
1740 assert_eq!(state.filter_text(), "");
1742
1743 state.set_filter_text("search term");
1745 assert_eq!(state.filter_text(), "search term");
1746
1747 state.set_filter_text("");
1749 assert_eq!(state.filter_text(), "");
1750 }
1751
1752 #[test]
1753 fn table_state_restore_wrong_snapshot_type_fails() {
1754 use std::any::Any;
1755 let mut state = TableState::default();
1756 let wrong_snapshot: Box<dyn Any + Send> = Box::new(42i32);
1757 assert!(!state.restore_snapshot(&*wrong_snapshot));
1758 }
1759}