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