1use crate::block::Block;
2use crate::mouse::MouseResult;
3use crate::undo_support::{TableUndoExt, UndoSupport, UndoWidgetId};
4use crate::{
5 MeasurableWidget, SizeConstraints, StatefulWidget, Widget, apply_style, set_style_area,
6};
7use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
8use ftui_core::geometry::{Rect, Size};
9use ftui_layout::{Constraint, Flex};
10use ftui_render::buffer::Buffer;
11use ftui_render::cell::Cell;
12use ftui_render::frame::{Frame, HitId, HitRegion};
13use ftui_style::{
14 Style, TableEffectResolver, TableEffectScope, TableEffectTarget, TableSection, TableTheme,
15};
16use ftui_text::Text;
17use std::any::Any;
18
19#[derive(Debug, Clone, Default)]
21pub struct Row {
22 cells: Vec<Text>,
23 height: u16,
24 style: Style,
25 bottom_margin: u16,
26}
27
28impl Row {
29 #[must_use]
31 pub fn new(cells: impl IntoIterator<Item = impl Into<Text>>) -> Self {
32 Self {
33 cells: cells.into_iter().map(|c| c.into()).collect(),
34 height: 1,
35 style: Style::default(),
36 bottom_margin: 0,
37 }
38 }
39
40 #[must_use]
42 pub fn height(mut self, height: u16) -> Self {
43 self.height = height;
44 self
45 }
46
47 #[must_use]
49 pub fn style(mut self, style: Style) -> Self {
50 self.style = style;
51 self
52 }
53
54 #[must_use]
56 pub fn bottom_margin(mut self, margin: u16) -> Self {
57 self.bottom_margin = margin;
58 self
59 }
60}
61
62#[derive(Debug, Clone, Default)]
64pub struct Table<'a> {
65 rows: Vec<Row>,
66 widths: Vec<Constraint>,
67 intrinsic_col_widths: Vec<u16>,
68 header: Option<Row>,
69 block: Option<Block<'a>>,
70 style: Style,
71 highlight_style: Style,
72 theme: TableTheme,
73 theme_phase: f32,
74 column_spacing: u16,
75 hit_id: Option<HitId>,
78}
79
80impl<'a> Table<'a> {
81 #[must_use]
83 pub fn new(
84 rows: impl IntoIterator<Item = Row>,
85 widths: impl IntoIterator<Item = Constraint>,
86 ) -> Self {
87 let rows: Vec<Row> = rows.into_iter().collect();
88 let widths: Vec<Constraint> = widths.into_iter().collect();
89 let col_count = widths.len();
90
91 let intrinsic_col_widths = if Self::requires_measurement(&widths) {
92 Self::compute_intrinsic_widths(&rows, None, col_count)
93 } else {
94 Vec::new()
95 };
96
97 Self {
98 rows,
99 widths,
100 intrinsic_col_widths,
101 header: None,
102 block: None,
103 style: Style::default(),
104 highlight_style: Style::default(),
105 theme: TableTheme::default(),
106 theme_phase: 0.0,
107 column_spacing: 1,
108 hit_id: None,
109 }
110 }
111
112 #[must_use]
114 pub fn header(mut self, header: Row) -> Self {
115 self.header = Some(header);
116 self
117 }
118
119 #[must_use]
121 pub fn block(mut self, block: Block<'a>) -> Self {
122 self.block = Some(block);
123 self
124 }
125
126 #[must_use]
128 pub fn style(mut self, style: Style) -> Self {
129 self.style = style;
130 self
131 }
132
133 #[must_use]
135 pub fn highlight_style(mut self, style: Style) -> Self {
136 self.highlight_style = style;
137 self
138 }
139
140 #[must_use]
142 pub fn theme(mut self, theme: TableTheme) -> Self {
143 self.theme = theme;
144 self
145 }
146
147 #[must_use]
151 pub fn theme_phase(mut self, phase: f32) -> Self {
152 self.theme_phase = phase;
153 self
154 }
155
156 #[must_use]
158 pub fn column_spacing(mut self, spacing: u16) -> Self {
159 self.column_spacing = spacing;
160 self
161 }
162
163 #[must_use]
169 pub fn hit_id(mut self, id: HitId) -> Self {
170 self.hit_id = Some(id);
171 self
172 }
173
174 fn requires_measurement(constraints: &[Constraint]) -> bool {
175 constraints.iter().any(|c| {
176 matches!(
177 c,
178 Constraint::FitContent | Constraint::FitContentBounded { .. } | Constraint::FitMin
179 )
180 })
181 }
182
183 fn compute_intrinsic_widths(rows: &[Row], header: Option<&Row>, col_count: usize) -> Vec<u16> {
184 if col_count == 0 {
185 return Vec::new();
186 }
187
188 let mut col_widths: Vec<u16> = vec![0; col_count];
189
190 if let Some(header) = header {
191 for (i, cell) in header.cells.iter().enumerate().take(col_count) {
192 let cell_width = cell.width().min(u16::MAX as usize) as u16;
193 col_widths[i] = col_widths[i].max(cell_width);
194 }
195 }
196
197 for row in rows {
198 for (i, cell) in row.cells.iter().enumerate().take(col_count) {
199 let cell_width = cell.width().min(u16::MAX as usize) as u16;
200 col_widths[i] = col_widths[i].max(cell_width);
201 }
202 }
203
204 col_widths
205 }
206}
207
208impl<'a> Widget for Table<'a> {
209 fn render(&self, area: Rect, frame: &mut Frame) {
210 let mut state = TableState::default();
211 StatefulWidget::render(self, area, frame, &mut state);
212 }
213}
214
215#[derive(Debug, Clone, Default)]
217pub struct TableState {
218 #[allow(dead_code)]
220 undo_id: UndoWidgetId,
221 pub selected: Option<usize>,
223 pub hovered: Option<usize>,
225 pub offset: usize,
227 persistence_id: Option<String>,
230 #[allow(dead_code)]
232 sort_column: Option<usize>,
233 #[allow(dead_code)]
235 sort_ascending: bool,
236 #[allow(dead_code)]
238 filter: String,
239}
240
241impl TableState {
242 pub fn select(&mut self, index: Option<usize>) {
244 self.selected = index;
245 if index.is_none() {
246 self.offset = 0;
247 }
248 }
249
250 #[must_use]
252 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
253 self.persistence_id = Some(id.into());
254 self
255 }
256
257 #[must_use = "use the persistence id (if any)"]
259 pub fn persistence_id(&self) -> Option<&str> {
260 self.persistence_id.as_deref()
261 }
262}
263
264#[derive(Clone, Debug, Default, PartialEq)]
273#[cfg_attr(
274 feature = "state-persistence",
275 derive(serde::Serialize, serde::Deserialize)
276)]
277pub struct TablePersistState {
278 pub selected: Option<usize>,
280 pub offset: usize,
282 pub sort_column: Option<usize>,
284 pub sort_ascending: bool,
286 pub filter: String,
288}
289
290impl crate::stateful::Stateful for TableState {
291 type State = TablePersistState;
292
293 fn state_key(&self) -> crate::stateful::StateKey {
294 crate::stateful::StateKey::new("Table", self.persistence_id.as_deref().unwrap_or("default"))
295 }
296
297 fn save_state(&self) -> TablePersistState {
298 TablePersistState {
299 selected: self.selected,
300 offset: self.offset,
301 sort_column: self.sort_column,
302 sort_ascending: self.sort_ascending,
303 filter: self.filter.clone(),
304 }
305 }
306
307 fn restore_state(&mut self, state: TablePersistState) {
308 self.selected = state.selected;
310 self.hovered = None;
311 self.offset = state.offset;
312 self.sort_column = state.sort_column;
313 self.sort_ascending = state.sort_ascending;
314 self.filter = state.filter;
315 }
316}
317
318#[derive(Debug, Clone)]
324pub struct TableStateSnapshot {
325 selected: Option<usize>,
326 offset: usize,
327 sort_column: Option<usize>,
328 sort_ascending: bool,
329 filter: String,
330}
331
332impl UndoSupport for TableState {
333 fn undo_widget_id(&self) -> UndoWidgetId {
334 self.undo_id
335 }
336
337 fn create_snapshot(&self) -> Box<dyn Any + Send> {
338 Box::new(TableStateSnapshot {
339 selected: self.selected,
340 offset: self.offset,
341 sort_column: self.sort_column,
342 sort_ascending: self.sort_ascending,
343 filter: self.filter.clone(),
344 })
345 }
346
347 fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool {
348 if let Some(snap) = snapshot.downcast_ref::<TableStateSnapshot>() {
349 self.selected = snap.selected;
350 self.hovered = None;
351 self.offset = snap.offset;
352 self.sort_column = snap.sort_column;
353 self.sort_ascending = snap.sort_ascending;
354 self.filter = snap.filter.clone();
355 true
356 } else {
357 false
358 }
359 }
360}
361
362impl TableUndoExt for TableState {
363 fn sort_state(&self) -> (Option<usize>, bool) {
364 (self.sort_column, self.sort_ascending)
365 }
366
367 fn set_sort_state(&mut self, column: Option<usize>, ascending: bool) {
368 self.sort_column = column;
369 self.sort_ascending = ascending;
370 }
371
372 fn filter_text(&self) -> &str {
373 &self.filter
374 }
375
376 fn set_filter_text(&mut self, filter: &str) {
377 self.filter = filter.to_string();
378 }
379}
380
381impl TableState {
382 #[must_use]
386 pub fn undo_id(&self) -> UndoWidgetId {
387 self.undo_id
388 }
389
390 #[must_use = "use the sort column (if any)"]
392 pub fn sort_column(&self) -> Option<usize> {
393 self.sort_column
394 }
395
396 #[must_use]
398 pub fn sort_ascending(&self) -> bool {
399 self.sort_ascending
400 }
401
402 pub fn set_sort(&mut self, column: Option<usize>, ascending: bool) {
404 self.sort_column = column;
405 self.sort_ascending = ascending;
406 }
407
408 #[must_use]
410 pub fn filter(&self) -> &str {
411 &self.filter
412 }
413
414 pub fn set_filter(&mut self, filter: impl Into<String>) {
416 self.filter = filter.into();
417 }
418
419 pub fn handle_mouse(
434 &mut self,
435 event: &MouseEvent,
436 hit: Option<(HitId, HitRegion, u64)>,
437 expected_id: HitId,
438 row_count: usize,
439 ) -> MouseResult {
440 match event.kind {
441 MouseEventKind::Down(MouseButton::Left) => {
442 if let Some((id, HitRegion::Content, data)) = hit
443 && id == expected_id
444 {
445 let index = data as usize;
446 if index < row_count {
447 if self.selected == Some(index) {
449 return MouseResult::Activated(index);
450 }
451 self.select(Some(index));
452 return MouseResult::Selected(index);
453 }
454 }
455 MouseResult::Ignored
456 }
457 MouseEventKind::Moved => {
458 if let Some((id, HitRegion::Content, data)) = hit
459 && id == expected_id
460 {
461 let index = data as usize;
462 if index < row_count {
463 let changed = self.hovered != Some(index);
464 self.hovered = Some(index);
465 return if changed {
466 MouseResult::HoverChanged
467 } else {
468 MouseResult::Ignored
469 };
470 }
471 }
472 if self.hovered.is_some() {
474 self.hovered = None;
475 MouseResult::HoverChanged
476 } else {
477 MouseResult::Ignored
478 }
479 }
480 MouseEventKind::ScrollUp => {
481 self.scroll_up(3);
482 MouseResult::Scrolled
483 }
484 MouseEventKind::ScrollDown => {
485 self.scroll_down(3, row_count);
486 MouseResult::Scrolled
487 }
488 _ => MouseResult::Ignored,
489 }
490 }
491
492 pub fn scroll_up(&mut self, lines: usize) {
494 self.offset = self.offset.saturating_sub(lines);
495 }
496
497 pub fn scroll_down(&mut self, lines: usize, row_count: usize) {
501 self.offset = self
502 .offset
503 .saturating_add(lines)
504 .min(row_count.saturating_sub(1));
505 }
506}
507
508impl<'a> StatefulWidget for Table<'a> {
509 type State = TableState;
510
511 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
512 #[cfg(feature = "tracing")]
513 let _span = tracing::debug_span!(
514 "widget_render",
515 widget = "Table",
516 x = area.x,
517 y = area.y,
518 w = area.width,
519 h = area.height
520 )
521 .entered();
522
523 if area.is_empty() {
524 return;
525 }
526
527 let apply_styling = frame.degradation.apply_styling();
528 let theme = &self.theme;
529 let effects_enabled = apply_styling && !theme.effects.is_empty();
530 let has_column_effects = effects_enabled && theme_has_column_effects(theme);
531 let effect_resolver = theme.effect_resolver();
532 let effects = if effects_enabled {
533 Some((&effect_resolver, self.theme_phase))
534 } else {
535 None
536 };
537
538 let table_area = match &self.block {
540 Some(b) => {
541 let mut block = b.clone();
542 if apply_styling {
543 block = block.border_style(theme.border);
544 }
545 block.render(area, frame);
546 block.inner(area)
547 }
548 None => area,
549 };
550
551 if table_area.is_empty() {
552 return;
553 }
554
555 frame.buffer.push_scissor(table_area);
558
559 if apply_styling {
561 let fill_style = self.style.merge(&theme.row);
562 set_style_area(&mut frame.buffer, table_area, fill_style);
563 }
564
565 let header_height = self
566 .header
567 .as_ref()
568 .map(|h| h.height.saturating_add(h.bottom_margin))
569 .unwrap_or(0);
570
571 if header_height > table_area.height {
572 frame.buffer.pop_scissor();
573 return;
574 }
575
576 let rows_top = table_area.y.saturating_add(header_height);
577 let rows_max_y = table_area.bottom();
578 let rows_height = rows_max_y.saturating_sub(rows_top);
579
580 if self.rows.is_empty() {
582 state.offset = 0;
583 } else {
584 let row_count = self.rows.len();
585 state.offset = state.offset.min(row_count.saturating_sub(1));
586
587 let available_height = rows_height;
594 let mut accumulated = 0u16;
595 let mut bottom_offset = row_count.saturating_sub(1);
596 for i in (0..row_count).rev() {
597 let row = &self.rows[i];
598 let total_row_height = if i == row_count - 1 {
599 row.height
600 } else {
601 row.height.saturating_add(row.bottom_margin)
602 };
603
604 if total_row_height > available_height.saturating_sub(accumulated) {
605 break;
607 }
608
609 accumulated = accumulated.saturating_add(total_row_height);
610 bottom_offset = i;
611 }
612
613 state.offset = state.offset.min(bottom_offset);
614 }
615
616 if let Some(selected) = state.selected {
617 if self.rows.is_empty() {
618 state.selected = None;
619 } else if selected >= self.rows.len() {
620 state.selected = Some(self.rows.len() - 1);
621 }
622 }
623
624 if let Some(selected) = state.selected {
626 if selected < state.offset {
627 state.offset = selected;
628 } else {
629 let mut current_y = rows_top;
632 let max_y = rows_max_y;
633 let mut last_visible = state.offset;
634
635 for (i, row) in self.rows.iter().enumerate().skip(state.offset) {
637 if row.height > max_y.saturating_sub(current_y) {
638 break;
639 }
640 current_y = current_y
641 .saturating_add(row.height)
642 .saturating_add(row.bottom_margin);
643 last_visible = i;
644 }
645
646 if selected > last_visible {
647 let mut new_offset = selected;
649 let mut accumulated_height = 0;
650 let available_height = rows_height;
651
652 for i in (0..=selected).rev() {
654 let row = &self.rows[i];
655 let total_row_height = if i == selected {
658 row.height
659 } else {
660 row.height.saturating_add(row.bottom_margin)
661 };
662
663 if total_row_height > available_height.saturating_sub(accumulated_height) {
664 if i == selected {
668 new_offset = selected;
669 } else {
670 new_offset = i + 1;
671 }
672 break;
673 }
674
675 accumulated_height = accumulated_height.saturating_add(total_row_height);
676 new_offset = i;
677 }
678 state.offset = new_offset;
679 }
680 }
681 }
682
683 let flex = Flex::horizontal()
685 .constraints(self.widths.clone())
686 .gap(self.column_spacing);
687
688 let column_rects = flex.split_with_measurer(
690 Rect::new(table_area.x, table_area.y, table_area.width, 1),
691 |idx, _| {
692 let row_width = self.intrinsic_col_widths.get(idx).copied().unwrap_or(0);
694 let header_width = self
695 .header
696 .as_ref()
697 .and_then(|h| h.cells.get(idx))
698 .map(|c| c.width().min(u16::MAX as usize) as u16)
699 .unwrap_or(0);
700 ftui_layout::LayoutSizeHint::exact(row_width.max(header_width))
701 },
702 );
703
704 let mut y = table_area.y;
705 let max_y = table_area.bottom();
706 let divider_char = divider_char(self.block.as_ref());
707
708 if let Some(header) = &self.header {
710 if header.height > max_y.saturating_sub(y) {
711 frame.buffer.pop_scissor();
712 return;
713 }
714 let row_area = Rect::new(table_area.x, y, table_area.width, header.height);
715 let header_style = if apply_styling {
716 let mut style = theme.header;
717 style = self.style.merge(&style);
718 header.style.merge(&style)
719 } else {
720 Style::default()
721 };
722
723 if apply_styling {
724 set_style_area(&mut frame.buffer, row_area, header_style);
725 if let Some((resolver, phase)) = effects {
726 for (col_idx, rect) in column_rects.iter().enumerate() {
727 let cell_area = Rect::new(rect.x, y, rect.width, header.height);
728 let scope = TableEffectScope {
729 section: TableSection::Header,
730 row: None,
731 column: Some(col_idx),
732 };
733 let style = resolver.resolve(header_style, scope, phase);
734 set_style_area(&mut frame.buffer, cell_area, style);
735 }
736 }
737 }
738
739 let divider_style = if apply_styling {
740 theme.divider.merge(&header_style)
741 } else {
742 Style::default()
743 };
744 draw_vertical_dividers(
745 &mut frame.buffer,
746 row_area,
747 &column_rects,
748 divider_char,
749 divider_style,
750 );
751
752 render_row(
753 header,
754 &column_rects,
755 frame,
756 y,
757 header_style,
758 TableSection::Header,
759 None,
760 effects,
761 effects.is_some(),
762 );
763 y = y
764 .saturating_add(header.height)
765 .saturating_add(header.bottom_margin);
766 }
767
768 if self.rows.is_empty() {
770 frame.buffer.pop_scissor();
771 return;
772 }
773
774 for (i, row) in self.rows.iter().enumerate().skip(state.offset) {
778 if y >= max_y {
779 break;
780 }
781
782 let is_selected = state.selected == Some(i);
783 let is_hovered = state.hovered == Some(i);
784 let row_area = Rect::new(table_area.x, y, table_area.width, row.height);
785 let row_style = if apply_styling {
786 let mut style = if i % 2 == 0 { theme.row } else { theme.row_alt };
787 if is_selected {
788 style = theme.row_selected.merge(&style);
789 }
790 if is_hovered {
791 style = theme.row_hover.merge(&style);
792 }
793 style = self.style.merge(&style);
794 style = row.style.merge(&style);
795 if is_selected {
796 style = self.highlight_style.merge(&style);
797 }
798 style
799 } else {
800 Style::default()
801 };
802
803 if apply_styling {
804 if let Some((resolver, phase)) = effects {
805 if has_column_effects {
806 set_style_area(&mut frame.buffer, row_area, row_style);
807 for (col_idx, rect) in column_rects.iter().enumerate() {
808 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
809 let scope = TableEffectScope {
810 section: TableSection::Body,
811 row: Some(i),
812 column: Some(col_idx),
813 };
814 let style = resolver.resolve(row_style, scope, phase);
815 set_style_area(&mut frame.buffer, cell_area, style);
816 }
817 } else {
818 let scope = TableEffectScope::row(TableSection::Body, i);
819 let style = resolver.resolve(row_style, scope, phase);
820 set_style_area(&mut frame.buffer, row_area, style);
821 }
822 } else {
823 set_style_area(&mut frame.buffer, row_area, row_style);
824 }
825 }
826
827 let divider_style = if apply_styling {
828 theme.divider.merge(&row_style)
829 } else {
830 Style::default()
831 };
832 draw_vertical_dividers(
833 &mut frame.buffer,
834 row_area,
835 &column_rects,
836 divider_char,
837 divider_style,
838 );
839
840 render_row(
841 row,
842 &column_rects,
843 frame,
844 y,
845 row_style,
846 TableSection::Body,
847 Some(i),
848 effects,
849 has_column_effects,
850 );
851
852 if let Some(id) = self.hit_id {
854 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
855 }
856
857 y = y
858 .saturating_add(row.height)
859 .saturating_add(row.bottom_margin);
860 }
861
862 frame.buffer.pop_scissor();
863 }
864}
865
866#[allow(clippy::too_many_arguments)]
867fn render_row(
868 row: &Row,
869 col_rects: &[Rect],
870 frame: &mut Frame,
871 y: u16,
872 base_style: Style,
873 section: TableSection,
874 row_idx: Option<usize>,
875 effects: Option<(&TableEffectResolver<'_>, f32)>,
876 column_effects: bool,
877) {
878 let apply_styling = frame.degradation.apply_styling();
879 let row_effect_base = if apply_styling {
880 if let Some((resolver, phase)) = effects {
881 if !column_effects {
882 let scope = TableEffectScope {
883 section,
884 row: row_idx,
885 column: None,
886 };
887 Some(resolver.resolve(base_style, scope, phase))
888 } else {
889 None
890 }
891 } else {
892 None
893 }
894 } else {
895 None
896 };
897
898 for (col_idx, cell_text) in row.cells.iter().enumerate() {
899 if col_idx >= col_rects.len() {
900 break;
901 }
902 let rect = col_rects[col_idx];
903 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
904 let scope = if effects.is_some() {
905 Some(TableEffectScope {
906 section,
907 row: row_idx,
908 column: if column_effects { Some(col_idx) } else { None },
909 })
910 } else {
911 None
912 };
913 let column_effect_base = if apply_styling && column_effects {
914 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
915 Some(resolver.resolve(base_style, scope, phase))
916 } else {
917 None
918 }
919 } else {
920 None
921 };
922
923 for (line_idx, line) in cell_text.lines().iter().enumerate() {
924 if line_idx as u16 >= row.height {
925 break;
926 }
927
928 let mut x = cell_area.x;
929 for span in line.spans() {
930 let mut span_style = if apply_styling {
932 match span.style {
933 Some(s) => s.merge(&base_style),
934 None => base_style,
935 }
936 } else {
937 Style::default()
938 };
939
940 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
941 if span.style.is_none() {
942 if let Some(base_effect) = column_effect_base.or(row_effect_base) {
943 span_style = base_effect;
944 } else {
945 span_style = resolver.resolve(span_style, scope, phase);
946 }
947 } else {
948 span_style = resolver.resolve(span_style, scope, phase);
949 }
950 }
951
952 x = crate::draw_text_span_with_link(
953 frame,
954 x,
955 cell_area.y.saturating_add(line_idx as u16),
956 &span.content,
957 span_style,
958 cell_area.right(),
959 span.link.as_deref(),
960 );
961 if x >= cell_area.right() {
962 break;
963 }
964 }
965 }
966 }
967}
968
969fn theme_has_column_effects(theme: &TableTheme) -> bool {
970 theme.effects.iter().any(|rule| {
971 matches!(
972 rule.target,
973 TableEffectTarget::Column(_) | TableEffectTarget::ColumnRange { .. }
974 )
975 })
976}
977
978fn divider_char(block: Option<&Block<'_>>) -> char {
979 block
980 .map(|b| b.border_set().vertical)
981 .unwrap_or(crate::borders::BorderSet::SQUARE.vertical)
982}
983
984fn draw_vertical_dividers(
985 buf: &mut Buffer,
986 row_area: Rect,
987 col_rects: &[Rect],
988 divider_char: char,
989 style: Style,
990) {
991 if col_rects.len() < 2 || row_area.is_empty() {
992 return;
993 }
994
995 for pair in col_rects.windows(2) {
996 let left = pair[0];
997 let right = pair[1];
998 let gap = right.x.saturating_sub(left.right());
999 if gap == 0 {
1000 continue;
1001 }
1002 let x = left.right();
1003 if x >= row_area.right() {
1004 continue;
1005 }
1006 for y in row_area.y..row_area.bottom() {
1007 let mut cell = Cell::from_char(divider_char);
1008 apply_style(&mut cell, style);
1009 buf.set_fast(x, y, cell);
1010 }
1011 }
1012}
1013
1014impl MeasurableWidget for Table<'_> {
1015 fn measure(&self, _available: Size) -> SizeConstraints {
1016 if self.rows.is_empty() && self.header.is_none() {
1017 return SizeConstraints::ZERO;
1018 }
1019
1020 let col_count = self.widths.len();
1021 if col_count == 0 {
1022 return SizeConstraints::ZERO;
1023 }
1024
1025 let fallback;
1026 let row_widths = if self.intrinsic_col_widths.len() == col_count {
1027 &self.intrinsic_col_widths
1028 } else {
1029 fallback = Self::compute_intrinsic_widths(&self.rows, None, col_count);
1031 &fallback
1032 };
1033
1034 let separator_width = if col_count > 1 {
1036 ((col_count - 1) as u16).saturating_mul(self.column_spacing)
1037 } else {
1038 0
1039 };
1040
1041 let mut summed_col_width = 0u16;
1042 for (i, &r_w) in row_widths.iter().enumerate() {
1043 let h_w = self
1044 .header
1045 .as_ref()
1046 .and_then(|h| h.cells.get(i))
1047 .map(|c| c.width().min(u16::MAX as usize) as u16)
1048 .unwrap_or(0);
1049 summed_col_width = summed_col_width.saturating_add(r_w.max(h_w));
1050 }
1051
1052 let content_width = summed_col_width.saturating_add(separator_width);
1053
1054 let header_height = self
1057 .header
1058 .as_ref()
1059 .map(|h| h.height.saturating_add(h.bottom_margin))
1060 .unwrap_or(0);
1061
1062 let rows_height: u16 = self.rows.iter().fold(0u16, |acc, r| {
1063 acc.saturating_add(r.height.saturating_add(r.bottom_margin))
1064 });
1065
1066 let content_height = header_height.saturating_add(rows_height);
1067
1068 let (block_width, block_height) = self
1070 .block
1071 .as_ref()
1072 .map(|b| {
1073 let inner = b.inner(Rect::new(0, 0, 100, 100));
1074 let w_overhead = 100u16.saturating_sub(inner.width);
1075 let h_overhead = 100u16.saturating_sub(inner.height);
1076 (w_overhead, h_overhead)
1077 })
1078 .unwrap_or((0, 0));
1079
1080 let total_width = content_width.saturating_add(block_width);
1081 let total_height = content_height.saturating_add(block_height);
1082
1083 SizeConstraints {
1084 min: Size::new(col_count as u16, 1), preferred: Size::new(total_width, total_height),
1086 max: Some(Size::new(total_width, total_height)), }
1088 }
1089
1090 fn has_intrinsic_size(&self) -> bool {
1091 !self.rows.is_empty() || self.header.is_some()
1092 }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use ftui_render::buffer::Buffer;
1099 use ftui_render::cell::PackedRgba;
1100 use ftui_render::grapheme_pool::GraphemePool;
1101 use ftui_text::{Line, Span};
1102
1103 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
1104 buf.get(x, y).and_then(|c| c.content.as_char())
1105 }
1106
1107 fn cell_fg(buf: &Buffer, x: u16, y: u16) -> Option<PackedRgba> {
1108 buf.get(x, y).map(|c| c.fg)
1109 }
1110
1111 fn row_text(buf: &Buffer, y: u16) -> String {
1112 let width = buf.width();
1113 let mut actual = String::new();
1114 for x in 0..width {
1115 let ch = buf
1116 .get(x, y)
1117 .and_then(|cell| cell.content.as_char())
1118 .unwrap_or(' ');
1119 actual.push(ch);
1120 }
1121 actual.trim().to_string()
1122 }
1123
1124 #[test]
1127 fn row_new_from_strings() {
1128 let row = Row::new(["A", "B", "C"]);
1129 assert_eq!(row.cells.len(), 3);
1130 assert_eq!(row.height, 1);
1131 assert_eq!(row.bottom_margin, 0);
1132 }
1133
1134 #[test]
1135 fn row_builder_methods() {
1136 let row = Row::new(["X"])
1137 .height(3)
1138 .bottom_margin(1)
1139 .style(Style::new().bold());
1140 assert_eq!(row.height, 3);
1141 assert_eq!(row.bottom_margin, 1);
1142 assert!(row.style.has_attr(ftui_style::StyleFlags::BOLD));
1143 }
1144
1145 #[test]
1148 fn table_state_default() {
1149 let state = TableState::default();
1150 assert_eq!(state.selected, None);
1151 assert_eq!(state.offset, 0);
1152 }
1153
1154 #[test]
1155 fn table_state_select() {
1156 let mut state = TableState::default();
1157 state.select(Some(5));
1158 assert_eq!(state.selected, Some(5));
1159 assert_eq!(state.offset, 0);
1160 }
1161
1162 #[test]
1163 fn table_state_deselect_resets_offset() {
1164 let mut state = TableState {
1165 offset: 10,
1166 ..Default::default()
1167 };
1168 state.select(Some(3));
1169 assert_eq!(state.selected, Some(3));
1170 state.select(None);
1171 assert_eq!(state.selected, None);
1172 assert_eq!(state.offset, 0);
1173 }
1174
1175 #[test]
1176 fn table_state_scroll_down_is_overflow_safe() {
1177 let mut state = TableState {
1179 offset: usize::MAX - 1,
1180 ..Default::default()
1181 };
1182 state.scroll_down(10, 100);
1183 assert_eq!(state.offset, 99);
1184 }
1185
1186 #[test]
1189 fn render_zero_area() {
1190 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
1191 let area = Rect::new(0, 0, 0, 0);
1192 let mut pool = GraphemePool::new();
1193 let mut frame = Frame::new(1, 1, &mut pool);
1194 Widget::render(&table, area, &mut frame);
1195 }
1197
1198 #[test]
1199 fn render_empty_rows() {
1200 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1201 let area = Rect::new(0, 0, 10, 5);
1202 let mut pool = GraphemePool::new();
1203 let mut frame = Frame::new(10, 5, &mut pool);
1204 Widget::render(&table, area, &mut frame);
1205 }
1207
1208 #[test]
1209 fn render_single_row_single_column() {
1210 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1211 let area = Rect::new(0, 0, 10, 3);
1212 let mut pool = GraphemePool::new();
1213 let mut frame = Frame::new(10, 3, &mut pool);
1214 Widget::render(&table, area, &mut frame);
1215
1216 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
1217 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('e'));
1218 assert_eq!(cell_char(&frame.buffer, 4, 0), Some('o'));
1219 }
1220
1221 #[test]
1222 fn render_multiple_rows() {
1223 let table = Table::new(
1224 [Row::new(["AA", "BB"]), Row::new(["CC", "DD"])],
1225 [Constraint::Fixed(4), Constraint::Fixed(4)],
1226 );
1227 let area = Rect::new(0, 0, 10, 3);
1228 let mut pool = GraphemePool::new();
1229 let mut frame = Frame::new(10, 3, &mut pool);
1230 Widget::render(&table, area, &mut frame);
1231
1232 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1234 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('C'));
1236 }
1237
1238 #[test]
1239 fn render_with_header() {
1240 let header = Row::new(["Name", "Val"]);
1241 let table = Table::new(
1242 [Row::new(["foo", "42"])],
1243 [Constraint::Fixed(5), Constraint::Fixed(4)],
1244 )
1245 .header(header);
1246
1247 let area = Rect::new(0, 0, 10, 3);
1248 let mut pool = GraphemePool::new();
1249 let mut frame = Frame::new(10, 3, &mut pool);
1250 Widget::render(&table, area, &mut frame);
1251
1252 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('N'));
1254 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('f'));
1256 }
1257
1258 #[test]
1259 fn render_with_block() {
1260 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).block(Block::bordered());
1261
1262 let area = Rect::new(0, 0, 10, 5);
1263 let mut pool = GraphemePool::new();
1264 let mut frame = Frame::new(10, 5, &mut pool);
1265 Widget::render(&table, area, &mut frame);
1266
1267 assert_eq!(cell_char(&frame.buffer, 1, 1), Some('X'));
1270 }
1271
1272 #[test]
1273 fn stateful_render_with_selection() {
1274 let table = Table::new(
1275 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
1276 [Constraint::Fixed(5)],
1277 )
1278 .highlight_style(Style::new().bold());
1279
1280 let area = Rect::new(0, 0, 5, 3);
1281 let mut pool = GraphemePool::new();
1282 let mut frame = Frame::new(5, 3, &mut pool);
1283 let mut state = TableState::default();
1284 state.select(Some(1));
1285
1286 StatefulWidget::render(&table, area, &mut frame, &mut state);
1287 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1290 }
1291
1292 #[test]
1293 fn row_style_merge_precedence_and_span_override() {
1294 let base_fg = PackedRgba::rgb(10, 0, 0);
1295 let selected_fg = PackedRgba::rgb(20, 0, 0);
1296 let hovered_fg = PackedRgba::rgb(30, 0, 0);
1297 let table_fg = PackedRgba::rgb(40, 0, 0);
1298 let row_fg = PackedRgba::rgb(50, 0, 0);
1299 let highlight_fg = PackedRgba::rgb(60, 0, 0);
1300 let span_fg = PackedRgba::rgb(70, 0, 0);
1301
1302 let base_row = Style::new().fg(base_fg);
1303 let theme = TableTheme {
1304 row: base_row,
1305 row_alt: base_row,
1306 row_selected: Style::new().fg(selected_fg),
1307 row_hover: Style::new().fg(hovered_fg),
1308 ..Default::default()
1309 };
1310
1311 let text = Text::from_line(Line::from_spans([
1312 Span::raw("A"),
1313 Span::styled("B", Style::new().fg(span_fg)),
1314 ]));
1315
1316 let table = Table::new(
1317 [Row::new([text]).style(Style::new().fg(row_fg))],
1318 [Constraint::Fixed(2)],
1319 )
1320 .style(Style::new().fg(table_fg))
1321 .highlight_style(Style::new().fg(highlight_fg))
1322 .theme(theme);
1323
1324 let area = Rect::new(0, 0, 2, 1);
1325 let mut pool = GraphemePool::new();
1326 let mut frame = Frame::new(2, 1, &mut pool);
1327 let mut state = TableState {
1328 selected: Some(0),
1329 hovered: Some(0),
1330 ..Default::default()
1331 };
1332
1333 StatefulWidget::render(&table, area, &mut frame, &mut state);
1334
1335 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(highlight_fg));
1336 assert_eq!(cell_fg(&frame.buffer, 1, 0), Some(span_fg));
1337 }
1338
1339 #[test]
1340 fn selection_below_offset_adjusts_offset() {
1341 let mut state = TableState {
1342 offset: 5,
1343 selected: Some(2), persistence_id: None,
1345 ..Default::default()
1346 };
1347
1348 let table = Table::new(
1349 (0..10).map(|i| Row::new([format!("Row {i}")])),
1350 [Constraint::Fixed(10)],
1351 );
1352 let area = Rect::new(0, 0, 10, 3);
1353 let mut pool = GraphemePool::new();
1354 let mut frame = Frame::new(10, 3, &mut pool);
1355 StatefulWidget::render(&table, area, &mut frame, &mut state);
1356
1357 assert_eq!(state.offset, 2);
1359 }
1360
1361 #[test]
1362 fn table_clamps_offset_to_fill_viewport_on_resize() {
1363 let rows: Vec<Row> = (0..10).map(|i| Row::new([format!("Row {i}")])).collect();
1364 let table = Table::new(rows, [Constraint::Min(10)]);
1365
1366 let mut pool = GraphemePool::new();
1367 let mut state = TableState {
1368 offset: 7,
1369 ..Default::default()
1370 };
1371
1372 let area_small = Rect::new(0, 0, 10, 3);
1374 let mut frame_small = Frame::new(10, 3, &mut pool);
1375 StatefulWidget::render(&table, area_small, &mut frame_small, &mut state);
1376 assert_eq!(state.offset, 7);
1377 assert_eq!(row_text(&frame_small.buffer, 0), "Row 7");
1378 assert_eq!(row_text(&frame_small.buffer, 2), "Row 9");
1379
1380 let area_large = Rect::new(0, 0, 10, 5);
1382 let mut frame_large = Frame::new(10, 5, &mut pool);
1383 StatefulWidget::render(&table, area_large, &mut frame_large, &mut state);
1384 assert_eq!(state.offset, 5);
1385 assert_eq!(row_text(&frame_large.buffer, 0), "Row 5");
1386 assert_eq!(row_text(&frame_large.buffer, 4), "Row 9");
1387 }
1388
1389 #[test]
1390 fn table_clamps_offset_to_fill_viewport_with_variable_row_heights() {
1391 let mut rows: Vec<Row> = (0..9).map(|i| Row::new([format!("Row {i}")])).collect();
1395 rows.push(Row::new(["Row 9"]).height(5));
1396 let table = Table::new(rows, [Constraint::Min(10)]);
1397
1398 let mut pool = GraphemePool::new();
1399 let mut state = TableState {
1400 offset: 9,
1401 ..Default::default()
1402 };
1403
1404 let area = Rect::new(0, 0, 10, 10);
1405 let mut frame = Frame::new(10, 10, &mut pool);
1406 StatefulWidget::render(&table, area, &mut frame, &mut state);
1407
1408 assert_eq!(state.offset, 4);
1409 assert_eq!(row_text(&frame.buffer, 0), "Row 4");
1410 }
1411
1412 #[test]
1413 fn selection_out_of_bounds_clamps_to_last_row() {
1414 let table = Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]);
1415 let area = Rect::new(0, 0, 5, 2);
1416 let mut pool = GraphemePool::new();
1417 let mut frame = Frame::new(5, 2, &mut pool);
1418 let mut state = TableState {
1419 offset: 0,
1420 selected: Some(99),
1421 persistence_id: None,
1422 ..Default::default()
1423 };
1424
1425 StatefulWidget::render(&table, area, &mut frame, &mut state);
1426 assert_eq!(state.selected, Some(1));
1427 }
1428
1429 #[test]
1430 fn selection_with_header_accounts_for_header_height() {
1431 let header = Row::new(["H"]);
1432 let table =
1433 Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]).header(header);
1434
1435 let area = Rect::new(0, 0, 5, 2);
1436 let mut pool = GraphemePool::new();
1437 let mut frame = Frame::new(5, 2, &mut pool);
1438 let mut state = TableState {
1439 offset: 0,
1440 selected: Some(1),
1441 persistence_id: None,
1442 ..Default::default()
1443 };
1444
1445 StatefulWidget::render(&table, area, &mut frame, &mut state);
1446 assert_eq!(state.offset, 1);
1447 }
1448
1449 #[test]
1450 fn rows_overflow_area_truncated() {
1451 let table = Table::new(
1452 (0..20).map(|i| Row::new([format!("R{i}")])),
1453 [Constraint::Fixed(5)],
1454 );
1455 let area = Rect::new(0, 0, 5, 3);
1456 let mut pool = GraphemePool::new();
1457 let mut frame = Frame::new(5, 3, &mut pool);
1458 Widget::render(&table, area, &mut frame);
1459
1460 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('R'));
1462 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('0'));
1463 assert_eq!(cell_char(&frame.buffer, 1, 2), Some('2'));
1464 }
1465
1466 #[test]
1467 fn column_spacing_applied() {
1468 let table = Table::new(
1469 [Row::new(["A", "B"])],
1470 [Constraint::Fixed(3), Constraint::Fixed(3)],
1471 )
1472 .column_spacing(2);
1473
1474 let area = Rect::new(0, 0, 10, 1);
1475 let mut pool = GraphemePool::new();
1476 let mut frame = Frame::new(10, 1, &mut pool);
1477 Widget::render(&table, area, &mut frame);
1478
1479 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1481 }
1482
1483 #[test]
1484 fn divider_style_overrides_row_style() {
1485 let row_fg = PackedRgba::rgb(120, 10, 10);
1486 let divider_fg = PackedRgba::rgb(0, 200, 0);
1487 let row_style = Style::new().fg(row_fg);
1488 let theme = TableTheme {
1489 row: row_style,
1490 row_alt: row_style,
1491 divider: Style::new().fg(divider_fg),
1492 ..Default::default()
1493 };
1494
1495 let table = Table::new(
1496 [Row::new(["AA", "BB"])],
1497 [Constraint::Fixed(2), Constraint::Fixed(2)],
1498 )
1499 .theme(theme);
1500
1501 let area = Rect::new(0, 0, 5, 1);
1502 let mut pool = GraphemePool::new();
1503 let mut frame = Frame::new(5, 1, &mut pool);
1504 Widget::render(&table, area, &mut frame);
1505
1506 assert_eq!(cell_fg(&frame.buffer, 2, 0), Some(divider_fg));
1507 }
1508
1509 #[test]
1510 fn block_border_uses_theme_border_style() {
1511 let border_fg = PackedRgba::rgb(1, 2, 3);
1512 let theme = TableTheme {
1513 border: Style::new().fg(border_fg),
1514 ..Default::default()
1515 };
1516
1517 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(1)])
1518 .block(Block::bordered())
1519 .theme(theme);
1520
1521 let area = Rect::new(0, 0, 3, 3);
1522 let mut pool = GraphemePool::new();
1523 let mut frame = Frame::new(3, 3, &mut pool);
1524 Widget::render(&table, area, &mut frame);
1525
1526 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(border_fg));
1527 }
1528
1529 #[test]
1530 fn render_clips_long_cell_to_column_width() {
1531 let table = Table::new([Row::new(["ABCDE"])], [Constraint::Fixed(3)]);
1532 let area = Rect::new(0, 0, 3, 1);
1533 let mut pool = GraphemePool::new();
1534 let mut frame = Frame::new(4, 1, &mut pool);
1535 Widget::render(&table, area, &mut frame);
1536
1537 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1538 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('B'));
1539 assert_eq!(cell_char(&frame.buffer, 2, 0), Some('C'));
1540 assert_ne!(cell_char(&frame.buffer, 3, 0), Some('D'));
1541 }
1542
1543 #[test]
1544 fn render_multiline_cell_respects_row_height() {
1545 let table = Table::new([Row::new(["A\nB"]).height(1)], [Constraint::Fixed(3)]);
1546 let area = Rect::new(0, 0, 3, 2);
1547 let mut pool = GraphemePool::new();
1548 let mut frame = Frame::new(3, 2, &mut pool);
1549 Widget::render(&table, area, &mut frame);
1550
1551 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1552 assert_ne!(cell_char(&frame.buffer, 0, 1), Some('B'));
1553 }
1554
1555 #[test]
1556 fn render_multiline_cell_draws_second_line_when_height_allows() {
1557 let table = Table::new([Row::new(["A\nB"]).height(2)], [Constraint::Fixed(3)]);
1558 let area = Rect::new(0, 0, 3, 2);
1559 let mut pool = GraphemePool::new();
1560 let mut frame = Frame::new(3, 2, &mut pool);
1561 Widget::render(&table, area, &mut frame);
1562
1563 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1564 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1565 }
1566
1567 #[test]
1568 fn more_cells_than_columns_truncated() {
1569 let table = Table::new(
1570 [Row::new(["A", "B", "C", "D"])],
1571 [Constraint::Fixed(3), Constraint::Fixed(3)],
1572 );
1573 let area = Rect::new(0, 0, 8, 1);
1574 let mut pool = GraphemePool::new();
1575 let mut frame = Frame::new(8, 1, &mut pool);
1576 Widget::render(&table, area, &mut frame);
1577 }
1579
1580 #[test]
1581 fn header_too_tall_for_area() {
1582 let header = Row::new(["H"]).height(10);
1583 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).header(header);
1584
1585 let area = Rect::new(0, 0, 5, 3);
1586 let mut pool = GraphemePool::new();
1587 let mut frame = Frame::new(5, 3, &mut pool);
1588 Widget::render(&table, area, &mut frame);
1589 }
1591
1592 #[test]
1593 fn row_with_bottom_margin() {
1594 let table = Table::new(
1595 [Row::new(["A"]).bottom_margin(1), Row::new(["B"])],
1596 [Constraint::Fixed(5)],
1597 );
1598 let area = Rect::new(0, 0, 5, 4);
1599 let mut pool = GraphemePool::new();
1600 let mut frame = Frame::new(5, 4, &mut pool);
1601 Widget::render(&table, area, &mut frame);
1602
1603 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1605 assert_eq!(cell_char(&frame.buffer, 0, 2), Some('B'));
1606 }
1607
1608 #[test]
1609 fn table_registers_hit_regions() {
1610 let table = Table::new(
1611 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
1612 [Constraint::Fixed(5)],
1613 )
1614 .hit_id(HitId::new(99));
1615
1616 let area = Rect::new(0, 0, 5, 3);
1617 let mut pool = GraphemePool::new();
1618 let mut frame = Frame::with_hit_grid(5, 3, &mut pool);
1619 let mut state = TableState::default();
1620 StatefulWidget::render(&table, area, &mut frame, &mut state);
1621
1622 let hit0 = frame.hit_test(2, 0);
1624 let hit1 = frame.hit_test(2, 1);
1625 let hit2 = frame.hit_test(2, 2);
1626
1627 assert_eq!(hit0, Some((HitId::new(99), HitRegion::Content, 0)));
1628 assert_eq!(hit1, Some((HitId::new(99), HitRegion::Content, 1)));
1629 assert_eq!(hit2, Some((HitId::new(99), HitRegion::Content, 2)));
1630 }
1631
1632 #[test]
1633 fn table_no_hit_without_hit_id() {
1634 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
1635 let area = Rect::new(0, 0, 5, 1);
1636 let mut pool = GraphemePool::new();
1637 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
1638 let mut state = TableState::default();
1639 StatefulWidget::render(&table, area, &mut frame, &mut state);
1640
1641 assert!(frame.hit_test(2, 0).is_none());
1643 }
1644
1645 #[test]
1646 fn table_no_hit_without_hit_grid() {
1647 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]).hit_id(HitId::new(1));
1648 let area = Rect::new(0, 0, 5, 1);
1649 let mut pool = GraphemePool::new();
1650 let mut frame = Frame::new(5, 1, &mut pool); let mut state = TableState::default();
1652 StatefulWidget::render(&table, area, &mut frame, &mut state);
1653
1654 assert!(frame.hit_test(2, 0).is_none());
1656 }
1657
1658 #[test]
1661 fn measure_empty_table() {
1662 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1663 let c = table.measure(Size::MAX);
1664 assert_eq!(c, SizeConstraints::ZERO);
1665 }
1666
1667 #[test]
1668 fn measure_empty_columns() {
1669 let table = Table::new([Row::new(["A"])], Vec::<Constraint>::new());
1670 let c = table.measure(Size::MAX);
1671 assert_eq!(c, SizeConstraints::ZERO);
1672 }
1673
1674 #[test]
1675 fn measure_single_row() {
1676 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1677 let c = table.measure(Size::MAX);
1678
1679 assert_eq!(c.preferred.width, 5); assert_eq!(c.preferred.height, 1); assert!(table.has_intrinsic_size());
1682 }
1683
1684 #[test]
1685 fn measure_multiple_columns() {
1686 let table = Table::new(
1687 [Row::new(["A", "BB", "CCC"])],
1688 [
1689 Constraint::Fixed(5),
1690 Constraint::Fixed(5),
1691 Constraint::Fixed(5),
1692 ],
1693 )
1694 .column_spacing(2);
1695
1696 let c = table.measure(Size::MAX);
1697
1698 assert_eq!(c.preferred.width, 10);
1700 assert_eq!(c.preferred.height, 1);
1701 }
1702
1703 #[test]
1704 fn measure_respects_row_height_and_column_spacing() {
1705 let table = Table::new(
1706 [Row::new(["A", "BB"]).height(2)],
1707 [Constraint::FitContent, Constraint::FitContent],
1708 )
1709 .column_spacing(2);
1710
1711 let c = table.measure(Size::MAX);
1712
1713 assert_eq!(c.preferred.width, 5);
1714 assert_eq!(c.preferred.height, 2);
1715 }
1716
1717 #[test]
1718 fn measure_accounts_for_wide_glyphs() {
1719 let table = Table::new(
1720 [Row::new(["界", "A"])],
1721 [Constraint::FitContent, Constraint::FitContent],
1722 )
1723 .column_spacing(1);
1724
1725 let c = table.measure(Size::MAX);
1726
1727 assert_eq!(c.preferred.width, 4);
1728 assert_eq!(c.preferred.height, 1);
1729 }
1730
1731 #[test]
1732 fn measure_with_header() {
1733 let header = Row::new(["Name", "Value"]);
1734 let table = Table::new(
1735 [Row::new(["foo", "42"])],
1736 [Constraint::Fixed(5), Constraint::Fixed(5)],
1737 )
1738 .header(header);
1739
1740 let c = table.measure(Size::MAX);
1741
1742 assert_eq!(c.preferred.width, 10);
1745 assert_eq!(c.preferred.height, 2);
1747 }
1748
1749 #[test]
1750 fn measure_with_row_margins() {
1751 let table = Table::new(
1752 [
1753 Row::new(["A"]).bottom_margin(2),
1754 Row::new(["B"]).bottom_margin(1),
1755 ],
1756 [Constraint::Fixed(5)],
1757 );
1758
1759 let c = table.measure(Size::MAX);
1760
1761 assert_eq!(c.preferred.height, 5);
1763 }
1764
1765 #[test]
1766 fn measure_column_widths_from_max_cell() {
1767 let table = Table::new(
1768 [Row::new(["A", "BB"]), Row::new(["CCC", "D"])],
1769 [Constraint::Fixed(5), Constraint::Fixed(5)],
1770 )
1771 .column_spacing(1);
1772
1773 let c = table.measure(Size::MAX);
1774
1775 assert_eq!(c.preferred.width, 6);
1779 assert_eq!(c.preferred.height, 2);
1780 }
1781
1782 #[test]
1783 fn measure_min_is_column_count() {
1784 let table = Table::new(
1785 [Row::new(["A", "B", "C"])],
1786 [
1787 Constraint::Fixed(5),
1788 Constraint::Fixed(5),
1789 Constraint::Fixed(5),
1790 ],
1791 );
1792
1793 let c = table.measure(Size::MAX);
1794
1795 assert_eq!(c.min.width, 3);
1797 assert_eq!(c.min.height, 1);
1798 }
1799
1800 #[test]
1801 fn measure_has_intrinsic_size() {
1802 let empty = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1803 assert!(!empty.has_intrinsic_size());
1804
1805 let with_rows = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]);
1806 assert!(with_rows.has_intrinsic_size());
1807
1808 let header_only =
1809 Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]).header(Row::new(["Header"]));
1810 assert!(header_only.has_intrinsic_size());
1811 }
1812
1813 use crate::stateful::Stateful;
1816
1817 #[test]
1818 fn table_state_with_persistence_id() {
1819 let state = TableState::default().with_persistence_id("my-table");
1820 assert_eq!(state.persistence_id(), Some("my-table"));
1821 }
1822
1823 #[test]
1824 fn table_state_default_no_persistence_id() {
1825 let state = TableState::default();
1826 assert_eq!(state.persistence_id(), None);
1827 }
1828
1829 #[test]
1830 fn table_state_save_restore_round_trip() {
1831 let mut state = TableState::default().with_persistence_id("test");
1832 state.select(Some(5));
1833 state.offset = 3;
1834 state.set_sort(Some(2), true);
1835 state.set_filter("search term");
1836
1837 let saved = state.save_state();
1838 assert_eq!(saved.selected, Some(5));
1839 assert_eq!(saved.offset, 3);
1840 assert_eq!(saved.sort_column, Some(2));
1841 assert!(saved.sort_ascending);
1842 assert_eq!(saved.filter, "search term");
1843
1844 state.select(None);
1846 state.offset = 0;
1847 state.set_sort(None, false);
1848 state.set_filter("");
1849 assert_eq!(state.selected, None);
1850 assert_eq!(state.offset, 0);
1851 assert_eq!(state.sort_column(), None);
1852 assert!(!state.sort_ascending());
1853 assert!(state.filter().is_empty());
1854
1855 state.restore_state(saved);
1857 assert_eq!(state.selected, Some(5));
1858 assert_eq!(state.offset, 3);
1859 assert_eq!(state.sort_column(), Some(2));
1860 assert!(state.sort_ascending());
1861 assert_eq!(state.filter(), "search term");
1862 }
1863
1864 #[test]
1865 fn table_state_key_uses_persistence_id() {
1866 let state = TableState::default().with_persistence_id("main-data-table");
1867 let key = state.state_key();
1868 assert_eq!(key.widget_type, "Table");
1869 assert_eq!(key.instance_id, "main-data-table");
1870 }
1871
1872 #[test]
1873 fn table_state_key_default_when_no_id() {
1874 let state = TableState::default();
1875 let key = state.state_key();
1876 assert_eq!(key.widget_type, "Table");
1877 assert_eq!(key.instance_id, "default");
1878 }
1879
1880 #[test]
1881 fn table_persist_state_default() {
1882 let persist = TablePersistState::default();
1883 assert_eq!(persist.selected, None);
1884 assert_eq!(persist.offset, 0);
1885 assert_eq!(persist.sort_column, None);
1886 assert!(!persist.sort_ascending);
1887 assert!(persist.filter.is_empty());
1888 }
1889
1890 #[test]
1895 fn table_state_undo_widget_id_unique() {
1896 let state1 = TableState::default();
1897 let state2 = TableState::default();
1898 assert_ne!(state1.undo_id(), state2.undo_id());
1899 }
1900
1901 #[test]
1902 fn table_state_undo_snapshot_and_restore() {
1903 let mut state = TableState::default();
1904 state.select(Some(5));
1905 state.offset = 2;
1906 state.set_sort(Some(1), false);
1907 state.set_filter("test filter");
1908
1909 let snapshot = state.create_snapshot();
1911
1912 state.select(Some(10));
1914 state.offset = 7;
1915 state.set_sort(Some(3), true);
1916 state.set_filter("new filter");
1917
1918 assert_eq!(state.selected, Some(10));
1919 assert_eq!(state.offset, 7);
1920 assert_eq!(state.sort_column(), Some(3));
1921 assert!(state.sort_ascending());
1922 assert_eq!(state.filter(), "new filter");
1923
1924 assert!(state.restore_snapshot(&*snapshot));
1926
1927 assert_eq!(state.selected, Some(5));
1929 assert_eq!(state.offset, 2);
1930 assert_eq!(state.sort_column(), Some(1));
1931 assert!(!state.sort_ascending());
1932 assert_eq!(state.filter(), "test filter");
1933 }
1934
1935 #[test]
1936 fn table_state_undo_ext_sort() {
1937 let mut state = TableState::default();
1938
1939 assert_eq!(state.sort_state(), (None, false));
1941
1942 state.set_sort_state(Some(2), true);
1944 assert_eq!(state.sort_state(), (Some(2), true));
1945
1946 state.set_sort_state(Some(0), false);
1948 assert_eq!(state.sort_state(), (Some(0), false));
1949 }
1950
1951 #[test]
1952 fn table_state_undo_ext_filter() {
1953 let mut state = TableState::default();
1954
1955 assert_eq!(state.filter_text(), "");
1957
1958 state.set_filter_text("search term");
1960 assert_eq!(state.filter_text(), "search term");
1961
1962 state.set_filter_text("");
1964 assert_eq!(state.filter_text(), "");
1965 }
1966
1967 #[test]
1968 fn table_state_restore_wrong_snapshot_type_fails() {
1969 use std::any::Any;
1970 let mut state = TableState::default();
1971 let wrong_snapshot: Box<dyn Any + Send> = Box::new(42i32);
1972 assert!(!state.restore_snapshot(&*wrong_snapshot));
1973 }
1974
1975 use crate::mouse::MouseResult;
1978 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
1979
1980 #[test]
1981 fn table_state_click_selects() {
1982 let mut state = TableState::default();
1983 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1984 let hit = Some((HitId::new(1), HitRegion::Content, 4u64));
1985 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1986 assert_eq!(result, MouseResult::Selected(4));
1987 assert_eq!(state.selected, Some(4));
1988 }
1989
1990 #[test]
1991 fn table_state_second_click_activates() {
1992 let mut state = TableState::default();
1993 state.select(Some(4));
1994
1995 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
1996 let hit = Some((HitId::new(1), HitRegion::Content, 4u64));
1997 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
1998 assert_eq!(result, MouseResult::Activated(4));
1999 assert_eq!(state.selected, Some(4));
2000 }
2001
2002 #[test]
2003 fn table_state_click_wrong_id_ignored() {
2004 let mut state = TableState::default();
2005 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2006 let hit = Some((HitId::new(99), HitRegion::Content, 4u64));
2007 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2008 assert_eq!(result, MouseResult::Ignored);
2009 }
2010
2011 #[test]
2012 fn table_state_hover_updates() {
2013 let mut state = TableState::default();
2014 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2015 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2016 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2017 assert_eq!(result, MouseResult::HoverChanged);
2018 assert_eq!(state.hovered, Some(3));
2019 }
2020
2021 #[test]
2022 #[allow(clippy::field_reassign_with_default)]
2023 fn table_state_hover_same_index_ignored() {
2024 let mut state = {
2025 let mut s = TableState::default();
2026 s.hovered = Some(3);
2027 s
2028 };
2029 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2030 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2031 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2032 assert_eq!(result, MouseResult::Ignored);
2033 assert_eq!(state.hovered, Some(3));
2034 }
2035
2036 #[test]
2037 #[allow(clippy::field_reassign_with_default)]
2038 fn table_state_hover_clears() {
2039 let mut state = {
2040 let mut s = TableState::default();
2041 s.hovered = Some(5);
2042 s
2043 };
2044 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2045 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2047 assert_eq!(result, MouseResult::HoverChanged);
2048 assert_eq!(state.hovered, None);
2049 }
2050
2051 #[test]
2052 fn table_state_hover_clear_when_already_none() {
2053 let mut state = TableState::default();
2054 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2055 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2056 assert_eq!(result, MouseResult::Ignored);
2057 }
2058
2059 #[test]
2060 #[allow(clippy::field_reassign_with_default)]
2061 fn table_state_scroll_wheel_up() {
2062 let mut state = {
2063 let mut s = TableState::default();
2064 s.offset = 10;
2065 s
2066 };
2067 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
2068 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
2069 assert_eq!(result, MouseResult::Scrolled);
2070 assert_eq!(state.offset, 7);
2071 }
2072
2073 #[test]
2074 fn table_state_scroll_wheel_down() {
2075 let mut state = TableState::default();
2076 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
2077 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
2078 assert_eq!(result, MouseResult::Scrolled);
2079 assert_eq!(state.offset, 3);
2080 }
2081
2082 #[test]
2083 #[allow(clippy::field_reassign_with_default)]
2084 fn table_state_scroll_down_clamps() {
2085 let mut state = {
2086 let mut s = TableState::default();
2087 s.offset = 18;
2088 s
2089 };
2090 state.scroll_down(5, 20);
2091 assert_eq!(state.offset, 19);
2092 }
2093
2094 #[test]
2095 #[allow(clippy::field_reassign_with_default)]
2096 fn table_state_scroll_up_clamps() {
2097 let mut state = {
2098 let mut s = TableState::default();
2099 s.offset = 1;
2100 s
2101 };
2102 state.scroll_up(5);
2103 assert_eq!(state.offset, 0);
2104 }
2105
2106 #[test]
2111 fn row_with_fewer_cells_than_columns() {
2112 let table = Table::new(
2114 [Row::new(["A"])],
2115 [
2116 Constraint::Fixed(3),
2117 Constraint::Fixed(3),
2118 Constraint::Fixed(3),
2119 ],
2120 );
2121 let area = Rect::new(0, 0, 12, 1);
2122 let mut pool = GraphemePool::new();
2123 let mut frame = Frame::new(12, 1, &mut pool);
2124 Widget::render(&table, area, &mut frame);
2125
2126 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2127 assert_ne!(cell_char(&frame.buffer, 4, 0), Some('A'));
2129 }
2130
2131 #[test]
2132 fn column_spacing_zero() {
2133 let table = Table::new(
2135 [Row::new(["AB", "CD"])],
2136 [Constraint::Fixed(2), Constraint::Fixed(2)],
2137 )
2138 .column_spacing(0);
2139
2140 let area = Rect::new(0, 0, 4, 1);
2141 let mut pool = GraphemePool::new();
2142 let mut frame = Frame::new(4, 1, &mut pool);
2143 Widget::render(&table, area, &mut frame);
2144
2145 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2146 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('B'));
2147 assert_eq!(cell_char(&frame.buffer, 2, 0), Some('C'));
2148 assert_eq!(cell_char(&frame.buffer, 3, 0), Some('D'));
2149 }
2150
2151 #[test]
2152 fn render_with_nonzero_origin() {
2153 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)]);
2155 let area = Rect::new(5, 3, 3, 1);
2156 let mut pool = GraphemePool::new();
2157 let mut frame = Frame::new(10, 6, &mut pool);
2158 Widget::render(&table, area, &mut frame);
2159
2160 assert_eq!(cell_char(&frame.buffer, 5, 3), Some('X'));
2161 assert_ne!(cell_char(&frame.buffer, 0, 0), Some('X'));
2163 }
2164
2165 #[test]
2166 fn single_row_height_exceeds_area() {
2167 let table = Table::new([Row::new(["T"]).height(10)], [Constraint::Fixed(3)]);
2169 let area = Rect::new(0, 0, 3, 2);
2170 let mut pool = GraphemePool::new();
2171 let mut frame = Frame::new(3, 2, &mut pool);
2172 Widget::render(&table, area, &mut frame);
2173
2174 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('T'));
2176 }
2177
2178 #[test]
2179 fn selection_and_hover_on_same_row() {
2180 let selected_fg = PackedRgba::rgb(100, 0, 0);
2182 let hovered_fg = PackedRgba::rgb(0, 100, 0);
2183 let highlight_fg = PackedRgba::rgb(0, 0, 100);
2184
2185 let theme = TableTheme {
2186 row_selected: Style::new().fg(selected_fg),
2187 row_hover: Style::new().fg(hovered_fg),
2188 ..Default::default()
2189 };
2190
2191 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)])
2192 .highlight_style(Style::new().fg(highlight_fg))
2193 .theme(theme);
2194
2195 let area = Rect::new(0, 0, 3, 1);
2196 let mut pool = GraphemePool::new();
2197 let mut frame = Frame::new(3, 1, &mut pool);
2198 let mut state = TableState {
2199 selected: Some(0),
2200 hovered: Some(0),
2201 ..Default::default()
2202 };
2203
2204 StatefulWidget::render(&table, area, &mut frame, &mut state);
2205 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(highlight_fg));
2207 }
2208
2209 #[test]
2210 fn alternating_row_styles() {
2211 let even_fg = PackedRgba::rgb(10, 10, 10);
2213 let odd_fg = PackedRgba::rgb(20, 20, 20);
2214 let theme = TableTheme {
2215 row: Style::new().fg(even_fg),
2216 row_alt: Style::new().fg(odd_fg),
2217 ..Default::default()
2218 };
2219
2220 let table = Table::new(
2221 [Row::new(["E"]), Row::new(["O"]), Row::new(["E2"])],
2222 [Constraint::Fixed(3)],
2223 )
2224 .theme(theme);
2225
2226 let area = Rect::new(0, 0, 3, 3);
2227 let mut pool = GraphemePool::new();
2228 let mut frame = Frame::new(3, 3, &mut pool);
2229 Widget::render(&table, area, &mut frame);
2230
2231 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(even_fg));
2233 assert_eq!(cell_fg(&frame.buffer, 0, 1), Some(odd_fg));
2234 assert_eq!(cell_fg(&frame.buffer, 0, 2), Some(even_fg));
2235 }
2236
2237 #[test]
2238 fn scroll_up_from_zero_stays_zero() {
2239 let mut state = TableState::default();
2240 state.scroll_up(10);
2241 assert_eq!(state.offset, 0);
2242 }
2243
2244 #[test]
2245 fn scroll_down_with_zero_rows() {
2246 let mut state = TableState::default();
2247 state.scroll_down(5, 0);
2248 assert_eq!(state.offset, 0);
2249 }
2250
2251 #[test]
2252 fn scroll_down_with_single_row() {
2253 let mut state = TableState::default();
2254 state.scroll_down(5, 1);
2255 assert_eq!(state.offset, 0);
2256 }
2257
2258 #[test]
2259 fn mouse_click_on_row_exceeding_row_count() {
2260 let mut state = TableState::default();
2262 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
2263 let hit = Some((HitId::new(1), HitRegion::Content, 100u64));
2264 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2265 assert_eq!(result, MouseResult::Ignored);
2266 assert_eq!(state.selected, None);
2267 }
2268
2269 #[test]
2270 fn mouse_right_click_ignored() {
2271 let mut state = TableState::default();
2272 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
2273 let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
2274 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2275 assert_eq!(result, MouseResult::Ignored);
2276 }
2277
2278 #[test]
2279 fn mouse_hover_on_row_exceeding_row_count() {
2280 let mut state = TableState::default();
2281 let event = MouseEvent::new(MouseEventKind::Moved, 0, 0);
2282 let hit = Some((HitId::new(1), HitRegion::Content, 100u64));
2283 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2284 assert_eq!(result, MouseResult::Ignored);
2286 assert_eq!(state.hovered, None);
2287 }
2288
2289 #[test]
2290 fn select_deselect_resets_offset_then_reselect() {
2291 let mut state = TableState {
2292 offset: 15,
2293 ..Default::default()
2294 };
2295 state.select(Some(20));
2296 assert_eq!(state.selected, Some(20));
2297 assert_eq!(state.offset, 15); state.select(None);
2300 assert_eq!(state.offset, 0); state.select(Some(3));
2303 assert_eq!(state.selected, Some(3));
2304 assert_eq!(state.offset, 0); }
2306
2307 #[test]
2308 fn offset_clamped_when_rows_empty() {
2309 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2310 let area = Rect::new(0, 0, 5, 3);
2311 let mut pool = GraphemePool::new();
2312 let mut frame = Frame::new(5, 3, &mut pool);
2313 let mut state = TableState {
2314 offset: 999,
2315 ..Default::default()
2316 };
2317 StatefulWidget::render(&table, area, &mut frame, &mut state);
2318 assert_eq!(state.offset, 0);
2319 }
2320
2321 #[test]
2322 fn selection_clamps_when_rows_empty() {
2323 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2324 let area = Rect::new(0, 0, 5, 3);
2325 let mut pool = GraphemePool::new();
2326 let mut frame = Frame::new(5, 3, &mut pool);
2327 let mut state = TableState {
2328 selected: Some(5),
2329 ..Default::default()
2330 };
2331 StatefulWidget::render(&table, area, &mut frame, &mut state);
2332 assert_eq!(state.selected, None);
2333 }
2334
2335 #[test]
2336 fn header_with_bottom_margin_offsets_rows() {
2337 let header = Row::new(["H"]).bottom_margin(2);
2338 let table = Table::new([Row::new(["D"])], [Constraint::Fixed(3)]).header(header);
2339
2340 let area = Rect::new(0, 0, 3, 5);
2341 let mut pool = GraphemePool::new();
2342 let mut frame = Frame::new(3, 5, &mut pool);
2343 Widget::render(&table, area, &mut frame);
2344
2345 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
2347 assert_eq!(cell_char(&frame.buffer, 0, 3), Some('D'));
2348 }
2349
2350 #[test]
2351 fn block_plus_header_fill_entire_area() {
2352 let header = Row::new(["H"]);
2355 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)])
2356 .block(Block::bordered())
2357 .header(header);
2358
2359 let area = Rect::new(0, 0, 5, 3);
2360 let mut pool = GraphemePool::new();
2361 let mut frame = Frame::new(5, 3, &mut pool);
2362 Widget::render(&table, area, &mut frame);
2363
2364 assert_eq!(cell_char(&frame.buffer, 1, 1), Some('H'));
2366 let data_rendered =
2368 (0..5).any(|x| (0..3).any(|y| cell_char(&frame.buffer, x, y) == Some('X')));
2369 assert!(!data_rendered);
2370 }
2371
2372 #[test]
2373 fn min_constraint_measure() {
2374 let table = Table::new([Row::new(["AB"])], [Constraint::Min(10)]);
2375 let c = table.measure(Size::MAX);
2376 assert_eq!(c.preferred.width, 2);
2378 assert_eq!(c.preferred.height, 1);
2379 }
2380
2381 #[test]
2382 fn percentage_constraint_render() {
2383 let table = Table::new(
2385 [Row::new(["A", "B"])],
2386 [Constraint::Percentage(50.0), Constraint::Percentage(50.0)],
2387 );
2388 let area = Rect::new(0, 0, 20, 1);
2389 let mut pool = GraphemePool::new();
2390 let mut frame = Frame::new(20, 1, &mut pool);
2391 Widget::render(&table, area, &mut frame);
2392
2393 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2394 }
2395
2396 #[test]
2397 fn fit_content_constraint_measure() {
2398 let table = Table::new(
2399 [Row::new(["Hello", "World"])],
2400 [Constraint::FitContent, Constraint::FitContent],
2401 )
2402 .column_spacing(1);
2403
2404 let c = table.measure(Size::MAX);
2405 assert_eq!(c.preferred.width, 11);
2407 }
2408
2409 #[test]
2410 fn measure_with_block_adds_overhead() {
2411 let table_no_block = Table::new([Row::new(["X"])], [Constraint::Fixed(3)]);
2412 let table_with_block =
2413 Table::new([Row::new(["X"])], [Constraint::Fixed(3)]).block(Block::bordered());
2414
2415 let c_no = table_no_block.measure(Size::MAX);
2416 let c_with = table_with_block.measure(Size::MAX);
2417
2418 assert_eq!(c_with.preferred.width, c_no.preferred.width + 2);
2420 assert_eq!(c_with.preferred.height, c_no.preferred.height + 2);
2421 }
2422
2423 #[test]
2424 fn variable_height_rows_selection_scrolls_down() {
2425 let rows = vec![
2428 Row::new(["A"]),
2429 Row::new(["B"]),
2430 Row::new(["C"]).height(5),
2431 Row::new(["D"]),
2432 Row::new(["E"]),
2433 ];
2434 let table = Table::new(rows, [Constraint::Fixed(5)]);
2435 let area = Rect::new(0, 0, 5, 4);
2436 let mut pool = GraphemePool::new();
2437 let mut frame = Frame::new(5, 4, &mut pool);
2438 let mut state = TableState {
2439 selected: Some(4),
2440 ..Default::default()
2441 };
2442 StatefulWidget::render(&table, area, &mut frame, &mut state);
2443
2444 assert!(state.offset > 0);
2446 assert_eq!(state.selected, Some(4));
2447 }
2448
2449 #[test]
2450 fn many_rows_with_margins_viewport_clamping() {
2451 let rows: Vec<Row> = (0..20)
2454 .map(|i| Row::new([format!("R{i}")]).bottom_margin(1))
2455 .collect();
2456 let table = Table::new(rows, [Constraint::Fixed(5)]);
2457 let area = Rect::new(0, 0, 5, 5);
2458 let mut pool = GraphemePool::new();
2459 let mut frame = Frame::new(5, 5, &mut pool);
2460 let mut state = TableState {
2461 offset: 19,
2462 ..Default::default()
2463 };
2464 StatefulWidget::render(&table, area, &mut frame, &mut state);
2465
2466 assert!(state.offset < 19);
2468 }
2469
2470 #[test]
2471 fn render_area_width_one() {
2472 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(5)]);
2474 let area = Rect::new(0, 0, 1, 1);
2475 let mut pool = GraphemePool::new();
2476 let mut frame = Frame::new(1, 1, &mut pool);
2477 Widget::render(&table, area, &mut frame);
2478
2479 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
2480 }
2481
2482 #[test]
2483 fn render_area_height_one() {
2484 let table = Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(3)]);
2486 let area = Rect::new(0, 0, 3, 1);
2487 let mut pool = GraphemePool::new();
2488 let mut frame = Frame::new(3, 1, &mut pool);
2489 Widget::render(&table, area, &mut frame);
2490
2491 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2492 }
2493
2494 #[test]
2495 fn hit_regions_with_offset() {
2496 let table = Table::new(
2498 (0..10).map(|i| Row::new([format!("R{i}")])),
2499 [Constraint::Fixed(5)],
2500 )
2501 .hit_id(HitId::new(42));
2502
2503 let area = Rect::new(0, 0, 5, 3);
2504 let mut pool = GraphemePool::new();
2505 let mut frame = Frame::with_hit_grid(5, 3, &mut pool);
2506 let mut state = TableState {
2507 offset: 5,
2508 ..Default::default()
2509 };
2510 StatefulWidget::render(&table, area, &mut frame, &mut state);
2511
2512 let hit0 = frame.hit_test(2, 0);
2514 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 5)));
2515
2516 let hit1 = frame.hit_test(2, 1);
2517 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 6)));
2518 }
2519
2520 #[test]
2521 fn table_state_sort_defaults() {
2522 let state = TableState::default();
2523 assert_eq!(state.sort_column(), None);
2524 assert!(!state.sort_ascending());
2525 assert!(state.filter().is_empty());
2526 }
2527
2528 #[test]
2529 fn table_state_set_sort_toggle() {
2530 let mut state = TableState::default();
2531 state.set_sort(Some(0), true);
2532 assert_eq!(state.sort_column(), Some(0));
2533 assert!(state.sort_ascending());
2534
2535 state.set_sort(Some(0), false);
2537 assert!(!state.sort_ascending());
2538
2539 state.set_sort(Some(3), true);
2541 assert_eq!(state.sort_column(), Some(3));
2542
2543 state.set_sort(None, false);
2545 assert_eq!(state.sort_column(), None);
2546 }
2547
2548 #[test]
2549 fn table_persist_round_trip_preserves_hovered_none() {
2550 let mut state = TableState::default().with_persistence_id("t");
2551 state.select(Some(3));
2552 state.hovered = Some(7);
2553 state.offset = 2;
2554
2555 let saved = state.save_state();
2556 state.restore_state(saved);
2557
2558 assert_eq!(state.hovered, None);
2560 assert_eq!(state.selected, Some(3));
2561 assert_eq!(state.offset, 2);
2562 }
2563
2564 #[test]
2565 fn undo_snapshot_clears_hovered() {
2566 let mut state = TableState::default();
2567 state.select(Some(2));
2568 state.hovered = Some(5);
2569
2570 let snap = state.create_snapshot();
2571
2572 state.select(Some(9));
2574 state.hovered = Some(8);
2575
2576 assert!(state.restore_snapshot(&*snap));
2578 assert_eq!(state.selected, Some(2));
2579 assert_eq!(state.hovered, None);
2581 }
2582
2583 #[test]
2584 fn wide_chars_in_render() {
2585 let table = Table::new([Row::new(["界界界"])], [Constraint::Fixed(4)]);
2588 let area = Rect::new(0, 0, 4, 1);
2589 let mut pool = GraphemePool::new();
2590 let mut frame = Frame::new(4, 1, &mut pool);
2591 Widget::render(&table, area, &mut frame);
2592
2593 let cell = frame.buffer.get(0, 0).unwrap();
2596 assert!(
2597 !cell.content.is_empty(),
2598 "first cell should contain CJK content, not be empty"
2599 );
2600 let cell1 = frame.buffer.get(1, 0).unwrap();
2602 assert!(
2603 cell1.content.is_continuation(),
2604 "second cell should be continuation of wide char"
2605 );
2606 }
2607
2608 #[test]
2609 fn empty_row_cells() {
2610 let table = Table::new(
2612 [Row::new(["", "", ""])],
2613 [
2614 Constraint::Fixed(3),
2615 Constraint::Fixed(3),
2616 Constraint::Fixed(3),
2617 ],
2618 );
2619 let area = Rect::new(0, 0, 11, 1);
2620 let mut pool = GraphemePool::new();
2621 let mut frame = Frame::new(11, 1, &mut pool);
2622 Widget::render(&table, area, &mut frame);
2623 }
2625
2626 #[test]
2627 fn measure_with_many_rows_saturates() {
2628 let rows: Vec<Row> = (0..10000).map(|_| Row::new(["X"]).height(100)).collect();
2630 let table = Table::new(rows, [Constraint::Fixed(3)]);
2631 let c = table.measure(Size::MAX);
2632
2633 assert!(c.preferred.height > 0);
2635 }
2636}