Skip to main content

rich_ext/table/
virtualized.rs

1//! Virtualised tables (#260): one window of a large row source.
2//!
3//! A [`VirtualTable`] renders the rows `offset..offset + height` of a
4//! [`VirtualRows`] source and nothing else, so a million-row source costs one
5//! window of memory. The source is anything that can fetch a row by index: a
6//! `Vec` of rows, a closure ([`FnRows`]), or a caller's own type (a file
7//! index, a query cursor, `rs-rich-data`'s rows).
8//!
9//! Column widths do not follow the window, so scrolling never makes columns
10//! jump: each is fixed by the caller ([`widths`](VirtualTable::widths), or a
11//! core `ColumnOptions::width`) or sampled once from the header and the first
12//! [`sample`](VirtualTable::sample) rows, capped at
13//! [`max_column_width`](VirtualTable::max_column_width). Cells do not wrap by
14//! default, so a row is one line and a viewport of N lines holds a known
15//! number of rows ([`chrome_lines`](VirtualTable::chrome_lines)); longer
16//! text ends in an ellipsis. A position line under the table says where the
17//! window is (`rows 1,001–1,040 of 1,000,000`), or how many rows are known to
18//! exist when the source cannot count them (`rows 1–40 of 41+`).
19//!
20//! This is a static renderer: interactive scrolling belongs to
21//! `rs-rich-interact`'s viewport, which moves the window with
22//! [`set_offset`](VirtualTable::set_offset) or
23//! [`scroll_by`](VirtualTable::scroll_by) and renders again.
24//!
25//! ```
26//! use rich::{Console, Justify};
27//! use rich_ext::table::{Column, FnRows, Value, VirtualTable};
28//!
29//! // Ten million rows, computed on demand: none of them is ever stored.
30//! let rows = FnRows::new(Some(10_000_000), |i| {
31//!     Some(vec![Value::Int(i as i64 + 1), format!("item {i}").into()])
32//! });
33//! let table = VirtualTable::new(
34//!     [Column::new("id").justify(Justify::Right), Column::new("name")],
35//!     rows,
36//! )
37//! .widths([8, 10])
38//! .offset(1_000)
39//! .height(3);
40//!
41//! let out = Console::builder().width(40).build().render_export(&table);
42//! assert_eq!(
43//!     out,
44//!     "\
45//! ┏━━━━━━━━━━┳━━━━━━━━━━━━┓
46//! ┃       id ┃ name       ┃
47//! ┡━━━━━━━━━━╇━━━━━━━━━━━━┩
48//! │     1001 │ item 1000  │
49//! │     1002 │ item 1001  │
50//! │     1003 │ item 1002  │
51//! └──────────┴────────────┘
52//! rows 1,001–1,003 of 10,000,000
53//! "
54//! );
55//! ```
56//!
57//! Without [`widths`](VirtualTable::widths), the widths would be sampled
58//! from the header and the first 100 rows (`id` up to `100`, `name` up to
59//! `item 99`), and the window's longer values would end in an ellipsis
60//! (`10…`). Fix widths when the caller knows better, or sample more rows.
61
62use std::sync::{Arc, Mutex, PoisonError};
63
64use rich::{Console, ConsoleOptions, Justify, Overflow, Renderable, Segment, Table, Text};
65
66use super::data::normalize;
67use super::stream::table_lines;
68use super::{frame_builders, style, Column, Frame, Value};
69
70/// The most rows one window shows; a larger [`height`](VirtualTable::height)
71/// is cut to this.
72pub const MAX_HEIGHT: usize = 10_000;
73
74/// The most rows sampled for column widths.
75pub const MAX_SAMPLE: usize = 10_000;
76
77/// Rows sampled for column widths by default.
78pub const DEFAULT_SAMPLE: usize = 100;
79
80/// The widest a sampled column gets by default, in cells.
81pub const DEFAULT_MAX_COLUMN_WIDTH: usize = 40;
82
83/// Rows fetched by index, for a [`VirtualTable`].
84///
85/// Only [`row`](VirtualRows::row) is required. A source that can fetch a
86/// range more cheaply than row by row (a file index, a cursor) overrides
87/// [`rows`](VirtualRows::rows); one that knows its length overrides
88/// [`row_count`](VirtualRows::row_count). A row may have more or fewer
89/// cells than the table has columns: missing cells are null and extra cells
90/// are dropped.
91///
92/// The methods take `&self` because rendering does. A source that reads
93/// from something stateful (a cursor) keeps that state behind a lock.
94pub trait VirtualRows {
95    /// How many rows there are, when the source knows.
96    fn row_count(&self) -> Option<usize> {
97        None
98    }
99
100    /// The row at `index` (0-based), or `None` past the end.
101    fn row(&self, index: usize) -> Option<Vec<Value>>;
102
103    /// Up to `len` rows from `start`, fewer at the end. The default calls
104    /// [`row`](VirtualRows::row) until it returns `None`.
105    fn rows(&self, start: usize, len: usize) -> Vec<Vec<Value>> {
106        (start..start.saturating_add(len))
107            .map_while(|index| self.row(index))
108            .collect()
109    }
110}
111
112impl VirtualRows for [Vec<Value>] {
113    fn row_count(&self) -> Option<usize> {
114        Some(self.len())
115    }
116
117    fn row(&self, index: usize) -> Option<Vec<Value>> {
118        self.get(index).cloned()
119    }
120
121    fn rows(&self, start: usize, len: usize) -> Vec<Vec<Value>> {
122        let start = start.min(self.len());
123        let end = start.saturating_add(len).min(self.len());
124        self[start..end].to_vec()
125    }
126}
127
128impl VirtualRows for Vec<Vec<Value>> {
129    fn row_count(&self) -> Option<usize> {
130        Some(self.len())
131    }
132
133    fn row(&self, index: usize) -> Option<Vec<Value>> {
134        self.as_slice().row(index)
135    }
136
137    fn rows(&self, start: usize, len: usize) -> Vec<Vec<Value>> {
138        self.as_slice().rows(start, len)
139    }
140}
141
142macro_rules! forward_rows {
143    ($($ptr:ty),*) => {$(
144        impl<T: VirtualRows + ?Sized> VirtualRows for $ptr {
145            fn row_count(&self) -> Option<usize> {
146                (**self).row_count()
147            }
148
149            fn row(&self, index: usize) -> Option<Vec<Value>> {
150                (**self).row(index)
151            }
152
153            fn rows(&self, start: usize, len: usize) -> Vec<Vec<Value>> {
154                (**self).rows(start, len)
155            }
156        }
157    )*};
158}
159forward_rows!(&T, Box<T>, Arc<T>);
160
161/// Rows computed by a closure: `row(index)` returns the row at `index`, or
162/// `None` past the end. The count is optional.
163///
164/// ```
165/// use rich_ext::table::{FnRows, Value, VirtualRows};
166///
167/// let squares = FnRows::new(None, |i| (i < 5).then(|| vec![Value::from(i * i)]));
168/// assert_eq!(squares.row(3), Some(vec![Value::Int(9)]));
169/// assert_eq!(squares.rows(3, 10).len(), 2);
170/// assert_eq!(squares.row_count(), None);
171/// ```
172pub struct FnRows<F> {
173    count: Option<usize>,
174    row: F,
175}
176
177impl<F> FnRows<F>
178where
179    F: Fn(usize) -> Option<Vec<Value>>,
180{
181    /// Rows from `row`, `count` of them when known.
182    pub fn new(count: Option<usize>, row: F) -> Self {
183        FnRows { count, row }
184    }
185}
186
187impl<F> std::fmt::Debug for FnRows<F> {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("FnRows")
190            .field("count", &self.count)
191            .finish_non_exhaustive()
192    }
193}
194
195impl<F> VirtualRows for FnRows<F>
196where
197    F: Fn(usize) -> Option<Vec<Value>>,
198{
199    fn row_count(&self) -> Option<usize> {
200        self.count
201    }
202
203    fn row(&self, index: usize) -> Option<Vec<Value>> {
204        if self.count.is_some_and(|count| index >= count) {
205            return None;
206        }
207        (self.row)(index)
208    }
209}
210
211/// The rows one window shows, from [`VirtualTable::page`].
212#[derive(Clone, Debug, PartialEq)]
213#[non_exhaustive]
214pub struct Page {
215    /// The 0-based index of the first row shown. It is the table's offset,
216    /// moved back when that is past the end of a source that knows its
217    /// length.
218    pub start: usize,
219    /// The rows shown, each with one cell per column.
220    pub rows: Vec<Vec<Value>>,
221    /// How many rows the source has: its own count, or the end when this
222    /// window reached it.
223    pub total: Option<usize>,
224    /// Whether a row is known to follow the window.
225    pub more: bool,
226}
227
228impl Page {
229    /// The 0-based indices of the rows shown.
230    pub fn range(&self) -> std::ops::Range<usize> {
231        self.start..self.start + self.rows.len()
232    }
233
234    /// Where the window is, in words: `rows 1,001–1,040 of 1,000,000`, `rows
235    /// 1–40 of 41+` when the source cannot count past the window, `no rows`,
236    /// or `no rows from 1,001` past the end of an uncounted source. `ascii`
237    /// writes the range with `-` instead of `–`.
238    pub fn position(&self, ascii: bool) -> String {
239        let number = |n: usize| crate::format::number(i64::try_from(n).unwrap_or(i64::MAX));
240        if self.rows.is_empty() {
241            return match self.start {
242                0 => "no rows".to_string(),
243                start => format!("no rows from {}", number(start + 1)),
244            };
245        }
246        let dash = if ascii { "-" } else { "–" };
247        let range = format!(
248            "rows {}{dash}{}",
249            number(self.start + 1),
250            number(self.start + self.rows.len())
251        );
252        match self.total {
253            Some(total) => format!("{range} of {}", number(total)),
254            None if self.more => {
255                format!(
256                    "{range} of {}+",
257                    number(self.start.saturating_add(self.rows.len() + 1))
258                )
259            }
260            None => range,
261        }
262    }
263}
264
265/// One window of a [`VirtualRows`] source as a table; see the
266/// [module docs](self).
267///
268/// The frame (title, caption, box, edges, expand, border style) is set with
269/// the same builders as [`TableData`](super::TableData).
270pub struct VirtualTable<S> {
271    source: S,
272    columns: Vec<Column>,
273    frame: Frame,
274    offset: usize,
275    height: usize,
276    widths: Vec<Option<usize>>,
277    sample: usize,
278    max_width: usize,
279    wrap: bool,
280    position: bool,
281    row_numbers: bool,
282    fit_window: bool,
283    null: Option<String>,
284    footnote: Option<String>,
285    sampled: Mutex<Option<Vec<usize>>>,
286}
287
288impl<S> std::fmt::Debug for VirtualTable<S> {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("VirtualTable")
291            .field("columns", &self.columns)
292            .field("offset", &self.offset)
293            .field("height", &self.height)
294            .field("widths", &self.widths)
295            .field("sample", &self.sample)
296            .finish_non_exhaustive()
297    }
298}
299
300frame_builders!([S] VirtualTable<S>);
301
302impl<S: VirtualRows> VirtualTable<S> {
303    /// A window of 20 rows from the top of `source`, under `columns`.
304    pub fn new(columns: impl IntoIterator<Item = Column>, source: S) -> Self {
305        VirtualTable {
306            source,
307            columns: columns.into_iter().collect(),
308            frame: Frame::default(),
309            offset: 0,
310            height: 20,
311            widths: Vec::new(),
312            sample: DEFAULT_SAMPLE,
313            max_width: DEFAULT_MAX_COLUMN_WIDTH,
314            wrap: false,
315            position: true,
316            row_numbers: false,
317            fit_window: false,
318            null: None,
319            footnote: None,
320            sampled: Mutex::new(None),
321        }
322    }
323
324    /// Start the window at row `offset` (0-based).
325    pub fn offset(mut self, offset: usize) -> Self {
326        self.offset = offset;
327        self
328    }
329
330    /// Show `rows` rows (at most [`MAX_HEIGHT`]).
331    pub fn height(mut self, rows: usize) -> Self {
332        self.set_height(rows);
333        self
334    }
335
336    /// Fix the column widths, in cells, in column order. A missing or `None`
337    /// entry leaves that column to its `ColumnOptions::width`, or to the
338    /// sample.
339    pub fn widths(mut self, widths: impl IntoIterator<Item = impl Into<Option<usize>>>) -> Self {
340        self.widths = widths.into_iter().map(Into::into).collect();
341        self.resample();
342        self
343    }
344
345    /// Sample the header and the first `rows` rows for column widths
346    /// (default [`DEFAULT_SAMPLE`], at most [`MAX_SAMPLE`]). Zero samples
347    /// only the header.
348    pub fn sample(mut self, rows: usize) -> Self {
349        self.sample = rows.min(MAX_SAMPLE);
350        self.resample();
351        self
352    }
353
354    /// Cap sampled widths at `cells` (default [`DEFAULT_MAX_COLUMN_WIDTH`],
355    /// at least 1). Fixed widths are not capped.
356    pub fn max_column_width(mut self, cells: usize) -> Self {
357        self.max_width = cells.max(1);
358        self.resample();
359        self
360    }
361
362    /// Wrap long cells onto more lines instead of cutting them short with an
363    /// ellipsis (default off, so a row is one line).
364    pub fn wrap(mut self, wrap: bool) -> Self {
365        self.wrap = wrap;
366        self
367    }
368
369    /// Show the position line under the table (default on).
370    pub fn show_position(mut self, show: bool) -> Self {
371        self.position = show;
372        self
373    }
374
375    /// Also widen sampled columns to fit the rows shown (default off), up to
376    /// [`max_column_width`](Self::max_column_width). For a window that is
377    /// rendered once: scrolling such a table can change its widths.
378    pub fn fit_window(mut self, fit: bool) -> Self {
379        self.fit_window = fit;
380        self
381    }
382
383    /// Show each row's 1-based number in a first `#` column (default off).
384    pub fn row_numbers(mut self, show: bool) -> Self {
385        self.row_numbers = show;
386        self
387    }
388
389    /// Show null cells as `marker` in the `table.null` style, instead of
390    /// empty like an empty string. Column formatters are not called for
391    /// them.
392    pub fn null_marker(mut self, marker: impl Into<String>) -> Self {
393        self.null = Some(marker.into());
394        self.resample();
395        self
396    }
397
398    /// A line under the position line, in the `table.position` style: a row
399    /// count, a timing, a note.
400    pub fn footnote(mut self, note: impl Into<String>) -> Self {
401        self.footnote = Some(note.into());
402        self
403    }
404
405    /// Move the window to row `offset` (0-based).
406    pub fn set_offset(&mut self, offset: usize) {
407        self.offset = offset;
408    }
409
410    /// Change how many rows the window shows (at most [`MAX_HEIGHT`]).
411    pub fn set_height(&mut self, rows: usize) {
412        self.height = rows.min(MAX_HEIGHT);
413    }
414
415    /// Move the window by `rows` (negative is up), stopping at the top and,
416    /// when the source knows its length, at the last full window.
417    pub fn scroll_by(&mut self, rows: isize) {
418        let offset = self.offset.saturating_add_signed(rows);
419        self.offset = match self.max_offset() {
420            Some(max) => offset.min(max),
421            None => offset,
422        };
423    }
424
425    /// The window's first row (0-based), as set.
426    pub fn window_offset(&self) -> usize {
427        self.offset
428    }
429
430    /// How many rows the window shows.
431    pub fn window_height(&self) -> usize {
432        self.height
433    }
434
435    /// The source's row count, when it knows.
436    pub fn row_count(&self) -> Option<usize> {
437        self.source.row_count()
438    }
439
440    /// The offset of the last full window, when the source knows its length.
441    pub fn max_offset(&self) -> Option<usize> {
442        self.row_count()
443            .map(|count| count.saturating_sub(self.height))
444    }
445
446    /// The columns.
447    pub fn columns(&self) -> &[Column] {
448        &self.columns
449    }
450
451    /// The source.
452    pub fn source(&self) -> &S {
453        &self.source
454    }
455
456    /// The source, to change. Call [`resample`](Self::resample) if the first
457    /// rows changed.
458    pub fn source_mut(&mut self) -> &mut S {
459        &mut self.source
460    }
461
462    /// Forget the sampled widths, so the next render samples again.
463    pub fn resample(&self) {
464        *self.sampled.lock().unwrap_or_else(PoisonError::into_inner) = None;
465    }
466
467    /// The rows the window shows, fetched from the source. An offset past
468    /// the end of a source that knows its length moves back to the last full
469    /// window; one past the end of a source that does not shows no rows.
470    pub fn page(&self) -> Page {
471        let count = self.source.row_count();
472        let start = match count {
473            Some(count) => self.offset.min(count.saturating_sub(self.height)),
474            None => self.offset,
475        };
476        // One extra row says whether more follow when the source cannot count.
477        let fetch = if count.is_some() {
478            self.height
479        } else {
480            self.height + 1
481        };
482        let mut rows = self.source.rows(start, fetch);
483        rows.truncate(fetch);
484        let more = match count {
485            Some(count) => start + rows.len().min(self.height) < count,
486            None => rows.len() > self.height,
487        };
488        rows.truncate(self.height);
489        let width = self.columns.len();
490        let rows: Vec<Vec<Value>> = rows.into_iter().map(|row| normalize(row, width)).collect();
491        let total = count.or_else(|| (!more && !rows.is_empty()).then(|| start + rows.len()));
492        Page {
493            start,
494            rows,
495            total,
496            more,
497        }
498    }
499
500    /// Each column's width in cells: fixed, or sampled from the header and
501    /// the first rows (and cached until [`resample`](Self::resample)).
502    pub fn column_widths(&self) -> Vec<usize> {
503        let mut sampled = self.sampled.lock().unwrap_or_else(PoisonError::into_inner);
504        if let Some(widths) = &*sampled {
505            return widths.clone();
506        }
507        let mut widths: Vec<usize> = self
508            .columns
509            .iter()
510            .map(|column| Text::new(column.header()).measurement().1)
511            .collect();
512        for row in self
513            .source
514            .rows(0, self.sample)
515            .into_iter()
516            .take(self.sample)
517        {
518            // Pad short rows as `page` does, so a missing cell is sampled
519            // as the null marker it will show.
520            let row = normalize(row, self.columns.len());
521            for ((width, column), value) in widths.iter_mut().zip(&self.columns).zip(&row) {
522                *width = (*width).max(self.cell(column, value).measurement().1);
523            }
524        }
525        let widths: Vec<usize> = widths
526            .into_iter()
527            .enumerate()
528            .map(|(index, sampled)| {
529                let fixed = self.widths.get(index).copied().flatten();
530                fixed
531                    .or(self.columns[index].column_options().width)
532                    .unwrap_or_else(|| sampled.clamp(1, self.max_width))
533            })
534            .collect();
535        *sampled = Some(widths.clone());
536        widths
537    }
538
539    /// How many lines the table takes around its rows at `width` cells: the
540    /// title, edges, header, caption, position line and footnote. A viewport
541    /// of `lines` lines holds `lines - chrome_lines` rows when cells do not
542    /// wrap.
543    pub fn chrome_lines(&self, console: &Console, width: usize) -> usize {
544        let options = console.options().update_width(width.max(1));
545        let empty = Page {
546            start: 0,
547            rows: Vec::new(),
548            total: None,
549            more: false,
550        };
551        let table = self.build(console, &empty);
552        table_lines(&table, console, &options).len()
553            + usize::from(self.position)
554            + usize::from(self.footnote.is_some())
555    }
556
557    /// The window as a core table (without the position line and footnote).
558    pub fn to_table(&self, console: &Console) -> Table {
559        self.build(console, &self.page())
560    }
561
562    fn cell(&self, column: &Column, value: &Value) -> Text {
563        match (&self.null, value) {
564            (Some(marker), Value::Null) => Text::new(marker.clone()),
565            _ => column.cell(value),
566        }
567    }
568
569    fn build(&self, console: &Console, page: &Page) -> Table {
570        let mut widths = self.column_widths();
571        if self.fit_window {
572            for (index, width) in widths.iter_mut().enumerate() {
573                let column = &self.columns[index];
574                let fixed = self.widths.get(index).copied().flatten();
575                if fixed.or(column.column_options().width).is_some() {
576                    continue;
577                }
578                for row in &page.rows {
579                    let cell = self.cell(column, &row[index]).measurement().1;
580                    *width = (*width).max(cell.min(self.max_width));
581                }
582            }
583        }
584        let mut columns: Vec<Column> = Vec::with_capacity(self.columns.len() + 1);
585        if self.row_numbers {
586            let last = page
587                .total
588                .unwrap_or(page.start + page.rows.len())
589                .max(page.start + page.rows.len());
590            let options = rich::ColumnOptions {
591                justify: Justify::Right,
592                width: Some(last.max(1).to_string().len()),
593                no_wrap: true,
594                style: style(console, "table.row_number"),
595                ..Default::default()
596            };
597            columns.push(Column::new("#").options(options));
598        }
599        for (column, width) in self.columns.iter().zip(&widths) {
600            let mut options = column.column_options().clone();
601            options.width = Some(*width);
602            if !self.wrap {
603                options.no_wrap = true;
604                if options.overflow == Overflow::Fold {
605                    options.overflow = Overflow::Ellipsis;
606                }
607            }
608            columns.push(Column::new(column.header()).options(options));
609        }
610        let headers: Vec<Text> = columns.iter().map(|c| Text::new(c.header())).collect();
611        let mut table = self.frame.table(&columns, &headers, true, true);
612        let null_style = style(console, "table.null");
613        for (index, row) in page.rows.iter().enumerate() {
614            let mut cells = Vec::with_capacity(columns.len());
615            if self.row_numbers {
616                cells.push(Text::new((page.start + index + 1).to_string()));
617            }
618            for (column, value) in self.columns.iter().zip(row) {
619                let mut text = self.cell(column, value);
620                if self.null.is_some() && matches!(value, Value::Null) {
621                    let len = text.plain().len();
622                    text.stylize(null_style.clone(), 0, len);
623                }
624                cells.push(text);
625            }
626            table.add_row_text(cells);
627        }
628        table
629    }
630
631    fn note_lines(
632        &self,
633        console: &Console,
634        options: &ConsoleOptions,
635        text: String,
636    ) -> Vec<Vec<Segment>> {
637        let text = Text::styled(text, style(console, "table.position"))
638            .no_wrap(true)
639            .overflow(Overflow::Ellipsis);
640        let mut options = options.clone();
641        options.height = None;
642        console.render_lines(&text, &options, false)
643    }
644
645    fn render_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Vec<Segment>> {
646        let page = self.page();
647        let table = self.build(console, &page);
648        let mut lines = table_lines(&table, console, options);
649        if self.position {
650            lines.extend(self.note_lines(console, options, page.position(console.ascii_only())));
651        }
652        if let Some(note) = &self.footnote {
653            lines.extend(self.note_lines(console, options, note.clone()));
654        }
655        lines
656    }
657}
658
659impl<S: VirtualRows> Renderable for VirtualTable<S> {
660    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
661        crate::event::flatten(self.render_lines(console, options))
662    }
663
664    /// The table's measurement, widened to the position line and footnote:
665    /// every line `rich_render` emits.
666    fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
667        let page = self.page();
668        let mut measurement = self.build(console, &page).measure(console, options);
669        let notes = self
670            .position
671            .then(|| page.position(console.ascii_only()))
672            .into_iter()
673            .chain(self.footnote.clone());
674        for note in notes {
675            measurement.maximum = measurement.maximum.max(Text::new(note).cell_len());
676        }
677        measurement
678    }
679}
680
681impl<S: VirtualRows> crate::a11y::AccessibleText for VirtualTable<S> {
682    fn accessible_text(&self, width: usize) -> String {
683        let console = Console::builder().width(width.max(1)).build();
684        self.to_table(&console).accessible_text(width)
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691
692    fn render(table: &impl Renderable, width: usize) -> String {
693        Console::builder()
694            .width(width)
695            .color_system(None)
696            .build()
697            .render_export(table)
698    }
699
700    fn numbered(count: Option<usize>) -> FnRows<impl Fn(usize) -> Option<Vec<Value>>> {
701        FnRows::new(count, |i| {
702            Some(vec![Value::from(i), format!("r{i}").into()])
703        })
704    }
705
706    #[test]
707    fn an_offset_past_the_end_shows_the_last_window() {
708        let rows: Vec<Vec<Value>> = (0..10).map(|i| vec![Value::from(i)]).collect();
709        let table = VirtualTable::new([Column::new("n")], rows)
710            .offset(50)
711            .height(3);
712        let page = table.page();
713        assert_eq!(page.range(), 7..10);
714        assert_eq!(page.total, Some(10));
715        assert!(!page.more);
716        assert_eq!(page.position(false), "rows 8–10 of 10");
717    }
718
719    #[test]
720    fn an_uncounted_source_reports_what_it_knows() {
721        let rows = FnRows::new(None, |i| (i < 5).then(|| vec![Value::from(i)]));
722        let table = VirtualTable::new([Column::new("n")], rows).height(2);
723        assert_eq!(table.page().position(false), "rows 1–2 of 3+");
724        let mut table = table;
725        table.set_offset(3);
726        let page = table.page();
727        assert_eq!((page.total, page.more), (Some(5), false));
728        assert_eq!(page.position(true), "rows 4-5 of 5");
729        table.set_offset(9);
730        assert_eq!(table.page().position(false), "no rows from 10");
731    }
732
733    #[test]
734    fn ragged_rows_are_padded_and_cut() {
735        let rows = vec![
736            vec![Value::from("a")],
737            vec![Value::from("b"), Value::from(2), Value::from("extra")],
738            vec![],
739        ];
740        let table = VirtualTable::new([Column::new("x"), Column::new("y")], rows);
741        let page = table.page();
742        assert!(page.rows.iter().all(|row| row.len() == 2));
743        let out = render(&table, 30);
744        assert!(out.contains("│ b │ 2 │"), "{out}");
745    }
746
747    #[test]
748    fn widths_hold_still_while_scrolling() {
749        let mut table = VirtualTable::new([Column::new("n"), Column::new("s")], numbered(None))
750            .sample(10)
751            .height(2);
752        let first = render(&table, 40);
753        table.scroll_by(500);
754        let later = render(&table, 40);
755        let width = |out: &str| out.lines().next().unwrap().chars().count();
756        assert_eq!(width(&first), width(&later));
757        // `n` was sampled from 0..10: one cell wide, so `500` is cut short.
758        assert!(later.contains("│ … │ r… │"), "{later}");
759        assert_eq!(table.column_widths(), [1, 2]);
760    }
761
762    #[test]
763    fn fixed_widths_win_and_the_cap_bounds_samples() {
764        let long = vec![vec![Value::from("x".repeat(500)), Value::from("y")]];
765        let table = VirtualTable::new([Column::new("a"), Column::new("b")], long.clone())
766            .max_column_width(8);
767        assert_eq!(table.column_widths(), [8, 1]);
768        let table =
769            VirtualTable::new([Column::new("a"), Column::new("b")], long).widths([None, Some(5)]);
770        assert_eq!(table.column_widths(), [40, 5]);
771    }
772
773    #[test]
774    fn scrolling_stops_at_both_ends() {
775        let mut table = VirtualTable::new([Column::new("n")], numbered(Some(100))).height(10);
776        table.scroll_by(-5);
777        assert_eq!(table.window_offset(), 0);
778        table.scroll_by(1_000);
779        assert_eq!(table.window_offset(), 90);
780        assert_eq!(table.max_offset(), Some(90));
781    }
782
783    #[test]
784    fn nulls_can_be_marked_apart_from_empty_text() {
785        let rows = vec![vec![Value::Null], vec![Value::from("")]];
786        let table = VirtualTable::new([Column::new("v")], rows)
787            .null_marker("NULL")
788            .show_position(false);
789        assert_eq!(
790            render(&table, 20),
791            "┏━━━━━━┓\n┃ v    ┃\n┡━━━━━━┩\n│ NULL │\n│      │\n└──────┘\n"
792        );
793    }
794
795    #[test]
796    fn row_numbers_and_footnote() {
797        let table = VirtualTable::new([Column::new("s")], numbered(Some(1_000)))
798            .offset(998)
799            .height(2)
800            .row_numbers(true)
801            .widths([3])
802            .footnote("(1,000 rows)");
803        let out = render(&table, 40);
804        assert!(out.contains("│  999 │ 998 │"), "{out}");
805        assert!(
806            out.ends_with("rows 999–1,000 of 1,000\n(1,000 rows)\n"),
807            "{out}"
808        );
809    }
810
811    #[test]
812    fn chrome_lines_count_everything_but_rows() {
813        let table = VirtualTable::new([Column::new("n")], numbered(Some(100))).height(5);
814        let console = Console::builder().width(40).build();
815        let out = render(&table, 40);
816        assert_eq!(out.lines().count(), 5 + table.chrome_lines(&console, 40));
817    }
818
819    #[test]
820    fn an_empty_source_renders_a_header_and_no_rows() {
821        let table = VirtualTable::new([Column::new("n")], Vec::<Vec<Value>>::new());
822        let out = render(&table, 20);
823        assert!(out.ends_with("no rows\n"), "{out}");
824        let table = VirtualTable::new(Vec::<Column>::new(), numbered(Some(3)));
825        render(&table, 20);
826    }
827}