Skip to main content

ftui_widgets/
table.rs

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