Skip to main content

ftui_widgets/
table.rs

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