1use crate::block::Block;
2use crate::mouse::MouseResult;
3use crate::undo_support::{TableUndoExt, UndoSupport, UndoWidgetId};
4use crate::{
5 MeasurableWidget, SizeConstraints, StatefulWidget, Widget, apply_style, clear_text_area,
6 set_style_area,
7};
8use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
9use ftui_core::geometry::{Rect, Size};
10use ftui_layout::{Constraint, Flex};
11use ftui_render::buffer::Buffer;
12use ftui_render::cell::Cell;
13use ftui_render::frame::{Frame, HitId, HitRegion};
14use ftui_style::{
15 Style, TableEffectResolver, TableEffectScope, TableEffectTarget, TableSection, TableTheme,
16};
17use ftui_text::{Line, Span, Text};
18use std::any::Any;
19
20fn text_into_owned(text: Text<'_>) -> Text<'static> {
21 Text::from_lines(
22 text.into_iter()
23 .map(|line| Line::from_spans(line.into_iter().map(Span::into_owned))),
24 )
25}
26
27#[derive(Debug, Clone, Default)]
29pub struct Row {
30 cells: Vec<Text<'static>>,
31 height: u16,
32 style: Style,
33 bottom_margin: u16,
34}
35
36impl Row {
37 #[must_use]
39 pub fn new<'a>(cells: impl IntoIterator<Item = impl Into<Text<'a>>>) -> Self {
40 Self {
41 cells: cells
42 .into_iter()
43 .map(|c| text_into_owned(c.into()))
44 .collect(),
45 height: 1,
46 style: Style::default(),
47 bottom_margin: 0,
48 }
49 }
50
51 #[must_use]
56 pub fn height(mut self, height: u16) -> Self {
57 self.height = height.max(1);
58 self
59 }
60
61 #[must_use]
63 pub fn style(mut self, style: Style) -> Self {
64 self.style = style;
65 self
66 }
67
68 #[must_use]
70 pub fn bottom_margin(mut self, margin: u16) -> Self {
71 self.bottom_margin = margin;
72 self
73 }
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct Table<'a> {
79 rows: Vec<Row>,
80 widths: Vec<Constraint>,
81 header: Option<Row>,
82 block: Option<Block<'a>>,
83 style: Style,
84 highlight_style: Style,
85 theme: TableTheme,
86 theme_phase: f32,
87 column_spacing: u16,
88 hit_id: Option<HitId>,
91 data_hash: Option<u64>,
93}
94
95impl<'a> Table<'a> {
96 #[must_use]
98 pub fn new(
99 rows: impl IntoIterator<Item = Row>,
100 widths: impl IntoIterator<Item = Constraint>,
101 ) -> Self {
102 let rows: Vec<Row> = rows.into_iter().collect();
103 let widths: Vec<Constraint> = widths.into_iter().collect();
104
105 Self {
106 rows,
107 widths,
108 header: None,
109 block: None,
110 style: Style::default(),
111 highlight_style: Style::default(),
112 theme: TableTheme::default(),
113 theme_phase: 0.0,
114 column_spacing: 1,
115 hit_id: None,
116 data_hash: None,
117 }
118 }
119
120 #[must_use]
127 pub fn data_hash(mut self, hash: u64) -> Self {
128 self.data_hash = Some(hash);
129 self
130 }
131
132 #[must_use]
134 pub fn header(mut self, header: Row) -> Self {
135 self.header = Some(header);
136 self
137 }
138
139 #[must_use]
141 pub fn block(mut self, block: Block<'a>) -> Self {
142 self.block = Some(block);
143 self
144 }
145
146 #[must_use]
148 pub fn style(mut self, style: Style) -> Self {
149 self.style = style;
150 self
151 }
152
153 #[must_use]
155 pub fn highlight_style(mut self, style: Style) -> Self {
156 self.highlight_style = style;
157 self
158 }
159
160 #[must_use]
162 pub fn theme(mut self, theme: TableTheme) -> Self {
163 self.theme = theme;
164 self
165 }
166
167 #[must_use]
171 pub fn theme_phase(mut self, phase: f32) -> Self {
172 self.theme_phase = phase;
173 self
174 }
175
176 #[must_use]
178 pub fn column_spacing(mut self, spacing: u16) -> Self {
179 self.column_spacing = spacing;
180 self
181 }
182
183 #[must_use]
189 pub fn hit_id(mut self, id: HitId) -> Self {
190 self.hit_id = Some(id);
191 self
192 }
193
194 fn filtered_and_sorted_indices(&self, state: &mut TableState) -> std::sync::Arc<[usize]> {
195 if let Some(hash) = self.data_hash
196 && let Some((cached_hash, cached_filter, cached_sort_col, cached_sort_asc, indices)) =
197 &state.cached_display_indices
198 && *cached_hash == hash
199 && *cached_filter == state.filter
200 && *cached_sort_col == state.sort_column
201 && *cached_sort_asc == state.sort_ascending
202 {
203 return std::sync::Arc::clone(indices);
204 }
205
206 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
207
208 if !state.filter.trim().is_empty() {
210 let query = state.filter.trim().to_lowercase();
211 indices.retain(|&i| {
212 let row = &self.rows[i];
213 row.cells.iter().any(|cell| {
214 if let Some(line) = cell.lines().first()
217 && cell.lines().len() == 1
218 && line.spans().len() == 1
219 {
220 return crate::contains_ignore_case(&line.spans()[0].content, &query);
221 }
222 crate::contains_ignore_case(&cell.to_plain_text(), &query)
223 })
224 });
225 }
226
227 if let Some(col_idx) = state.sort_column {
229 use std::borrow::Cow;
230 let mut sort_keys: Vec<(usize, Cow<str>)> = indices
231 .iter()
232 .map(|&i| {
233 let cell_text = self.rows[i].cells.get(col_idx);
234 let key = match cell_text {
235 Some(text) => {
236 if let Some(line) = text.lines().first() {
238 if text.lines().len() == 1 && line.spans().len() == 1 {
239 Cow::Borrowed(line.spans()[0].content.as_ref())
240 } else {
241 Cow::Owned(text.to_plain_text())
242 }
243 } else {
244 Cow::Borrowed("")
245 }
246 }
247 None => Cow::Borrowed(""),
248 };
249 (i, key)
250 })
251 .collect();
252
253 if state.sort_ascending {
254 sort_keys.sort_unstable_by(|a, b| a.1.cmp(&b.1));
255 } else {
256 sort_keys.sort_unstable_by(|a, b| b.1.cmp(&a.1));
257 }
258
259 indices = sort_keys.into_iter().map(|(i, _)| i).collect();
260 }
261
262 let arc_indices: std::sync::Arc<[usize]> = indices.into();
263
264 if let Some(hash) = self.data_hash {
265 state.cached_display_indices = Some((
266 hash,
267 state.filter.clone(),
268 state.sort_column,
269 state.sort_ascending,
270 std::sync::Arc::clone(&arc_indices),
271 ));
272 }
273
274 arc_indices
275 }
276
277 fn requires_measurement(constraints: &[Constraint]) -> bool {
278 constraints.iter().any(|c| {
279 matches!(
280 c,
281 Constraint::FitContent | Constraint::FitContentBounded { .. } | Constraint::FitMin
282 )
283 })
284 }
285
286 fn compute_intrinsic_widths(rows: &[Row], header: Option<&Row>, col_count: usize) -> Vec<u16> {
287 if col_count == 0 {
288 return Vec::new();
289 }
290
291 let mut col_widths: Vec<u16> = vec![0; col_count];
292
293 if let Some(header) = header {
294 for (i, cell) in header.cells.iter().enumerate().take(col_count) {
295 let cell_width = cell
296 .lines()
297 .iter()
298 .take(header.height as usize)
299 .map(|l| l.width())
300 .max()
301 .unwrap_or(0)
302 .min(u16::MAX as usize) as u16;
303 col_widths[i] = col_widths[i].max(cell_width);
304 }
305 }
306
307 for row in rows {
308 for (i, cell) in row.cells.iter().enumerate().take(col_count) {
309 let cell_width = cell
310 .lines()
311 .iter()
312 .take(row.height as usize)
313 .map(|l| l.width())
314 .max()
315 .unwrap_or(0)
316 .min(u16::MAX as usize) as u16;
317 col_widths[i] = col_widths[i].max(cell_width);
318 }
319 }
320
321 col_widths
322 }
323}
324
325impl<'a> Widget for Table<'a> {
326 fn render(&self, area: Rect, frame: &mut Frame) {
327 let mut state = TableState::default();
328 StatefulWidget::render(self, area, frame, &mut state);
329 }
330}
331
332impl ftui_a11y::Accessible for Table<'_> {
333 fn accessibility_nodes(&self, area: Rect) -> Vec<ftui_a11y::node::A11yNodeInfo> {
334 use ftui_a11y::node::{A11yNodeInfo, A11yRole};
335
336 let base_id = crate::a11y_node_id(area);
337 let row_count = self.rows.len();
338 let col_count = self.widths.len();
339
340 let title = self
341 .block
342 .as_ref()
343 .and_then(|b| b.title_text())
344 .unwrap_or_default();
345
346 let mut table_node = A11yNodeInfo::new(base_id, A11yRole::Table, area)
347 .with_description(format!("{row_count} rows, {col_count} columns"));
348 if !title.is_empty() {
349 table_node = table_node.with_name(title);
350 }
351
352 vec![table_node]
353 }
354}
355
356pub type CachedTableDisplayIndices = (u64, String, Option<usize>, bool, std::sync::Arc<[usize]>);
357
358#[derive(Debug, Clone, Default)]
360pub struct TableState {
361 #[allow(dead_code)]
363 undo_id: UndoWidgetId,
364 pub selected: Option<usize>,
366 pub hovered: Option<usize>,
368 pub offset: usize,
370 persistence_id: Option<String>,
373 pub sort_column: Option<usize>,
375 pub sort_ascending: bool,
377 pub filter: String,
379 coherence: ftui_layout::CoherenceCache,
381 #[doc(hidden)]
383 pub cached_display_indices: Option<CachedTableDisplayIndices>,
384 #[doc(hidden)]
386 pub cached_intrinsic_widths: Option<(u64, std::sync::Arc<[u16]>)>,
387}
388
389impl TableState {
390 pub fn select(&mut self, index: Option<usize>) {
392 self.selected = index;
393 }
394
395 #[must_use]
397 pub fn with_persistence_id(mut self, id: impl Into<String>) -> Self {
398 self.persistence_id = Some(id.into());
399 self
400 }
401
402 #[must_use = "use the persistence id (if any)"]
404 pub fn persistence_id(&self) -> Option<&str> {
405 self.persistence_id.as_deref()
406 }
407}
408
409#[derive(Clone, Debug, Default, PartialEq)]
418#[cfg_attr(
419 feature = "state-persistence",
420 derive(serde::Serialize, serde::Deserialize)
421)]
422pub struct TablePersistState {
423 pub selected: Option<usize>,
425 pub offset: usize,
427 pub sort_column: Option<usize>,
429 pub sort_ascending: bool,
431 pub filter: String,
433}
434
435impl crate::stateful::Stateful for TableState {
436 type State = TablePersistState;
437
438 fn state_key(&self) -> crate::stateful::StateKey {
439 crate::stateful::StateKey::new("Table", self.persistence_id.as_deref().unwrap_or("default"))
440 }
441
442 fn save_state(&self) -> TablePersistState {
443 TablePersistState {
444 selected: self.selected,
445 offset: self.offset,
446 sort_column: self.sort_column,
447 sort_ascending: self.sort_ascending,
448 filter: self.filter.clone(),
449 }
450 }
451
452 fn restore_state(&mut self, state: TablePersistState) {
453 self.selected = state.selected;
455 self.hovered = None;
456 self.offset = state.offset;
457 self.sort_column = state.sort_column;
458 self.sort_ascending = state.sort_ascending;
459 self.filter = state.filter;
460 }
461}
462
463#[derive(Debug, Clone)]
469pub struct TableStateSnapshot {
470 selected: Option<usize>,
471 offset: usize,
472 sort_column: Option<usize>,
473 sort_ascending: bool,
474 filter: String,
475}
476
477impl UndoSupport for TableState {
478 fn undo_widget_id(&self) -> UndoWidgetId {
479 self.undo_id
480 }
481
482 fn create_snapshot(&self) -> Box<dyn Any + Send> {
483 Box::new(TableStateSnapshot {
484 selected: self.selected,
485 offset: self.offset,
486 sort_column: self.sort_column,
487 sort_ascending: self.sort_ascending,
488 filter: self.filter.clone(),
489 })
490 }
491
492 fn restore_snapshot(&mut self, snapshot: &dyn Any) -> bool {
493 if let Some(snap) = snapshot.downcast_ref::<TableStateSnapshot>() {
494 self.selected = snap.selected;
495 self.hovered = None;
496 self.offset = snap.offset;
497 self.sort_column = snap.sort_column;
498 self.sort_ascending = snap.sort_ascending;
499 self.filter = snap.filter.clone();
500 true
501 } else {
502 false
503 }
504 }
505}
506
507impl TableUndoExt for TableState {
508 fn sort_state(&self) -> (Option<usize>, bool) {
509 (self.sort_column, self.sort_ascending)
510 }
511
512 fn set_sort_state(&mut self, column: Option<usize>, ascending: bool) {
513 self.sort_column = column;
514 self.sort_ascending = ascending;
515 }
516
517 fn filter_text(&self) -> &str {
518 &self.filter
519 }
520
521 fn set_filter_text(&mut self, filter: &str) {
522 self.filter = filter.to_string();
523 }
524}
525
526impl TableState {
527 #[must_use]
531 pub fn undo_id(&self) -> UndoWidgetId {
532 self.undo_id
533 }
534
535 #[must_use = "use the sort column (if any)"]
537 pub fn sort_column(&self) -> Option<usize> {
538 self.sort_column
539 }
540
541 #[must_use]
543 pub fn sort_ascending(&self) -> bool {
544 self.sort_ascending
545 }
546
547 pub fn set_sort(&mut self, column: Option<usize>, ascending: bool) {
549 self.sort_column = column;
550 self.sort_ascending = ascending;
551 }
552
553 #[must_use]
555 pub fn filter(&self) -> &str {
556 &self.filter
557 }
558
559 pub fn set_filter(&mut self, filter: impl Into<String>) {
561 self.filter = filter.into();
562 }
563
564 pub fn handle_mouse(
579 &mut self,
580 event: &MouseEvent,
581 hit: Option<(HitId, HitRegion, u64)>,
582 expected_id: HitId,
583 row_count: usize,
584 ) -> MouseResult {
585 match event.kind {
586 MouseEventKind::Down(MouseButton::Left) => {
587 if let Some((id, HitRegion::Content, data)) = hit
588 && id == expected_id
589 {
590 let index = data as usize;
591 if index < row_count {
592 if self.selected == Some(index) {
594 return MouseResult::Activated(index);
595 }
596 self.select(Some(index));
597 return MouseResult::Selected(index);
598 }
599 }
600 MouseResult::Ignored
601 }
602 MouseEventKind::Moved => {
603 if let Some((id, HitRegion::Content, data)) = hit
604 && id == expected_id
605 {
606 let index = data as usize;
607 if index < row_count {
608 let changed = self.hovered != Some(index);
609 self.hovered = Some(index);
610 return if changed {
611 MouseResult::HoverChanged
612 } else {
613 MouseResult::Ignored
614 };
615 }
616 }
617 if self.hovered.is_some() {
619 self.hovered = None;
620 MouseResult::HoverChanged
621 } else {
622 MouseResult::Ignored
623 }
624 }
625 MouseEventKind::ScrollUp => {
626 self.scroll_up(3);
627 MouseResult::Scrolled
628 }
629 MouseEventKind::ScrollDown => {
630 self.scroll_down(3, row_count);
631 MouseResult::Scrolled
632 }
633 _ => MouseResult::Ignored,
634 }
635 }
636
637 pub fn scroll_up(&mut self, rows: usize) {
639 self.offset = self.offset.saturating_sub(rows);
640 }
641
642 pub fn scroll_down(&mut self, rows: usize, row_count: usize) {
646 self.offset = self
647 .offset
648 .saturating_add(rows)
649 .min(row_count.saturating_sub(1));
650 }
651}
652
653impl<'a> StatefulWidget for Table<'a> {
654 type State = TableState;
655
656 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
657 #[cfg(feature = "tracing")]
658 let _widget_span = tracing::debug_span!(
659 "widget_render",
660 widget = "Table",
661 x = area.x,
662 y = area.y,
663 w = area.width,
664 h = area.height
665 )
666 .entered();
667
668 if area.is_empty() {
669 return;
670 }
671
672 let apply_styling = frame.degradation.apply_styling();
673 let theme = &self.theme;
674 let effects_enabled = apply_styling && !theme.effects.is_empty();
675 let has_column_effects = effects_enabled && theme_has_column_effects(theme);
676 let effect_resolver = theme.effect_resolver();
677 let effects = if effects_enabled {
678 Some((&effect_resolver, self.theme_phase))
679 } else {
680 None
681 };
682
683 let table_area = match &self.block {
685 Some(b) => {
686 let mut block = b.clone();
687 if apply_styling {
688 block = block.border_style(theme.border);
689 }
690 block.render(area, frame);
691 block.inner(area)
692 }
693 None => area,
694 };
695
696 if table_area.is_empty() {
697 return;
698 }
699
700 frame.buffer.push_scissor(table_area);
703
704 let fill_style = if apply_styling {
707 self.style.merge(&theme.row)
708 } else {
709 Style::default()
710 };
711 clear_text_area(frame, table_area, fill_style);
712
713 let header_height = self
714 .header
715 .as_ref()
716 .map(|h| h.height.saturating_add(h.bottom_margin))
717 .unwrap_or(0);
718
719 if header_height > table_area.height {
720 frame.buffer.pop_scissor();
721 return;
722 }
723
724 let rows_height = table_area.height.saturating_sub(header_height);
726 let rows_top = table_area.y.saturating_add(header_height);
727 let rows_max_y = table_area.bottom();
728
729 let display_indices = self.filtered_and_sorted_indices(state);
731 let row_count = display_indices.len();
732
733 if row_count == 0 {
735 state.offset = 0;
736 } else {
737 state.offset = state.offset.min(row_count.saturating_sub(1));
738
739 let available_height = rows_height;
743 let mut accumulated = 0u16;
744 let mut bottom_offset = row_count.saturating_sub(1);
745 for i in (0..row_count).rev() {
746 let row = &self.rows[display_indices[i]];
747 let total_row_height = if i == row_count - 1 {
748 row.height
749 } else {
750 row.height.saturating_add(row.bottom_margin)
751 };
752
753 if total_row_height > available_height.saturating_sub(accumulated) {
754 break;
755 }
756
757 accumulated = accumulated.saturating_add(total_row_height);
758 bottom_offset = i;
759 }
760
761 state.offset = state.offset.min(bottom_offset);
762 }
763
764 if let Some(selected) = state.selected {
766 if display_indices.is_empty() {
767 state.selected = None;
768 } else if !display_indices.contains(&selected) {
769 state.selected = display_indices.first().copied();
770 }
771 }
772
773 if let Some(selected) = state.selected
775 && let Some(selected_display_idx) =
776 display_indices.iter().position(|&idx| idx == selected)
777 {
778 if selected_display_idx < state.offset {
779 state.offset = selected_display_idx;
780 } else {
781 let mut current_y = rows_top;
783 let max_y = rows_max_y;
784 let mut last_visible = state.offset;
785
786 for (i, &row_idx) in display_indices.iter().enumerate().skip(state.offset) {
787 let row = &self.rows[row_idx];
788 if row.height > max_y.saturating_sub(current_y) {
789 break;
790 }
791 current_y = current_y
792 .saturating_add(row.height)
793 .saturating_add(row.bottom_margin);
794 last_visible = i;
795 }
796
797 if selected_display_idx > last_visible {
798 let mut new_offset = selected_display_idx;
799 let mut accumulated_height: u16 = 0;
800 let available_height = rows_height;
801
802 for i in (0..=selected_display_idx).rev() {
803 let row = &self.rows[display_indices[i]];
804 let total_row_height = if i == selected_display_idx {
805 row.height
806 } else {
807 row.height.saturating_add(row.bottom_margin)
808 };
809
810 if total_row_height > available_height.saturating_sub(accumulated_height) {
811 if i == selected_display_idx {
812 new_offset = selected_display_idx;
813 } else {
814 new_offset = i + 1;
815 }
816 break;
817 }
818
819 accumulated_height = accumulated_height.saturating_add(total_row_height);
820 new_offset = i;
821 }
822 state.offset = new_offset;
823 }
824 }
825 }
826
827 #[cfg(feature = "tracing")]
828 let table_span = tracing::debug_span!(
829 "table.render",
830 total_rows = self.rows.len(),
831 visible_rows = row_count,
832 offset = state.offset,
833 viewport_height = rows_height,
834 rendered_rows = tracing::field::Empty,
835 );
836 #[cfg(feature = "tracing")]
837 let _table_span_guard = table_span.clone().entered();
838
839 let flex = Flex::horizontal()
841 .constraints(self.widths.clone())
842 .gap(self.column_spacing);
843
844 let intrinsic_col_widths = if Self::requires_measurement(&self.widths) {
845 if let Some(hash) = self.data_hash {
846 if let Some((cached_hash, ref widths)) = state.cached_intrinsic_widths
847 && cached_hash == hash
848 && widths.len() == self.widths.len()
849 {
850 widths.clone()
851 } else {
852 let widths: std::sync::Arc<[u16]> =
853 Self::compute_intrinsic_widths(&self.rows, None, self.widths.len()).into();
854 state.cached_intrinsic_widths = Some((hash, widths.clone()));
855 widths
856 }
857 } else {
858 Self::compute_intrinsic_widths(&self.rows, None, self.widths.len()).into()
859 }
860 } else {
861 std::sync::Arc::new([])
862 };
863
864 let column_rects = flex.split_with_measurer_stably(
866 Rect::new(table_area.x, table_area.y, table_area.width, 1),
867 |idx, _| {
868 let row_width = intrinsic_col_widths.get(idx).copied().unwrap_or(0);
870 let header_width = self
871 .header
872 .as_ref()
873 .and_then(|h| h.cells.get(idx))
874 .map(|c| c.width().min(u16::MAX as usize) as u16)
875 .unwrap_or(0);
876 ftui_layout::LayoutSizeHint::exact(row_width.max(header_width))
877 },
878 &mut state.coherence,
879 );
880
881 let mut y = table_area.y;
882 let max_y = table_area.bottom();
883 let divider_char = divider_char(self.block.as_ref());
884
885 if let Some(header) = &self.header {
887 if y >= max_y {
888 frame.buffer.pop_scissor();
889 return;
890 }
891 let row_area = Rect::new(table_area.x, y, table_area.width, header.height);
892 let divider_area = Rect::new(
894 table_area.x,
895 y,
896 table_area.width,
897 header.height.saturating_add(header.bottom_margin),
898 );
899
900 let header_style = if apply_styling {
901 let mut style = self.style;
902 style = theme.header.merge(&style);
903 header.style.merge(&style)
904 } else {
905 Style::default()
906 };
907
908 clear_text_area(frame, row_area, header_style);
909
910 if apply_styling && let Some((resolver, phase)) = effects {
911 for (col_idx, rect) in column_rects.iter().enumerate() {
912 let cell_area = Rect::new(rect.x, y, rect.width, header.height);
913 let scope = TableEffectScope {
914 section: TableSection::Header,
915 row: None,
916 column: Some(col_idx),
917 };
918 let style = resolver.resolve(header_style, scope, phase);
919 set_style_area(&mut frame.buffer, cell_area, style);
920 }
921 }
922
923 let divider_style = if apply_styling {
924 theme.divider.merge(&header_style)
925 } else {
926 Style::default()
927 };
928 draw_vertical_dividers(
929 &mut frame.buffer,
930 divider_area,
931 &column_rects,
932 divider_char,
933 divider_style,
934 );
935
936 render_row(
937 header,
938 &column_rects,
939 frame,
940 y,
941 header_style,
942 TableSection::Header,
943 None,
944 effects,
945 effects.is_some(),
946 );
947
948 if let Some(col) = state.sort_column
950 && col < column_rects.len()
951 {
952 let rect = column_rects[col];
953 let symbol = if state.sort_ascending { "▲" } else { "▼" };
954 let x = rect.right().saturating_sub(1);
956 if x >= rect.x {
957 crate::draw_text_span(frame, x, y, symbol, header_style, rect.right());
958 }
959 }
960
961 y = y
962 .saturating_add(header.height)
963 .saturating_add(header.bottom_margin);
964 }
965
966 if row_count == 0 {
968 #[cfg(feature = "tracing")]
969 table_span.record("rendered_rows", 0_u64);
970 frame.buffer.pop_scissor();
971 return;
972 }
973
974 let mut rendered_rows = 0usize;
975 for (i, &row_idx) in display_indices.iter().enumerate().skip(state.offset) {
976 if y >= max_y {
977 break;
978 }
979
980 let row = &self.rows[row_idx];
981 let is_selected = state.selected == Some(row_idx);
982 let is_hovered = state.hovered == Some(row_idx);
983 let row_area = Rect::new(table_area.x, y, table_area.width, row.height);
984 let divider_area = Rect::new(
986 table_area.x,
987 y,
988 table_area.width,
989 row.height.saturating_add(row.bottom_margin),
990 );
991
992 let row_style = if apply_styling {
993 let mut style = self.style;
995 let stripe = if i % 2 == 0 { theme.row } else { theme.row_alt };
997 style = stripe.merge(&style);
998 style = row.style.merge(&style);
1000 if is_selected {
1002 style = theme.row_selected.merge(&style);
1003 }
1004 if is_hovered {
1006 style = theme.row_hover.merge(&style);
1007 }
1008 if is_selected {
1010 style = self.highlight_style.merge(&style);
1011 }
1012 style
1013 } else {
1014 Style::default()
1015 };
1016
1017 clear_text_area(frame, row_area, row_style);
1018
1019 if apply_styling && let Some((resolver, phase)) = effects {
1020 if has_column_effects {
1021 for (col_idx, rect) in column_rects.iter().enumerate() {
1022 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
1023 let scope = TableEffectScope {
1024 section: TableSection::Body,
1025 row: Some(i),
1026 column: Some(col_idx),
1027 };
1028 let style = resolver.resolve(row_style, scope, phase);
1029 set_style_area(&mut frame.buffer, cell_area, style);
1030 }
1031 } else {
1032 let scope = TableEffectScope::row(TableSection::Body, i);
1033 let style = resolver.resolve(row_style, scope, phase);
1034 set_style_area(&mut frame.buffer, row_area, style);
1035 }
1036 }
1037
1038 let divider_style = if apply_styling {
1039 theme.divider.merge(&row_style)
1040 } else {
1041 Style::default()
1042 };
1043 draw_vertical_dividers(
1044 &mut frame.buffer,
1045 divider_area,
1046 &column_rects,
1047 divider_char,
1048 divider_style,
1049 );
1050
1051 render_row(
1052 row,
1053 &column_rects,
1054 frame,
1055 y,
1056 row_style,
1057 TableSection::Body,
1058 Some(i),
1059 effects,
1060 has_column_effects,
1061 );
1062
1063 if let Some(id) = self.hit_id {
1065 frame.register_hit(row_area, id, HitRegion::Content, row_idx as u64);
1067 }
1068
1069 rendered_rows = rendered_rows.saturating_add(1);
1070 y = y
1071 .saturating_add(row.height)
1072 .saturating_add(row.bottom_margin);
1073 }
1074
1075 #[cfg(feature = "tracing")]
1076 table_span.record("rendered_rows", rendered_rows as u64);
1077 frame.buffer.pop_scissor();
1078 }
1079}
1080
1081#[allow(clippy::too_many_arguments)]
1082fn render_row(
1083 row: &Row,
1084 col_rects: &[Rect],
1085 frame: &mut Frame,
1086 y: u16,
1087 base_style: Style,
1088 section: TableSection,
1089 row_idx: Option<usize>,
1090 effects: Option<(&TableEffectResolver<'_>, f32)>,
1091 column_effects: bool,
1092) {
1093 let apply_styling = frame.degradation.apply_styling();
1094 let row_effect_base = if apply_styling {
1095 if let Some((resolver, phase)) = effects {
1096 if !column_effects {
1097 let scope = TableEffectScope {
1098 section,
1099 row: row_idx,
1100 column: None,
1101 };
1102 Some(resolver.resolve(base_style, scope, phase))
1103 } else {
1104 None
1105 }
1106 } else {
1107 None
1108 }
1109 } else {
1110 None
1111 };
1112
1113 for (col_idx, cell_text) in row.cells.iter().enumerate() {
1114 if col_idx >= col_rects.len() {
1115 break;
1116 }
1117 let rect = col_rects[col_idx];
1118 let cell_area = Rect::new(rect.x, y, rect.width, row.height);
1119 let scope = if effects.is_some() {
1120 Some(TableEffectScope {
1121 section,
1122 row: row_idx,
1123 column: if column_effects { Some(col_idx) } else { None },
1124 })
1125 } else {
1126 None
1127 };
1128 let column_effect_base = if apply_styling && column_effects {
1129 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
1130 Some(resolver.resolve(base_style, scope, phase))
1131 } else {
1132 None
1133 }
1134 } else {
1135 None
1136 };
1137
1138 for (line_idx, line) in cell_text.lines().iter().enumerate() {
1139 if line_idx as u16 >= row.height {
1140 break;
1141 }
1142
1143 let mut x = cell_area.x;
1144 for span in line.spans() {
1145 let mut span_style = if apply_styling {
1147 match span.style {
1148 Some(s) => s.merge(&base_style),
1149 None => base_style,
1150 }
1151 } else {
1152 Style::default()
1153 };
1154
1155 if let (Some((resolver, phase)), Some(scope)) = (effects, scope) {
1156 if span.style.is_none() {
1157 if let Some(base_effect) = column_effect_base.or(row_effect_base) {
1158 span_style = base_effect;
1159 } else {
1160 span_style = resolver.resolve(span_style, scope, phase);
1161 }
1162 } else {
1163 span_style = resolver.resolve(span_style, scope, phase);
1164 }
1165 }
1166
1167 x = crate::draw_text_span_with_link(
1168 frame,
1169 x,
1170 cell_area.y.saturating_add(line_idx as u16),
1171 &span.content,
1172 span_style,
1173 cell_area.right(),
1174 span.link.as_deref(),
1175 );
1176 if x >= cell_area.right() {
1177 break;
1178 }
1179 }
1180 }
1181 }
1182}
1183
1184fn theme_has_column_effects(theme: &TableTheme) -> bool {
1185 theme.effects.iter().any(|rule| {
1186 matches!(
1187 rule.target,
1188 TableEffectTarget::Column(_) | TableEffectTarget::ColumnRange { .. }
1189 )
1190 })
1191}
1192
1193fn divider_char(block: Option<&Block<'_>>) -> char {
1194 block
1195 .map(|b| b.border_set().vertical)
1196 .unwrap_or(crate::borders::BorderSet::SQUARE.vertical)
1197}
1198
1199fn draw_vertical_dividers(
1200 buf: &mut Buffer,
1201 row_area: Rect,
1202 col_rects: &[Rect],
1203 divider_char: char,
1204 style: Style,
1205) {
1206 if col_rects.len() < 2 || row_area.is_empty() {
1207 return;
1208 }
1209
1210 for pair in col_rects.windows(2) {
1211 let left = pair[0];
1212 let right = pair[1];
1213 let gap = right.x.saturating_sub(left.right());
1214 if gap == 0 {
1215 continue;
1216 }
1217 let x = left.right();
1218 if x >= row_area.right() {
1219 continue;
1220 }
1221 let mut cell = Cell::from_char(divider_char);
1222 apply_style(&mut cell, style);
1223 for y in row_area.y..row_area.bottom() {
1224 buf.set_fast(x, y, cell);
1225 }
1226 }
1227}
1228
1229impl MeasurableWidget for Table<'_> {
1230 fn measure(&self, _available: Size) -> SizeConstraints {
1231 if self.rows.is_empty() && self.header.is_none() {
1232 return SizeConstraints::ZERO;
1233 }
1234
1235 let col_count = self.widths.len();
1236 if col_count == 0 {
1237 return SizeConstraints::ZERO;
1238 }
1239
1240 let row_widths = Self::compute_intrinsic_widths(&self.rows, None, col_count);
1241
1242 let separator_width = if col_count > 1 {
1244 ((col_count - 1) as u16).saturating_mul(self.column_spacing)
1245 } else {
1246 0
1247 };
1248
1249 let mut summed_col_width = 0u16;
1250 for (i, &r_w) in row_widths.iter().enumerate() {
1251 let h_w = self
1252 .header
1253 .as_ref()
1254 .and_then(|h| h.cells.get(i))
1255 .map(|c| c.width().min(u16::MAX as usize) as u16)
1256 .unwrap_or(0);
1257 summed_col_width = summed_col_width.saturating_add(r_w.max(h_w));
1258 }
1259
1260 let content_width = summed_col_width.saturating_add(separator_width);
1261
1262 let header_height = self
1265 .header
1266 .as_ref()
1267 .map(|h| h.height.saturating_add(h.bottom_margin))
1268 .unwrap_or(0);
1269
1270 let rows_height: u16 = self.rows.iter().fold(0u16, |acc, r| {
1271 acc.saturating_add(r.height.saturating_add(r.bottom_margin))
1272 });
1273
1274 let content_height = header_height.saturating_add(rows_height);
1275
1276 let (block_width, block_height) = self
1278 .block
1279 .as_ref()
1280 .map(|b| {
1281 let inner = b.inner(Rect::new(0, 0, 100, 100));
1282 let w_overhead = 100u16.saturating_sub(inner.width);
1283 let h_overhead = 100u16.saturating_sub(inner.height);
1284 (w_overhead, h_overhead)
1285 })
1286 .unwrap_or((0, 0));
1287
1288 let total_width = content_width.saturating_add(block_width);
1289 let total_height = content_height.saturating_add(block_height);
1290
1291 SizeConstraints {
1292 min: Size::new(
1293 (col_count as u16).saturating_add(block_width),
1294 header_height.max(1).saturating_add(block_height),
1295 ),
1296 preferred: Size::new(total_width, total_height),
1297 max: Some(Size::new(total_width, total_height)), }
1299 }
1300
1301 fn has_intrinsic_size(&self) -> bool {
1302 !self.rows.is_empty() || self.header.is_some()
1303 }
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use ftui_render::buffer::Buffer;
1310 use ftui_render::cell::PackedRgba;
1311 use ftui_render::grapheme_pool::GraphemePool;
1312 use ftui_text::{Line, Span};
1313 #[cfg(feature = "tracing")]
1314 use std::sync::{Arc, Mutex};
1315 #[cfg(feature = "tracing")]
1316 use tracing::Subscriber;
1317 #[cfg(feature = "tracing")]
1318 use tracing_subscriber::Layer;
1319 #[cfg(feature = "tracing")]
1320 use tracing_subscriber::layer::{Context, SubscriberExt};
1321
1322 fn cell_char(buf: &Buffer, x: u16, y: u16) -> Option<char> {
1323 buf.get(x, y).and_then(|c| c.content.as_char())
1324 }
1325
1326 fn cell_fg(buf: &Buffer, x: u16, y: u16) -> Option<PackedRgba> {
1327 buf.get(x, y).map(|c| c.fg)
1328 }
1329
1330 fn row_text(buf: &Buffer, y: u16) -> String {
1331 let width = buf.width();
1332 let mut actual = String::new();
1333 for x in 0..width {
1334 let ch = buf
1335 .get(x, y)
1336 .and_then(|cell| cell.content.as_char())
1337 .unwrap_or(' ');
1338 actual.push(ch);
1339 }
1340 actual.trim().to_string()
1341 }
1342
1343 fn raw_row_text(buf: &Buffer, y: u16) -> String {
1344 let width = buf.width();
1345 let mut actual = String::new();
1346 for x in 0..width {
1347 let ch = buf
1348 .get(x, y)
1349 .and_then(|cell| cell.content.as_char())
1350 .unwrap_or(' ');
1351 actual.push(ch);
1352 }
1353 actual
1354 }
1355
1356 #[cfg(feature = "tracing")]
1357 #[derive(Debug, Default)]
1358 struct TableTraceState {
1359 span_count: usize,
1360 has_total_rows_field: bool,
1361 has_rendered_rows_field: bool,
1362 total_rows: Vec<u64>,
1363 rendered_rows: Vec<u64>,
1364 }
1365
1366 #[cfg(feature = "tracing")]
1367 struct TableTraceCapture {
1368 state: Arc<Mutex<TableTraceState>>,
1369 }
1370
1371 #[cfg(feature = "tracing")]
1372 #[derive(Default)]
1373 struct TableRenderVisitor {
1374 total_rows: Option<u64>,
1375 rendered_rows: Option<u64>,
1376 }
1377
1378 #[cfg(feature = "tracing")]
1379 impl tracing::field::Visit for TableRenderVisitor {
1380 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
1381 match field.name() {
1382 "total_rows" => self.total_rows = Some(value),
1383 "rendered_rows" => self.rendered_rows = Some(value),
1384 _ => {}
1385 }
1386 }
1387
1388 fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
1389 if let Ok(value) = u64::try_from(value) {
1390 self.record_u64(field, value);
1391 }
1392 }
1393
1394 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1395 let value = format!("{value:?}");
1396 if let Ok(parsed) = value.parse::<u64>() {
1397 self.record_u64(field, parsed);
1398 }
1399 }
1400 }
1401
1402 #[cfg(feature = "tracing")]
1403 impl<S> Layer<S> for TableTraceCapture
1404 where
1405 S: Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>,
1406 {
1407 fn on_new_span(
1408 &self,
1409 attrs: &tracing::span::Attributes<'_>,
1410 _id: &tracing::Id,
1411 _ctx: Context<'_, S>,
1412 ) {
1413 if attrs.metadata().name() != "table.render" {
1414 return;
1415 }
1416
1417 let mut visitor = TableRenderVisitor::default();
1418 attrs.record(&mut visitor);
1419 let fields = attrs.metadata().fields();
1420
1421 let mut state = self.state.lock().expect("table trace state lock");
1422 state.span_count += 1;
1423 state.has_total_rows_field |= fields.field("total_rows").is_some();
1424 state.has_rendered_rows_field |= fields.field("rendered_rows").is_some();
1425 if let Some(total_rows) = visitor.total_rows {
1426 state.total_rows.push(total_rows);
1427 }
1428 if let Some(rendered_rows) = visitor.rendered_rows {
1429 state.rendered_rows.push(rendered_rows);
1430 }
1431 }
1432
1433 fn on_record(
1434 &self,
1435 id: &tracing::Id,
1436 values: &tracing::span::Record<'_>,
1437 ctx: Context<'_, S>,
1438 ) {
1439 let Some(span_ref) = ctx.span(id) else {
1440 return;
1441 };
1442 if span_ref.metadata().name() != "table.render" {
1443 return;
1444 }
1445
1446 let mut visitor = TableRenderVisitor::default();
1447 values.record(&mut visitor);
1448
1449 let mut state = self.state.lock().expect("table trace state lock");
1450 if let Some(total_rows) = visitor.total_rows {
1451 state.total_rows.push(total_rows);
1452 }
1453 if let Some(rendered_rows) = visitor.rendered_rows {
1454 state.rendered_rows.push(rendered_rows);
1455 }
1456 }
1457 }
1458
1459 #[test]
1462 fn row_new_from_strings() {
1463 let row = Row::new(["A", "B", "C"]);
1464 assert_eq!(row.cells.len(), 3);
1465 assert_eq!(row.height, 1);
1466 assert_eq!(row.bottom_margin, 0);
1467 }
1468
1469 #[test]
1470 fn row_builder_methods() {
1471 let row = Row::new(["X"])
1472 .height(3)
1473 .bottom_margin(1)
1474 .style(Style::new().bold());
1475 assert_eq!(row.height, 3);
1476 assert_eq!(row.bottom_margin, 1);
1477 assert!(row.style.has_attr(ftui_style::StyleFlags::BOLD));
1478 }
1479
1480 #[test]
1481 fn row_height_zero_clamps_to_one() {
1482 let row = Row::new(["X"]).height(0);
1483 assert_eq!(row.height, 1);
1484 }
1485
1486 #[test]
1489 fn table_state_default() {
1490 let state = TableState::default();
1491 assert_eq!(state.selected, None);
1492 assert_eq!(state.offset, 0);
1493 }
1494
1495 #[test]
1496 fn table_state_select() {
1497 let mut state = TableState::default();
1498 state.select(Some(5));
1499 assert_eq!(state.selected, Some(5));
1500 assert_eq!(state.offset, 0);
1501 }
1502
1503 #[test]
1504 fn table_state_deselect_preserves_offset() {
1505 let mut state = TableState {
1506 offset: 10,
1507 ..Default::default()
1508 };
1509 state.select(Some(3));
1510 assert_eq!(state.selected, Some(3));
1511 state.select(None);
1512 assert_eq!(state.selected, None);
1513 assert_eq!(state.offset, 10);
1514 }
1515
1516 #[test]
1517 fn table_state_scroll_down_is_overflow_safe() {
1518 let mut state = TableState {
1520 offset: usize::MAX - 1,
1521 ..Default::default()
1522 };
1523 state.scroll_down(10, 100);
1524 assert_eq!(state.offset, 99);
1525 }
1526
1527 #[test]
1530 fn render_zero_area() {
1531 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
1532 let area = Rect::new(0, 0, 0, 0);
1533 let mut pool = GraphemePool::new();
1534 let mut frame = Frame::new(1, 1, &mut pool);
1535 Widget::render(&table, area, &mut frame);
1536 }
1538
1539 #[test]
1540 fn render_empty_rows() {
1541 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1542 let area = Rect::new(0, 0, 10, 5);
1543 let mut pool = GraphemePool::new();
1544 let mut frame = Frame::new(10, 5, &mut pool);
1545 Widget::render(&table, area, &mut frame);
1546 }
1548
1549 #[test]
1550 fn render_empty_rows_clears_stale_viewport() {
1551 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
1552 let area = Rect::new(0, 0, 10, 3);
1553 let mut pool = GraphemePool::new();
1554 let mut frame = Frame::new(10, 3, &mut pool);
1555 frame.buffer.fill(area, Cell::from_char('X'));
1556
1557 Widget::render(&table, area, &mut frame);
1558
1559 assert_eq!(raw_row_text(&frame.buffer, 0), " ");
1560 assert_eq!(raw_row_text(&frame.buffer, 1), " ");
1561 assert_eq!(raw_row_text(&frame.buffer, 2), " ");
1562 }
1563
1564 #[test]
1565 fn render_single_row_single_column() {
1566 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1567 let area = Rect::new(0, 0, 10, 3);
1568 let mut pool = GraphemePool::new();
1569 let mut frame = Frame::new(10, 3, &mut pool);
1570 Widget::render(&table, area, &mut frame);
1571
1572 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
1573 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('e'));
1574 assert_eq!(cell_char(&frame.buffer, 4, 0), Some('o'));
1575 }
1576
1577 #[test]
1578 fn render_shorter_cell_clears_stale_suffix() {
1579 let area = Rect::new(0, 0, 10, 1);
1580 let mut pool = GraphemePool::new();
1581 let mut frame = Frame::new(10, 1, &mut pool);
1582
1583 let long = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
1584 Widget::render(&long, area, &mut frame);
1585
1586 let short = Table::new([Row::new(["Hi"])], [Constraint::Fixed(10)]);
1587 Widget::render(&short, area, &mut frame);
1588
1589 assert_eq!(raw_row_text(&frame.buffer, 0), "Hi ");
1590 }
1591
1592 #[test]
1593 fn render_multiple_rows() {
1594 let table = Table::new(
1595 [Row::new(["AA", "BB"]), Row::new(["CC", "DD"])],
1596 [Constraint::Fixed(4), Constraint::Fixed(4)],
1597 );
1598 let area = Rect::new(0, 0, 10, 3);
1599 let mut pool = GraphemePool::new();
1600 let mut frame = Frame::new(10, 3, &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, 1), Some('C'));
1607 }
1608
1609 #[test]
1610 fn render_with_header() {
1611 let header = Row::new(["Name", "Val"]);
1612 let table = Table::new(
1613 [Row::new(["foo", "42"])],
1614 [Constraint::Fixed(5), Constraint::Fixed(4)],
1615 )
1616 .header(header);
1617
1618 let area = Rect::new(0, 0, 10, 3);
1619 let mut pool = GraphemePool::new();
1620 let mut frame = Frame::new(10, 3, &mut pool);
1621 Widget::render(&table, area, &mut frame);
1622
1623 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('N'));
1625 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('f'));
1627 }
1628
1629 #[test]
1630 fn render_shorter_header_clears_stale_suffix() {
1631 let area = Rect::new(0, 0, 10, 2);
1632 let mut pool = GraphemePool::new();
1633 let mut frame = Frame::new(10, 2, &mut pool);
1634
1635 let long =
1636 Table::new([Row::new(["row"])], [Constraint::Fixed(10)]).header(Row::new(["Header"]));
1637 Widget::render(&long, area, &mut frame);
1638
1639 let short =
1640 Table::new([Row::new(["row"])], [Constraint::Fixed(10)]).header(Row::new(["H"]));
1641 Widget::render(&short, area, &mut frame);
1642
1643 assert_eq!(raw_row_text(&frame.buffer, 0), "H ");
1644 }
1645
1646 #[test]
1647 fn zero_height_row_clamps_and_preserves_vertical_flow() {
1648 let table = Table::new(
1649 [Row::new(["A"]).height(0), Row::new(["B"])],
1650 [Constraint::Fixed(3)],
1651 );
1652 let area = Rect::new(0, 0, 3, 2);
1653 let mut pool = GraphemePool::new();
1654 let mut frame = Frame::new(3, 2, &mut pool);
1655 Widget::render(&table, area, &mut frame);
1656
1657 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1658 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1659 }
1660
1661 #[test]
1662 fn zero_height_header_clamps_to_one_and_offsets_rows() {
1663 let header = Row::new(["H"]).height(0);
1664 let table = Table::new([Row::new(["D"])], [Constraint::Fixed(3)]).header(header);
1665
1666 let area = Rect::new(0, 0, 3, 2);
1667 let mut pool = GraphemePool::new();
1668 let mut frame = Frame::new(3, 2, &mut pool);
1669 Widget::render(&table, area, &mut frame);
1670
1671 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
1672 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('D'));
1673 }
1674
1675 #[test]
1676 fn render_with_block() {
1677 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).block(Block::bordered());
1678
1679 let area = Rect::new(0, 0, 10, 5);
1680 let mut pool = GraphemePool::new();
1681 let mut frame = Frame::new(10, 5, &mut pool);
1682 Widget::render(&table, area, &mut frame);
1683
1684 assert_eq!(cell_char(&frame.buffer, 2, 2), Some('X'));
1686 }
1687
1688 #[test]
1689 fn stateful_render_with_selection() {
1690 let table = Table::new(
1691 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
1692 [Constraint::Fixed(5)],
1693 )
1694 .highlight_style(Style::new().bold());
1695
1696 let area = Rect::new(0, 0, 5, 3);
1697 let mut pool = GraphemePool::new();
1698 let mut frame = Frame::new(5, 3, &mut pool);
1699 let mut state = TableState::default();
1700 state.select(Some(1));
1701
1702 StatefulWidget::render(&table, area, &mut frame, &mut state);
1703 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1706 }
1707
1708 #[test]
1709 fn row_style_merge_precedence_and_span_override() {
1710 let base_fg = PackedRgba::rgb(10, 0, 0);
1711 let selected_fg = PackedRgba::rgb(20, 0, 0);
1712 let hovered_fg = PackedRgba::rgb(30, 0, 0);
1713 let table_fg = PackedRgba::rgb(40, 0, 0);
1714 let row_fg = PackedRgba::rgb(50, 0, 0);
1715 let highlight_fg = PackedRgba::rgb(60, 0, 0);
1716 let span_fg = PackedRgba::rgb(70, 0, 0);
1717
1718 let base_row = Style::new().fg(base_fg);
1719 let theme = TableTheme {
1720 row: base_row,
1721 row_alt: base_row,
1722 row_selected: Style::new().fg(selected_fg),
1723 row_hover: Style::new().fg(hovered_fg),
1724 ..Default::default()
1725 };
1726
1727 let text = Text::from_line(Line::from_spans([
1728 Span::raw("A"),
1729 Span::styled("B", Style::new().fg(span_fg)),
1730 ]));
1731
1732 let table = Table::new(
1733 [Row::new([text]).style(Style::new().fg(row_fg))],
1734 [Constraint::Fixed(2)],
1735 )
1736 .style(Style::new().fg(table_fg))
1737 .highlight_style(Style::new().fg(highlight_fg))
1738 .theme(theme);
1739
1740 let area = Rect::new(0, 0, 2, 1);
1741 let mut pool = GraphemePool::new();
1742 let mut frame = Frame::new(2, 1, &mut pool);
1743 let mut state = TableState {
1744 selected: Some(0),
1745 hovered: Some(0),
1746 ..Default::default()
1747 };
1748
1749 StatefulWidget::render(&table, area, &mut frame, &mut state);
1750
1751 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(highlight_fg));
1752 assert_eq!(cell_fg(&frame.buffer, 1, 0), Some(span_fg));
1753 }
1754
1755 #[test]
1756 fn selection_below_offset_adjusts_offset() {
1757 let mut state = TableState {
1758 offset: 5,
1759 selected: Some(2), persistence_id: None,
1761 ..Default::default()
1762 };
1763
1764 let table = Table::new(
1765 (0..10).map(|i| Row::new([format!("Row {i}")])),
1766 [Constraint::Fixed(10)],
1767 );
1768 let area = Rect::new(0, 0, 10, 3);
1769 let mut pool = GraphemePool::new();
1770 let mut frame = Frame::new(10, 3, &mut pool);
1771 StatefulWidget::render(&table, area, &mut frame, &mut state);
1772
1773 assert_eq!(state.offset, 2);
1775 }
1776
1777 #[test]
1778 fn table_clamps_offset_to_fill_viewport_on_resize() {
1779 let rows: Vec<Row> = (0..10).map(|i| Row::new([format!("Row {i}")])).collect();
1780 let table = Table::new(rows, [Constraint::Min(10)]);
1781
1782 let mut pool = GraphemePool::new();
1783 let mut state = TableState {
1784 offset: 7,
1785 ..Default::default()
1786 };
1787
1788 let area_small = Rect::new(0, 0, 10, 3);
1790 let mut frame_small = Frame::new(10, 3, &mut pool);
1791 StatefulWidget::render(&table, area_small, &mut frame_small, &mut state);
1792 assert_eq!(state.offset, 7);
1793 assert_eq!(row_text(&frame_small.buffer, 0), "Row 7");
1794 assert_eq!(row_text(&frame_small.buffer, 2), "Row 9");
1795
1796 let area_large = Rect::new(0, 0, 10, 5);
1798 let mut frame_large = Frame::new(10, 5, &mut pool);
1799 StatefulWidget::render(&table, area_large, &mut frame_large, &mut state);
1800 assert_eq!(state.offset, 5);
1801 assert_eq!(row_text(&frame_large.buffer, 0), "Row 5");
1802 assert_eq!(row_text(&frame_large.buffer, 4), "Row 9");
1803 }
1804
1805 #[test]
1806 fn table_clamps_offset_to_fill_viewport_with_variable_row_heights() {
1807 let mut rows: Vec<Row> = (0..9).map(|i| Row::new([format!("Row {i}")])).collect();
1811 rows.push(Row::new(["Row 9"]).height(5));
1812 let table = Table::new(rows, [Constraint::Min(10)]);
1813
1814 let mut pool = GraphemePool::new();
1815 let mut state = TableState {
1816 offset: 9,
1817 ..Default::default()
1818 };
1819
1820 let area = Rect::new(0, 0, 10, 10);
1821 let mut frame = Frame::new(10, 10, &mut pool);
1822 StatefulWidget::render(&table, area, &mut frame, &mut state);
1823
1824 assert_eq!(state.offset, 4);
1825 assert_eq!(row_text(&frame.buffer, 0), "Row 4");
1826 }
1827
1828 #[test]
1829 fn selection_invalid_index_falls_back_to_first_row() {
1830 let table = Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]);
1831 let area = Rect::new(0, 0, 5, 2);
1832 let mut pool = GraphemePool::new();
1833 let mut frame = Frame::new(5, 2, &mut pool);
1834 let mut state = TableState {
1835 offset: 0,
1836 selected: Some(99),
1837 persistence_id: None,
1838 ..Default::default()
1839 };
1840
1841 StatefulWidget::render(&table, area, &mut frame, &mut state);
1842 assert_eq!(state.selected, Some(0));
1843 }
1844
1845 #[test]
1846 fn selection_with_header_accounts_for_header_height() {
1847 let header = Row::new(["H"]);
1848 let table =
1849 Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(5)]).header(header);
1850
1851 let area = Rect::new(0, 0, 5, 2);
1852 let mut pool = GraphemePool::new();
1853 let mut frame = Frame::new(5, 2, &mut pool);
1854 let mut state = TableState {
1855 offset: 0,
1856 selected: Some(1),
1857 persistence_id: None,
1858 ..Default::default()
1859 };
1860
1861 StatefulWidget::render(&table, area, &mut frame, &mut state);
1862 assert_eq!(state.offset, 1);
1863 }
1864
1865 #[test]
1866 fn rows_overflow_area_truncated() {
1867 let table = Table::new(
1868 (0..20).map(|i| Row::new([format!("R{i}")])),
1869 [Constraint::Fixed(5)],
1870 );
1871 let area = Rect::new(0, 0, 5, 3);
1872 let mut pool = GraphemePool::new();
1873 let mut frame = Frame::new(5, 3, &mut pool);
1874 Widget::render(&table, area, &mut frame);
1875
1876 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('R'));
1878 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('0'));
1879 assert_eq!(cell_char(&frame.buffer, 1, 2), Some('2'));
1880 }
1881
1882 #[test]
1883 fn column_spacing_applied() {
1884 let table = Table::new(
1885 [Row::new(["A", "B"])],
1886 [Constraint::Fixed(3), Constraint::Fixed(3)],
1887 )
1888 .column_spacing(2);
1889
1890 let area = Rect::new(0, 0, 10, 1);
1891 let mut pool = GraphemePool::new();
1892 let mut frame = Frame::new(10, 1, &mut pool);
1893 Widget::render(&table, area, &mut frame);
1894
1895 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1897 }
1898
1899 #[test]
1900 fn divider_style_overrides_row_style() {
1901 let row_fg = PackedRgba::rgb(120, 10, 10);
1902 let divider_fg = PackedRgba::rgb(0, 200, 0);
1903 let row_style = Style::new().fg(row_fg);
1904 let theme = TableTheme {
1905 row: row_style,
1906 row_alt: row_style,
1907 divider: Style::new().fg(divider_fg),
1908 ..Default::default()
1909 };
1910
1911 let table = Table::new(
1912 [Row::new(["AA", "BB"])],
1913 [Constraint::Fixed(2), Constraint::Fixed(2)],
1914 )
1915 .theme(theme);
1916
1917 let area = Rect::new(0, 0, 5, 1);
1918 let mut pool = GraphemePool::new();
1919 let mut frame = Frame::new(5, 1, &mut pool);
1920 Widget::render(&table, area, &mut frame);
1921
1922 assert_eq!(cell_fg(&frame.buffer, 2, 0), Some(divider_fg));
1923 }
1924
1925 #[test]
1926 fn block_border_uses_theme_border_style() {
1927 let border_fg = PackedRgba::rgb(1, 2, 3);
1928 let theme = TableTheme {
1929 border: Style::new().fg(border_fg),
1930 ..Default::default()
1931 };
1932
1933 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(1)])
1934 .block(Block::bordered())
1935 .theme(theme);
1936
1937 let area = Rect::new(0, 0, 3, 3);
1938 let mut pool = GraphemePool::new();
1939 let mut frame = Frame::new(3, 3, &mut pool);
1940 Widget::render(&table, area, &mut frame);
1941
1942 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(border_fg));
1943 }
1944
1945 #[test]
1946 fn render_clips_long_cell_to_column_width() {
1947 let table = Table::new([Row::new(["ABCDE"])], [Constraint::Fixed(3)]);
1948 let area = Rect::new(0, 0, 3, 1);
1949 let mut pool = GraphemePool::new();
1950 let mut frame = Frame::new(4, 1, &mut pool);
1951 Widget::render(&table, area, &mut frame);
1952
1953 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1954 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('B'));
1955 assert_eq!(cell_char(&frame.buffer, 2, 0), Some('C'));
1956 assert_ne!(cell_char(&frame.buffer, 3, 0), Some('D'));
1957 }
1958
1959 #[test]
1960 fn render_multiline_cell_respects_row_height() {
1961 let table = Table::new([Row::new(["A\nB"]).height(1)], [Constraint::Fixed(3)]);
1962 let area = Rect::new(0, 0, 3, 2);
1963 let mut pool = GraphemePool::new();
1964 let mut frame = Frame::new(3, 2, &mut pool);
1965 Widget::render(&table, area, &mut frame);
1966
1967 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1968 assert_ne!(cell_char(&frame.buffer, 0, 1), Some('B'));
1969 }
1970
1971 #[test]
1972 fn render_multiline_cell_draws_second_line_when_height_allows() {
1973 let table = Table::new([Row::new(["A\nB"]).height(2)], [Constraint::Fixed(3)]);
1974 let area = Rect::new(0, 0, 3, 2);
1975 let mut pool = GraphemePool::new();
1976 let mut frame = Frame::new(3, 2, &mut pool);
1977 Widget::render(&table, area, &mut frame);
1978
1979 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
1980 assert_eq!(cell_char(&frame.buffer, 0, 1), Some('B'));
1981 }
1982
1983 #[test]
1984 fn more_cells_than_columns_truncated() {
1985 let table = Table::new(
1986 [Row::new(["A", "B", "C", "D"])],
1987 [Constraint::Fixed(3), Constraint::Fixed(3)],
1988 );
1989 let area = Rect::new(0, 0, 8, 1);
1990 let mut pool = GraphemePool::new();
1991 let mut frame = Frame::new(8, 1, &mut pool);
1992 Widget::render(&table, area, &mut frame);
1993 }
1995
1996 #[test]
1997 fn header_too_tall_for_area() {
1998 let header = Row::new(["H"]).height(10);
1999 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]).header(header);
2000
2001 let area = Rect::new(0, 0, 5, 3);
2002 let mut pool = GraphemePool::new();
2003 let mut frame = Frame::new(5, 3, &mut pool);
2004 Widget::render(&table, area, &mut frame);
2005 }
2007
2008 #[test]
2009 fn row_with_bottom_margin() {
2010 let table = Table::new(
2011 [Row::new(["A"]).bottom_margin(1), Row::new(["B"])],
2012 [Constraint::Fixed(5)],
2013 );
2014 let area = Rect::new(0, 0, 5, 4);
2015 let mut pool = GraphemePool::new();
2016 let mut frame = Frame::new(5, 4, &mut pool);
2017 Widget::render(&table, area, &mut frame);
2018
2019 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2021 assert_eq!(cell_char(&frame.buffer, 0, 2), Some('B'));
2022 }
2023
2024 #[test]
2025 fn table_registers_hit_regions() {
2026 let table = Table::new(
2027 [Row::new(["A"]), Row::new(["B"]), Row::new(["C"])],
2028 [Constraint::Fixed(5)],
2029 )
2030 .hit_id(HitId::new(99));
2031
2032 let area = Rect::new(0, 0, 5, 3);
2033 let mut pool = GraphemePool::new();
2034 let mut frame = Frame::with_hit_grid(5, 3, &mut pool);
2035 let mut state = TableState::default();
2036 StatefulWidget::render(&table, area, &mut frame, &mut state);
2037
2038 let hit0 = frame.hit_test(2, 0);
2040 let hit1 = frame.hit_test(2, 1);
2041 let hit2 = frame.hit_test(2, 2);
2042
2043 assert_eq!(hit0, Some((HitId::new(99), HitRegion::Content, 0)));
2044 assert_eq!(hit1, Some((HitId::new(99), HitRegion::Content, 1)));
2045 assert_eq!(hit2, Some((HitId::new(99), HitRegion::Content, 2)));
2046 }
2047
2048 #[test]
2049 fn table_no_hit_without_hit_id() {
2050 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]);
2051 let area = Rect::new(0, 0, 5, 1);
2052 let mut pool = GraphemePool::new();
2053 let mut frame = Frame::with_hit_grid(5, 1, &mut pool);
2054 let mut state = TableState::default();
2055 StatefulWidget::render(&table, area, &mut frame, &mut state);
2056
2057 assert!(frame.hit_test(2, 0).is_none());
2059 }
2060
2061 #[test]
2062 fn table_no_hit_without_hit_grid() {
2063 let table = Table::new([Row::new(["A"])], [Constraint::Fixed(5)]).hit_id(HitId::new(1));
2064 let area = Rect::new(0, 0, 5, 1);
2065 let mut pool = GraphemePool::new();
2066 let mut frame = Frame::new(5, 1, &mut pool); let mut state = TableState::default();
2068 StatefulWidget::render(&table, area, &mut frame, &mut state);
2069
2070 assert!(frame.hit_test(2, 0).is_none());
2072 }
2073
2074 #[test]
2077 fn measure_empty_table() {
2078 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2079 let c = table.measure(Size::MAX);
2080 assert_eq!(c, SizeConstraints::ZERO);
2081 }
2082
2083 #[test]
2084 fn measure_empty_columns() {
2085 let table = Table::new([Row::new(["A"])], Vec::<Constraint>::new());
2086 let c = table.measure(Size::MAX);
2087 assert_eq!(c, SizeConstraints::ZERO);
2088 }
2089
2090 #[test]
2091 fn measure_single_row() {
2092 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(10)]);
2093 let c = table.measure(Size::MAX);
2094
2095 assert_eq!(c.preferred.width, 5); assert_eq!(c.preferred.height, 1); assert!(table.has_intrinsic_size());
2098 }
2099
2100 #[test]
2101 fn measure_multiple_columns() {
2102 let table = Table::new(
2103 [Row::new(["A", "BB", "CCC"])],
2104 [
2105 Constraint::Fixed(5),
2106 Constraint::Fixed(5),
2107 Constraint::Fixed(5),
2108 ],
2109 )
2110 .column_spacing(2);
2111
2112 let c = table.measure(Size::MAX);
2113
2114 assert_eq!(c.preferred.width, 10);
2116 assert_eq!(c.preferred.height, 1);
2117 }
2118
2119 #[test]
2120 fn measure_respects_row_height_and_column_spacing() {
2121 let table = Table::new(
2122 [Row::new(["A", "BB"]).height(2)],
2123 [Constraint::FitContent, Constraint::FitContent],
2124 )
2125 .column_spacing(2);
2126
2127 let c = table.measure(Size::MAX);
2128
2129 assert_eq!(c.preferred.width, 5);
2130 assert_eq!(c.preferred.height, 2);
2131 }
2132
2133 #[test]
2134 fn measure_accounts_for_wide_glyphs() {
2135 let table = Table::new(
2136 [Row::new(["界", "A"])],
2137 [Constraint::FitContent, Constraint::FitContent],
2138 )
2139 .column_spacing(1);
2140
2141 let c = table.measure(Size::MAX);
2142
2143 assert_eq!(c.preferred.width, 4);
2144 assert_eq!(c.preferred.height, 1);
2145 }
2146
2147 #[test]
2148 fn measure_with_header() {
2149 let header = Row::new(["Name", "Value"]);
2150 let table = Table::new(
2151 [Row::new(["foo", "42"])],
2152 [Constraint::Fixed(5), Constraint::Fixed(5)],
2153 )
2154 .header(header);
2155
2156 let c = table.measure(Size::MAX);
2157
2158 assert_eq!(c.preferred.width, 10);
2161 assert_eq!(c.preferred.height, 2);
2163 }
2164
2165 #[test]
2166 fn measure_with_row_margins() {
2167 let table = Table::new(
2168 [
2169 Row::new(["A"]).bottom_margin(2),
2170 Row::new(["B"]).bottom_margin(1),
2171 ],
2172 [Constraint::Fixed(5)],
2173 );
2174
2175 let c = table.measure(Size::MAX);
2176
2177 assert_eq!(c.preferred.height, 5);
2179 }
2180
2181 #[test]
2182 fn measure_column_widths_from_max_cell() {
2183 let table = Table::new(
2184 [Row::new(["A", "BB"]), Row::new(["CCC", "D"])],
2185 [Constraint::Fixed(5), Constraint::Fixed(5)],
2186 )
2187 .column_spacing(1);
2188
2189 let c = table.measure(Size::MAX);
2190
2191 assert_eq!(c.preferred.width, 6);
2195 assert_eq!(c.preferred.height, 2);
2196 }
2197
2198 #[test]
2199 fn measure_min_is_column_count() {
2200 let table = Table::new(
2201 [Row::new(["A", "B", "C"])],
2202 [
2203 Constraint::Fixed(5),
2204 Constraint::Fixed(5),
2205 Constraint::Fixed(5),
2206 ],
2207 );
2208
2209 let c = table.measure(Size::MAX);
2210
2211 assert_eq!(c.min.width, 3);
2213 assert_eq!(c.min.height, 1);
2214 }
2215
2216 #[test]
2217 fn measure_has_intrinsic_size() {
2218 let empty = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2219 assert!(!empty.has_intrinsic_size());
2220
2221 let with_rows = Table::new([Row::new(["X"])], [Constraint::Fixed(5)]);
2222 assert!(with_rows.has_intrinsic_size());
2223
2224 let header_only =
2225 Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]).header(Row::new(["Header"]));
2226 assert!(header_only.has_intrinsic_size());
2227 }
2228
2229 use crate::stateful::Stateful;
2232
2233 #[test]
2234 fn table_state_with_persistence_id() {
2235 let state = TableState::default().with_persistence_id("my-table");
2236 assert_eq!(state.persistence_id(), Some("my-table"));
2237 }
2238
2239 #[test]
2240 fn table_state_default_no_persistence_id() {
2241 let state = TableState::default();
2242 assert_eq!(state.persistence_id(), None);
2243 }
2244
2245 #[test]
2246 fn table_state_save_restore_round_trip() {
2247 let mut state = TableState::default().with_persistence_id("test");
2248 state.select(Some(5));
2249 state.offset = 3;
2250 state.set_sort(Some(2), true);
2251 state.set_filter("search term");
2252
2253 let saved = state.save_state();
2254 assert_eq!(saved.selected, Some(5));
2255 assert_eq!(saved.offset, 3);
2256 assert_eq!(saved.sort_column, Some(2));
2257 assert!(saved.sort_ascending);
2258 assert_eq!(saved.filter, "search term");
2259
2260 state.select(None);
2262 state.offset = 0;
2263 state.set_sort(None, false);
2264 state.set_filter("");
2265 assert_eq!(state.selected, None);
2266 assert_eq!(state.offset, 0);
2267 assert_eq!(state.sort_column(), None);
2268 assert!(!state.sort_ascending());
2269 assert!(state.filter().is_empty());
2270
2271 state.restore_state(saved);
2273 assert_eq!(state.selected, Some(5));
2274 assert_eq!(state.offset, 3);
2275 assert_eq!(state.sort_column(), Some(2));
2276 assert!(state.sort_ascending());
2277 assert_eq!(state.filter(), "search term");
2278 }
2279
2280 #[test]
2281 fn table_state_key_uses_persistence_id() {
2282 let state = TableState::default().with_persistence_id("main-data-table");
2283 let key = state.state_key();
2284 assert_eq!(key.widget_type, "Table");
2285 assert_eq!(key.instance_id, "main-data-table");
2286 }
2287
2288 #[test]
2289 fn table_state_key_default_when_no_id() {
2290 let state = TableState::default();
2291 let key = state.state_key();
2292 assert_eq!(key.widget_type, "Table");
2293 assert_eq!(key.instance_id, "default");
2294 }
2295
2296 #[test]
2297 fn table_persist_state_default() {
2298 let persist = TablePersistState::default();
2299 assert_eq!(persist.selected, None);
2300 assert_eq!(persist.offset, 0);
2301 assert_eq!(persist.sort_column, None);
2302 assert!(!persist.sort_ascending);
2303 assert!(persist.filter.is_empty());
2304 }
2305
2306 #[test]
2311 fn table_state_undo_widget_id_unique() {
2312 let state1 = TableState::default();
2313 let state2 = TableState::default();
2314 assert_ne!(state1.undo_id(), state2.undo_id());
2315 }
2316
2317 #[test]
2318 fn table_state_undo_snapshot_and_restore() {
2319 let mut state = TableState::default();
2320 state.select(Some(5));
2321 state.offset = 2;
2322 state.set_sort(Some(1), false);
2323 state.set_filter("test filter");
2324
2325 let snapshot = state.create_snapshot();
2327
2328 state.select(Some(10));
2330 state.offset = 7;
2331 state.set_sort(Some(3), true);
2332 state.set_filter("new filter");
2333
2334 assert_eq!(state.selected, Some(10));
2335 assert_eq!(state.offset, 7);
2336 assert_eq!(state.sort_column(), Some(3));
2337 assert!(state.sort_ascending());
2338 assert_eq!(state.filter(), "new filter");
2339
2340 assert!(state.restore_snapshot(&*snapshot));
2342
2343 assert_eq!(state.selected, Some(5));
2345 assert_eq!(state.offset, 2);
2346 assert_eq!(state.sort_column(), Some(1));
2347 assert!(!state.sort_ascending());
2348 assert_eq!(state.filter(), "test filter");
2349 }
2350
2351 #[test]
2352 fn table_state_undo_ext_sort() {
2353 let mut state = TableState::default();
2354
2355 assert_eq!(state.sort_state(), (None, false));
2357
2358 state.set_sort_state(Some(2), true);
2360 assert_eq!(state.sort_state(), (Some(2), true));
2361
2362 state.set_sort_state(Some(0), false);
2364 assert_eq!(state.sort_state(), (Some(0), false));
2365 }
2366
2367 #[test]
2368 fn table_state_undo_ext_filter() {
2369 let mut state = TableState::default();
2370
2371 assert_eq!(state.filter_text(), "");
2373
2374 state.set_filter_text("search term");
2376 assert_eq!(state.filter_text(), "search term");
2377
2378 state.set_filter_text("");
2380 assert_eq!(state.filter_text(), "");
2381 }
2382
2383 #[test]
2384 fn table_state_restore_wrong_snapshot_type_fails() {
2385 use std::any::Any;
2386 let mut state = TableState::default();
2387 let wrong_snapshot: Box<dyn Any + Send> = Box::new(42i32);
2388 assert!(!state.restore_snapshot(&*wrong_snapshot));
2389 }
2390
2391 use crate::mouse::MouseResult;
2394 use ftui_core::event::{MouseButton, MouseEvent, MouseEventKind};
2395
2396 #[test]
2397 fn table_state_click_selects() {
2398 let mut state = TableState::default();
2399 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2400 let hit = Some((HitId::new(1), HitRegion::Content, 4u64));
2401 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2402 assert_eq!(result, MouseResult::Selected(4));
2403 assert_eq!(state.selected, Some(4));
2404 }
2405
2406 #[test]
2407 fn table_state_second_click_activates() {
2408 let mut state = TableState::default();
2409 state.select(Some(4));
2410
2411 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2412 let hit = Some((HitId::new(1), HitRegion::Content, 4u64));
2413 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2414 assert_eq!(result, MouseResult::Activated(4));
2415 assert_eq!(state.selected, Some(4));
2416 }
2417
2418 #[test]
2419 fn table_state_click_wrong_id_ignored() {
2420 let mut state = TableState::default();
2421 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 5, 2);
2422 let hit = Some((HitId::new(99), HitRegion::Content, 4u64));
2423 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2424 assert_eq!(result, MouseResult::Ignored);
2425 }
2426
2427 #[test]
2428 fn table_state_hover_updates() {
2429 let mut state = TableState::default();
2430 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2431 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2432 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2433 assert_eq!(result, MouseResult::HoverChanged);
2434 assert_eq!(state.hovered, Some(3));
2435 }
2436
2437 #[test]
2438 #[allow(clippy::field_reassign_with_default)]
2439 fn table_state_hover_same_index_ignored() {
2440 let mut state = {
2441 let mut s = TableState::default();
2442 s.hovered = Some(3);
2443 s
2444 };
2445 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2446 let hit = Some((HitId::new(1), HitRegion::Content, 3u64));
2447 let result = state.handle_mouse(&event, hit, HitId::new(1), 10);
2448 assert_eq!(result, MouseResult::Ignored);
2449 assert_eq!(state.hovered, Some(3));
2450 }
2451
2452 #[test]
2453 #[allow(clippy::field_reassign_with_default)]
2454 fn table_state_hover_clears() {
2455 let mut state = {
2456 let mut s = TableState::default();
2457 s.hovered = Some(5);
2458 s
2459 };
2460 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2461 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2463 assert_eq!(result, MouseResult::HoverChanged);
2464 assert_eq!(state.hovered, None);
2465 }
2466
2467 #[test]
2468 fn table_state_hover_clear_when_already_none() {
2469 let mut state = TableState::default();
2470 let event = MouseEvent::new(MouseEventKind::Moved, 5, 2);
2471 let result = state.handle_mouse(&event, None, HitId::new(1), 10);
2472 assert_eq!(result, MouseResult::Ignored);
2473 }
2474
2475 #[test]
2476 #[allow(clippy::field_reassign_with_default)]
2477 fn table_state_scroll_wheel_up() {
2478 let mut state = {
2479 let mut s = TableState::default();
2480 s.offset = 10;
2481 s
2482 };
2483 let event = MouseEvent::new(MouseEventKind::ScrollUp, 0, 0);
2484 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
2485 assert_eq!(result, MouseResult::Scrolled);
2486 assert_eq!(state.offset, 7);
2487 }
2488
2489 #[test]
2490 fn table_state_scroll_wheel_down() {
2491 let mut state = TableState::default();
2492 let event = MouseEvent::new(MouseEventKind::ScrollDown, 0, 0);
2493 let result = state.handle_mouse(&event, None, HitId::new(1), 20);
2494 assert_eq!(result, MouseResult::Scrolled);
2495 assert_eq!(state.offset, 3);
2496 }
2497
2498 #[test]
2499 #[allow(clippy::field_reassign_with_default)]
2500 fn table_state_scroll_down_clamps() {
2501 let mut state = {
2502 let mut s = TableState::default();
2503 s.offset = 18;
2504 s
2505 };
2506 state.scroll_down(5, 20);
2507 assert_eq!(state.offset, 19);
2508 }
2509
2510 #[test]
2511 #[allow(clippy::field_reassign_with_default)]
2512 fn table_state_scroll_up_clamps() {
2513 let mut state = {
2514 let mut s = TableState::default();
2515 s.offset = 1;
2516 s
2517 };
2518 state.scroll_up(5);
2519 assert_eq!(state.offset, 0);
2520 }
2521
2522 #[test]
2527 fn row_with_fewer_cells_than_columns() {
2528 let table = Table::new(
2530 [Row::new(["A"])],
2531 [
2532 Constraint::Fixed(3),
2533 Constraint::Fixed(3),
2534 Constraint::Fixed(3),
2535 ],
2536 );
2537 let area = Rect::new(0, 0, 12, 1);
2538 let mut pool = GraphemePool::new();
2539 let mut frame = Frame::new(12, 1, &mut pool);
2540 Widget::render(&table, area, &mut frame);
2541
2542 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2543 assert_ne!(cell_char(&frame.buffer, 4, 0), Some('A'));
2545 }
2546
2547 #[test]
2548 fn column_spacing_zero() {
2549 let table = Table::new(
2551 [Row::new(["AB", "CD"])],
2552 [Constraint::Fixed(2), Constraint::Fixed(2)],
2553 )
2554 .column_spacing(0);
2555
2556 let area = Rect::new(0, 0, 4, 1);
2557 let mut pool = GraphemePool::new();
2558 let mut frame = Frame::new(4, 1, &mut pool);
2559 Widget::render(&table, area, &mut frame);
2560
2561 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2562 assert_eq!(cell_char(&frame.buffer, 1, 0), Some('B'));
2563 assert_eq!(cell_char(&frame.buffer, 2, 0), Some('C'));
2564 assert_eq!(cell_char(&frame.buffer, 3, 0), Some('D'));
2565 }
2566
2567 #[test]
2568 fn render_with_nonzero_origin() {
2569 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)]);
2571 let area = Rect::new(5, 3, 3, 1);
2572 let mut pool = GraphemePool::new();
2573 let mut frame = Frame::new(10, 6, &mut pool);
2574 Widget::render(&table, area, &mut frame);
2575
2576 assert_eq!(cell_char(&frame.buffer, 5, 3), Some('X'));
2577 assert_ne!(cell_char(&frame.buffer, 0, 0), Some('X'));
2579 }
2580
2581 #[test]
2582 fn single_row_height_exceeds_area() {
2583 let table = Table::new([Row::new(["T"]).height(10)], [Constraint::Fixed(3)]);
2585 let area = Rect::new(0, 0, 3, 2);
2586 let mut pool = GraphemePool::new();
2587 let mut frame = Frame::new(3, 2, &mut pool);
2588 Widget::render(&table, area, &mut frame);
2589
2590 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('T'));
2592 }
2593
2594 #[test]
2595 fn selection_and_hover_on_same_row() {
2596 let selected_fg = PackedRgba::rgb(100, 0, 0);
2598 let hovered_fg = PackedRgba::rgb(0, 100, 0);
2599 let highlight_fg = PackedRgba::rgb(0, 0, 100);
2600
2601 let theme = TableTheme {
2602 row_selected: Style::new().fg(selected_fg),
2603 row_hover: Style::new().fg(hovered_fg),
2604 ..Default::default()
2605 };
2606
2607 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)])
2608 .highlight_style(Style::new().fg(highlight_fg))
2609 .theme(theme);
2610
2611 let area = Rect::new(0, 0, 3, 1);
2612 let mut pool = GraphemePool::new();
2613 let mut frame = Frame::new(3, 1, &mut pool);
2614 let mut state = TableState {
2615 selected: Some(0),
2616 hovered: Some(0),
2617 ..Default::default()
2618 };
2619
2620 StatefulWidget::render(&table, area, &mut frame, &mut state);
2621 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(highlight_fg));
2623 }
2624
2625 #[test]
2626 fn alternating_row_styles() {
2627 let even_fg = PackedRgba::rgb(10, 10, 10);
2629 let odd_fg = PackedRgba::rgb(20, 20, 20);
2630 let theme = TableTheme {
2631 row: Style::new().fg(even_fg),
2632 row_alt: Style::new().fg(odd_fg),
2633 ..Default::default()
2634 };
2635
2636 let table = Table::new(
2637 [Row::new(["E"]), Row::new(["O"]), Row::new(["E2"])],
2638 [Constraint::Fixed(3)],
2639 )
2640 .theme(theme);
2641
2642 let area = Rect::new(0, 0, 3, 3);
2643 let mut pool = GraphemePool::new();
2644 let mut frame = Frame::new(3, 3, &mut pool);
2645 Widget::render(&table, area, &mut frame);
2646
2647 assert_eq!(cell_fg(&frame.buffer, 0, 0), Some(even_fg));
2649 assert_eq!(cell_fg(&frame.buffer, 0, 1), Some(odd_fg));
2650 assert_eq!(cell_fg(&frame.buffer, 0, 2), Some(even_fg));
2651 }
2652
2653 #[test]
2654 fn scroll_up_from_zero_stays_zero() {
2655 let mut state = TableState::default();
2656 state.scroll_up(10);
2657 assert_eq!(state.offset, 0);
2658 }
2659
2660 #[test]
2661 fn scroll_down_with_zero_rows() {
2662 let mut state = TableState::default();
2663 state.scroll_down(5, 0);
2664 assert_eq!(state.offset, 0);
2665 }
2666
2667 #[test]
2668 fn scroll_down_with_single_row() {
2669 let mut state = TableState::default();
2670 state.scroll_down(5, 1);
2671 assert_eq!(state.offset, 0);
2672 }
2673
2674 #[test]
2675 fn mouse_click_on_row_exceeding_row_count() {
2676 let mut state = TableState::default();
2678 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Left), 0, 0);
2679 let hit = Some((HitId::new(1), HitRegion::Content, 100u64));
2680 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2681 assert_eq!(result, MouseResult::Ignored);
2682 assert_eq!(state.selected, None);
2683 }
2684
2685 #[test]
2686 fn mouse_right_click_ignored() {
2687 let mut state = TableState::default();
2688 let event = MouseEvent::new(MouseEventKind::Down(MouseButton::Right), 0, 0);
2689 let hit = Some((HitId::new(1), HitRegion::Content, 2u64));
2690 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2691 assert_eq!(result, MouseResult::Ignored);
2692 }
2693
2694 #[test]
2695 fn mouse_hover_on_row_exceeding_row_count() {
2696 let mut state = TableState::default();
2697 let event = MouseEvent::new(MouseEventKind::Moved, 0, 0);
2698 let hit = Some((HitId::new(1), HitRegion::Content, 100u64));
2699 let result = state.handle_mouse(&event, hit, HitId::new(1), 5);
2700 assert_eq!(result, MouseResult::Ignored);
2702 assert_eq!(state.hovered, None);
2703 }
2704
2705 #[test]
2706 fn select_deselect_preserves_offset_then_reselect() {
2707 let mut state = TableState {
2708 offset: 15,
2709 ..Default::default()
2710 };
2711 state.select(Some(20));
2712 assert_eq!(state.selected, Some(20));
2713 assert_eq!(state.offset, 15); state.select(None);
2716 assert_eq!(state.offset, 15); state.select(Some(3));
2719 assert_eq!(state.selected, Some(3));
2720 assert_eq!(state.offset, 15); }
2722
2723 #[test]
2724 fn offset_clamped_when_rows_empty() {
2725 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2726 let area = Rect::new(0, 0, 5, 3);
2727 let mut pool = GraphemePool::new();
2728 let mut frame = Frame::new(5, 3, &mut pool);
2729 let mut state = TableState {
2730 offset: 999,
2731 ..Default::default()
2732 };
2733 StatefulWidget::render(&table, area, &mut frame, &mut state);
2734 assert_eq!(state.offset, 0);
2735 }
2736
2737 #[test]
2738 fn selection_clamps_when_rows_empty() {
2739 let table = Table::new(Vec::<Row>::new(), [Constraint::Fixed(5)]);
2740 let area = Rect::new(0, 0, 5, 3);
2741 let mut pool = GraphemePool::new();
2742 let mut frame = Frame::new(5, 3, &mut pool);
2743 let mut state = TableState {
2744 selected: Some(5),
2745 ..Default::default()
2746 };
2747 StatefulWidget::render(&table, area, &mut frame, &mut state);
2748 assert_eq!(state.selected, None);
2749 }
2750
2751 #[test]
2752 fn header_with_bottom_margin_offsets_rows() {
2753 let header = Row::new(["H"]).bottom_margin(2);
2754 let table = Table::new([Row::new(["D"])], [Constraint::Fixed(3)]).header(header);
2755
2756 let area = Rect::new(0, 0, 3, 5);
2757 let mut pool = GraphemePool::new();
2758 let mut frame = Frame::new(3, 5, &mut pool);
2759 Widget::render(&table, area, &mut frame);
2760
2761 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
2763 assert_eq!(cell_char(&frame.buffer, 0, 3), Some('D'));
2764 }
2765
2766 #[test]
2767 fn block_plus_header_fill_entire_area() {
2768 let header = Row::new(["H"]);
2771 let table = Table::new([Row::new(["X"])], [Constraint::Fixed(3)])
2772 .block(Block::bordered())
2773 .header(header);
2774
2775 let area = Rect::new(0, 0, 5, 5);
2776 let mut pool = GraphemePool::new();
2777 let mut frame = Frame::new(5, 5, &mut pool);
2778 Widget::render(&table, area, &mut frame);
2779
2780 assert_eq!(cell_char(&frame.buffer, 2, 2), Some('H'));
2782 let data_rendered =
2784 (0..5).any(|x| (0..5).any(|y| cell_char(&frame.buffer, x, y) == Some('X')));
2785 assert!(!data_rendered);
2786 }
2787
2788 #[test]
2789 fn min_constraint_measure() {
2790 let table = Table::new([Row::new(["AB"])], [Constraint::Min(10)]);
2791 let c = table.measure(Size::MAX);
2792 assert_eq!(c.preferred.width, 2);
2794 assert_eq!(c.preferred.height, 1);
2795 }
2796
2797 #[test]
2798 fn percentage_constraint_render() {
2799 let table = Table::new(
2801 [Row::new(["A", "B"])],
2802 [Constraint::Percentage(50.0), Constraint::Percentage(50.0)],
2803 );
2804 let area = Rect::new(0, 0, 20, 1);
2805 let mut pool = GraphemePool::new();
2806 let mut frame = Frame::new(20, 1, &mut pool);
2807 Widget::render(&table, area, &mut frame);
2808
2809 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2810 }
2811
2812 #[test]
2813 fn fit_content_constraint_measure() {
2814 let table = Table::new(
2815 [Row::new(["Hello", "World"])],
2816 [Constraint::FitContent, Constraint::FitContent],
2817 )
2818 .column_spacing(1);
2819
2820 let c = table.measure(Size::MAX);
2821 assert_eq!(c.preferred.width, 11);
2823 }
2824
2825 #[test]
2826 fn measure_with_block_adds_overhead() {
2827 let table_no_block = Table::new([Row::new(["X"])], [Constraint::Fixed(3)]);
2828 let table_with_block =
2829 Table::new([Row::new(["X"])], [Constraint::Fixed(3)]).block(Block::bordered());
2830
2831 let c_no = table_no_block.measure(Size::MAX);
2832 let c_with = table_with_block.measure(Size::MAX);
2833
2834 assert_eq!(c_with.preferred.width, c_no.preferred.width + 4);
2836 assert_eq!(c_with.preferred.height, c_no.preferred.height + 4);
2837 }
2838
2839 #[test]
2840 fn variable_height_rows_selection_scrolls_down() {
2841 let rows = vec![
2844 Row::new(["A"]),
2845 Row::new(["B"]),
2846 Row::new(["C"]).height(5),
2847 Row::new(["D"]),
2848 Row::new(["E"]),
2849 ];
2850 let table = Table::new(rows, [Constraint::Fixed(5)]);
2851 let area = Rect::new(0, 0, 5, 4);
2852 let mut pool = GraphemePool::new();
2853 let mut frame = Frame::new(5, 4, &mut pool);
2854 let mut state = TableState {
2855 selected: Some(4),
2856 ..Default::default()
2857 };
2858 StatefulWidget::render(&table, area, &mut frame, &mut state);
2859
2860 assert!(state.offset > 0);
2862 assert_eq!(state.selected, Some(4));
2863 }
2864
2865 #[test]
2866 fn many_rows_with_margins_viewport_clamping() {
2867 let rows: Vec<Row> = (0..20)
2870 .map(|i| Row::new([format!("R{i}")]).bottom_margin(1))
2871 .collect();
2872 let table = Table::new(rows, [Constraint::Fixed(5)]);
2873 let area = Rect::new(0, 0, 5, 5);
2874 let mut pool = GraphemePool::new();
2875 let mut frame = Frame::new(5, 5, &mut pool);
2876 let mut state = TableState {
2877 offset: 19,
2878 ..Default::default()
2879 };
2880 StatefulWidget::render(&table, area, &mut frame, &mut state);
2881
2882 assert!(state.offset < 19);
2884 }
2885
2886 #[test]
2887 fn render_area_width_one() {
2888 let table = Table::new([Row::new(["Hello"])], [Constraint::Fixed(5)]);
2890 let area = Rect::new(0, 0, 1, 1);
2891 let mut pool = GraphemePool::new();
2892 let mut frame = Frame::new(1, 1, &mut pool);
2893 Widget::render(&table, area, &mut frame);
2894
2895 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('H'));
2896 }
2897
2898 #[test]
2899 fn render_area_height_one() {
2900 let table = Table::new([Row::new(["A"]), Row::new(["B"])], [Constraint::Fixed(3)]);
2902 let area = Rect::new(0, 0, 3, 1);
2903 let mut pool = GraphemePool::new();
2904 let mut frame = Frame::new(3, 1, &mut pool);
2905 Widget::render(&table, area, &mut frame);
2906
2907 assert_eq!(cell_char(&frame.buffer, 0, 0), Some('A'));
2908 }
2909
2910 #[test]
2911 fn hit_regions_with_offset() {
2912 let table = Table::new(
2914 (0..10).map(|i| Row::new([format!("R{i}")])),
2915 [Constraint::Fixed(5)],
2916 )
2917 .hit_id(HitId::new(42));
2918
2919 let area = Rect::new(0, 0, 5, 3);
2920 let mut pool = GraphemePool::new();
2921 let mut frame = Frame::with_hit_grid(5, 3, &mut pool);
2922 let mut state = TableState {
2923 offset: 5,
2924 ..Default::default()
2925 };
2926 StatefulWidget::render(&table, area, &mut frame, &mut state);
2927
2928 let hit0 = frame.hit_test(2, 0);
2930 assert_eq!(hit0, Some((HitId::new(42), HitRegion::Content, 5)));
2931
2932 let hit1 = frame.hit_test(2, 1);
2933 assert_eq!(hit1, Some((HitId::new(42), HitRegion::Content, 6)));
2934 }
2935
2936 #[test]
2937 fn table_state_sort_defaults() {
2938 let state = TableState::default();
2939 assert_eq!(state.sort_column(), None);
2940 assert!(!state.sort_ascending());
2941 assert!(state.filter().is_empty());
2942 }
2943
2944 #[test]
2945 fn table_state_set_sort_toggle() {
2946 let mut state = TableState::default();
2947 state.set_sort(Some(0), true);
2948 assert_eq!(state.sort_column(), Some(0));
2949 assert!(state.sort_ascending());
2950
2951 state.set_sort(Some(0), false);
2953 assert!(!state.sort_ascending());
2954
2955 state.set_sort(Some(3), true);
2957 assert_eq!(state.sort_column(), Some(3));
2958
2959 state.set_sort(None, false);
2961 assert_eq!(state.sort_column(), None);
2962 }
2963
2964 #[test]
2965 fn table_persist_round_trip_preserves_hovered_none() {
2966 let mut state = TableState::default().with_persistence_id("t");
2967 state.select(Some(3));
2968 state.hovered = Some(7);
2969 state.offset = 2;
2970
2971 let saved = state.save_state();
2972 state.restore_state(saved);
2973
2974 assert_eq!(state.hovered, None);
2976 assert_eq!(state.selected, Some(3));
2977 assert_eq!(state.offset, 2);
2978 }
2979
2980 #[test]
2981 fn undo_snapshot_clears_hovered() {
2982 let mut state = TableState::default();
2983 state.select(Some(2));
2984 state.hovered = Some(5);
2985
2986 let snap = state.create_snapshot();
2987
2988 state.select(Some(9));
2990 state.hovered = Some(8);
2991
2992 assert!(state.restore_snapshot(&*snap));
2994 assert_eq!(state.selected, Some(2));
2995 assert_eq!(state.hovered, None);
2997 }
2998
2999 #[test]
3000 fn wide_chars_in_render() {
3001 let table = Table::new([Row::new(["界界界"])], [Constraint::Fixed(4)]);
3004 let area = Rect::new(0, 0, 4, 1);
3005 let mut pool = GraphemePool::new();
3006 let mut frame = Frame::new(4, 1, &mut pool);
3007 Widget::render(&table, area, &mut frame);
3008
3009 let cell = frame.buffer.get(0, 0).unwrap();
3012 assert!(
3013 !cell.content.is_empty(),
3014 "first cell should contain CJK content, not be empty"
3015 );
3016 let cell1 = frame.buffer.get(1, 0).unwrap();
3018 assert!(
3019 cell1.content.is_continuation(),
3020 "second cell should be continuation of wide char"
3021 );
3022 }
3023
3024 #[test]
3025 fn empty_row_cells() {
3026 let table = Table::new(
3028 [Row::new(["", "", ""])],
3029 [
3030 Constraint::Fixed(3),
3031 Constraint::Fixed(3),
3032 Constraint::Fixed(3),
3033 ],
3034 );
3035 let area = Rect::new(0, 0, 11, 1);
3036 let mut pool = GraphemePool::new();
3037 let mut frame = Frame::new(11, 1, &mut pool);
3038 Widget::render(&table, area, &mut frame);
3039 }
3041
3042 #[test]
3043 fn measure_with_many_rows_saturates() {
3044 let rows: Vec<Row> = (0..10000).map(|_| Row::new(["X"]).height(100)).collect();
3046 let table = Table::new(rows, [Constraint::Fixed(3)]);
3047 let c = table.measure(Size::MAX);
3048
3049 assert!(c.preferred.height > 0);
3051 }
3052
3053 #[test]
3054 fn variable_height_rows_respect_viewport_visible_range() {
3055 let rows = vec![
3056 Row::new(["R0"]),
3057 Row::new(["R1"]).height(2),
3058 Row::new(["R2"]),
3059 Row::new(["R3"]),
3060 ];
3061 let table = Table::new(rows, [Constraint::Fixed(4)]);
3062 let area = Rect::new(0, 0, 4, 3);
3063 let mut pool = GraphemePool::new();
3064 let mut frame = Frame::new(4, 3, &mut pool);
3065 let mut state = TableState {
3066 offset: 1,
3067 ..Default::default()
3068 };
3069
3070 StatefulWidget::render(&table, area, &mut frame, &mut state);
3071
3072 assert_eq!(state.offset, 1);
3073 assert_eq!(row_text(&frame.buffer, 0), "R1");
3074 assert_eq!(row_text(&frame.buffer, 1), "");
3075 assert_eq!(row_text(&frame.buffer, 2), "R2");
3076 }
3077
3078 #[test]
3079 fn render_100k_rows_stays_within_8ms_frame_budget() {
3080 use std::time::{Duration, Instant};
3081
3082 let rows: Vec<Row> = (0..100_000).map(|_| Row::new(["row"])).collect();
3083 let table = Table::new(rows, [Constraint::Fixed(12)]);
3084 let area = Rect::new(0, 0, 12, 24);
3085 let mut state = TableState {
3086 offset: 50_000,
3087 ..Default::default()
3088 };
3089 let mut pool = GraphemePool::new();
3090
3091 let mut warmup = Frame::new(12, 24, &mut pool);
3093 StatefulWidget::render(&table, area, &mut warmup, &mut state);
3094
3095 let iterations = 20u32;
3096 let start = Instant::now();
3097 for _ in 0..iterations {
3098 let mut frame = Frame::new(12, 24, &mut pool);
3099 StatefulWidget::render(&table, area, &mut frame, &mut state);
3100 }
3101 let per_frame = start.elapsed() / iterations;
3102
3103 assert!(
3104 per_frame <= Duration::from_millis(8),
3105 "100k-row table render exceeded 8ms budget: {per_frame:?}"
3106 );
3107 }
3108
3109 #[cfg(feature = "tracing")]
3110 #[test]
3111 fn tracing_table_render_span_reports_row_counts() {
3112 let trace_state = Arc::new(Mutex::new(TableTraceState::default()));
3113 let _trace_test_guard = crate::tracing_test_support::acquire();
3114 let subscriber = tracing_subscriber::registry().with(TableTraceCapture {
3115 state: Arc::clone(&trace_state),
3116 });
3117 let _guard = tracing::subscriber::set_default(subscriber);
3118 tracing::callsite::rebuild_interest_cache();
3119
3120 let rows: Vec<Row> = (0..20).map(|i| Row::new([format!("R{i}")])).collect();
3121 let table = Table::new(rows, [Constraint::Fixed(6)]);
3122 let area = Rect::new(0, 0, 6, 4);
3123 let mut state = TableState {
3124 offset: 3,
3125 ..Default::default()
3126 };
3127 let mut pool = GraphemePool::new();
3128 let mut frame = Frame::new(6, 4, &mut pool);
3129 tracing::callsite::rebuild_interest_cache();
3132 StatefulWidget::render(&table, area, &mut frame, &mut state);
3133
3134 tracing::callsite::rebuild_interest_cache();
3135 let snapshot = trace_state.lock().expect("table trace state lock");
3136 assert!(
3137 snapshot.span_count >= 1,
3138 "expected at least one table.render span, got {}",
3139 snapshot.span_count
3140 );
3141 assert!(
3142 snapshot.has_total_rows_field,
3143 "table.render span missing total_rows field"
3144 );
3145 assert!(
3146 snapshot.has_rendered_rows_field,
3147 "table.render span missing rendered_rows field"
3148 );
3149 assert!(
3150 snapshot.total_rows.contains(&20),
3151 "expected total_rows=20 in span fields, got {:?}",
3152 snapshot.total_rows
3153 );
3154 assert!(
3155 snapshot.rendered_rows.iter().any(|&n| n > 0 && n <= 4),
3156 "expected rendered_rows between 1 and 4, got {:?}",
3157 snapshot.rendered_rows
3158 );
3159 }
3160}