Skip to main content

datui_lib/widgets/
table.rs

1//! The table widget: draws a [`DataTableState`]'s page, fitting the columns to the
2//! width, and records what it drew for the clicks that follow.
3
4use std::borrow::Cow;
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use polars::prelude::*;
9use ratatui::{
10    buffer::Buffer,
11    layout::Rect,
12    style::{Color, Modifier, Style},
13    text::{Line, Span, Text},
14    widgets::{
15        Block, Borders, Cell, HighlightSpacing, Padding, Paragraph, Row, StatefulWidget, Table,
16        TableState, Widget,
17    },
18};
19
20use crate::error_display::user_message_from_polars;
21use crate::formats::column_types::dtype_label;
22use crate::numfmt::{self, CellFormatter, NumberFormatSettings};
23use crate::table::{DataTableState, DrawnTable, visible_slice};
24use crate::widgets::column_paging::{OnScreen, Room};
25use crate::widgets::column_widths::{ColumnWidths, PageMeasure, WidthChoice};
26
27pub struct DataTable {
28    pub header_bg: Color,
29    pub header_fg: Color,
30    pub row_numbers_fg: Color,
31    pub separator_fg: Color,
32    pub table_cell_padding: u16,
33    pub alternate_row_bg: Option<Color>,
34    /// Stripes by view row, moving with a scroll; false, by screen line.
35    pub stripes_follow_rows: bool,
36    /// When true, colorize cells by column type using the optional colors below.
37    pub column_colors: bool,
38    pub str_col: Option<Color>,
39    pub int_col: Option<Color>,
40    pub float_col: Option<Color>,
41    pub bool_col: Option<Color>,
42    pub temporal_col: Option<Color>,
43    /// Color for binary-column placeholder cells (the `‹binary›` stub). Applied with italic,
44    /// independent of `column_colors`, so stubs always read as "placeholder, not data".
45    pub binary_col: Option<Color>,
46    /// Names of columns that are binary in the source schema. Their cells hold the `‹binary›`
47    /// stub (see `binary_stub`) and are styled with `binary_col` + italic.
48    pub binary_cols: std::collections::HashSet<String>,
49    /// Display-time number formatting (digit grouping, separators, alignment).
50    pub number_format: NumberFormatSettings,
51    /// Draw a second header row naming each column's type.
52    pub dtype_row: bool,
53    /// Tint under the row the cursor is on. `None` falls back to reversed video.
54    pub selected_bg: Option<Color>,
55    /// The full selected-row style, from the theme's `highlight_style` helper.
56    pub selection_style: Style,
57    /// The rail beside the selected row and the off-screen column hints.
58    pub accent: Color,
59    /// Null cells and the type row.
60    pub dimmed: Color,
61    /// Per row on screen, its file's drift group. Empty when the dataset's files agree,
62    /// or when the rows no longer stand for rows of a file.
63    pub drift_rows: Vec<u32>,
64    /// What each drift group is missing. Indexed by the values in `drift_rows`.
65    pub drift_groups: Arc<Vec<crate::formats::schema_union::DriftGroup>>,
66    /// Columns the view is sorted by; each carries a direction mark in the header.
67    /// Filled from the state at render, so the marks always describe the frame drawn.
68    pub sort_columns: Vec<String>,
69    /// Which way each of them runs, per column, as it is applied.
70    pub sort_descending: Vec<bool>,
71    /// The column cursor's column: its header and cells are tinted.
72    pub current_column: Option<String>,
73    /// The column cursor's cells, from the theme's `column_cursor_style` helper.
74    pub column_cursor_style: Style,
75    /// The column cursor's header and the current cell, from the theme's
76    /// `cell_cursor_style` helper.
77    pub cell_cursor_style: Style,
78    /// The glyph set the table draws with: the terminal's, unless a test asks for one.
79    pub glyphs: &'static crate::glyphs::Glyphs,
80    /// The terminal's width, which bounds automatic text widths (see
81    /// [`crate::widgets::column_widths::text_cap`]) and how much of a value a cell keeps.
82    /// 0 takes the table's own width.
83    pub screen_width: u16,
84    /// The cell a find landed on: its view row and column.
85    pub find_cell: Option<(usize, String)>,
86    /// How that cell is drawn, from the theme's `find_match_style`.
87    pub find_style: Style,
88    /// The found cell's column, while the cursor is on its row: set at render.
89    pub(crate) find_column: Option<String>,
90    /// The cells a find being typed matches, by view row and column, drawn as
91    /// found.
92    pub match_cells: Option<std::sync::Arc<crate::find::MatchCells>>,
93    /// The view row the first row drawn is: set at render.
94    pub(crate) drawn_from: usize,
95    /// Each column's unit from a delimited spec's unit row, for the type row: set at
96    /// render.
97    pub(crate) units: Vec<(String, String)>,
98    /// The columns the view gave a type: their type row is in the accent.
99    pub(crate) retyped: Vec<String>,
100}
101
102impl Default for DataTable {
103    fn default() -> Self {
104        Self {
105            header_bg: Color::Reset,
106            header_fg: Color::Reset,
107            row_numbers_fg: Color::Reset,
108            separator_fg: Color::Reset,
109            table_cell_padding: 1,
110            alternate_row_bg: None,
111            stripes_follow_rows: true,
112            column_colors: false,
113            str_col: None,
114            int_col: None,
115            float_col: None,
116            bool_col: None,
117            temporal_col: None,
118            binary_col: None,
119            binary_cols: std::collections::HashSet::new(),
120            number_format: NumberFormatSettings::default(),
121            dtype_row: false,
122            selected_bg: None,
123            selection_style: Style::default(),
124            accent: Color::Reset,
125            dimmed: Color::Reset,
126            drift_rows: Vec::new(),
127            drift_groups: Arc::new(Vec::new()),
128            sort_columns: Vec::new(),
129            sort_descending: Vec::new(),
130            current_column: None,
131            column_cursor_style: Style::default(),
132            cell_cursor_style: Style::default(),
133            glyphs: crate::glyphs::get(),
134            screen_width: 0,
135            find_cell: None,
136            find_style: Style::default(),
137            find_column: None,
138            match_cells: None,
139            drawn_from: 0,
140            units: Vec::new(),
141            retyped: Vec::new(),
142        }
143    }
144}
145
146/// Parameters for rendering the row numbers column.
147struct RowNumbersParams {
148    start_row: usize,
149    visible_rows: usize,
150    num_rows: usize,
151    /// The number each row on screen shows: see [`DataTableState::row_numbers_from`].
152    numbers: Vec<usize>,
153    selected_row: Option<usize>,
154}
155
156/// One column of the rows on screen, formatted once: what the layout measures and
157/// what the table draws.
158struct ColumnSlice {
159    name: String,
160    drift_mark: &'static str,
161    sort_mark: &'static str,
162    /// Cells for the name and both marks.
163    header_width: u16,
164    type_label: Option<String>,
165    type_width: u16,
166    cells: Arc<Cells>,
167    /// The width the column is laid out at: its stable width once sized (see
168    /// [`ColumnWidths`]), until then what shows this page whole.
169    width: u16,
170    right_align: bool,
171    /// Whether a value may be shown clipped beside other columns (see
172    /// [`is_truncatable_dtype`]).
173    clips: bool,
174    cell_style: Option<Style>,
175    /// The column's type colour, for its heading.
176    colour: Option<Color>,
177}
178
179impl ColumnSlice {
180    /// The width the column asks for: whole within it, or clipped behind the marker.
181    fn natural_width(&self) -> u16 {
182        self.width
183    }
184
185    fn measure(&self) -> PageMeasure {
186        PageMeasure {
187            header: self.header_width,
188            type_label: self.type_width,
189            values: self.cells.value_width,
190            has_values: self.cells.has_values,
191            clips: self.clips,
192        }
193    }
194}
195
196/// What sizes columns for one draw: the stable widths, the types that key them, and
197/// the cap on automatic text.
198struct Sizing<'a> {
199    widths: &'a mut ColumnWidths,
200    schema: &'a Schema,
201    cap: u16,
202    /// Where the rows drawn come from, when they come from the state: `offset` rows
203    /// into the frame on hand, whose columns key the cells kept between frames.
204    page: Option<(&'a mut PageCells, &'a DataFrame, usize)>,
205}
206
207/// A column's values on screen, formatted, and what they measure.
208#[derive(Clone)]
209pub(crate) struct Cells {
210    cells: Vec<SliceCell>,
211    /// Each cell's width, so a scroll measures the page without measuring again.
212    widths: Vec<u16>,
213    /// Cells for the widest value on screen.
214    value_width: u16,
215    /// Whether any row on screen holds a value rather than a null.
216    has_values: bool,
217}
218
219impl Cells {
220    fn new(cells: Vec<SliceCell>, widths: Vec<u16>) -> Self {
221        Self {
222            value_width: widths.iter().copied().max().unwrap_or(0),
223            has_values: cells.iter().any(|c| matches!(c, SliceCell::Value(_))),
224            cells,
225            widths,
226        }
227    }
228}
229
230/// The cells the last frame drew, reused while a column is the same series,
231/// formatting, cut, glyphs and null marks: whole when the page is unchanged (cursor
232/// move, spinner), and for the rows still on screen after a scroll, so a scroll
233/// formats only the rows it brings in.
234#[derive(Default)]
235pub(crate) struct PageCells {
236    columns: std::collections::HashMap<String, KeptCells>,
237}
238
239struct KeptCells {
240    /// Held so that its address names it: no other series can take the address while
241    /// these cells are kept.
242    series: Series,
243    offset: usize,
244    rows: usize,
245    format: CellFormatter,
246    cut: usize,
247    glyphs: &'static crate::glyphs::Glyphs,
248    nulls: Vec<&'static str>,
249    drift: Vec<u32>,
250    cells: Arc<Cells>,
251    /// Drawn by the frame being drawn, or by the one before.
252    used: bool,
253}
254
255impl PageCells {
256    /// A new frame: the cells the frame before did not draw are let go.
257    pub(crate) fn next_frame(&mut self) {
258        self.columns
259            .retain(|_, kept| std::mem::take(&mut kept.used));
260    }
261}
262
263#[derive(Clone)]
264enum SliceCell {
265    /// A null, drawn as the glyph for its kind of empty.
266    Null(&'static str),
267    Value(String),
268}
269
270/// Where the scrolling columns are, for the off-screen hints over the header.
271struct ScrollCue {
272    area: Rect,
273    more_left: bool,
274    /// Columns not drawn at all, right of the last drawn.
275    more_right: usize,
276}
277
278/// Columns laid out for one side of the table: the columns that fit, the width each
279/// gets, and the rows they are drawn for.
280struct FittedColumns {
281    cols: Vec<ColumnSlice>,
282    widths: Vec<u16>,
283    rows: usize,
284    /// The last column ends at the right edge with more after it: its heading yields its
285    /// last cell to the off-screen hint.
286    hint_cell: bool,
287}
288
289/// The least and most the scrolling side keeps beside frozen columns: a third of the
290/// table between, never over half. Enough for any number or timestamp whole.
291const MIN_SCROLL_RESERVE: u16 = 12;
292
293const MAX_SCROLL_RESERVE: u16 = 40;
294
295/// Narrower than this, a column is not drawn as a clipped sliver: the clip marker plus
296/// at least one cell of what it marks.
297fn min_partial_width(g: &crate::glyphs::Glyphs) -> u16 {
298    let marker = u16::try_from(crate::glyphs::cell_width(g.ellipsis)).unwrap_or(u16::MAX);
299    marker.saturating_add(1).max(3)
300}
301
302/// Which side of the frozen separator a layout is for. Both follow one sizing rule;
303/// they differ in what they do with a column that does not fit whole.
304#[derive(Clone, Copy, PartialEq, Eq)]
305enum Side {
306    /// Only the first column may be clipped, and never as a numeric preview: any other
307    /// column that does not fit whole scrolls instead, where it can be read whole.
308    Frozen,
309    /// The last column shown may be clipped, and a first column with no room for its
310    /// values shows a marked preview rather than nothing.
311    Scrolling,
312}
313
314/// A column's width with `remaining` cells left, or `None` to leave it for the next
315/// scroll. Heading and values fit separately (a long heading clips, never hiding
316/// values). Text may clip; a number or timestamp that does not fit waits for the scroll
317/// (cut, it reads as another value), unless it is the first scrolling column and
318/// nothing else would show: then it is clipped behind the marker.
319fn fit_column(
320    col: &ColumnSlice,
321    remaining: u16,
322    min_partial: u16,
323    first: bool,
324    side: Side,
325) -> Option<u16> {
326    if col.natural_width() <= remaining {
327        return Some(col.natural_width());
328    }
329    if side == Side::Frozen && !first {
330        return None;
331    }
332    if remaining >= min_partial && (col.cells.value_width <= remaining || col.clips) {
333        return Some(remaining);
334    }
335    (side == Side::Scrolling && first && remaining > 0).then_some(remaining)
336}
337
338/// Fitted `spans` as one line of a `width`-cell column, right-aligned when `right`.
339/// ratatui places spans by string width, which can differ from drawn cells (`لا`,
340/// halfwidth marks), so padding is counted in drawn cells and disagreeing spans are
341/// merged.
342fn cell_line<'a>(mut spans: Vec<Span<'a>>, width: u16, right: bool) -> Line<'a> {
343    use unicode_width::UnicodeWidthStr;
344    let drawn = |s: &Span| crate::glyphs::cell_width(&s.content);
345    if spans.len() > 1 && spans.iter().any(|s| drawn(s) != s.content.width()) {
346        let style = spans[0].style;
347        let joined: String = spans.iter().map(|s| s.content.as_ref()).collect();
348        spans = vec![Span::styled(joined, style)];
349    }
350    let used: usize = spans.iter().map(drawn).sum();
351    let pad = usize::from(width).saturating_sub(used);
352    if right && pad > 0 {
353        // A cell's padding borrows from one run of spaces rather than making its own.
354        const SPACES: &str = "                                                                ";
355        let blank = match SPACES.get(..pad) {
356            Some(blank) => Span::raw(blank),
357            None => Span::raw(" ".repeat(pad)),
358        };
359        spans.insert(0, blank);
360    }
361    Line::from(spans)
362}
363
364/// The on-screen rows of `df` (`len` from `offset`), as [`visible_slice`], or its
365/// columns with no rows when none are on screen, so an empty table draws its header;
366/// `None` past the end.
367fn visible_or_header(df: &DataFrame, offset: usize, len: usize) -> Option<DataFrame> {
368    if df.width() == 0 {
369        return None;
370    }
371    Some(visible_slice(df, offset, len).unwrap_or_else(|| df.clear()))
372}
373
374/// Whether a column may be shown truncated: text, bytes, categorical labels and nested
375/// previews (a clipped value still reads as its start; whole nested values would hold
376/// a huge width). Not numbers, temporals or booleans: cut, they read as wrong values.
377fn is_truncatable_dtype(dtype: &DataType) -> bool {
378    match dtype {
379        DataType::String | DataType::Binary => true,
380        other => other.is_categorical() || other.is_enum() || other.is_nested(),
381    }
382}
383
384impl DataTable {
385    pub fn new() -> Self {
386        Self::default()
387    }
388
389    pub fn with_colors(
390        mut self,
391        header_bg: Color,
392        header_fg: Color,
393        row_numbers_fg: Color,
394        separator_fg: Color,
395    ) -> Self {
396        self.header_bg = header_bg;
397        self.header_fg = header_fg;
398        self.row_numbers_fg = row_numbers_fg;
399        self.separator_fg = separator_fg;
400        self
401    }
402
403    pub fn with_cell_padding(mut self, padding: u16) -> Self {
404        self.table_cell_padding = padding;
405        self
406    }
407
408    /// The terminal's width, so automatic widths do not change when a sidebar opens.
409    pub fn with_screen_width(mut self, width: u16) -> Self {
410        self.screen_width = width;
411        self
412    }
413
414    pub fn with_alternate_row_bg(mut self, color: Option<Color>) -> Self {
415        self.alternate_row_bg = color;
416        self
417    }
418
419    /// Stripes by view row (`display.scroll_region` on), or by screen line.
420    pub fn with_stripes_follow_rows(mut self, on: bool) -> Self {
421        self.stripes_follow_rows = on;
422        self
423    }
424
425    /// Whether the `index`th line drawn is striped. By view row the stripes move with
426    /// a scroll, so the terminal can move the lines (app/draw.rs); by screen line a
427    /// scroll redrawn in place changes only the text.
428    fn striped(&self, index: usize) -> bool {
429        let first = if self.stripes_follow_rows {
430            self.drawn_from
431        } else {
432            0
433        };
434        (first + index) % 2 == 1
435    }
436
437    /// Enable column-type coloring and set colors for string, int, float, bool, and temporal columns.
438    pub fn with_column_type_colors(
439        mut self,
440        str_col: Color,
441        int_col: Color,
442        float_col: Color,
443        bool_col: Color,
444        temporal_col: Color,
445    ) -> Self {
446        self.column_colors = true;
447        self.str_col = Some(str_col);
448        self.int_col = Some(int_col);
449        self.float_col = Some(float_col);
450        self.bool_col = Some(bool_col);
451        self.temporal_col = Some(temporal_col);
452        self
453    }
454
455    /// Set the color used for binary-column placeholder cells.
456    pub fn with_binary_col(mut self, color: Color) -> Self {
457        self.binary_col = Some(color);
458        self
459    }
460
461    /// Set the names of binary columns, whose cells render the `‹binary›` stub.
462    pub fn with_binary_columns(mut self, names: std::collections::HashSet<String>) -> Self {
463        self.binary_cols = names;
464        self
465    }
466
467    /// Set display-time number formatting (digit grouping and alignment).
468    pub fn with_number_format(mut self, settings: NumberFormatSettings) -> Self {
469        self.number_format = settings;
470        self
471    }
472
473    /// Show or hide the second header row of column types.
474    pub fn with_dtype_row(mut self, on: bool) -> Self {
475        self.dtype_row = on;
476        self
477    }
478
479    /// Tell the table which rows came from files missing which columns, so a cell the
480    /// file never had draws differently from a null the data holds.
481    pub fn with_drift(
482        mut self,
483        rows: Vec<u32>,
484        groups: Arc<Vec<crate::formats::schema_union::DriftGroup>>,
485    ) -> Self {
486        self.drift_rows = rows;
487        self.drift_groups = groups;
488        self
489    }
490
491    /// The sort columns and directions for the header marks; the stateful render fills
492    /// these itself, the builder is for direct `render_dataframe` callers like tests.
493    #[cfg(test)]
494    pub fn with_sort(mut self, columns: Vec<String>, descending: Vec<bool>) -> Self {
495        debug_assert_eq!(columns.len(), descending.len());
496        self.sort_columns = columns;
497        self.sort_descending = descending;
498        self
499    }
500
501    /// The direction mark after a sorted column's name; every column of a multi-sort gets
502    /// its own (no position number). Empty when unsorted.
503    fn sort_mark_for(&self, column: &str) -> &'static str {
504        if let Some(i) = self.sort_columns.iter().position(|c| c == column) {
505            let g = self.glyphs;
506            if self.sort_descending.get(i).copied().unwrap_or(false) {
507                g.sort_desc
508            } else {
509                g.sort_asc
510            }
511        } else {
512            ""
513        }
514    }
515
516    /// The footnote mark after a column's name, when it is not in every file or the
517    /// files disagree on its type. Empty otherwise.
518    fn drift_mark_for(&self, column: &str, drifting: &HashSet<&str>) -> &'static str {
519        if drifting.contains(column) {
520            self.glyphs.drift_mark
521        } else {
522            ""
523        }
524    }
525
526    /// Every column some file lacks, gathered once a frame so most columns cost one hash
527    /// lookup.
528    fn drifting_columns(&self) -> HashSet<&str> {
529        self.drift_groups
530            .iter()
531            .flat_map(|group| group.absent.iter().chain(group.unread.iter()))
532            .map(|name| name.as_str())
533            .collect()
534    }
535
536    /// What a null in `column` draws as per drift group: the null glyph, the absent glyph
537    /// (files without the column), or the conflict glyph (files with another type). Empty
538    /// when nothing drifts.
539    fn null_glyphs_for(
540        &self,
541        column: &str,
542        g: &'static crate::glyphs::Glyphs,
543        drifting: &HashSet<&str>,
544    ) -> Vec<&'static str> {
545        if self.drift_rows.is_empty() || !drifting.contains(column) {
546            return Vec::new();
547        }
548        self.drift_groups
549            .iter()
550            .map(|group| {
551                if group.absent.iter().any(|c| c == column) {
552                    g.absent
553                } else if group.unread.iter().any(|c| c == column) {
554                    g.conflict
555                } else {
556                    g.null
557                }
558            })
559            .collect()
560    }
561
562    /// The selected row's style and tint, rail color, and dim null color, from the theme's
563    /// `highlight_style`; no fallbacks of its own.
564    pub fn with_selection_colors(
565        mut self,
566        selection_style: Style,
567        selected_bg: Option<Color>,
568        accent: Color,
569        dimmed: Color,
570    ) -> Self {
571        self.selection_style = selection_style;
572        self.selected_bg = selected_bg;
573        self.accent = accent;
574        self.dimmed = dimmed;
575        self
576    }
577
578    /// Draw these cells (view row, column) as found: a find's matches as it is typed.
579    pub fn with_match_cells(
580        mut self,
581        cells: Option<std::sync::Arc<crate::find::MatchCells>>,
582    ) -> Self {
583        self.match_cells = cells;
584        self
585    }
586
587    /// Mark the cell a find landed on, drawn in `style` while the cursor is on it.
588    pub fn with_find_cell(mut self, cell: Option<(usize, String)>, style: Style) -> Self {
589        self.find_cell = cell;
590        self.find_style = style;
591        self
592    }
593
594    /// The column cursor's styles, from the theme's helpers: its cells, and its
595    /// header and the current cell.
596    pub fn with_cursor_styles(mut self, column: Style, cell: Style) -> Self {
597        self.column_cursor_style = column;
598        self.cell_cursor_style = cell;
599        self
600    }
601
602    /// How many rows the header takes: the names, plus the type row when it is on.
603    pub fn header_height(&self) -> u16 {
604        if self.dtype_row { 2 } else { 1 }
605    }
606
607    /// Style of the highlighted row, from the theme's `highlight_style` helper.
608    fn highlight_style(&self) -> Style {
609        self.selection_style
610    }
611
612    /// Return the color for a column dtype when column_colors is enabled.
613    fn column_type_color(&self, dtype: &DataType) -> Option<Color> {
614        if !self.column_colors {
615            return None;
616        }
617        match dtype {
618            DataType::String => self.str_col,
619            DataType::Int8
620            | DataType::Int16
621            | DataType::Int32
622            | DataType::Int64
623            | DataType::UInt8
624            | DataType::UInt16
625            | DataType::UInt32
626            | DataType::UInt64 => self.int_col,
627            DataType::Float32 | DataType::Float64 => self.float_col,
628            DataType::Boolean => self.bool_col,
629            DataType::Date | DataType::Datetime(_, _) | DataType::Time | DataType::Duration(_) => {
630                self.temporal_col
631            }
632            _ => None,
633        }
634    }
635
636    /// Render `df` into `area` alone with widths learned from this page, returning the
637    /// columns shown; for layout tests. `leading_gap` keeps the first column off the left
638    /// edge (right of the frozen separator).
639    #[cfg(test)]
640    pub(crate) fn render_dataframe(
641        &self,
642        df: &DataFrame,
643        area: Rect,
644        buf: &mut Buffer,
645        state: &mut TableState,
646        leading_gap: bool,
647    ) -> usize {
648        let mut widths = ColumnWidths::default();
649        let sizing = Sizing {
650            widths: &mut widths,
651            schema: df.schema(),
652            cap: self.text_cap(area.width),
653            page: None,
654        };
655        self.render_scrolling(df, area, buf, state, leading_gap, sizing)
656            .0
657    }
658
659    /// The cap on automatic text widths, from the terminal's width when known.
660    fn text_cap(&self, table_width: u16) -> u16 {
661        let basis = if self.screen_width > 0 {
662            self.screen_width
663        } else {
664            table_width
665        };
666        crate::widgets::column_widths::text_cap(basis)
667    }
668
669    /// Lay out and draw the scrolling columns, at their stable widths. Returns how
670    /// many were drawn.
671    fn render_scrolling(
672        &self,
673        df: &DataFrame,
674        area: Rect,
675        buf: &mut Buffer,
676        state: &mut TableState,
677        leading_gap: bool,
678        mut sizing: Sizing,
679    ) -> (usize, Vec<(u16, u16, String)>, usize) {
680        let rows = df
681            .height()
682            .min((area.height as usize).saturating_sub(self.header_height() as usize));
683        let lead = u16::from(leading_gap);
684        let mut fitted = self.fit_columns(df, rows, area.width, lead, Side::Scrolling, &mut sizing);
685        let shown = fitted.cols.len();
686        let mut used = fitted
687            .widths
688            .iter()
689            .fold(lead, |used, &w| used.saturating_add(w))
690            + self
691                .table_cell_padding
692                .saturating_mul(u16::try_from(shown.saturating_sub(1)).unwrap_or(u16::MAX));
693        if let Some(filled) =
694            self.fill_last_column(&mut fitted, area.width.saturating_sub(used), &mut sizing)
695        {
696            used = used.saturating_add(filled);
697        }
698        fitted.hint_cell = shown > 0 && shown < df.width() && used >= area.width;
699        let (columns, rows) = self.draw_columns(&fitted, area, buf, state, leading_gap);
700        (shown, columns, rows)
701    }
702
703    /// Widen the last drawn column by the `room` left at the right edge so long text runs
704    /// to the edge. Only an automatic-width, fully drawn, non-right-aligned column. Returns
705    /// the cells taken.
706    fn fill_last_column(
707        &self,
708        fitted: &mut FittedColumns,
709        room: u16,
710        sizing: &mut Sizing,
711    ) -> Option<u16> {
712        let (col, width) = fitted.cols.last().zip(fitted.widths.last_mut())?;
713        if room == 0 || col.right_align || *width < col.natural_width() {
714            return None;
715        }
716        let dtype = sizing.schema.get(col.name.as_str())?;
717        if sizing.widths.choice(&col.name, dtype) != WidthChoice::Auto {
718            return None;
719        }
720        *width = width.saturating_add(room);
721        sizing.widths.fill(&col.name, dtype, *width);
722        Some(room)
723    }
724
725    /// The frozen columns that fit beside a usable scrolling side, with widths (`width` is
726    /// right of the row numbers). The scrolling side keeps a third of the table (within
727    /// bounds); frozen columns that do not fit move to it. Only the first frozen column
728    /// may clip to stay frozen, never a number.
729    fn fit_frozen_columns(
730        &self,
731        locked: &DataFrame,
732        rows: usize,
733        width: u16,
734        nothing_else_scrolls: bool,
735        table_width: u16,
736        sizing: &mut Sizing,
737    ) -> FittedColumns {
738        // The space before the separator, and the separator. The gap after it is the
739        // scrolling side's.
740        let room = width.saturating_sub(2);
741        if nothing_else_scrolls {
742            let fitted = self.fit_columns(locked, rows, room, 0, Side::Frozen, sizing);
743            if fitted.cols.len() == locked.width() {
744                return fitted;
745            }
746        }
747        let reserve = (table_width / 3)
748            .clamp(MIN_SCROLL_RESERVE, MAX_SCROLL_RESERVE)
749            .min(table_width / 2);
750        self.fit_columns(
751            locked,
752            rows,
753            room.saturating_sub(reserve),
754            0,
755            Side::Frozen,
756            sizing,
757        )
758    }
759
760    /// Lay `df`'s columns left to right in `width` cells (`lead` first), formatting a
761    /// column's first `rows` values only when reached. One rule for frozen and scrolling:
762    /// each at its stable width, learned in cells from the rows on screen when first drawn.
763    fn fit_columns(
764        &self,
765        df: &DataFrame,
766        rows: usize,
767        width: u16,
768        lead: u16,
769        side: Side,
770        sizing: &mut Sizing,
771    ) -> FittedColumns {
772        let drifting = self.drifting_columns();
773        let min_partial = min_partial_width(self.glyphs);
774        // Reused across every cell so formatting allocates only the string each cell keeps.
775        let mut scratch = String::new();
776        let mut fitted = FittedColumns {
777            cols: Vec::new(),
778            widths: Vec::new(),
779            rows,
780            hint_cell: false,
781        };
782        let mut used = lead;
783        for col_index in 0..df.width() {
784            let remaining = width.saturating_sub(used);
785            if remaining == 0 {
786                break;
787            }
788            let source = sizing.page.as_mut().map(|(cells, frame, offset)| {
789                let series = frame[col_index].as_materialized_series().clone();
790                (&mut **cells, series, *offset)
791            });
792            let mut col = self.slice_column(df, col_index, rows, &drifting, &mut scratch, source);
793            let dtype = sizing
794                .schema
795                .get(col.name.as_str())
796                .unwrap_or_else(|| df[col_index].dtype());
797            col.width = sizing
798                .widths
799                .width(&col.name, dtype, col.measure(), sizing.cap);
800            let first = fitted.cols.is_empty();
801            let Some(w) = fit_column(&col, remaining, min_partial, first, side) else {
802                break;
803            };
804            let whole = w >= col.natural_width();
805            used = used
806                .saturating_add(w)
807                .saturating_add(self.table_cell_padding);
808            fitted.cols.push(col);
809            fitted.widths.push(w);
810            if !whole {
811                // A clipped column took everything left; nothing after it can fit.
812                break;
813            }
814        }
815        fitted
816    }
817
818    /// The most cells a cell is cut to: past any width it can be drawn at, the
819    /// terminal's or one set by hand, so the cut never shows.
820    fn cell_cut(&self) -> usize {
821        crate::exact::cell_cut(self.screen_width)
822    }
823
824    /// The first `rows` values of `col_data`, formatted for their cells and measured.
825    /// With `kept`, the cells of a page `shift` rows earlier (the same series and
826    /// formatting), each row still on screen is taken from it rather than formatted.
827    fn format_cells(
828        &self,
829        col_data: &Column,
830        rows: usize,
831        col_fmt: &CellFormatter,
832        null_glyph_by_group: &[&'static str],
833        scratch: &mut String,
834        kept: Option<(Cells, &[u32], isize)>,
835    ) -> Cells {
836        let mut cells = Vec::with_capacity(rows);
837        let mut widths = Vec::with_capacity(rows);
838        let (mut old, old_drift, shift) = match kept {
839            Some((old, drift, shift)) => (Some(old), drift, shift),
840            None => (None, &[][..], 0),
841        };
842        for row_index in 0..rows {
843            let from = old.as_mut().and_then(|old| {
844                let at = usize::try_from(row_index as isize + shift).ok()?;
845                // A null's glyph follows its row's file, which the scroll moves too.
846                (at < old.cells.len() && old_drift.get(at) == self.drift_rows.get(row_index)).then(
847                    || {
848                        let cell = std::mem::replace(&mut old.cells[at], SliceCell::Null(""));
849                        (cell, old.widths[at])
850                    },
851                )
852            });
853            let (cell, width) = from.unwrap_or_else(|| {
854                self.format_cell(col_data, row_index, col_fmt, null_glyph_by_group, scratch)
855            });
856            cells.push(cell);
857            widths.push(width);
858        }
859        Cells::new(cells, widths)
860    }
861
862    /// Row `row_index` of `col_data`, formatted for its cell, and its width.
863    fn format_cell(
864        &self,
865        col_data: &Column,
866        row_index: usize,
867        col_fmt: &CellFormatter,
868        null_glyph_by_group: &[&'static str],
869        scratch: &mut String,
870    ) -> (SliceCell, u16) {
871        #[cfg(test)]
872        tests::FORMATTED.with(|n| n.set(n.get() + 1));
873        let g = self.glyphs;
874        let width = |text: &str| u16::try_from(crate::glyphs::cell_width(text)).unwrap_or(u16::MAX);
875        let value = col_data.get(row_index).unwrap();
876        if matches!(value, AnyValue::Null) {
877            let glyph = self
878                .drift_rows
879                .get(row_index)
880                .and_then(|group| null_glyph_by_group.get(*group as usize))
881                .copied()
882                .unwrap_or(g.null);
883            return (SliceCell::Null(glyph), width(glyph));
884        }
885        // A list is previewed here, for the cells on screen only: the buffer keeps
886        // it a list, as formatting a whole row group's lists stalled every scroll.
887        let text = match &value {
888            AnyValue::List(items) => Cow::Owned(crate::exact::list_preview(items)),
889            value => numfmt::format_any_value(col_fmt, value, scratch),
890        };
891        // Breaks and tabs would vanish and run text together; only what a cell can
892        // show is kept and measured, not a huge value whole.
893        let text = crate::exact::cell_text(text, g, self.cell_cut());
894        let w = width(&text);
895        (SliceCell::Value(text), w)
896    }
897
898    /// One column's heading, type and first `rows` values, formatted and measured.
899    fn slice_column(
900        &self,
901        df: &DataFrame,
902        col_index: usize,
903        rows: usize,
904        drifting: &HashSet<&str>,
905        scratch: &mut String,
906        source: Option<(&mut PageCells, Series, usize)>,
907    ) -> ColumnSlice {
908        let g = self.glyphs;
909        let col_data = &df[col_index];
910        let name = col_data.name().as_str();
911        let dtype = col_data.dtype();
912        // Binary columns hold the `‹binary›` stub: style them with binary_col + italic so they
913        // read as a placeholder rather than data, regardless of the column_colors setting.
914        let is_binary = self.binary_cols.contains(name);
915        let cell_style = if is_binary {
916            let mut s = Style::default().add_modifier(Modifier::ITALIC);
917            if let Some(c) = self.binary_col {
918                s = s.fg(c);
919            }
920            Some(s)
921        } else {
922            self.column_type_color(dtype)
923                .map(|c| Style::default().fg(c))
924        };
925        // Resolved once per column (dtype eligibility, include/exclude globs). Binary columns
926        // hold the stub, so pass through.
927        let col_fmt = if is_binary {
928            CellFormatter::Passthrough
929        } else {
930            self.number_format.formatter_for(name, dtype)
931        };
932        // Numeric columns render flush-right so magnitudes line up; strings, booleans,
933        // temporals and binary stubs stay left.
934        let right_align = self.number_format.align_numeric_right
935            && !is_binary
936            && numfmt::is_right_aligned_dtype(dtype);
937        // A null here may be the data's, a file without the column, or a file storing it in
938        // another type: resolved once per column, by group.
939        let null_glyph_by_group = self.null_glyphs_for(name, g, drifting);
940
941        let rows = rows.min(col_data.len());
942        let drift = &self.drift_rows[..rows.min(self.drift_rows.len())];
943        let cut = self.cell_cut();
944        let cells = match source {
945            Some((page, series, offset)) => match page.columns.get_mut(name) {
946                Some(kept)
947                    if Arc::ptr_eq(&kept.series.0, &series.0)
948                        && kept.format == col_fmt
949                        && kept.cut == cut
950                        && std::ptr::eq(kept.glyphs, g)
951                        && kept.nulls == null_glyph_by_group =>
952                {
953                    kept.used = true;
954                    if kept.offset != offset || kept.rows != rows || kept.drift != drift {
955                        let old = std::mem::replace(
956                            &mut kept.cells,
957                            Arc::new(Cells::new(Vec::new(), Vec::new())),
958                        );
959                        // The frame before let go of its columns, so the cells move.
960                        let old = Arc::try_unwrap(old).unwrap_or_else(|old| (*old).clone());
961                        let shift = offset as isize - kept.offset as isize;
962                        kept.cells = Arc::new(self.format_cells(
963                            col_data,
964                            rows,
965                            &col_fmt,
966                            &null_glyph_by_group,
967                            scratch,
968                            Some((old, &kept.drift, shift)),
969                        ));
970                        kept.offset = offset;
971                        kept.rows = rows;
972                        kept.drift.clear();
973                        kept.drift.extend_from_slice(drift);
974                    }
975                    kept.cells.clone()
976                }
977                _ => {
978                    let cells = Arc::new(self.format_cells(
979                        col_data,
980                        rows,
981                        &col_fmt,
982                        &null_glyph_by_group,
983                        scratch,
984                        None,
985                    ));
986                    let kept = KeptCells {
987                        series,
988                        offset,
989                        rows,
990                        format: col_fmt,
991                        cut,
992                        glyphs: g,
993                        nulls: null_glyph_by_group,
994                        drift: drift.to_vec(),
995                        cells: cells.clone(),
996                        used: true,
997                    };
998                    page.columns.insert(name.to_string(), kept);
999                    cells
1000                }
1001            },
1002            None => Arc::new(self.format_cells(
1003                col_data,
1004                rows,
1005                &col_fmt,
1006                &null_glyph_by_group,
1007                scratch,
1008                None,
1009            )),
1010        };
1011
1012        let drift_mark = self.drift_mark_for(name, drifting);
1013        let sort_mark = self.sort_mark_for(name);
1014        // Both header marks widen the column, or a sorted or drifting column's last
1015        // character would be pushed out of its cell.
1016        let header_width = crate::glyphs::cell_width(name)
1017            + crate::glyphs::cell_width(drift_mark)
1018            + crate::glyphs::cell_width(sort_mark);
1019        // The type row is part of the header, so a column is at least as wide as its type name
1020        // (or a spec unit beside it: `f64 · deg F`). Binary's type is the source's.
1021        let type_label = self.dtype_row.then(|| {
1022            let label = if is_binary {
1023                dtype_label(&DataType::Binary)
1024            } else {
1025                dtype_label(dtype)
1026            };
1027            match self.units.iter().find(|(column, _)| column == name) {
1028                Some((_, unit)) => format!("{label} {} {unit}", self.glyphs.middot),
1029                None => label,
1030            }
1031        });
1032        let type_width = type_label
1033            .as_deref()
1034            .map(crate::glyphs::cell_width)
1035            .unwrap_or(0);
1036        let cells_u16 = |w: usize| u16::try_from(w).unwrap_or(u16::MAX);
1037        let width = cells_u16(header_width.max(type_width)).max(cells.value_width);
1038        ColumnSlice {
1039            name: name.to_string(),
1040            drift_mark,
1041            sort_mark,
1042            header_width: cells_u16(header_width),
1043            type_label,
1044            type_width: cells_u16(type_width),
1045            cells,
1046            width,
1047            right_align,
1048            clips: is_binary || is_truncatable_dtype(dtype),
1049            cell_style,
1050            colour: if is_binary {
1051                self.binary_col
1052            } else {
1053                self.column_type_color(dtype)
1054            },
1055        }
1056    }
1057
1058    /// Draw fitted columns into `area`. Every heading, type and value is pre-fitted at a
1059    /// grapheme boundary and marked where cut, so ratatui never truncates (it cuts
1060    /// right-aligned values from the left: `1234567` to `34567`).
1061    fn draw_columns<'f>(
1062        &self,
1063        fitted: &'f FittedColumns,
1064        area: Rect,
1065        buf: &mut Buffer,
1066        state: &mut TableState,
1067        leading_gap: bool,
1068    ) -> (Vec<(u16, u16, String)>, usize) {
1069        let g = self.glyphs;
1070        // Borrowed from the cells wherever the text fits whole, which is most cells.
1071        let fit = |text: &'f str, width: u16| -> Cow<'f, str> {
1072            crate::glyphs::fit_cells(text, usize::from(width), g.ellipsis)
1073        };
1074        // A null is drawn as a glyph in the dim colour, so it can never be mistaken
1075        // for an empty string or a zero that happens to be blank.
1076        let null_style = Style::default()
1077            .fg(self.dimmed)
1078            .add_modifier(Modifier::ITALIC);
1079        let columns = || fitted.cols.iter().zip(fitted.widths.iter().copied());
1080
1081        let rows: Vec<Row> = (0..fitted.rows)
1082            .map(|row_index| {
1083                let cells: Vec<Cell> = columns()
1084                    .map(|(col, w)| {
1085                        let span = match col.cells.cells.get(row_index) {
1086                            Some(SliceCell::Null(glyph)) => Span::styled(fit(glyph, w), null_style),
1087                            Some(SliceCell::Value(text)) => {
1088                                let mut style = col.cell_style.unwrap_or_default();
1089                                if self.match_cells.as_ref().is_some_and(|cells| {
1090                                    cells.get(col.name.as_str()).is_some_and(|rows| {
1091                                        rows.contains(&(self.drawn_from + row_index))
1092                                    })
1093                                }) {
1094                                    style = style.patch(self.find_style);
1095                                }
1096                                Span::styled(fit(text, w), style)
1097                            }
1098                            None => return Cell::default(),
1099                        };
1100                        // Room for the padding a right-aligned cell puts before it.
1101                        let mut spans = Vec::with_capacity(1 + usize::from(col.right_align));
1102                        spans.push(span);
1103                        Cell::from(cell_line(spans, w, col.right_align))
1104                    })
1105                    .collect();
1106                let row_style = if self.striped(row_index) {
1107                    self.alternate_row_bg
1108                        .map(|c| Style::default().bg(c))
1109                        .unwrap_or_default()
1110                } else {
1111                    Style::default()
1112                };
1113                Row::new(cells).style(row_style)
1114            })
1115            .collect();
1116
1117        let header_row_style = if self.header_bg == Color::Reset {
1118            Style::default().fg(self.header_fg)
1119        } else {
1120            Style::default().bg(self.header_bg).fg(self.header_fg)
1121        };
1122        // The name takes its column's type color, bold; the type row repeats it plain. Headings
1123        // follow their column's alignment.
1124        let last = fitted.cols.len().saturating_sub(1);
1125        // The column cursor, when its column is among these.
1126        let cursor = self
1127            .current_column
1128            .as_deref()
1129            .and_then(|name| fitted.cols.iter().position(|c| c.name == name));
1130        // The current cell is drawn as found while a find's cell is the cursor's: a find
1131        // moves the cursor to the cell it lands on.
1132        let cell_style = if cursor.is_some() && self.find_column == self.current_column {
1133            self.find_style
1134        } else {
1135            self.cell_cursor_style
1136        };
1137        // Under a reversed row (`table_selected = "reversed"`), a reversed cell would
1138        // read as the rest of the row: the current cell is the one drawn upright.
1139        let cell_style = if self
1140            .highlight_style()
1141            .add_modifier
1142            .contains(Modifier::REVERSED)
1143        {
1144            cell_style.remove_modifier(Modifier::REVERSED)
1145        } else {
1146            cell_style
1147        };
1148        let headers: Vec<Cell> = columns()
1149            .enumerate()
1150            .map(|(i, (col, w))| {
1151                // The off-screen hint goes on the type row when there is one, else on
1152                // the name row: that line of the last column stops a cell short.
1153                let hint = u16::from(fitted.hint_cell && i == last && w > 1);
1154                let (name_w, type_w) = if col.type_label.is_some() {
1155                    (w, w - hint)
1156                } else {
1157                    (w - hint, w)
1158                };
1159                let name_style = match col.colour {
1160                    Some(c) => Style::default().fg(c).add_modifier(Modifier::BOLD),
1161                    None => Style::default().add_modifier(Modifier::BOLD),
1162                };
1163                // The marks are state, so a long name gives way to them: the name is
1164                // what gets clipped, never the sort direction or the drift footnote.
1165                let marks = crate::glyphs::cell_width(col.drift_mark)
1166                    + crate::glyphs::cell_width(col.sort_mark);
1167                let mut heading = Vec::with_capacity(3);
1168                match u16::try_from(marks).ok().filter(|&m| m < name_w) {
1169                    Some(marks) => {
1170                        heading.push(Span::styled(fit(&col.name, name_w - marks), name_style));
1171                        if !col.drift_mark.is_empty() {
1172                            heading.push(Span::styled(
1173                                col.drift_mark,
1174                                Style::default().fg(self.dimmed),
1175                            ));
1176                        }
1177                        // In the name's own style: the mark says how this column's
1178                        // values run, so it reads as part of the heading.
1179                        if !col.sort_mark.is_empty() {
1180                            heading.push(Span::styled(col.sort_mark, name_style));
1181                        }
1182                    }
1183                    None => heading.push(Span::styled(fit(&col.name, name_w), name_style)),
1184                }
1185                let mut lines = vec![cell_line(heading, name_w, col.right_align)];
1186                if let Some(label) = &col.type_label {
1187                    let type_style = match col.colour {
1188                        // A type the view gave, not the read: it shows.
1189                        _ if self.retyped.contains(&col.name) => Style::default().fg(self.accent),
1190                        Some(c) => Style::default().fg(c),
1191                        None => Style::default().fg(self.dimmed),
1192                    };
1193                    let label = Span::styled(fit(label, type_w), type_style);
1194                    lines.push(cell_line(vec![label], type_w, col.right_align));
1195                }
1196                let cell = Cell::from(Text::from(lines));
1197                if cursor == Some(i) {
1198                    cell.style(self.cell_cursor_style)
1199                } else {
1200                    cell
1201                }
1202            })
1203            .collect();
1204
1205        let mut table = Table::new(rows, fitted.widths.clone())
1206            .column_spacing(self.table_cell_padding)
1207            .header(
1208                Row::new(headers)
1209                    .style(header_row_style)
1210                    .height(self.header_height()),
1211            )
1212            .row_highlight_style(self.highlight_style())
1213            .column_highlight_style(self.column_cursor_style)
1214            .cell_highlight_style(cell_style);
1215        if leading_gap {
1216            // A blank selection column on every row, so the gap stripes and highlights with the row.
1217            table = table
1218                .highlight_symbol(" ")
1219                .highlight_spacing(HighlightSpacing::Always);
1220        }
1221        // The frozen and scrolling sides share the row selection; the column is each
1222        // side's own, so it is set for this draw only.
1223        state.select_column(cursor);
1224        StatefulWidget::render(table, area, buf, state);
1225        state.select_column(None);
1226        // Where the Table put each column: past the gap's selection column, laid out
1227        // as it lays them out, so a click finds the column it drew.
1228        let lead = u16::from(leading_gap).min(area.width);
1229        let columns_area = Rect {
1230            x: area.x + lead,
1231            width: area.width - lead,
1232            ..area
1233        };
1234        let spans = ratatui::layout::Layout::horizontal(
1235            fitted
1236                .widths
1237                .iter()
1238                .map(|&w| ratatui::layout::Constraint::Length(w)),
1239        )
1240        .flex(ratatui::layout::Flex::Start)
1241        .spacing(self.table_cell_padding)
1242        .split(columns_area);
1243        let columns = spans
1244            .iter()
1245            .zip(&fitted.cols)
1246            .map(|(span, col)| (span.x, span.right(), col.name.clone()))
1247            .collect();
1248        (
1249            columns,
1250            fitted.rows.min(usize::from(
1251                area.height.saturating_sub(self.header_height()),
1252            )),
1253        )
1254    }
1255
1256    /// A scrolling column's drawn width: as last drawn in this view, or measured from the
1257    /// held rows on screen as drawing would learn it. Sideways pages are planned with it.
1258    fn measure_column(
1259        &self,
1260        state: &mut DataTableState,
1261        name: &str,
1262        offset: usize,
1263        rows: usize,
1264        cap: u16,
1265    ) -> u16 {
1266        if let Some(width) = state.drawn_width(name) {
1267            return width;
1268        }
1269        let Some(page) = state.page_column(name, offset, rows) else {
1270            return crate::widgets::column_widths::UNSEEN_WIDTH;
1271        };
1272        let col = self.slice_column(
1273            &page,
1274            0,
1275            page.height(),
1276            &self.drifting_columns(),
1277            &mut String::new(),
1278            None,
1279        );
1280        let dtype = state.width_dtype(name);
1281        state.widths.width(name, &dtype, col.measure(), cap)
1282    }
1283
1284    /// Fit each column waiting for it to the rows on screen, from the buffer already
1285    /// held, so a column scrolled out of view is fitted to this page too.
1286    fn fit_pending(&self, state: &mut DataTableState, offset: usize, rows: usize, cap: u16) {
1287        let pending = state.widths.fits_pending();
1288        if pending.is_empty() || !state.buffer_on_hand() {
1289            return;
1290        }
1291        let drifting = self.drifting_columns();
1292        let mut scratch = String::new();
1293        for (name, dtype) in pending {
1294            let Some(page) = state.page_column(&name, offset, rows) else {
1295                continue;
1296            };
1297            let col = self.slice_column(&page, 0, page.height(), &drifting, &mut scratch, None);
1298            state.widths.fit(&name, &dtype, col.measure(), cap);
1299        }
1300    }
1301
1302    fn render_row_numbers(&self, area: Rect, buf: &mut Buffer, params: RowNumbersParams) {
1303        // Header row: same style as the rest of the column headers (fill full width so color matches)
1304        let header_style = if self.header_bg == Color::Reset {
1305            Style::default().fg(self.header_fg)
1306        } else {
1307            Style::default().bg(self.header_bg).fg(self.header_fg)
1308        };
1309        let header_h = self.header_height().min(area.height);
1310        let header_fill = " ".repeat(area.width as usize);
1311        for dy in 0..header_h {
1312            Paragraph::new(header_fill.clone())
1313                .style(header_style)
1314                .render(
1315                    Rect {
1316                        x: area.x,
1317                        y: area.y + dy,
1318                        width: area.width,
1319                        height: 1,
1320                    },
1321                    buf,
1322                );
1323        }
1324
1325        // Only render up to the actual number of rows in the data
1326        let rows_to_render = params
1327            .visible_rows
1328            .min(params.num_rows.saturating_sub(params.start_row));
1329
1330        if rows_to_render == 0 {
1331            return;
1332        }
1333
1334        let number = |row_idx: usize| params.numbers.get(row_idx).copied().unwrap_or_default();
1335        let max_row_num = (0..rows_to_render).map(number).max().unwrap_or_default();
1336        let max_width = max_row_num.checked_ilog10().unwrap_or(0) as usize + 1;
1337        // One line's text, written again for each row.
1338        let mut padded_text = String::with_capacity(max_width);
1339
1340        for row_idx in 0..rows_to_render.min(area.height.saturating_sub(header_h) as usize) {
1341            use std::fmt::Write as _;
1342            padded_text.clear();
1343            let _ = write!(padded_text, "{:>max_width$}", number(row_idx));
1344
1345            // Match the table background (alternate rows striped); the selected row carries the
1346            // table's highlight tint.
1347            let is_selected = params.selected_row == Some(row_idx);
1348            let striped = self.striped(row_idx);
1349            let (fg, bg) = if is_selected {
1350                (
1351                    Color::Reset,
1352                    self.selected_bg
1353                        .or(self.alternate_row_bg.filter(|_| striped)),
1354                )
1355            } else {
1356                (
1357                    self.row_numbers_fg,
1358                    self.alternate_row_bg.filter(|_| striped),
1359                )
1360            };
1361            let row_num_style = match bg {
1362                Some(bg_color) => Style::default().fg(fg).bg(bg_color),
1363                None => Style::default().fg(fg),
1364            };
1365
1366            let y = area.y + row_idx as u16 + header_h;
1367            if y < area.y + area.height {
1368                let line = Rect {
1369                    x: area.x,
1370                    y,
1371                    width: area.width,
1372                    height: 1,
1373                };
1374                buf.set_style(line, row_num_style);
1375                buf.set_stringn(
1376                    line.x,
1377                    y,
1378                    &padded_text,
1379                    usize::from(line.width),
1380                    Style::default(),
1381                );
1382            }
1383        }
1384    }
1385}
1386
1387impl StatefulWidget for DataTable {
1388    type State = DataTableState;
1389
1390    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
1391        state.page_cells.next_frame();
1392        if self.screen_width == 0 {
1393            self.screen_width = area.width;
1394        }
1395        // The view's own sort, not the grouped original's: it is what ordered the
1396        // rows being drawn, so the header marks can never disagree with them.
1397        (self.sort_columns, self.sort_descending) = state.header_sort();
1398        self.current_column = state.current_column().map(str::to_string);
1399        self.units = state.units();
1400        self.retyped = state.retyped_columns();
1401        // The leftmost column is the rail: accented on the cursor's row, blank elsewhere, and
1402        // holding the "columns off to the left" hint in the header.
1403        let cap = self.text_cap(area.width);
1404        let whole = area;
1405        state.drawn = None;
1406        let rail_area = Rect {
1407            x: area.x,
1408            y: area.y,
1409            width: 1.min(area.width),
1410            height: area.height,
1411        };
1412        let area = Rect {
1413            x: area.x.saturating_add(1),
1414            y: area.y,
1415            width: area.width.saturating_sub(1),
1416            height: area.height,
1417        };
1418        let header_h = self.header_height();
1419        state.visible_termcols = area.width as usize;
1420        let new_visible_rows = (area.height as usize).saturating_sub(header_h as usize);
1421        let visible_rows_changed = new_visible_rows != state.visible_rows;
1422        state.visible_rows = new_visible_rows;
1423
1424        // Fewer rows (the footer grew a line): the page starts that much later, so the
1425        // row the cursor is on stays the row it is on.
1426        if let Some(selected) = state.table_state.selected()
1427            && selected >= state.visible_rows
1428            && state.visible_rows > 0
1429        {
1430            let overflow = selected - (state.visible_rows - 1);
1431            state.view.start_row += overflow;
1432            state.table_state.select(Some(state.visible_rows - 1));
1433        }
1434
1435        // Only a page the rows on hand do not cover needs a read: the footer growing
1436        // and shrinking a line must not re-read the buffer each time.
1437        if visible_rows_changed && !state.page_on_hand(state.view.start_row) {
1438            // The App event loop checks this flag after each render and triggers an
1439            // async collect.
1440            state.needs_recollect = true;
1441        }
1442
1443        // Only show errors in main view if not suppressed (e.g., when query input is active)
1444        // Query errors should only be shown in the query input frame
1445        if let Some(error) = state.error()
1446            && !state.suppress_error_display
1447        {
1448            Paragraph::new(format!("Error: {}", user_message_from_polars(error)))
1449                .centered()
1450                .block(
1451                    Block::default()
1452                        .borders(Borders::NONE)
1453                        .padding(Padding::top(area.height / 2)),
1454                )
1455                .wrap(ratatui::widgets::Wrap { trim: true })
1456                .render(area, buf);
1457            return;
1458        }
1459        // If suppress_error_display is true, continue rendering the table normally
1460
1461        let start_row = state.start_to_draw();
1462        self.drawn_from = start_row;
1463        state.on_screen = None;
1464        // Only on the cursor's row (and, when drawn, its column): the cursor is what a
1465        // find moves, and a mark left behind would read as a second match.
1466        let selected = state.table_state.selected();
1467        self.find_column = self.find_cell.take().and_then(|(row, name)| {
1468            (row.checked_sub(start_row) == selected && selected.is_some()).then_some(name)
1469        });
1470
1471        // Where the scrolling columns are, for the cue drawn over the header after them.
1472        let mut scroll_indicator: Option<ScrollCue> = None;
1473
1474        // The numbers `#` shows, and the column wide enough for the widest.
1475        let numbers = if state.row_numbers() {
1476            state.row_numbers_from(start_row, state.visible_rows)
1477        } else {
1478            Vec::new()
1479        };
1480        let row_num_width = if state.row_numbers() {
1481            let widest = numbers.iter().max().copied().unwrap_or(1);
1482            widest.to_string().len().max(1) as u16 + 1 // +1 for spacing
1483        } else {
1484            0
1485        };
1486        let row_num_width = row_num_width.min(area.width);
1487        let data_area = Rect {
1488            x: area.x + row_num_width,
1489            width: area.width - row_num_width,
1490            ..area
1491        };
1492        let row_num_area = Rect {
1493            width: row_num_width,
1494            ..area
1495        };
1496        let visible_rows = state.visible_rows;
1497        let row_numbers = |start_row, num_rows, numbers, selected_row| RowNumbersParams {
1498            start_row,
1499            visible_rows,
1500            num_rows,
1501            numbers,
1502            selected_row,
1503        };
1504        let row_number_params = row_numbers(
1505            start_row,
1506            state.num_rows(),
1507            numbers,
1508            state.table_state.selected(),
1509        );
1510
1511        // Both sides are cut to the same rows on screen, so the frozen columns are
1512        // measured on what they show, not on the head of the buffer.
1513        let offset = start_row.saturating_sub(state.view.buffered_start_row);
1514        let rows_room = (area.height as usize).saturating_sub(header_h as usize);
1515        let locked_slice = state
1516            .view
1517            .locked_df
1518            .as_ref()
1519            .and_then(|df| visible_or_header(df, offset, state.visible_rows));
1520        self.fit_pending(state, offset, state.visible_rows.min(rows_room), cap);
1521
1522        if state.view.df.is_some() || state.view.locked_df.is_some() {
1523            if state.row_numbers() {
1524                self.render_row_numbers(row_num_area, buf, row_number_params);
1525            }
1526            let mut drawn_columns = Vec::new();
1527            let mut drawn_rows = 0;
1528            let mut scroll_area = data_area;
1529            let mut leading_gap = false;
1530            if let Some(locked) = locked_slice {
1531                let asked = state.locked_columns_count();
1532                // The rule runs down the header and the rows on screen, and stops
1533                // under the last: below it is no table to divide.
1534                let rule_bottom = (area.y + header_h)
1535                    .saturating_add(locked.height().min(rows_room) as u16)
1536                    .min(area.bottom());
1537                let mut fitted = self.fit_frozen_columns(
1538                    &locked,
1539                    locked.height().min(rows_room),
1540                    data_area.width,
1541                    state.view.column_order.len() <= asked,
1542                    area.width,
1543                    &mut Sizing {
1544                        widths: &mut state.widths,
1545                        schema: &state.view.schema,
1546                        cap,
1547                        page: state
1548                            .view
1549                            .locked_df
1550                            .as_ref()
1551                            .map(|frame| (&mut state.page_cells, frame, offset)),
1552                    },
1553                );
1554                state.fit_frozen(fitted.cols.len());
1555                // The state may not take the fit while no buffer is on hand; it then
1556                // keeps its count, and only what fits of it is drawn.
1557                let shown = state.frozen_shown().min(fitted.cols.len());
1558                fitted.cols.truncate(shown);
1559                fitted.widths.truncate(shown);
1560                let mut separator_x = data_area.x;
1561                if shown > 0 {
1562                    let gaps = self.table_cell_padding.saturating_mul(shown as u16 - 1);
1563                    let columns_width = fitted.widths.iter().sum::<u16>().saturating_add(gaps);
1564                    // One cell more than the columns: the space before the separator,
1565                    // which takes the header fill and the row tints.
1566                    let frozen_area = Rect {
1567                        width: columns_width.saturating_add(1).min(data_area.width),
1568                        ..data_area
1569                    };
1570                    let (columns, rows) =
1571                        self.draw_columns(&fitted, frozen_area, buf, &mut state.table_state, false);
1572                    drawn_columns.extend(columns);
1573                    drawn_rows = drawn_rows.max(rows);
1574                    separator_x = frozen_area.right();
1575                }
1576                if separator_x < data_area.right() {
1577                    // A broken rule while some frozen columns had to scroll: the window
1578                    // holds fewer than were asked for, and they come back with room.
1579                    let rule = if shown < asked {
1580                        self.glyphs.rule_broken
1581                    } else {
1582                        self.glyphs.rule
1583                    };
1584                    for y in area.y..rule_bottom {
1585                        let cell = &mut buf[(separator_x, y)];
1586                        cell.set_symbol(rule);
1587                        cell.set_style(Style::default().fg(self.separator_fg));
1588                    }
1589                }
1590                let scroll_x = separator_x.saturating_add(1).min(data_area.right());
1591                scroll_area = Rect {
1592                    x: scroll_x,
1593                    width: data_area.right() - scroll_x,
1594                    ..data_area
1595                };
1596                leading_gap = true;
1597            }
1598            // A page asked for lands here, where the room it is planned in is known.
1599            let room = Room {
1600                width: scroll_area.width,
1601                lead: u16::from(leading_gap),
1602                padding: self.table_cell_padding,
1603            };
1604            let rows = state.visible_rows.min(rows_room);
1605            state.land_column_moves(room, |state, name| {
1606                self.measure_column(state, name, offset, rows, cap)
1607            });
1608            if let Some(sliced_df) = state
1609                .view
1610                .df
1611                .as_ref()
1612                .and_then(|df| visible_or_header(df, offset, state.visible_rows))
1613            {
1614                let total_cols = sliced_df.width();
1615                let (shown, columns, rows) = self.render_scrolling(
1616                    &sliced_df,
1617                    scroll_area,
1618                    buf,
1619                    &mut state.table_state,
1620                    leading_gap,
1621                    Sizing {
1622                        widths: &mut state.widths,
1623                        schema: &state.view.schema,
1624                        cap,
1625                        page: state
1626                            .view
1627                            .df
1628                            .as_ref()
1629                            .map(|frame| (&mut state.page_cells, frame, offset)),
1630                    },
1631                );
1632                drawn_columns.extend(columns);
1633                drawn_rows = drawn_rows.max(rows);
1634                let more_left = state.termcol_index > 0;
1635                let more_right = total_cols.saturating_sub(shown);
1636                let first = state.frozen_shown() + state.termcol_index + 1;
1637                let total = state.view.column_order.len();
1638                state.on_screen =
1639                    state
1640                        .cursor_index()
1641                        .filter(|_| total > 1)
1642                        .map(|cursor| OnScreen {
1643                            first,
1644                            last: first + shown.saturating_sub(1),
1645                            cursor: cursor + 1,
1646                            total,
1647                        });
1648                scroll_indicator = Some(ScrollCue {
1649                    area: scroll_area,
1650                    more_left,
1651                    more_right,
1652                });
1653            } else {
1654                // Every column frozen: all on screen, and the cursor walks them.
1655                let total = state.view.column_order.len();
1656                state.on_screen =
1657                    state
1658                        .cursor_index()
1659                        .filter(|_| total > 1)
1660                        .map(|cursor| OnScreen {
1661                            first: 1,
1662                            last: total,
1663                            cursor: cursor + 1,
1664                            total,
1665                        });
1666            }
1667            state.drawn = Some(DrawnTable {
1668                area: whole,
1669                header: header_h,
1670                start_row,
1671                rows: drawn_rows,
1672                columns: drawn_columns,
1673            });
1674        } else if !state.view.column_order.is_empty() {
1675            // No rows on hand, but a schema: the header alone, each column its own type.
1676            let empty_columns: Vec<_> = state
1677                .view
1678                .column_order
1679                .iter()
1680                .map(|name| {
1681                    let dtype = state
1682                        .view
1683                        .schema
1684                        .get(name.as_str())
1685                        .cloned()
1686                        .unwrap_or(DataType::String);
1687                    Series::new_empty(name.as_str().into(), &dtype).into()
1688                })
1689                .collect();
1690            match DataFrame::new_infer_height(empty_columns) {
1691                Ok(empty_df) => {
1692                    if state.row_numbers() {
1693                        self.render_row_numbers(
1694                            row_num_area,
1695                            buf,
1696                            row_numbers(0, 0, Vec::new(), None),
1697                        );
1698                    }
1699                    self.render_scrolling(
1700                        &empty_df,
1701                        data_area,
1702                        buf,
1703                        &mut state.table_state,
1704                        false,
1705                        Sizing {
1706                            widths: &mut state.widths,
1707                            schema: &state.view.schema,
1708                            cap,
1709                            page: None,
1710                        },
1711                    );
1712                }
1713                _ => {
1714                    Paragraph::new("No data").render(area, buf);
1715                }
1716            }
1717        } else {
1718            // Truly empty: no schema, not loaded, or blank file
1719            Paragraph::new("No data").render(area, buf);
1720        }
1721
1722        // A table known to hold no rows says so under its header.
1723        let empty = state.view.num_rows_valid
1724            && state.num_rows() == 0
1725            && !state.view.column_order.is_empty();
1726        if empty && area.height > header_h && data_area.width > 0 {
1727            let line = Rect {
1728                y: area.y + header_h,
1729                height: 1,
1730                ..data_area
1731            };
1732            Paragraph::new("No rows")
1733                .style(Style::default().fg(self.dimmed))
1734                .render(line, buf);
1735        }
1736
1737        // The rail: the header rows take the header fill so the bar runs edge to edge,
1738        // and the selected row gets the accent mark.
1739        if rail_area.width > 0 && rail_area.height > 0 {
1740            let g = self.glyphs;
1741            let header_style = if self.header_bg == Color::Reset {
1742                Style::default().fg(self.header_fg)
1743            } else {
1744                Style::default().bg(self.header_bg).fg(self.header_fg)
1745            };
1746            for dy in 0..header_h.min(rail_area.height) {
1747                let cell = &mut buf[(rail_area.x, rail_area.y + dy)];
1748                cell.set_char(' ');
1749                cell.set_style(header_style);
1750            }
1751            if state.view.df.is_some()
1752                && !empty
1753                && let Some(sel) = state.table_state.selected()
1754            {
1755                let y = rail_area.y + header_h + sel as u16;
1756                if y < rail_area.y + rail_area.height {
1757                    let cell = &mut buf[(rail_area.x, y)];
1758                    cell.set_symbol(g.rail.trim_end());
1759                    let mut style = Style::default()
1760                        .fg(self.accent)
1761                        .add_modifier(Modifier::BOLD);
1762                    if let Some(bg) = self.selected_bg {
1763                        style = style.bg(bg);
1764                    }
1765                    cell.set_style(style);
1766                }
1767            }
1768        }
1769
1770        // Off-screen hints: the left in the rail; the right counts hidden columns, on the type
1771        // row if shown (short labels leave room), else the name row, after the last column.
1772        if let Some(cue) = scroll_indicator
1773            && cue.area.width > 0
1774            && cue.area.height > 0
1775        {
1776            let g = self.glyphs;
1777            let scroll_area = cue.area;
1778            let hidden = cue.more_right;
1779            let hint_style = if self.header_bg == Color::Reset {
1780                Style::default()
1781                    .fg(self.accent)
1782                    .add_modifier(Modifier::BOLD)
1783            } else {
1784                Style::default()
1785                    .bg(self.header_bg)
1786                    .fg(self.accent)
1787                    .add_modifier(Modifier::BOLD)
1788            };
1789            if cue.more_left && rail_area.width > 0 {
1790                let cell = &mut buf[(rail_area.x, rail_area.y)];
1791                cell.set_symbol(g.arrow_left);
1792                cell.set_style(hint_style);
1793            }
1794            if hidden > 0 {
1795                let y = if header_h > 1 {
1796                    scroll_area.y + 1
1797                } else {
1798                    scroll_area.y
1799                };
1800                // The count when the blank run at the end of the row holds it, the arrow
1801                // alone when not, so the count never covers a heading or a type.
1802                let right = scroll_area.x + scroll_area.width;
1803                let free = (scroll_area.x..right)
1804                    .rev()
1805                    .take_while(|&x| buf[(x, y)].symbol() == " ")
1806                    .count();
1807                let mut text = format!(" +{hidden} {}", g.arrow_right);
1808                if text.chars().count() > free {
1809                    text = g.arrow_right.to_string();
1810                }
1811                let w = text.chars().count() as u16;
1812                if scroll_area.width >= w {
1813                    let x0 = right - w;
1814                    for (i, ch) in text.chars().enumerate() {
1815                        let cell = &mut buf[(x0 + i as u16, y)];
1816                        cell.set_char(ch);
1817                        cell.set_style(hint_style);
1818                    }
1819                }
1820            }
1821        }
1822    }
1823}
1824
1825#[cfg(test)]
1826pub(crate) mod tests;