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