Skip to main content

datui_lib/widgets/
info.rs

1//! Info panel: tabbed Schema and Resources view for dataset technical info.
2
3use std::collections::HashMap;
4
5use crate::numfmt::group_chrome;
6use std::path::Path;
7use std::sync::Arc;
8
9use polars::prelude::*;
10
11use crate::parquet_footer::Footer;
12use ratatui::buffer::Buffer;
13use ratatui::layout::{Constraint, Direction, Layout, Rect};
14use ratatui::prelude::Stylize;
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{HighlightSpacing, Paragraph, Row, StatefulWidget, Table, Widget};
18
19use super::datatable::DataTableState;
20use crate::export_modal::ExportFormat;
21use crate::render::context::RenderContext;
22use crate::widgets::ui::{HintBar, SectionRule, Surface};
23
24/// One drawn line of the Notes tab.
25struct NoteRow {
26    text: String,
27    /// Drawn in the panel's dim color: the line a note rests on, not the note.
28    dim: bool,
29}
30
31/// Which notes to draw, as a half-open range.
32///
33/// `heights` is each note's rows; a blank line sits between adjacent notes. `stored` is
34/// where the panel was last scrolled to, and `show` the rows available.
35///
36/// Guarantees, whatever it is given:
37///
38/// - `first <= selected < last`, so the note the cursor is on is always drawn;
39/// - the notes in the range fit `show` rows, unless the range is one note that does not
40///   fit on its own — then it is drawn as far as it goes, since leaving it out would
41///   make it unreachable;
42/// - `last` is as large as it can be, so rows are never left blank while a whole note
43///   is out of view.
44///
45/// Review after review found defects in this arithmetic when it was inline in the
46/// render, expressed in rows and mixed with drawing. It is a function so the rules
47/// above can be checked directly rather than through a terminal.
48fn notes_window(heights: &[usize], selected: usize, stored: usize, show: usize) -> (usize, usize) {
49    if heights.is_empty() {
50        return (0, 0);
51    }
52    let selected = selected.min(heights.len() - 1);
53    // Rows that notes `a..b` take, counting the blank line between adjacent ones.
54    let span = |a: usize, b: usize| heights[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1);
55    // Start no later than the selected note, and far enough back that it still fits.
56    let mut first = stored.min(selected);
57    while first < selected && span(first, selected + 1) > show {
58        first += 1;
59    }
60    // Then take as many following notes as the rest of the panel holds.
61    let mut last = selected + 1;
62    while last < heights.len() && span(first, last + 1) <= show {
63        last += 1;
64    }
65    // And give back any room left at the top, so the panel is never part empty while a
66    // whole note is hidden above it.
67    while first > 0 && span(first - 1, last) <= show {
68        first -= 1;
69    }
70    (first, last)
71}
72
73/// A note's lines: its summary and the line saying what it is based on, both wrapped to
74/// the panel. Selection changes only the marker, never a height.
75fn note_rows(note: &crate::notes::Note, selected: bool, width: usize) -> Vec<NoteRow> {
76    let mut rows = Vec::new();
77    let marker = if selected {
78        crate::glyphs::get().prompt
79    } else {
80        "  "
81    };
82    let mut first = true;
83    for line in wrap_to(&note.summary, width.saturating_sub(2)) {
84        rows.push(NoteRow {
85            text: format!("{}{line}", if first { marker } else { "  " }),
86            dim: false,
87        });
88        first = false;
89    }
90    for line in wrap_to(&note.scope, width.saturating_sub(4)) {
91        rows.push(NoteRow {
92            text: format!("    {line}"),
93            dim: true,
94        });
95    }
96    rows
97}
98
99/// Break `text` on spaces so no line runs past `width` columns.
100///
101/// Measured in columns rather than characters: a column name can be any text the data
102/// holds, and a name whose characters are double-width would otherwise be clipped by
103/// the terminal after this said it fitted. A single word longer than the panel is left
104/// whole rather than split mid-word, though the terminal still clips what runs past
105/// the edge — the wrapping protects the note's height, not a single long word.
106pub(crate) fn wrap_to(text: &str, width: usize) -> Vec<String> {
107    use unicode_width::UnicodeWidthStr;
108    if width == 0 {
109        return vec![text.to_string()];
110    }
111    let mut lines = Vec::new();
112    let mut line = String::new();
113    for word in text.split_whitespace() {
114        let room = if line.is_empty() {
115            width
116        } else {
117            width.saturating_sub(line.width() + 1)
118        };
119        if word.width() > room && !line.is_empty() {
120            lines.push(std::mem::take(&mut line));
121        }
122        if !line.is_empty() {
123            line.push(' ');
124        }
125        line.push_str(word);
126    }
127    if !line.is_empty() || lines.is_empty() {
128        lines.push(line);
129    }
130    lines
131}
132
133/// `n` comma-grouped, for the panel's own labels.
134pub(crate) fn group_u64(n: u64) -> String {
135    let mut out = String::new();
136    crate::numfmt::NumberFormat::CHROME.write_u64(n, &mut out);
137    out
138}
139
140/// `n` and the noun for it: `1 tensor`, `291 tensors`.
141pub(crate) fn count_of(n: u64, one: &str, many: &str) -> String {
142    format!("{} {}", group_u64(n), if n == 1 { one } else { many })
143}
144
145/// A parameter count as a model card says it: `8.0B`, `124.4M`, `950`.
146pub(crate) fn short_count(n: u64) -> String {
147    const STEPS: [(u64, &str); 4] = [
148        (1_000_000_000_000, "T"),
149        (1_000_000_000, "B"),
150        (1_000_000, "M"),
151        (1_000, "K"),
152    ];
153    for (size, suffix) in STEPS {
154        if n >= size {
155            return format!("{:.1}{suffix}", n as f64 / size as f64);
156        }
157    }
158    n.to_string()
159}
160
161/// Break one line of text into lines no wider than `width` columns, between words: the
162/// spaces at a break are dropped, and leading spaces (a template's indentation) kept.
163/// Only a word wider than `width` is broken, where it reaches the edge, so a long URL
164/// or hash is shown whole rather than clipped.
165fn wrap_words(text: &str, width: usize) -> Vec<String> {
166    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
167    let mut lines = Vec::new();
168    let mut line = String::new();
169    let mut line_width = 0;
170    // A line a break started: spaces at its start are the break's, not indentation.
171    let mut broken = false;
172    let mut rest = text;
173    while !rest.is_empty() {
174        let space = rest.starts_with(' ');
175        let end = rest
176            .find(|c: char| (c == ' ') != space)
177            .unwrap_or(rest.len());
178        let (token, after) = rest.split_at(end);
179        rest = after;
180        let w = token.width();
181        if space {
182            if broken && line.is_empty() {
183                continue;
184            }
185            if line_width + w <= width {
186                line.push_str(token);
187                line_width += w;
188            } else {
189                // Indentation wider than the room starts no line of its own.
190                if !line.is_empty() {
191                    lines.push(std::mem::take(&mut line));
192                }
193                line_width = 0;
194                broken = true;
195            }
196            continue;
197        }
198        if line_width + w > width && !line.is_empty() && w <= width {
199            lines.push(std::mem::take(&mut line).trim_end().to_string());
200            line_width = 0;
201            broken = true;
202        }
203        for c in token.chars() {
204            let cw = c.width().unwrap_or(0);
205            if line_width + cw > width && !line.is_empty() {
206                lines.push(std::mem::take(&mut line));
207                line_width = 0;
208                broken = true;
209            }
210            line.push(c);
211            line_width += cw;
212        }
213    }
214    // Spaces dropped at a break leave no blank line after the text.
215    if !(broken && line.is_empty()) || lines.is_empty() {
216        lines.push(line);
217    }
218    lines
219}
220
221/// One metadata value as text: an array that was listed, or how long it is.
222pub(crate) fn meta_text(value: &crate::model_files::MetaValue) -> String {
223    use crate::model_files::MetaValue;
224    match value {
225        MetaValue::Text(text) => text.clone(),
226        MetaValue::List { of, len, items } if items.len() as u64 == *len && *len > 0 => {
227            let quote = *of == "strings";
228            let items: Vec<String> = items
229                .iter()
230                .map(|i| if quote { format!("{i:?}") } else { i.clone() })
231                .collect();
232            format!("[{}]", items.join(", "))
233        }
234        MetaValue::List { of, len, .. } => format!("[{} {of}]", group_u64(*len)),
235    }
236}
237
238/// The most of one metadata value a detail tab draws. A chat template is a few KB and
239/// is shown whole; a GGUF can carry a whole `tokenizer.json` as one string, megabytes
240/// that would be wrapped again on every frame.
241pub(crate) const VALUE_SHOWN_BYTES: usize = 64 * 1024;
242
243/// The metadata as drawn lines: the key on a value's first line, blank under it, and
244/// each value cut at its own newlines and wrapped to what is left of `width`. A value
245/// past [`VALUE_SHOWN_BYTES`] ends with a line saying how much more there is.
246pub(crate) fn metadata_lines(
247    metadata: &[(String, crate::model_files::MetaValue)],
248    width: usize,
249) -> Vec<(String, String)> {
250    use unicode_width::UnicodeWidthStr;
251    let longest = metadata.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
252    // Two columns between key and value; the key takes no more than two fifths.
253    let key_width = longest.min(width * 2 / 5).max(1);
254    let value_width = width.saturating_sub(key_width + 2).max(1);
255    let mut out = Vec::new();
256    for (key, value) in metadata {
257        let key_cell = format!("{:<w$}  ", clip(key, key_width), w = key_width);
258        let blank = " ".repeat(key_width + 2);
259        // Borrowed, not copied: this runs every frame.
260        let listed;
261        let text = match value {
262            crate::model_files::MetaValue::Text(text) => text.as_str(),
263            other => {
264                listed = meta_text(other);
265                listed.as_str()
266            }
267        };
268        let cut = text.floor_char_boundary(VALUE_SHOWN_BYTES);
269        let (text, more) = (&text[..cut], text.len() - cut);
270        let mut first = true;
271        for raw in text.split('\n') {
272            // Tabs as a space and other control characters dropped, so the widths
273            // measured here are the widths drawn.
274            let clean: String = raw
275                .chars()
276                .filter_map(|c| match c {
277                    '\t' => Some(' '),
278                    c if c.is_control() => None,
279                    c => Some(c),
280                })
281                .collect();
282            for line in wrap_words(&clean, value_width) {
283                let k = if first {
284                    key_cell.clone()
285                } else {
286                    blank.clone()
287                };
288                first = false;
289                out.push((k, line));
290            }
291        }
292        if more > 0 {
293            let g = crate::glyphs::get();
294            out.push((
295                blank,
296                format!("{} {} more", g.ellipsis, format_bytes(more as u64)),
297            ));
298        }
299    }
300    out
301}
302
303/// A length of time as a clock: `0:03.250`, `1:02:03.250`.
304pub(crate) fn clock(seconds: f64) -> String {
305    let ms = (seconds.max(0.0) * 1000.0).round() as u64;
306    let (h, m, s, ms) = (ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000);
307    if h > 0 {
308        format!("{h}:{m:02}:{s:02}.{ms:03}")
309    } else {
310        format!("{m}:{s:02}.{ms:03}")
311    }
312}
313
314/// Human-readable byte size (e.g. "1.2 MiB", "456 KiB").
315pub fn format_bytes(n: u64) -> String {
316    const K: u64 = 1024;
317    const M: u64 = K * K;
318    const G: u64 = M * K;
319    if n >= G {
320        format!("{:.1} GiB", n as f64 / G as f64)
321    } else if n >= M {
322        format!("{:.1} MiB", n as f64 / M as f64)
323    } else if n >= K {
324        format!("{:.1} KiB", n as f64 / K as f64)
325    } else {
326        format!("{} B", n)
327    }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
331pub enum InfoTab {
332    #[default]
333    Schema,
334    /// A delimited spec's metadata line, as key and value.
335    Metadata,
336    /// What the file says besides its rows, as its reader found it
337    /// ([`crate::text_formats::Detail`]): a model's totals, a VCD header. Titled by
338    /// the detail.
339    Format,
340    Resources,
341    Partitions,
342    Notes,
343    /// What the catalog that lists the dataset says of it: the page `Ctrl+E` shows on
344    /// home.
345    Documentation,
346}
347
348/// Which of the optional tabs the dataset on screen offers.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
350pub struct TabsOffered {
351    /// A delimited spec read a metadata line.
352    pub metadata: bool,
353    /// The file said something besides its rows.
354    pub format: bool,
355    pub partitions: bool,
356    pub notes: bool,
357    /// A catalog lists the dataset. Not the state's to say: the App sets it.
358    pub documentation: bool,
359}
360
361impl TabsOffered {
362    /// What `state` offers; `facts_tab` is the format's tab the file facts fill, when
363    /// they will (see [`InfoContext::facts_tab`]).
364    pub fn of(state: &DataTableState, facts_tab: Option<&'static str>) -> Self {
365        Self {
366            metadata: state
367                .delimited_read()
368                .is_some_and(|read| read.metadata.is_some()),
369            format: state.format_detail().is_some() || facts_tab.is_some(),
370            partitions: state
371                .partition_columns()
372                .map(|v| !v.is_empty())
373                .unwrap_or(false),
374            notes: state.has_notes(),
375            documentation: false,
376        }
377    }
378}
379
380impl InfoTab {
381    /// The tabs on offer, in order: the file's own tab only when it says something
382    /// besides its rows, beside the schema it explains; Partitions only for a
383    /// partitioned dataset; Notes only when datui has something to say about the data.
384    pub fn visible(offered: TabsOffered) -> Vec<InfoTab> {
385        let mut tabs = vec![InfoTab::Schema];
386        if offered.documentation {
387            tabs.push(InfoTab::Documentation);
388        }
389        if offered.metadata {
390            tabs.push(InfoTab::Metadata);
391        }
392        if offered.format {
393            tabs.push(InfoTab::Format);
394        }
395        tabs.push(InfoTab::Resources);
396        if offered.partitions {
397            tabs.push(InfoTab::Partitions);
398        }
399        if offered.notes {
400            tabs.push(InfoTab::Notes);
401        }
402        tabs
403    }
404
405    pub fn title(self) -> &'static str {
406        match self {
407            InfoTab::Schema => "Schema",
408            InfoTab::Metadata => "Metadata",
409            InfoTab::Format => "Format",
410            InfoTab::Resources => "Resources",
411            InfoTab::Partitions => "Partitions",
412            InfoTab::Notes => "Notes",
413            InfoTab::Documentation => "Documentation",
414        }
415    }
416
417    /// Next tab, wrapping. A tab that is not on offer starts from the first.
418    pub fn next(self, offered: TabsOffered) -> Self {
419        let tabs = Self::visible(offered);
420        let at = self.index(offered);
421        tabs[(at + 1) % tabs.len()]
422    }
423
424    pub fn prev(self, offered: TabsOffered) -> Self {
425        let tabs = Self::visible(offered);
426        let at = self.index(offered);
427        tabs[(at + tabs.len() - 1) % tabs.len()]
428    }
429
430    /// Where this tab sits among the ones on offer; 0 when it is not among them.
431    pub fn index(self, offered: TabsOffered) -> usize {
432        Self::visible(offered)
433            .iter()
434            .position(|tab| *tab == self)
435            .unwrap_or(0)
436    }
437}
438
439/// Modal state for the Info panel: focus, tab, schema table selection/scroll.
440#[derive(Default)]
441pub struct InfoModal {
442    pub active: bool,
443    pub active_tab: InfoTab,
444    pub schema_selected_index: usize,
445    pub schema_scroll_offset: usize,
446    pub schema_table_state: ratatui::widgets::TableState,
447    /// Last visible height for schema table (data rows), set during render.
448    pub schema_visible_height: usize,
449    /// The note the cursor is on, and the first note drawn.
450    pub notes_selected_index: usize,
451    /// The first row of the notes list on screen; the render keeps the selected note
452    /// inside the window.
453    pub notes_scroll_offset: usize,
454    /// The first line of a detail tab's list on screen. The render clamps it. One for
455    /// all: a dataset has one detail tab at most.
456    pub detail_scroll: usize,
457    /// The list lines a detail tab last had room for; set during render.
458    pub detail_visible: usize,
459    /// The entry the cursor is on, in a detail tab whose list is the file's tables
460    /// (Excel's worksheets, SQLite's tables), where Enter opens one. The render clamps
461    /// it and keeps it in view.
462    pub detail_selected: usize,
463}
464
465impl InfoModal {
466    pub fn new() -> Self {
467        Self::default()
468    }
469
470    pub fn open(&mut self) {
471        self.open_on(InfoTab::Schema);
472    }
473
474    /// Open with `tab` in front. The accented `i` chip promises unread notes;
475    /// arriving on the Schema tab instead made the reader hunt for them.
476    pub fn open_on(&mut self, tab: InfoTab) {
477        self.active = true;
478        self.active_tab = tab;
479        self.schema_selected_index = 0;
480        self.schema_scroll_offset = 0;
481        self.schema_table_state.select(Some(0));
482        self.notes_selected_index = 0;
483        self.notes_scroll_offset = 0;
484        self.detail_scroll = 0;
485        self.detail_selected = 0;
486    }
487
488    pub fn close(&mut self) {
489        self.active = false;
490    }
491
492    /// Switch to the next of the tabs on offer.
493    pub fn switch_tab(&mut self, offered: TabsOffered) {
494        self.active_tab = self.active_tab.next(offered);
495        if self.active_tab == InfoTab::Schema {
496            self.schema_selected_index = 0;
497            self.schema_scroll_offset = 0;
498            self.schema_table_state.select(Some(0));
499        }
500    }
501
502    /// Switch to the previous of the tabs on offer.
503    pub fn switch_tab_prev(&mut self, offered: TabsOffered) {
504        self.active_tab = self.active_tab.prev(offered);
505        if self.active_tab == InfoTab::Schema {
506            self.schema_selected_index = 0;
507            self.schema_scroll_offset = 0;
508            self.schema_table_state.select(Some(0));
509        }
510    }
511
512    /// Scroll a detail tab's list by `delta` lines; the render keeps it
513    /// in range.
514    pub fn detail_scroll_by(&mut self, delta: isize) {
515        self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
516    }
517
518    /// Scroll a detail tab's list by a page.
519    pub fn detail_page(&mut self, down: bool) {
520        let page = self.detail_visible.max(1) as isize;
521        self.detail_scroll_by(if down { page } else { -page });
522    }
523
524    /// Move the cursor through the notes. Returns true when something changed.
525    ///
526    /// Only the index moves: the render scrolls to whatever is selected, so how tall a
527    /// note happens to be can never decide how far the cursor may go.
528    pub fn notes_move(&mut self, delta: isize, total: usize) -> bool {
529        if total == 0 {
530            return false;
531        }
532        let last = total - 1;
533        let next = (self.notes_selected_index as isize + delta).clamp(0, last as isize) as usize;
534        if next == self.notes_selected_index {
535            return false;
536        }
537        self.notes_selected_index = next;
538        true
539    }
540
541    /// Scroll and selection for schema table. `total_rows` = schema len,
542    /// `visible_height` = rows shown. Returns true if state changed.
543    pub fn schema_table_down(&mut self, total_rows: usize, visible_height: usize) -> bool {
544        if total_rows == 0 {
545            return false;
546        }
547        let max_idx = total_rows.saturating_sub(1);
548        if self.schema_selected_index >= max_idx {
549            return false;
550        }
551        self.schema_selected_index += 1;
552        let visible_end = self.schema_scroll_offset + visible_height;
553        if visible_height > 0 && self.schema_selected_index >= visible_end {
554            self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
555        }
556        let local = self
557            .schema_selected_index
558            .saturating_sub(self.schema_scroll_offset);
559        self.schema_table_state.select(Some(local));
560        true
561    }
562
563    pub fn schema_table_up(&mut self, total_rows: usize, _visible_height: usize) -> bool {
564        if total_rows == 0 || self.schema_selected_index == 0 {
565            return false;
566        }
567        self.schema_selected_index -= 1;
568        if self.schema_selected_index < self.schema_scroll_offset {
569            self.schema_scroll_offset = self.schema_selected_index;
570        }
571        let local = self
572            .schema_selected_index
573            .saturating_sub(self.schema_scroll_offset);
574        self.schema_table_state.select(Some(local));
575        true
576    }
577
578    /// Sync table state from selected_index/offset (e.g. after tab switch or total_rows change).
579    pub fn sync_schema_table_state(&mut self, total_rows: usize, visible_height: usize) {
580        if total_rows == 0 {
581            self.schema_table_state.select(None);
582            return;
583        }
584        let max_idx = total_rows.saturating_sub(1);
585        self.schema_selected_index = self.schema_selected_index.min(max_idx);
586        if self.schema_scroll_offset + visible_height <= self.schema_selected_index
587            && visible_height > 0
588        {
589            self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
590        }
591        if self.schema_selected_index < self.schema_scroll_offset {
592            self.schema_scroll_offset = self.schema_selected_index;
593        }
594        let local = self
595            .schema_selected_index
596            .saturating_sub(self.schema_scroll_offset);
597        self.schema_table_state.select(Some(local));
598    }
599}
600
601/// What the open file says about itself beyond its rows: its size on disk and, for a
602/// format whose reader has a facts read ([`crate::readers::Reader::facts`]), its tab
603/// of this panel and the footer it was made from.
604///
605/// Read on a worker, once per dataset, and drawn from here. A stat or a footer read on a
606/// mount that has stopped answering hangs the thread that makes it, so neither is made
607/// where keys are read or frames drawn (#457).
608#[derive(Debug, Clone)]
609pub enum FileFacts {
610    /// Asked for; the worker has not answered.
611    Reading,
612    /// What the worker found.
613    Read {
614        /// `None` for a directory, whose own size is not the data's.
615        size: Option<u64>,
616        /// A footer that gives the Schema tab's Compression column; `None` for a
617        /// format without one.
618        footer: Option<Footer>,
619        /// The format's tab, made from what the read found.
620        detail: Option<Arc<crate::text_formats::Detail>>,
621    },
622    /// The read failed, and why. Kept for the dataset rather than asked again: a file
623    /// that could not be read a moment ago is not worth a read per frame.
624    Failed(String),
625}
626
627impl FileFacts {
628    /// Stat `path` and, with `facts`, its format's facts read. Blocking: call it on a
629    /// worker.
630    ///
631    /// The reason for a failure is short enough for the panel's one line; the whole
632    /// error goes to the log.
633    pub(crate) fn read(
634        path: &Path,
635        facts: Option<crate::readers::Facts>,
636    ) -> std::result::Result<Self, String> {
637        let io = |e: std::io::Error| {
638            log::warn!(target: "datui", "file size of {}: {e}", path.display());
639            match e.kind() {
640                std::io::ErrorKind::NotFound => "file not found".to_string(),
641                std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
642                // The OS's own words, without the errno the log already has.
643                _ => {
644                    let said = e.to_string();
645                    match said.rsplit_once(" (os error") {
646                        Some((words, _)) => words.to_string(),
647                        None => said,
648                    }
649                }
650            }
651        };
652        let meta = std::fs::metadata(path).map_err(io)?;
653        if meta.is_dir() {
654            return Ok(Self::Read {
655                size: None,
656                footer: None,
657                detail: None,
658            });
659        }
660        let read = match facts {
661            Some(facts) => (facts.read)(path).map_err(|e| {
662                log::warn!(target: "datui", "footer of {}: {e}", path.display());
663                "unreadable footer".to_string()
664            })?,
665            None => crate::readers::FormatFacts::default(),
666        };
667        Ok(Self::Read {
668            size: Some(meta.len()),
669            footer: read.footer,
670            detail: read.detail,
671        })
672    }
673}
674
675/// `text` cut to `room` columns, with the ellipsis glyph saying where.
676fn clip(text: &str, room: usize) -> String {
677    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
678    if text.width() <= room {
679        return text.to_string();
680    }
681    let mark = crate::glyphs::get().ellipsis;
682    let mut kept = String::new();
683    for ch in text.chars() {
684        if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
685            break;
686        }
687        kept.push(ch);
688    }
689    kept + mark
690}
691
692/// Context for the info panel: the format, and what the file says about itself.
693///
694/// What the open cost is not here: it belongs to the dataset, and the panel already
695/// has the dataset.
696pub struct InfoContext<'a> {
697    pub format: Option<ExportFormat>,
698    /// The file declares its columns' types, as its format's descriptor says.
699    pub declared_types: bool,
700    /// `None` when there is no one file on this machine to ask: a remote source, a
701    /// glob, or a dataset opened from several paths.
702    pub facts: Option<&'a FileFacts>,
703    /// The format's tab that the facts fill, for one local file whose reader has a
704    /// facts read: offered, named and given its room before they land, so nothing moves
705    /// when they do.
706    pub facts_tab: Option<&'static str>,
707    /// The facts read a footer that gives the Compression column, whose room is kept
708    /// while it is read.
709    pub footer_expected: bool,
710}
711
712impl<'a> InfoContext<'a> {
713    /// Where the column types came from, as the Schema rule's chip says it.
714    pub fn schema_source(&self) -> &'static str {
715        if self.declared_types {
716            "types declared"
717        } else {
718            "types inferred"
719        }
720    }
721
722    /// The footer the facts read, once it has landed.
723    pub fn footer(&self) -> Option<&'a Footer> {
724        match self.facts? {
725            FileFacts::Read { footer, .. } => footer.as_ref(),
726            FileFacts::Reading | FileFacts::Failed(_) => None,
727        }
728    }
729
730    /// The format's tab the facts made, once it has landed.
731    fn facts_detail(&self) -> Option<&'a crate::text_formats::Detail> {
732        match self.facts? {
733            FileFacts::Read { detail, .. } => detail.as_deref(),
734            FileFacts::Reading | FileFacts::Failed(_) => None,
735        }
736    }
737
738    /// Whether the worker has yet to answer.
739    fn reading(&self) -> bool {
740        matches!(self.facts, Some(FileFacts::Reading))
741    }
742}
743
744pub struct DataTableInfo<'a> {
745    pub state: &'a DataTableState,
746    pub ctx: InfoContext<'a>,
747    pub modal: &'a mut InfoModal,
748    pub theme: &'a RenderContext,
749    /// The dataset is one local file, which `x` shows as hex.
750    pub hex: bool,
751    /// The dataset is delimited text, whose first row `H` reads the other way.
752    pub header_toggle: bool,
753    /// What the columns mean, from the catalog that lists the dataset.
754    pub codebook: Option<&'a crate::codebook::Codebook>,
755    /// The Documentation tab's page, when a catalog lists the dataset.
756    pub documentation: Option<&'a mut crate::widgets::documentation::DocState>,
757    /// The row count from a sample of the dataset's footers, until it is counted.
758    pub estimate: Option<crate::schema_union::RowEstimate>,
759}
760
761/// The Resources tab's `Read:` value: how the open reads the data, and that a remote
762/// file was downloaded first. `None` for a frame no open found, such as Python's.
763fn read_line(state: &DataTableState) -> Option<String> {
764    let mode = state.read_mode()?.label();
765    Some(if state.fetched() {
766        format!("download {} {mode}", crate::glyphs::get().arrow_right)
767    } else {
768        mode.to_string()
769    })
770}
771
772/// The first line of the Schema tab: the dataset's size, or that it does not know yet.
773///
774/// Told `None` rather than a number, because what a state holds before it has been
775/// counted is how far its buffer reached — printed under a heading that says "total",
776/// that reads as the size of the dataset. On a directory of thousands of files still
777/// being counted it would say `Rows (total): 70` beside a control bar showing a spinner.
778fn rows_and_columns(rows: Option<usize>, columns: usize) -> String {
779    let middot = crate::glyphs::get().middot;
780    match rows {
781        Some(rows) => format!(
782            "Rows (total): {} {middot} Columns: {}",
783            format_int(rows),
784            columns
785        ),
786        None => format!("Rows (total): counting... {middot} Columns: {columns}"),
787    }
788}
789
790/// [`rows_and_columns`] for a count estimated from a sample of footers, with how many
791/// were read and the key that counts them all.
792fn estimated_rows_and_columns(
793    estimate: crate::schema_union::RowEstimate,
794    columns: usize,
795) -> String {
796    let middot = crate::glyphs::get().middot;
797    format!(
798        "Rows (total): ~{} (est. from {} of {} files; c counts) {middot} Columns: {columns}",
799        crate::discover::format_rows(estimate.rows as usize),
800        format_int(estimate.sampled),
801        format_int(estimate.files),
802    )
803}
804
805impl<'a> DataTableInfo<'a> {
806    pub fn new(
807        state: &'a DataTableState,
808        ctx: InfoContext<'a>,
809        modal: &'a mut InfoModal,
810        theme: &'a RenderContext,
811    ) -> Self {
812        Self {
813            state,
814            ctx,
815            modal,
816            theme,
817            hex: false,
818            header_toggle: false,
819            codebook: None,
820            documentation: None,
821            estimate: None,
822        }
823    }
824
825    /// The codebook, when it has a note for one of the columns on screen.
826    fn codebook_here(&self) -> Option<&'a crate::codebook::Codebook> {
827        self.codebook
828            .filter(|book| book.covers(self.state.schema().iter_names().map(|n| n.as_str())))
829    }
830
831    fn render_schema_tab(&mut self, area: Rect, buf: &mut Buffer) {
832        let summary = self.render_schema_summary(area, buf);
833        let mut rest = Rect {
834            y: area.y + summary,
835            height: area.height.saturating_sub(summary),
836            ..area
837        };
838        if rest.height == 0 {
839            return;
840        }
841        // The selected column's note in full below the table: a blank row and three of
842        // text, kept whichever column is selected so nothing moves.
843        let note_rows = if self.codebook_here().is_some() && rest.height >= 10 {
844            4
845        } else {
846            0
847        };
848        rest.height -= note_rows;
849        let used = self.render_schema_table(rest, buf);
850        if note_rows > 0 {
851            // Right under the table's last row, where the eye already is.
852            self.render_column_note(
853                Rect {
854                    y: rest.y + used + 1,
855                    height: note_rows - 1,
856                    ..rest
857                },
858                buf,
859            );
860        }
861    }
862
863    /// What the codebook says of the selected column: its meaning and unit, then its
864    /// codes.
865    fn render_column_note(&self, area: Rect, buf: &mut Buffer) {
866        let Some(book) = self.codebook_here() else {
867            return;
868        };
869        let Some((name, _)) = self
870            .state
871            .schema()
872            .get_at_index(self.modal.schema_selected_index)
873        else {
874            return;
875        };
876        let width = area.width as usize;
877        let rows = area.height as usize;
878        let mut lines: Vec<String> = Vec::new();
879        match book.column(name.as_str()) {
880            Some(column) => {
881                let about = column.about();
882                if !about.is_empty() {
883                    lines.extend(wrap_to(&format!("{name}: {about}"), width));
884                }
885                if !column.values.is_empty() {
886                    let sep = format!(" {} ", crate::glyphs::get().middot);
887                    let codes: Vec<&str> = column
888                        .values
889                        .keys()
890                        .map(|k| if k.is_empty() { "blank" } else { k.as_str() })
891                        .collect();
892                    let left = rows.saturating_sub(lines.len()).max(1);
893                    let mut wrapped = wrap_to(&format!("Codes: {}", codes.join(&sep)), width);
894                    if wrapped.len() > left {
895                        // The last line that fits carries the rest, cut with the ellipsis.
896                        let rest = wrapped[left - 1..].join(" ");
897                        wrapped.truncate(left - 1);
898                        wrapped.push(clip(&rest, width));
899                    }
900                    lines.extend(wrapped);
901                }
902            }
903            None => lines.push(format!("{name}: not documented")),
904        }
905        for (i, line) in lines.iter().take(rows).enumerate() {
906            Paragraph::new(clip(line, width))
907                .style(Style::default().fg(self.theme.text_secondary))
908                .render(
909                    Rect {
910                        y: area.y + i as u16,
911                        height: 1,
912                        ..area
913                    },
914                    buf,
915                );
916        }
917    }
918
919    fn render_schema_summary(&self, area: Rect, buf: &mut Buffer) -> u16 {
920        let ncols = self.state.schema().len();
921        let mut lines = vec![];
922        // `num_rows_if_valid`, not `num_rows`: see `rows_and_columns`.
923        lines.push(match self.estimate {
924            Some(estimate) => estimated_rows_and_columns(estimate, ncols),
925            None => rows_and_columns(self.state.num_rows_if_valid(), ncols),
926        });
927        let by_type = columns_by_type(self.state.schema().as_ref());
928        if !by_type.is_empty() {
929            lines.push(by_type);
930        }
931        // A file of several tables (an NMEA log's sentence types) names the others.
932        let others = self.state.other_tables();
933        if !others.is_empty() {
934            let sep = format!(" {} ", crate::glyphs::get().middot);
935            lines.push(format!("Other tables (--table): {}", others.join(&sep)));
936        }
937        if let Some(book) = self.codebook_here()
938            && !book.source.is_empty()
939        {
940            lines.push(clip(
941                &format!("Documentation: {}", book.source),
942                area.width as usize,
943            ));
944        }
945        for (i, s) in lines.iter().enumerate() {
946            Paragraph::new(s.as_str()).render(
947                Rect {
948                    x: area.x,
949                    y: area.y + i as u16,
950                    width: area.width,
951                    height: 1,
952                },
953                buf,
954            );
955        }
956        lines.len() as u16
957    }
958
959    /// Draw the schema table; returns the rows it drew, rule and header included.
960    fn render_schema_table(&mut self, area: Rect, buf: &mut Buffer) -> u16 {
961        // A dataset of many files says which footers its columns came from; one file
962        // says only whether its format declared them.
963        let dataset = self.state.dataset_schema();
964        let src = match dataset {
965            Some(dataset) => dataset.origin.to_string(),
966            // A model's, an audio file's or MIDI's columns are datui's own.
967            None if self.state.format_detail().is_some_and(|d| d.own_columns) => {
968                "types declared".to_string()
969            }
970            None => self.ctx.schema_source().to_string(),
971        };
972        // Per column, how many of the footers read carry it. Only a dataset of files
973        // can vary this per column; for a single file the dataset-level fact already
974        // sits in the block title, so no column repeats it.
975        let presence = dataset.map(|dataset| {
976            let readable = dataset.files.saturating_sub(dataset.unreadable.len());
977            let present_by_name: HashMap<&str, usize> = dataset
978                .columns
979                .iter()
980                .map(|c| (c.name.as_str(), c.present_in))
981                .collect();
982            (readable, present_by_name)
983        });
984        let has_files = presence.is_some();
985        let compression = self.ctx.footer().map(|m| {
986            crate::parquet_footer::column_compression(m.as_ref(), self.state.schema().as_ref())
987        });
988        // Kept for a file whose footer is still out, so the columns do not re-proportion
989        // when it lands.
990        let has_comp =
991            self.ctx.footer_expected || compression.as_ref().is_some_and(|c| !c.is_empty());
992        // A delimited spec's unit row: each column's unit, beside its type.
993        let has_units = !self.state.units().is_empty();
994        let book = self.codebook_here();
995        let mut header_cells = vec!["Column", "Type"];
996        if has_units {
997            header_cells.push("Unit");
998        }
999        if book.is_some() {
1000            header_cells.push("About");
1001        }
1002        if has_files {
1003            header_cells.push("Files");
1004        }
1005        if has_comp {
1006            header_cells.push("Compression");
1007        }
1008        let header = Row::new(header_cells).bold();
1009
1010        let total_rows = self.state.schema().len();
1011        // The body always has the keys: the tabs switch from anywhere, so the
1012        // rule is accented and the row carries the rail.
1013        let body_focused = true;
1014        // A noun on the rule, and where its types came from in the chip, as every
1015        // section rule says a fact about its section.
1016        SectionRule {
1017            title: "Schema",
1018            chip: Some(&src),
1019        }
1020        .render(Rect { height: 1, ..area }, buf, self.theme);
1021        let inner = Rect {
1022            y: area.y + 1,
1023            height: area.height.saturating_sub(1),
1024            ..area
1025        };
1026        let visible_height = inner.height as usize;
1027
1028        // One row of header; one more reserved for the out-of-view count when
1029        // the columns do not all fit, so their existence is stated before any
1030        // scrolling ("… 3 more" beats half a schema presented as whole).
1031        let fits = total_rows <= visible_height.saturating_sub(1);
1032        let data_height = visible_height.saturating_sub(1 + usize::from(!fits));
1033        self.modal.schema_visible_height = data_height;
1034        self.modal.sync_schema_table_state(total_rows, data_height);
1035
1036        let offset = self.modal.schema_scroll_offset;
1037        let take = data_height.min(total_rows.saturating_sub(offset));
1038        let mut rows: Vec<Vec<String>> = vec![];
1039        for (idx, (name, dtype)) in self.state.schema().iter().enumerate() {
1040            if idx < offset {
1041                continue;
1042            }
1043            if idx >= offset + take {
1044                break;
1045            }
1046            let name_str: &str = name.as_ref();
1047            let mut cells = vec![name.to_string(), dtype.to_string()];
1048            if has_units {
1049                cells.push(self.state.unit_of(name_str).unwrap_or_default().to_string());
1050            }
1051            if let Some(book) = book {
1052                cells.push(book.column(name_str).map(|c| c.about()).unwrap_or_default());
1053            }
1054            if let Some((readable, present_by_name)) = &presence {
1055                // A column the footers never named — one built by a query or added
1056                // from the file names — has no per-file fact to state.
1057                let files_str = match present_by_name.get(name_str) {
1058                    Some(present) if present >= readable => {
1059                        format!("all {}", format_int(*readable))
1060                    }
1061                    Some(present) => {
1062                        format!("{} of {}", format_int(*present), format_int(*readable))
1063                    }
1064                    None => crate::glyphs::get().dash.to_string(),
1065                };
1066                cells.push(files_str);
1067            }
1068            if has_comp {
1069                let comp_str = match compression.as_ref().map(|c| c.get(name_str)) {
1070                    Some(Some((codec, ratio))) => {
1071                        format!("{} {:.1}{}", codec, ratio, crate::glyphs::get().times)
1072                    }
1073                    // Blank until the footer lands, rather than a dash that says it did.
1074                    None if self.ctx.reading() => String::new(),
1075                    _ => crate::glyphs::get().dash.to_string(),
1076                };
1077                cells.push(comp_str);
1078            }
1079            rows.push(cells);
1080        }
1081
1082        let widths: Vec<Constraint> = if book.is_some() {
1083            // The note takes what the name and type leave.
1084            let mut weights = vec![3, 2];
1085            weights.extend(has_units.then_some(2));
1086            weights.push(7);
1087            weights.extend(has_files.then_some(2));
1088            weights.extend(has_comp.then_some(3));
1089            weights.into_iter().map(Constraint::Fill).collect()
1090        } else if has_units {
1091            // Name and type as wide as each other, the rest narrower.
1092            let mut weights = vec![3, 3, 2];
1093            weights.extend(has_files.then_some(2));
1094            weights.extend(has_comp.then_some(3));
1095            weights.into_iter().map(Constraint::Fill).collect()
1096        } else {
1097            match (has_files, has_comp) {
1098                (true, true) => vec![
1099                    Constraint::Percentage(25),
1100                    Constraint::Percentage(30),
1101                    Constraint::Percentage(20),
1102                    Constraint::Percentage(25),
1103                ],
1104                (true, false) => vec![
1105                    Constraint::Percentage(35),
1106                    Constraint::Percentage(40),
1107                    Constraint::Percentage(25),
1108                ],
1109                (false, true) => vec![
1110                    Constraint::Percentage(30),
1111                    Constraint::Percentage(40),
1112                    Constraint::Percentage(30),
1113                ],
1114                (false, false) => vec![Constraint::Percentage(50), Constraint::Percentage(50)],
1115            }
1116        };
1117        // The rail and the tint while the table has focus; the accent alone when
1118        // it does not, so the cursor stays visible without claiming focus. The
1119        // rail's column is kept either way, so focus arriving moves nothing.
1120        let g = crate::glyphs::get();
1121        let (highlight, symbol) = if body_focused {
1122            (self.theme.highlight_style(), g.selector)
1123        } else {
1124            (Style::default().fg(self.theme.accent), g.selector_blank)
1125        };
1126        let symbol = Span::styled(symbol, Style::default().fg(self.theme.accent));
1127        // A note longer than its column ends in the ellipsis rather than mid-word: the
1128        // whole of it is under the table.
1129        if book.is_some() {
1130            let about = 2 + usize::from(has_units);
1131            let room = Rect {
1132                width: inner
1133                    .width
1134                    .saturating_sub(crate::glyphs::cell_width(g.selector) as u16),
1135                ..inner
1136            };
1137            let cols = Layout::horizontal(widths.clone()).spacing(1).split(room);
1138            if let Some(col) = cols.get(about) {
1139                for cells in &mut rows {
1140                    if let Some(cell) = cells.get_mut(about) {
1141                        *cell = clip(cell, col.width as usize);
1142                    }
1143                }
1144            }
1145        }
1146        let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
1147        let table = Table::new(rows, widths)
1148            .header(header)
1149            .column_spacing(1)
1150            .row_highlight_style(highlight)
1151            .highlight_symbol(symbol)
1152            .highlight_spacing(HighlightSpacing::Always);
1153        let table_area = Rect {
1154            height: inner.height.saturating_sub(u16::from(!fits)),
1155            ..inner
1156        };
1157        StatefulWidget::render(table, table_area, buf, &mut self.modal.schema_table_state);
1158
1159        if !fits && inner.height > 0 {
1160            let above = offset;
1161            let below = total_rows.saturating_sub(offset + take);
1162            let counted = match (above, below) {
1163                (0, 0) => None,
1164                (0, n) => Some(format!("{} below", format_int(n))),
1165                (n, 0) => Some(format!("{} above", format_int(n))),
1166                (a, b) => Some(format!("{} above, {} below", format_int(a), format_int(b))),
1167            };
1168            if let Some(text) = counted {
1169                Paragraph::new(text)
1170                    .style(Style::default().fg(self.theme.dimmed))
1171                    .alignment(ratatui::layout::Alignment::Right)
1172                    .render(
1173                        Rect {
1174                            y: inner.y + inner.height - 1,
1175                            height: 1,
1176                            ..inner
1177                        },
1178                        buf,
1179                    );
1180            }
1181        }
1182        // The rule, the header, the rows and the count under them.
1183        (2 + take + usize::from(!fits)).min(area.height as usize) as u16
1184    }
1185
1186    fn render_resources_tab(&self, area: Rect, buf: &mut Buffer) {
1187        // One past the longest label, "Buffer (Rows):", with room to spare.
1188        const LABEL_WIDTH: u16 = 17;
1189        let label_constraint = Constraint::Length(LABEL_WIDTH);
1190        let value_constraint = Constraint::Min(1);
1191        let mut y = area.y;
1192        let h = area.height;
1193        let w = area.width;
1194
1195        fn label_value_row(label: &str, value: &str, area: Rect, buf: &mut Buffer, label_w: u16) {
1196            let chunks = Layout::default()
1197                .direction(Direction::Horizontal)
1198                .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1199                .split(area);
1200            Paragraph::new(label).render(chunks[0], buf);
1201            Paragraph::new(value).render(chunks[1], buf);
1202        }
1203
1204        if y >= area.y + h {
1205            return;
1206        }
1207        let size_chunks = Layout::default()
1208            .direction(Direction::Horizontal)
1209            .constraints([label_constraint, value_constraint])
1210            .split(Rect {
1211                y,
1212                width: w,
1213                height: 1,
1214                ..area
1215            });
1216        // Drawn from what the worker left; reading the file here would hang the frame
1217        // on a mount that has stopped answering.
1218        let file_size = match self.ctx.facts {
1219            None | Some(FileFacts::Read { size: None, .. }) => Span::raw(crate::glyphs::get().dash),
1220            Some(FileFacts::Read {
1221                size: Some(size), ..
1222            }) => Span::raw(format_bytes(*size)),
1223            Some(FileFacts::Reading) => {
1224                Span::styled("reading...", Style::default().fg(self.theme.dimmed))
1225            }
1226            Some(FileFacts::Failed(why)) => Span::styled(
1227                clip(why, size_chunks[1].width as usize),
1228                Style::default().fg(self.theme.error),
1229            ),
1230        };
1231        Paragraph::new("File size:").render(size_chunks[0], buf);
1232        Paragraph::new(Line::from(file_size)).render(size_chunks[1], buf);
1233        y += 1;
1234
1235        if y >= area.y + h {
1236            return;
1237        }
1238        let fmt = self
1239            .ctx
1240            .format
1241            .map(|f| f.as_str())
1242            .unwrap_or(crate::glyphs::get().dash);
1243        label_value_row(
1244            "Format:",
1245            fmt,
1246            Rect {
1247                y,
1248                width: w,
1249                height: 1,
1250                ..area
1251            },
1252            buf,
1253            LABEL_WIDTH,
1254        );
1255        y += 1;
1256
1257        // How the open reads it: whether scrolling reads the file or memory.
1258        if let Some(read) = read_line(self.state) {
1259            if y >= area.y + h {
1260                return;
1261            }
1262            label_value_row(
1263                "Read:",
1264                &read,
1265                Rect {
1266                    y,
1267                    width: w,
1268                    height: 1,
1269                    ..area
1270                },
1271                buf,
1272                LABEL_WIDTH,
1273            );
1274            y += 1;
1275        }
1276
1277        if y >= area.y + h {
1278            return;
1279        }
1280        let buf_rows = self.state.buffered_rows();
1281        let max_rows = self.state.max_buffered_rows();
1282        let row_area = Rect {
1283            y,
1284            width: w,
1285            height: 1,
1286            ..area
1287        };
1288        let row_chunks = Layout::default()
1289            .direction(Direction::Horizontal)
1290            .constraints([label_constraint, value_constraint])
1291            .split(row_area);
1292        Paragraph::new("Buffer (Rows):").render(row_chunks[0], buf);
1293        // Values start in the value column, as every other row's do.
1294        if max_rows > 0 {
1295            let label = format!("{} / {}", format_int(buf_rows), format_int(max_rows));
1296            Paragraph::new(label).render(row_chunks[1], buf);
1297        } else {
1298            Paragraph::new(format_int(buf_rows)).render(row_chunks[1], buf);
1299        }
1300        y += 1;
1301
1302        if y >= area.y + h {
1303            return;
1304        }
1305        let buf_mb = self
1306            .state
1307            .buffered_memory_bytes()
1308            .map(|b| b / (1024 * 1024));
1309        let max_mb = self.state.max_buffered_mb();
1310        let mb_area = Rect {
1311            y,
1312            width: w,
1313            height: 1,
1314            ..area
1315        };
1316        let mb_chunks = Layout::default()
1317            .direction(Direction::Horizontal)
1318            .constraints([label_constraint, value_constraint])
1319            .split(mb_area);
1320        Paragraph::new("Buffer (MB):").render(mb_chunks[0], buf);
1321        if max_mb > 0 {
1322            let label = match buf_mb {
1323                Some(m) => format!("{:.1} / {} MiB", m as f64, max_mb),
1324                None => crate::glyphs::get().dash.to_string(),
1325            };
1326            Paragraph::new(label).render(mb_chunks[1], buf);
1327        } else {
1328            let value = buf_mb
1329                .map(|m| format!("{:.1} MiB", m as f64))
1330                .unwrap_or_else(|| {
1331                    self.state
1332                        .buffered_memory_bytes()
1333                        .map(|b| format_bytes(b as u64))
1334                        .unwrap_or_else(|| crate::glyphs::get().dash.to_string())
1335                });
1336            Paragraph::new(value).render(mb_chunks[1], buf);
1337        }
1338        y += 1;
1339
1340        self.render_measurements(area, buf, &mut y, LABEL_WIDTH);
1341    }
1342
1343    /// What the open cost, under its own heading at the foot of the tab.
1344    ///
1345    /// Only what was measured: a row appears for a stretch of work that happened, and a
1346    /// stretch that made no requests of its own shows a time and a count and stops
1347    /// there. A figure datui cannot stand behind is not shown as a zero — see
1348    /// [`crate::measurements`] and `docs/user-guide/dataset-info.md`.
1349    fn render_measurements(&self, area: Rect, buf: &mut Buffer, y: &mut u16, label_w: u16) {
1350        let meter = self.state.measurements();
1351        let mut rows: Vec<(&str, String)> = [
1352            ("Listing:", "files", "file", meter.listing()),
1353            ("Footers:", "footers read", "footer read", meter.footers()),
1354            ("Last page:", "files read", "file read", meter.last_page()),
1355        ]
1356        .into_iter()
1357        .filter_map(|(label, unit, singular, cost)| {
1358            Some((label, measurement_line(&cost?, unit, singular)))
1359        })
1360        .collect();
1361        if let Some(total) = meter.total() {
1362            rows.push(("Total:", total_line(&total)));
1363        }
1364        if rows.is_empty() {
1365            return;
1366        }
1367        let bottom = area.y + area.height;
1368        // The heading and at least one row, or neither: a heading alone says a section
1369        // was cut off where there may have been nothing to cut. The blank line is at
1370        // `y`, the heading at `y + 1` and the first row at `y + 2`, so all three have
1371        // to fit — for every tab layout there is exactly one height at which checking
1372        // any fewer leaves a bare heading.
1373        if *y + 2 >= bottom {
1374            return;
1375        }
1376        *y += 1;
1377        SectionRule {
1378            title: "Measurements",
1379            chip: None,
1380        }
1381        .render(
1382            Rect {
1383                y: *y,
1384                width: area.width,
1385                height: 1,
1386                ..area
1387            },
1388            buf,
1389            self.theme,
1390        );
1391        *y += 1;
1392        for (label, line) in rows {
1393            if *y >= bottom {
1394                return;
1395            }
1396            let chunks = Layout::default()
1397                .direction(Direction::Horizontal)
1398                .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1399                .split(Rect {
1400                    y: *y,
1401                    width: area.width,
1402                    height: 1,
1403                    ..area
1404                });
1405            Paragraph::new(label).render(chunks[0], buf);
1406            Paragraph::new(line).render(chunks[1], buf);
1407            *y += 1;
1408        }
1409    }
1410
1411    /// A delimited spec's metadata line: its title, then each key and value. A line
1412    /// that is not key=value pairs is shown as it is.
1413    fn render_metadata_tab(&mut self, area: Rect, buf: &mut Buffer) {
1414        let Some(read) = self.state.delimited_read().cloned() else {
1415            return;
1416        };
1417        let Some(metadata) = &read.metadata else {
1418            return;
1419        };
1420        if area.height == 0 || area.width < 8 {
1421            return;
1422        }
1423        let mut lines = Vec::new();
1424        if let Some(title) = &metadata.title {
1425            lines.push((title.clone(), Style::default()));
1426        }
1427        if let Some(file) = &read.facts_from {
1428            lines.push((format!("From {file}"), Style::default()));
1429        }
1430        let shown: Vec<(String, crate::model_files::MetaValue)> = if metadata.pairs.is_empty() {
1431            let line = read.delimited().metadata_line.unwrap_or(1);
1432            vec![(
1433                format!("line {line}"),
1434                crate::model_files::MetaValue::Text(metadata.raw.clone()),
1435            )]
1436        } else {
1437            metadata
1438                .pairs
1439                .iter()
1440                .map(|(k, v)| (k.clone(), crate::model_files::MetaValue::Text(v.clone())))
1441                .collect()
1442        };
1443        self.render_detail(area, buf, &lines, "Metadata", &shown);
1444    }
1445
1446    /// A file's lines, its warnings, then its list: signals, tags, metadata, tracks.
1447    fn render_format_tab(&mut self, area: Rect, buf: &mut Buffer) {
1448        let state = self.state;
1449        let Some(detail) = state.format_detail().or_else(|| self.ctx.facts_detail()) else {
1450            // The facts that fill it are still out, or could not be read.
1451            let (said, style) = match self.ctx.facts {
1452                Some(FileFacts::Failed(why)) => {
1453                    (why.clone(), Style::default().fg(self.theme.error))
1454                }
1455                _ => (
1456                    "reading...".to_string(),
1457                    Style::default().fg(self.theme.dimmed),
1458                ),
1459            };
1460            Paragraph::new(clip(&said, area.width as usize))
1461                .style(style)
1462                .render(
1463                    Rect {
1464                        height: area.height.min(1),
1465                        ..area
1466                    },
1467                    buf,
1468                );
1469            return;
1470        };
1471        let warn = Style::default().fg(self.theme.warning);
1472        let lines: Vec<(String, Style)> = detail
1473            .lines
1474            .iter()
1475            .map(|line| (line.clone(), Style::default()))
1476            .chain(detail.warnings.iter().map(|line| (line.clone(), warn)))
1477            .collect();
1478        let pick = !detail.tables.is_empty();
1479        self.render_detail_list(area, buf, &lines, detail.list_title, &detail.list, pick);
1480    }
1481
1482    /// A detail tab's head lines, then a blank line, a rule titled `title` and the list
1483    /// as key and value, scrolled by `detail_scroll`. Each value is drawn whole: it
1484    /// wraps over as many lines as it takes.
1485    fn render_detail(
1486        &mut self,
1487        area: Rect,
1488        buf: &mut Buffer,
1489        lines: &[(String, Style)],
1490        title: &str,
1491        list: &[(String, crate::model_files::MetaValue)],
1492    ) {
1493        self.render_detail_list(area, buf, lines, title, list, false);
1494    }
1495
1496    /// [`Self::render_detail`]; with `pick`, the list has a cursor on one entry
1497    /// (`detail_selected`), drawn with the rail, which the scroll follows.
1498    fn render_detail_list(
1499        &mut self,
1500        area: Rect,
1501        buf: &mut Buffer,
1502        lines: &[(String, Style)],
1503        title: &str,
1504        list: &[(String, crate::model_files::MetaValue)],
1505        pick: bool,
1506    ) {
1507        if area.height == 0 || area.width < 8 {
1508            return;
1509        }
1510        let width = area.width as usize;
1511        let mut y = area.y;
1512        let bottom = area.y + area.height;
1513        // Wrapped rather than cut: a warning cut off mid-sentence says less than nothing.
1514        for (line, style) in lines {
1515            for part in wrap_to(line, width) {
1516                if y >= bottom {
1517                    return;
1518                }
1519                Paragraph::new(clip(&part, width)).style(*style).render(
1520                    Rect {
1521                        y,
1522                        height: 1,
1523                        ..area
1524                    },
1525                    buf,
1526                );
1527                y += 1;
1528            }
1529        }
1530        // A blank line, then the rule, then at least one line of the list, or none: an
1531        // empty list has no rule to count it.
1532        if list.is_empty() || y + 2 >= bottom {
1533            return;
1534        }
1535        y += 1;
1536        let count = group_u64(list.len() as u64);
1537        SectionRule {
1538            title,
1539            chip: Some(&count),
1540        }
1541        .render(
1542            Rect {
1543                y,
1544                height: 1,
1545                ..area
1546            },
1547            buf,
1548            self.theme,
1549        );
1550        y += 1;
1551
1552        // A list with a cursor keeps a column for its rail, so the cursor arriving
1553        // moves nothing.
1554        let gutter = u16::from(pick);
1555        let rows = metadata_lines(list, width.saturating_sub(gutter as usize));
1556        let room = (bottom - y) as usize;
1557        let fits = rows.len() <= room;
1558        // The last row says what is out of view when not everything fits.
1559        let shown = if fits { room } else { room.saturating_sub(1) };
1560        self.modal.detail_visible = shown;
1561        let max_scroll = rows.len().saturating_sub(shown);
1562        // The lines of the entry under the cursor: an entry's first line names it,
1563        // the lines a long value wraps onto leave its key blank.
1564        let picked = pick.then(|| {
1565            let starts: Vec<usize> = (0..rows.len())
1566                .filter(|&i| !rows[i].0.trim().is_empty())
1567                .collect();
1568            let at = self
1569                .modal
1570                .detail_selected
1571                .min(starts.len().saturating_sub(1));
1572            self.modal.detail_selected = at;
1573            let start = starts.get(at).copied().unwrap_or(0);
1574            let end = starts.get(at + 1).copied().unwrap_or(rows.len());
1575            if start < self.modal.detail_scroll {
1576                self.modal.detail_scroll = start;
1577            } else if end > self.modal.detail_scroll + shown {
1578                self.modal.detail_scroll = end.saturating_sub(shown).min(start);
1579            }
1580            start..end
1581        });
1582        self.modal.detail_scroll = self.modal.detail_scroll.min(max_scroll);
1583        let first = self.modal.detail_scroll;
1584        let key_style = Style::default().fg(self.theme.text_secondary);
1585        let rail = crate::glyphs::get().rail;
1586        for (i, (key, value)) in rows.iter().enumerate().skip(first).take(shown) {
1587            let row = Rect {
1588                y,
1589                height: 1,
1590                ..area
1591            };
1592            let on = picked.as_ref().is_some_and(|p| p.contains(&i));
1593            let (key_style, value_style) = if on {
1594                let hl = self.theme.highlight_style();
1595                (key_style.patch(hl), Style::default().patch(hl))
1596            } else {
1597                (key_style, Style::default())
1598            };
1599            if on && picked.as_ref().is_some_and(|p| p.start == i) {
1600                buf.set_string(row.x, row.y, rail, Style::default().fg(self.theme.accent));
1601            }
1602            Paragraph::new(Line::from(vec![
1603                Span::styled(key.clone(), key_style),
1604                Span::styled(value.clone(), value_style),
1605            ]))
1606            .render(
1607                Rect {
1608                    x: row.x + gutter,
1609                    width: row.width.saturating_sub(gutter),
1610                    ..row
1611                },
1612                buf,
1613            );
1614            y += 1;
1615        }
1616        if !fits && shown > 0 {
1617            let above = first;
1618            let below = rows.len().saturating_sub(first + shown);
1619            let text = match (above, below) {
1620                (0, n) => format!("{} below", group_chrome(n)),
1621                (n, 0) => format!("{} above", group_chrome(n)),
1622                (a, b) => format!("{} above, {} below", group_chrome(a), group_chrome(b)),
1623            };
1624            Paragraph::new(text)
1625                .style(Style::default().fg(self.theme.dimmed))
1626                .alignment(ratatui::layout::Alignment::Right)
1627                .render(
1628                    Rect {
1629                        y: bottom - 1,
1630                        height: 1,
1631                        ..area
1632                    },
1633                    buf,
1634                );
1635        }
1636    }
1637
1638    /// What datui noticed: each note's summary and the line saying what it is based on.
1639    ///
1640    /// Whole notes only. A note half on screen is worse than one left off: a claim with
1641    /// no basis under it, and a basis with no claim above it, are both the misreading
1642    /// the basis exists to prevent. Which notes those are is [`notes_window`]'s job,
1643    /// and its post-conditions are what make that true.
1644    ///
1645    /// Deliberately plain: no error styling, nothing that reads as an alarm. These are
1646    /// observations about the data, not faults in it.
1647    fn render_notes_tab(&mut self, area: Rect, buf: &mut Buffer) {
1648        let notes = self.state.notes();
1649        if area.height == 0 || area.width <= 4 || notes.is_empty() {
1650            return;
1651        }
1652        let selected = self.modal.notes_selected_index.min(notes.len() - 1);
1653        let width = area.width as usize;
1654        let blocks: Vec<Vec<NoteRow>> = notes
1655            .iter()
1656            .enumerate()
1657            .map(|(index, note)| note_rows(note, index == selected, width))
1658            .collect();
1659        let heights: Vec<usize> = blocks.iter().map(Vec::len).collect();
1660        let dim = Style::default().fg(self.theme.dimmed);
1661
1662        // Try the whole panel first. Only when that leaves notes out is a row needed
1663        // to count them, and only then do the notes have one row fewer — deciding it
1664        // in advance spent a row that a note which exactly fitted could have used.
1665        let full = area.height as usize;
1666        let (first, last) = notes_window(&heights, selected, self.modal.notes_scroll_offset, full);
1667        let all_shown = first == 0 && last == heights.len();
1668        // A row for the count of what is hidden, but only when something is hidden and
1669        // the note can spare it. A note that exactly fills the panel keeps its last
1670        // row: saying "no room to show one" about a note that fits is worse than not
1671        // saying how many are behind it.
1672        // A row is worth spending on the offer too: a note that says a column is not
1673        // read from some files, with no way to see what is there, is half a note.
1674        let offer = notes[selected]
1675            .read_as_text
1676            .as_ref()
1677            .map(|column| format!("Enter  read {column} as text"));
1678        let reserve = (!all_shown || offer.is_some()) && heights[selected] < full;
1679        let show = if reserve { full - 1 } else { full };
1680        if heights[selected] > show {
1681            // The note the cursor is on cannot show its summary and the line it rests
1682            // on. Drawing the summary alone would be a claim from nowhere, so say what
1683            // is there instead. Says "this one", not "one": a shorter note elsewhere in
1684            // the list may well fit, and the cursor can be moved to it.
1685            let count = notes.len();
1686            Paragraph::new(Line::from(Span::styled(
1687                format!(
1688                    "{} {} {} selected: no room",
1689                    group_chrome(count),
1690                    if count == 1 { "note" } else { "notes" },
1691                    crate::glyphs::get().middot
1692                ),
1693                dim,
1694            )))
1695            .render(Rect { height: 1, ..area }, buf);
1696            return;
1697        }
1698        let (first, last) = if reserve {
1699            notes_window(&heights, selected, self.modal.notes_scroll_offset, show)
1700        } else {
1701            (first, last)
1702        };
1703        self.modal.notes_scroll_offset = first;
1704
1705        let mut y = area.y;
1706        let bottom = area.y + show as u16;
1707        for (offset, block) in blocks[first..last].iter().enumerate() {
1708            if offset > 0 && y < bottom {
1709                y += 1;
1710            }
1711            for row in block {
1712                if y >= bottom {
1713                    break;
1714                }
1715                let at = Rect {
1716                    y,
1717                    height: 1,
1718                    ..area
1719                };
1720                if row.dim {
1721                    Paragraph::new(Line::from(Span::styled(row.text.clone(), dim))).render(at, buf);
1722                } else {
1723                    Paragraph::new(row.text.as_str()).render(at, buf);
1724                }
1725                y += 1;
1726            }
1727        }
1728
1729        // The offer and the count of what is out of view share the last row, so the
1730        // room goes to the count first and the offer takes what is left. The count is
1731        // a handful of characters and the offer is as long as a column name; giving
1732        // the offer its width first would push the count off the edge, and the two
1733        // drawn over each other read as neither.
1734        let (above, below) = (first, notes.len() - last);
1735        let hidden = match (reserve, above, below) {
1736            (false, _, _) | (_, 0, 0) => None,
1737            (_, 0, n) => Some(format!("{} below", group_chrome(n))),
1738            (_, n, 0) => Some(format!("{} above", group_chrome(n))),
1739            (_, a, b) => Some(format!(
1740                "{} above, {} below",
1741                group_chrome(a),
1742                group_chrome(b)
1743            )),
1744        };
1745        if !reserve || (hidden.is_none() && offer.is_none()) {
1746            return;
1747        }
1748        let last_row = Rect {
1749            y: area.y + area.height - 1,
1750            height: 1,
1751            ..area
1752        };
1753        // A space between them, so they never read as one phrase when both are there.
1754        use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1755        let taken = hidden
1756            .as_ref()
1757            .map(|text| (text.width() as u16).saturating_add(1))
1758            .unwrap_or(0);
1759        if let Some(offer) = offer.as_ref() {
1760            let room = last_row.width.saturating_sub(taken) as usize;
1761            // Cut with a mark, never silently. `Enter  read measurement_value` is a
1762            // whole sentence that has lost `as text`, and `Enter  read me` is an offer
1763            // about a column called `me`; both read as something datui did not say.
1764            let offer = if offer.width() > room {
1765                // Cut by width rather than by word: the spacing after the key name is
1766                // part of how the line reads, and wrapping would close it up.
1767                let mark = crate::glyphs::get().ellipsis;
1768                let mut kept = String::new();
1769                for ch in offer.chars() {
1770                    if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
1771                        break;
1772                    }
1773                    kept.push(ch);
1774                }
1775                Some(format!("{kept}{mark}"))
1776            } else {
1777                Some(offer.clone())
1778            };
1779            // Below about a word there is no offer left to make, only the mark.
1780            if let Some(offer) = offer.filter(|_| room >= 8) {
1781                Paragraph::new(Line::from(Span::styled(offer, dim))).render(
1782                    Rect {
1783                        width: room as u16,
1784                        ..last_row
1785                    },
1786                    buf,
1787                );
1788            }
1789        }
1790        if let Some(hidden) = hidden {
1791            Paragraph::new(Line::from(Span::styled(hidden, dim)))
1792                .right_aligned()
1793                .render(last_row, buf);
1794        }
1795    }
1796
1797    fn render_partitioned_data_tab(&self, area: Rect, buf: &mut Buffer) {
1798        let y = area.y;
1799        let w = area.width;
1800
1801        let Some(partition_columns) = self.state.partition_columns() else {
1802            Paragraph::new("Partition columns: unknown").render(
1803                Rect {
1804                    y,
1805                    width: w,
1806                    height: 1,
1807                    ..area
1808                },
1809                buf,
1810            );
1811            return;
1812        };
1813
1814        if partition_columns.is_empty() {
1815            Paragraph::new("Partition columns: none").render(
1816                Rect {
1817                    y,
1818                    width: w,
1819                    height: 1,
1820                    ..area
1821                },
1822                buf,
1823            );
1824            return;
1825        }
1826
1827        let line = format!("Partition columns: {}", partition_columns.join(", "));
1828        Paragraph::new(line).render(
1829            Rect {
1830                y,
1831                width: w,
1832                height: 1,
1833                ..area
1834            },
1835            buf,
1836        );
1837    }
1838}
1839
1840/// How long a stretch took, in a unit that does not round it away.
1841///
1842/// Milliseconds under a second, to two places. Most of these figures are under a
1843/// second — a local directory of a few files is walked in a fraction of a millisecond —
1844/// and in seconds to two places every one of them prints `0.00s`, which reads as "not
1845/// measured" rather than "quick".
1846///
1847/// Two places rather than one because a one-file directory's listing really is tens of
1848/// microseconds. There is still a floor: under five microseconds this prints
1849/// `0.00 ms`. Nothing datui can do makes a five-microsecond walk legible, and a figure
1850/// that small is honestly reported as none.
1851fn format_took(took: std::time::Duration) -> String {
1852    let ms = took.as_secs_f64() * 1000.0;
1853    // Rounded to the two places that are printed, then chosen. Rounding to whole
1854    // milliseconds instead moves the switch to 999.5 ms, which is a wide band of
1855    // figures the doc promises in milliseconds and would hand back in seconds; and not
1856    // rounding at all prints `1000.00 ms` for 999.997, which reads as larger than the
1857    // `1.00s` a tick later.
1858    if (ms * 100.0).round() < 100_000.0 {
1859        format!("{ms:.2} ms")
1860    } else {
1861        format!("{:.2}s", took.as_secs_f64())
1862    }
1863}
1864
1865/// What datui asked for over a network, where it did the asking.
1866fn wire_line(wire: crate::measurements::OverTheWire) -> String {
1867    let mut line = format!(
1868        ", {} request{}",
1869        format_int(wire.requests),
1870        if wire.requests == 1 { "" } else { "s" }
1871    );
1872    // A byte figure only where datui counted the bytes. Everything that reports
1873    // requests today also weighs them; this is what stops a stretch that one day does
1874    // not from printing `0 B`, which would say its requests came back empty.
1875    if let Some(bytes) = wire.bytes {
1876        line.push_str(&format!(", {}", format_bytes(bytes)));
1877    }
1878    line
1879}
1880
1881/// One measurement as a line: how long, over how many of whatever it counted, and —
1882/// where datui made the requests itself — how many and how much came back.
1883///
1884/// `unit` is not always "files". The listing counts the dataset's files; a footer pass
1885/// counts footers read, and those are not the same number — a dataset that opens before
1886/// its footers are read has them read again behind the open, and one that cannot settle
1887/// its row count reads them all again to count. Calling both "files" would put a figure
1888/// larger than the dataset under the word the listing uses for the dataset's size.
1889fn measurement_line(cost: &crate::measurements::Cost, unit: &str, singular: &str) -> String {
1890    let mut line = format_took(cost.took);
1891    // A stretch that never learned a count says a time and stops, rather than putting
1892    // a number that is not the size of the dataset under the word the other rows use
1893    // for exactly that.
1894    if let Some(files) = cost.files {
1895        let unit = if files == 1 { singular } else { unit };
1896        line.push_str(&format!(", {} {unit}", format_int(files)));
1897    }
1898    if let Some(wire) = cost.over_the_wire {
1899        line.push_str(&wire_line(wire));
1900    }
1901    line
1902}
1903
1904/// The total as a line: a time, and the requests behind it.
1905///
1906/// No file count, deliberately — see [`crate::measurements::Meter::total`].
1907fn total_line(total: &crate::measurements::Total) -> String {
1908    let mut line = format_took(total.took);
1909    if let Some(wire) = total.over_the_wire {
1910        line.push_str(&wire_line(wire));
1911    }
1912    line
1913}
1914
1915/// Comma-group a count for the info panel. Thin alias over the shared chrome
1916/// formatter, kept so call sites and tests read the same as before.
1917fn format_int(n: usize) -> String {
1918    crate::numfmt::group_chrome(n)
1919}
1920
1921fn columns_by_type(schema: &Schema) -> String {
1922    let mut counts: HashMap<String, usize> = HashMap::new();
1923    for (_, dtype) in schema.iter() {
1924        let k = dtype.to_string();
1925        *counts.entry(k).or_default() += 1;
1926    }
1927    let mut pairs: Vec<_> = counts.into_iter().collect();
1928    pairs.sort_by(|a, b| a.0.cmp(&b.0));
1929    pairs
1930        .into_iter()
1931        .map(|(k, v)| format!("{}: {}", k, v))
1932        .collect::<Vec<_>>()
1933        .join(&format!(" {} ", crate::glyphs::get().middot))
1934}
1935
1936impl<'a> Widget for &mut DataTableInfo<'a> {
1937    fn render(self, area: Rect, buf: &mut Buffer) {
1938        let ctx = self.theme;
1939        let offered = TabsOffered {
1940            documentation: self.documentation.is_some(),
1941            ..TabsOffered::of(self.state, self.ctx.facts_tab)
1942        };
1943        let tab = self.modal.active_tab;
1944
1945        // The panel's own keys, said where they work and only while they work:
1946        // nothing here may live only in `?`.
1947        let g = crate::glyphs::get();
1948        let scrolls = match tab {
1949            InfoTab::Schema => true,
1950            InfoTab::Notes => offered.notes,
1951            InfoTab::Metadata => offered.metadata,
1952            InfoTab::Format => offered.format,
1953            InfoTab::Documentation => offered.documentation,
1954            _ => false,
1955        };
1956        // A tab whose list is the file's tables has a cursor, and Enter opens one.
1957        let tables = tab == InfoTab::Format
1958            && offered.format
1959            && self
1960                .state
1961                .format_detail()
1962                .is_some_and(|d| !d.tables.is_empty());
1963        let mut footer = HintBar::from_ctx(ctx).hint_weighted(g.updown_lr, "Tabs", 3);
1964        if tables {
1965            footer = footer
1966                .hint_weighted("Enter", "Open", 2)
1967                .hint_weighted(g.updown, "Move", 2);
1968        } else if scrolls {
1969            footer = footer.hint_weighted(g.updown, "Scroll", 2);
1970        }
1971        if tab == InfoTab::Documentation && offered.documentation {
1972            footer = footer.hint_weighted("Enter", "Values", 1);
1973            if self
1974                .documentation
1975                .as_deref()
1976                .is_some_and(|d| d.offers_open())
1977            {
1978                footer = footer.hint_weighted("o", "Open", 1);
1979            }
1980            footer = footer.hint_weighted("y", "Copy", 1);
1981        }
1982        if tab == InfoTab::Schema && self.header_toggle {
1983            footer = footer.hint_weighted("H", "Header", -1);
1984        }
1985        if self.hex {
1986            footer = footer.hint_weighted("x", "Hex", 0);
1987        }
1988        let footer = footer.hint_weighted("Esc", "Close", 4);
1989        // A frame of three rows has one inside it: the body's, so a panel too
1990        // short for a note still says so rather than showing only keys.
1991        let surface = Surface::new("Info");
1992        let surface = if area.height > 3 {
1993            surface.footer(&footer)
1994        } else {
1995            surface
1996        };
1997        let content = surface.render(area, buf, ctx);
1998        if content.height == 0 || content.width < 4 {
1999            return;
2000        }
2001        // Short of height, the blank row under the tabs goes first, then the tab
2002        // line: the body is what the panel is for.
2003        let tab_rows = u16::from(content.height >= 4);
2004        let gap = u16::from(content.height >= 6);
2005
2006        // Tab line: the active tab carries the accent. The tab bar never takes
2007        // focus (the tabs switch from anywhere), so it carries no rail; the slot
2008        // stays, keeping the names where they were.
2009        let tabs = InfoTab::visible(offered);
2010        let active = tabs[tab.index(offered)];
2011        let current = tab.index(offered);
2012        let mut spans = Vec::new();
2013        let mut clicks = Vec::new();
2014        for (i, t) in tabs.iter().enumerate() {
2015            let is_active = *t == active;
2016            if i > 0 {
2017                spans.push(Span::styled(
2018                    format!(" {}", g.rule),
2019                    Style::default().fg(ctx.dimmed),
2020                ));
2021            }
2022            spans.push(Span::raw(" "));
2023            let style = if is_active {
2024                Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
2025            } else {
2026                Style::default().fg(ctx.text_secondary)
2027            };
2028            // The Format tab is named by the file's detail (VCD, Model, Audio), or by
2029            // the format's descriptor while the facts that fill it are read.
2030            let title = match (t, self.state.format_detail()) {
2031                (InfoTab::Format, Some(detail)) => detail.tab,
2032                (InfoTab::Format, None) => self.ctx.facts_tab.unwrap_or(t.title()),
2033                _ => t.title(),
2034            };
2035            // A click steps the tabs there, as ← / → do from anywhere here.
2036            clicks.push((
2037                spans.len(),
2038                crate::pointer::Hit::Option {
2039                    field: None,
2040                    index: i,
2041                    current,
2042                },
2043            ));
2044            spans.push(Span::styled(title, style));
2045        }
2046        let tab_area = Rect {
2047            height: tab_rows,
2048            ..content
2049        };
2050        let line = Line::from(spans);
2051        if tab_rows > 0 {
2052            crate::pointer::record_spans(tab_area, &line, clicks);
2053        }
2054        Paragraph::new(line).render(tab_area, buf);
2055
2056        // A blank row under the tabs rather than a rule: the tab line is state,
2057        // not a section.
2058        let body = Rect {
2059            y: content.y + tab_rows + gap,
2060            height: content.height - tab_rows - gap,
2061            ..content
2062        };
2063        match tab {
2064            InfoTab::Schema => self.render_schema_tab(body, buf),
2065            InfoTab::Resources => self.render_resources_tab(body, buf),
2066            InfoTab::Metadata if offered.metadata => self.render_metadata_tab(body, buf),
2067            InfoTab::Format if offered.format => self.render_format_tab(body, buf),
2068            InfoTab::Partitions if offered.partitions => {
2069                self.render_partitioned_data_tab(body, buf)
2070            }
2071            InfoTab::Notes if offered.notes => self.render_notes_tab(body, buf),
2072            InfoTab::Documentation if offered.documentation => {
2073                if let Some(page) = self.documentation.as_deref_mut() {
2074                    crate::widgets::documentation::render_page(page, body, buf, ctx);
2075                }
2076            }
2077            InfoTab::Metadata
2078            | InfoTab::Format
2079            | InfoTab::Partitions
2080            | InfoTab::Notes
2081            | InfoTab::Documentation => self.render_schema_tab(body, buf),
2082        }
2083    }
2084}
2085
2086#[cfg(test)]
2087mod tests {
2088    use super::*;
2089
2090    /// The optional tabs: Partitions and Notes as asked, no others.
2091    fn offer(partitions: bool, notes: bool) -> TabsOffered {
2092        TabsOffered {
2093            metadata: false,
2094            format: false,
2095            partitions,
2096            notes,
2097            documentation: false,
2098        }
2099    }
2100
2101    /// What a file says about itself: a size and, for a format with a facts read, its
2102    /// footer and tab; a directory has no size of its own to give; a file that is gone,
2103    /// or whose footer is not one, is a reason rather than a blank.
2104    #[test]
2105    fn file_facts_read_what_each_source_has() {
2106        let dir = tempfile::tempdir().unwrap();
2107        let csv = dir.path().join("rows.csv");
2108        std::fs::write(&csv, "a\n1\n").unwrap();
2109        let parquet = dir.path().join("rows.parquet");
2110        let mut df = df!("a" => &[1i64, 2, 3]).unwrap();
2111        ParquetWriter::new(std::fs::File::create(&parquet).unwrap())
2112            .finish(&mut df)
2113            .unwrap();
2114        let parquet_len = std::fs::metadata(&parquet).unwrap().len();
2115        let facts = crate::readers::of(crate::FileFormat::Parquet).facts;
2116
2117        assert!(matches!(
2118            FileFacts::read(&csv, None),
2119            Ok(FileFacts::Read {
2120                size: Some(4),
2121                footer: None,
2122                detail: None,
2123            })
2124        ));
2125        match FileFacts::read(&parquet, facts) {
2126            Ok(FileFacts::Read {
2127                size: Some(size),
2128                footer: Some(footer),
2129                detail: Some(detail),
2130            }) => {
2131                assert_eq!(size, parquet_len);
2132                assert_eq!(footer.num_rows, 3);
2133                assert_eq!(detail.tab, "Parquet");
2134            }
2135            other => panic!("a Parquet file's size, footer and tab: {other:?}"),
2136        }
2137        assert!(matches!(
2138            FileFacts::read(dir.path(), facts),
2139            Ok(FileFacts::Read {
2140                size: None,
2141                footer: None,
2142                detail: None,
2143            })
2144        ));
2145        // Reasons short enough for the panel's one line.
2146        assert_eq!(
2147            FileFacts::read(&dir.path().join("gone.parquet"), facts).unwrap_err(),
2148            "file not found"
2149        );
2150        assert_eq!(
2151            FileFacts::read(&csv, facts).expect_err("a CSV has no footer"),
2152            "unreadable footer"
2153        );
2154    }
2155
2156    /// A dataset that has not been counted says so rather than showing how far it got.
2157    ///
2158    /// Through a rendered panel, not the helper: the helper cannot tell whether its
2159    /// caller passed `num_rows_if_valid()` or the raw field, and the raw field is what
2160    /// the bug was.
2161    #[test]
2162    fn the_schema_tab_does_not_call_a_partial_the_total() {
2163        use crate::widgets::datatable::DataTableState;
2164        use polars::prelude::*;
2165
2166        let rows = || df!("id" => (0..70i64).collect::<Vec<_>>()).unwrap().lazy();
2167        let mut lf = rows();
2168        let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2169        let mut state = DataTableState::from_schema_and_lazyframe(
2170            schema,
2171            rows(),
2172            &crate::OpenOptions::default(),
2173            None,
2174        )
2175        .unwrap();
2176        // As a staged open leaves it: a provisional from however far the buffer reached,
2177        // with no count taken.
2178        state.set_provisional_rows(70);
2179
2180        let theme = RenderContext::for_test();
2181        let painted = |state: &DataTableState| {
2182            let area = Rect::new(0, 0, 60, 12);
2183            let mut buf = Buffer::empty(area);
2184            let mut modal = InfoModal::default();
2185            let panel = DataTableInfo::new(
2186                state,
2187                InfoContext {
2188                    format: None,
2189                    facts: None,
2190                    facts_tab: None,
2191                    footer_expected: false,
2192                    declared_types: false,
2193                },
2194                &mut modal,
2195                &theme,
2196            );
2197            panel.render_schema_summary(area, &mut buf);
2198            (0..area.height)
2199                .map(|y| {
2200                    (0..area.width)
2201                        .map(|x| buf[(x, y)].symbol().to_string())
2202                        .collect::<String>()
2203                })
2204                .collect::<Vec<_>>()
2205                .join("\n")
2206        };
2207
2208        let uncounted = painted(&state);
2209        assert!(
2210            uncounted.contains("counting..."),
2211            "a count not taken is not a total: {uncounted}"
2212        );
2213        assert!(
2214            !uncounted.contains("70"),
2215            "and the buffer's height is not shown in its place: {uncounted}"
2216        );
2217
2218        assert!(state.count_landed(state.len_generation(), 70, None));
2219        let counted = painted(&state);
2220        assert!(
2221            counted.contains("Rows (total): 70"),
2222            "and once it has been counted, that is what it says: {counted}"
2223        );
2224    }
2225
2226    /// A schema taller than the panel says how many columns are out of view
2227    /// before any scrolling, the selection carries the shared rail, and the
2228    /// panel names its keys in a footer.
2229    #[test]
2230    fn a_tall_schema_counts_its_hidden_columns() {
2231        use crate::widgets::datatable::DataTableState;
2232        use polars::prelude::*;
2233
2234        let wide = || {
2235            let base = df!("col_0" => &[1i64]).unwrap().lazy();
2236            let extra: Vec<Expr> = (1..24)
2237                .map(|i| lit(1i64).alias(format!("col_{i}")))
2238                .collect();
2239            base.with_columns(extra)
2240        };
2241        let mut lf = wide();
2242        let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2243        let state = DataTableState::from_schema_and_lazyframe(
2244            schema,
2245            wide(),
2246            &crate::OpenOptions::default(),
2247            None,
2248        )
2249        .unwrap();
2250
2251        let theme = RenderContext::for_test();
2252        let area = Rect::new(0, 0, 60, 16);
2253        let mut buf = Buffer::empty(area);
2254        let mut modal = InfoModal::default();
2255        let mut panel = DataTableInfo::new(
2256            &state,
2257            InfoContext {
2258                format: None,
2259                facts: None,
2260                facts_tab: None,
2261                footer_expected: false,
2262                declared_types: false,
2263            },
2264            &mut modal,
2265            &theme,
2266        );
2267        (&mut panel).render(area, &mut buf);
2268        let text = (0..area.height)
2269            .map(|y| {
2270                (0..area.width)
2271                    .map(|x| buf[(x, y)].symbol().to_string())
2272                    .collect::<String>()
2273            })
2274            .collect::<Vec<_>>()
2275            .join("\n");
2276        assert!(
2277            text.contains("below"),
2278            "the hidden columns are counted: {text}"
2279        );
2280        assert!(text.contains("Esc"), "the footer names the way out: {text}");
2281        assert!(text.contains("Tabs"), "and the tab keys: {text}");
2282        assert!(!text.contains(">>"), "the bespoke marker is gone: {text}");
2283    }
2284
2285    /// The Schema tab's footer offers `H` for delimited text alone, and no other
2286    /// tab does.
2287    #[test]
2288    fn the_schema_footer_offers_h_only_where_it_works() {
2289        use crate::widgets::datatable::DataTableState;
2290        use polars::prelude::*;
2291
2292        let lf = || df!("a" => &[1i64], "b" => &[2i64]).unwrap().lazy();
2293        let schema = std::sync::Arc::new((*lf().collect_schema().unwrap()).clone());
2294        let state = DataTableState::from_schema_and_lazyframe(
2295            schema,
2296            lf(),
2297            &crate::OpenOptions::default(),
2298            None,
2299        )
2300        .unwrap();
2301        let theme = RenderContext::for_test();
2302        let area = Rect::new(0, 0, 80, 16);
2303        let footer = |header_toggle: bool, tab: InfoTab| {
2304            let mut buf = Buffer::empty(area);
2305            let mut modal = InfoModal {
2306                active_tab: tab,
2307                ..Default::default()
2308            };
2309            let mut panel = DataTableInfo::new(
2310                &state,
2311                InfoContext {
2312                    format: None,
2313                    facts: None,
2314                    facts_tab: None,
2315                    footer_expected: false,
2316                    declared_types: false,
2317                },
2318                &mut modal,
2319                &theme,
2320            );
2321            panel.header_toggle = header_toggle;
2322            (&mut panel).render(area, &mut buf);
2323            (0..area.height)
2324                .map(|y| {
2325                    (0..area.width)
2326                        .map(|x| buf[(x, y)].symbol().to_string())
2327                        .collect::<String>()
2328                })
2329                .collect::<Vec<_>>()
2330                .join("\n")
2331        };
2332        let shown = footer(true, InfoTab::Schema);
2333        assert!(shown.contains("Header"), "{shown}");
2334        assert!(!footer(false, InfoTab::Schema).contains("Header"));
2335        assert!(!footer(true, InfoTab::Resources).contains("Header"));
2336    }
2337
2338    /// Times read in the unit the docs promise, on both sides of the switch.
2339    ///
2340    /// The band just under a second is the whole point. Judging it in whole
2341    /// milliseconds moves the switch to 999.5 ms, so half a millisecond's worth of
2342    /// figures the page promises in milliseconds come back in seconds; not rounding at
2343    /// all prints `1000.00 ms`, which beside the `1.00s` a tick later says the slower
2344    /// open was the faster one. Neither shows up in a test that only uses round
2345    /// numbers, which is why these are not round.
2346    #[test]
2347    fn a_time_reads_in_the_unit_the_page_promises() {
2348        use std::time::Duration;
2349
2350        let cases = [
2351            (Duration::ZERO, "0.00 ms"),
2352            (Duration::from_nanos(1_000), "0.00 ms"),
2353            (Duration::from_nanos(5_000), "0.01 ms"),
2354            (Duration::from_micros(344), "0.34 ms"),
2355            (Duration::from_micros(999_500), "999.50 ms"),
2356            (Duration::from_nanos(999_994_999), "999.99 ms"),
2357            (Duration::from_nanos(999_995_000), "1.00s"),
2358            (Duration::from_secs(1), "1.00s"),
2359            (Duration::from_millis(3_880), "3.88s"),
2360        ];
2361        for (took, expected) in cases {
2362            assert_eq!(
2363                format_took(took),
2364                expected,
2365                "{took:?} should read as {expected}"
2366            );
2367        }
2368    }
2369
2370    /// The Resources tab says how the open reads the data, and nothing for a frame no
2371    /// open found.
2372    #[test]
2373    fn the_resources_tab_says_how_the_data_is_read() {
2374        use crate::widgets::datatable::{DataTableState, OpenFacts};
2375        let painted = |read_mode: Option<crate::ReadMode>| {
2376            let rows = || df!("id" => [1i64, 2]).unwrap().lazy();
2377            let schema = Arc::new((*rows().collect_schema().unwrap()).clone());
2378            let state = DataTableState::from_schema_and_lazyframe(
2379                schema,
2380                rows(),
2381                &crate::OpenOptions::default(),
2382                None,
2383            )
2384            .unwrap()
2385            .with_open(OpenFacts {
2386                read_mode,
2387                ..Default::default()
2388            });
2389            let theme = RenderContext::for_test();
2390            let area = Rect::new(0, 0, 60, 12);
2391            let mut buf = Buffer::empty(area);
2392            let mut modal = InfoModal::default();
2393            DataTableInfo::new(
2394                &state,
2395                InfoContext {
2396                    format: None,
2397                    facts: None,
2398                    facts_tab: None,
2399                    footer_expected: false,
2400                    declared_types: false,
2401                },
2402                &mut modal,
2403                &theme,
2404            )
2405            .render_resources_tab(area, &mut buf);
2406            (0..area.height)
2407                .map(|y| {
2408                    (0..area.width)
2409                        .map(|x| buf[(x, y)].symbol())
2410                        .collect::<String>()
2411                })
2412                .collect::<Vec<_>>()
2413        };
2414        let lines = painted(Some(crate::ReadMode::InMemory));
2415        assert!(
2416            lines
2417                .iter()
2418                .any(|l| l.trim_end() == format!("{:<17}in memory", "Read:")),
2419            "{lines:#?}"
2420        );
2421        let converted = painted(Some(crate::ReadMode::Converted));
2422        assert!(converted.iter().any(|l| l.contains("converted to Arrow")));
2423        assert!(!painted(None).iter().any(|l| l.starts_with("Read:")));
2424    }
2425
2426    /// The Resources tab shows what the open cost, and shows only what was measured.
2427    ///
2428    /// Through the rendered tab rather than [`measurement_line`], because the bug worth
2429    /// guarding is a row reaching the panel for a stretch of work that never ran — a
2430    /// dataset opened before any of this existed would otherwise read as one whose
2431    /// listing took no time at all.
2432    #[test]
2433    fn the_resources_tab_shows_what_was_measured_and_nothing_else() {
2434        use crate::measurements::Meter;
2435        use crate::widgets::datatable::DataTableState;
2436        use polars::prelude::*;
2437        use std::time::Duration;
2438
2439        // The meter rides on the dataset, so each case paints a dataset carrying the
2440        // meter under test rather than handing one to the panel beside it.
2441        let dataset_with = |meter: &std::sync::Arc<Meter>| {
2442            let rows = || df!("id" => (0..3i64).collect::<Vec<_>>()).unwrap().lazy();
2443            let mut lf = rows();
2444            let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2445            DataTableState::from_schema_and_lazyframe(
2446                schema,
2447                rows(),
2448                &crate::OpenOptions::default(),
2449                None,
2450            )
2451            .unwrap()
2452            .with_open(crate::widgets::datatable::OpenFacts {
2453                measurements: meter.clone(),
2454                ..Default::default()
2455            })
2456        };
2457
2458        let theme = RenderContext::for_test();
2459        let painted = |meter: &std::sync::Arc<Meter>, height: u16| {
2460            let state = dataset_with(meter);
2461            let area = Rect::new(0, 0, 70, height);
2462            let mut buf = Buffer::empty(area);
2463            let mut modal = InfoModal::default();
2464            let panel = DataTableInfo::new(
2465                &state,
2466                InfoContext {
2467                    format: None,
2468                    facts: None,
2469                    facts_tab: None,
2470                    footer_expected: false,
2471                    declared_types: false,
2472                },
2473                &mut modal,
2474                &theme,
2475            );
2476            panel.render_resources_tab(area, &mut buf);
2477            (0..area.height)
2478                .map(|y| {
2479                    (0..area.width)
2480                        .map(|x| buf[(x, y)].symbol().to_string())
2481                        .collect::<String>()
2482                })
2483                .collect::<Vec<_>>()
2484                .join("\n")
2485        };
2486
2487        // Nothing measured: no heading, and above all no row of zeroes standing in for
2488        // a measurement that was never taken.
2489        let unmeasured = painted(&std::sync::Arc::new(Meter::default()), 24);
2490        assert!(
2491            !unmeasured.contains("Measurements"),
2492            "a meter holding nothing has nothing to show: {unmeasured}"
2493        );
2494
2495        // A local open: two stretches, neither of which made a request.
2496        let local = std::sync::Arc::new(Meter::default());
2497        local.listed(Duration::from_micros(344), Some(6541), false);
2498        local.read_footers(Duration::from_millis(3880), Some(6541), false);
2499        let shown = painted(&local, 24);
2500        assert!(
2501            shown.contains("Measurements"),
2502            "once there is something to say, the section appears: {shown}"
2503        );
2504        assert!(
2505            shown.contains("0.34 ms, 6,541 files"),
2506            "a listing that really took a third of a millisecond says so, rather than \
2507             rounding to a figure that reads as unmeasured: {shown}"
2508        );
2509        assert!(
2510            shown.contains("3.88s, 6,541 footers read"),
2511            "and a stretch over a second is in seconds, counting footers rather than files: {shown}"
2512        );
2513        assert!(
2514            shown.contains("Total:") && shown.contains("3.88s"),
2515            "the total is a time: {shown}"
2516        );
2517        // Every value starts in the one value column, the buffer's too.
2518        for label in [
2519            "File size:",
2520            "Buffer (Rows):",
2521            "Buffer (MB):",
2522            "Listing:",
2523            "Total:",
2524        ] {
2525            let line = shown
2526                .lines()
2527                .find(|l| l.starts_with(label))
2528                .unwrap_or_else(|| panic!("{label} in {shown}"));
2529            let cells: Vec<char> = line.chars().collect();
2530            assert!(
2531                cells[label.len()..17].iter().all(|c| *c == ' ') && cells[17] != ' ',
2532                "{label} value at column 17: {line:?}"
2533            );
2534        }
2535        assert!(
2536            shown
2537                .lines()
2538                .any(|l| l.starts_with("Measurements ") && l.contains(crate::glyphs::get().rule_h)),
2539            "the heading is a section rule: {shown}"
2540        );
2541        assert!(
2542            !shown.contains("13,082"),
2543            "and not the two file counts added together, which is not the size of \
2544             anything: {shown}"
2545        );
2546        assert!(
2547            !shown.contains("requests"),
2548            "a local open made none, and says nothing rather than saying zero: {shown}"
2549        );
2550
2551        // One of a thing is one of a thing. A one-file directory and a single remote
2552        // object both reach this, and "1 files read" is what the counts are for.
2553        let just_one = std::sync::Arc::new(Meter::default());
2554        just_one.listed(Duration::from_millis(1), Some(1), false);
2555        just_one.footer_request(512);
2556        just_one.read_footers(Duration::from_millis(2), Some(1), true);
2557        let singular = painted(&just_one, 24);
2558        assert!(
2559            singular.contains("1 file,") || singular.contains("1 file "),
2560            "one file, not one files: {singular}"
2561        );
2562        assert!(
2563            singular.contains("1 footer read,"),
2564            "and one footer read, not one footers read: {singular}"
2565        );
2566        assert!(
2567            !singular.contains("1 files") && !singular.contains("1 footers"),
2568            "neither plural appears anywhere: {singular}"
2569        );
2570
2571        // A glob: a listing with no file count and nothing over the wire, beside
2572        // footers that have both. The row must show a bare time — a `0 files` or a
2573        // `0 requests` here would each say datui looked and found none.
2574        let globbed = std::sync::Arc::new(Meter::default());
2575        globbed.listed(Duration::from_millis(1), None, false);
2576        // Two footers, two requests each: this route must ask an object's size before
2577        // it can ask for its tail.
2578        for _ in 0..4 {
2579            globbed.footer_request(250);
2580        }
2581        globbed.read_footers(Duration::from_millis(3), Some(2), true);
2582        let glob_shown = painted(&globbed, 24);
2583        let row = |label: &str| -> String {
2584            glob_shown
2585                .lines()
2586                .find(|l| l.trim_start().starts_with(label))
2587                .unwrap_or_else(|| panic!("{label} row is shown: {glob_shown}"))
2588                .to_string()
2589        };
2590        let listing_row = row("Listing:");
2591        assert_eq!(
2592            listing_row.trim_end(),
2593            "Listing:         1.00 ms",
2594            "the walk reports a time and nothing else: no file count it never learned, \
2595             and no request count no listing route can take"
2596        );
2597        let total_row = row("Total:");
2598        assert!(
2599            total_row.contains("4.00 ms") && total_row.contains("4 requests"),
2600            "and the total is both times with the footer reads' requests: {total_row:?}"
2601        );
2602
2603        // A remote open: the footer pass counted its own requests and bytes.
2604        let remote = std::sync::Arc::new(Meter::default());
2605        remote.listed(Duration::from_millis(500), Some(3), false);
2606        remote.footer_request(49_152);
2607        remote.read_footers(Duration::from_millis(1500), Some(3), true);
2608        let over_wire = painted(&remote, 24);
2609        assert!(
2610            over_wire.contains("1.50s, 3 footers read, 1 request, 48.0 KiB"),
2611            "the footer row says what datui asked for and what came back: {over_wire}"
2612        );
2613        assert!(
2614            over_wire.contains("500.00 ms, 3 files") && !over_wire.contains("500.00 ms, 3 files, "),
2615            "while the listing, whose pages the store turns over itself, claims no \
2616             requests of its own: {over_wire}"
2617        );
2618
2619        // Every height, down to one that fits nothing. A heading with no row under it
2620        // is the failure this checks for: it says a section was cut off where there may
2621        // have been nothing to cut, and there is exactly one height per tab layout at
2622        // which a guard that is short by one produces it.
2623        for height in 1..=24u16 {
2624            let short = painted(&local, height);
2625            if short.contains("Measurements") {
2626                assert!(
2627                    short.contains("Listing:"),
2628                    "at height {height} the heading is shown with no row under it: {short}"
2629                );
2630            }
2631        }
2632    }
2633
2634    /// A file of several tables names the others under the schema's size; a file of
2635    /// one adds no line.
2636    #[test]
2637    fn the_schema_tab_names_a_file_s_other_tables() {
2638        use crate::widgets::datatable::{DataTableState, OpenFacts};
2639        use polars::prelude::*;
2640
2641        let theme = RenderContext::for_test();
2642        let paint = |other_tables: Vec<String>| {
2643            let mut lf = df!("id" => &[1i64, 2]).unwrap().lazy();
2644            let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2645            let state = DataTableState::from_schema_and_lazyframe(
2646                schema,
2647                lf,
2648                &crate::OpenOptions::default(),
2649                None,
2650            )
2651            .unwrap()
2652            .with_open(OpenFacts {
2653                other_tables,
2654                ..Default::default()
2655            });
2656            let area = Rect::new(0, 0, 70, 12);
2657            let mut buf = Buffer::empty(area);
2658            let mut modal = InfoModal::default();
2659            modal.open();
2660            let mut panel = DataTableInfo::new(
2661                &state,
2662                InfoContext {
2663                    format: None,
2664                    facts: None,
2665                    facts_tab: None,
2666                    footer_expected: false,
2667                    declared_types: false,
2668                },
2669                &mut modal,
2670                &theme,
2671            );
2672            (&mut panel).render(area, &mut buf);
2673            (0..area.height)
2674                .map(|y| {
2675                    (0..area.width)
2676                        .map(|x| buf[(x, y)].symbol().to_string())
2677                        .collect::<String>()
2678                })
2679                .collect::<Vec<_>>()
2680        };
2681        let middot = crate::glyphs::get().middot;
2682        let text = paint(vec!["GSV 9".into(), "sentences".into()]);
2683        let line = format!("Other tables (--table): GSV 9 {middot} sentences");
2684        assert!(text.iter().any(|row| row.contains(&line)), "{text:#?}");
2685        let text = paint(Vec::new());
2686        assert!(
2687            !text.iter().any(|row| row.contains("Other tables")),
2688            "{text:#?}"
2689        );
2690    }
2691
2692    /// The format tab is named for the format, shows its lines, then its list under a
2693    /// rule with a count, and says how much of the list is out of view.
2694    #[test]
2695    fn the_format_tab_shows_its_lines_and_list() {
2696        use crate::model_files::MetaValue;
2697        use crate::text_formats::Detail;
2698        use crate::widgets::datatable::{DataTableState, OpenFacts};
2699        use polars::prelude::*;
2700
2701        let theme = RenderContext::for_test();
2702        let mut lf = df!("time" => &[1i64]).unwrap().lazy();
2703        let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2704        let list: Vec<(String, MetaValue)> = (0..30)
2705            .map(|i| {
2706                (
2707                    format!("tb.sig{i}"),
2708                    MetaValue::Text(format!("wire 1 bit id {i}")),
2709                )
2710            })
2711            .collect();
2712        let state = DataTableState::from_schema_and_lazyframe(
2713            schema,
2714            lf,
2715            &crate::OpenOptions::default(),
2716            None,
2717        )
2718        .unwrap()
2719        .with_open(OpenFacts {
2720            detail: Some(std::sync::Arc::new(Detail {
2721                tab: "VCD",
2722                lines: vec!["VCD timescale 1ns".into(), "Version: Icarus".into()],
2723                list_title: "Signals",
2724                list,
2725                first: true,
2726                ..Default::default()
2727            })),
2728            ..Default::default()
2729        });
2730        let area = Rect::new(0, 0, 60, 20);
2731        let mut buf = Buffer::empty(area);
2732        let mut modal = InfoModal::default();
2733        modal.open_on(InfoTab::Format);
2734        let mut panel = DataTableInfo::new(
2735            &state,
2736            InfoContext {
2737                format: None,
2738                facts: None,
2739                facts_tab: None,
2740                footer_expected: false,
2741                declared_types: false,
2742            },
2743            &mut modal,
2744            &theme,
2745        );
2746        (&mut panel).render(area, &mut buf);
2747        let text: Vec<String> = (0..area.height)
2748            .map(|y| {
2749                (0..area.width)
2750                    .map(|x| buf[(x, y)].symbol().to_string())
2751                    .collect()
2752            })
2753            .collect();
2754        let has = |needle: &str| text.iter().any(|row| row.contains(needle));
2755        assert!(has("VCD") && !has("Format"), "{text:#?}");
2756        assert!(has("Version: Icarus"), "{text:#?}");
2757        assert!(has("Signals") && has("30"), "{text:#?}");
2758        assert!(has("tb.sig0") && has("wire 1 bit id 0"), "{text:#?}");
2759        assert!(has("below"), "the rest is counted: {text:#?}");
2760    }
2761
2762    /// The body has the keys: the schema's rule is bright and the row carries the
2763    /// rail, and the tab line, which never takes focus, has none. One frame, the
2764    /// footer inside it.
2765    #[test]
2766    fn the_body_has_the_accent_and_the_tab_bar_none() {
2767        use crate::widgets::datatable::DataTableState;
2768        use polars::prelude::*;
2769
2770        let rows = || {
2771            df!("id" => &[1i64, 2], "name" => &["a", "b"])
2772                .unwrap()
2773                .lazy()
2774        };
2775        let mut lf = rows();
2776        let schema = std::sync::Arc::new((*lf.collect_schema().unwrap()).clone());
2777        let state = DataTableState::from_schema_and_lazyframe(
2778            schema,
2779            rows(),
2780            &crate::OpenOptions::default(),
2781            None,
2782        )
2783        .unwrap();
2784        let theme = RenderContext::for_test();
2785        let g = crate::glyphs::get();
2786
2787        let paint = || {
2788            let area = Rect::new(0, 0, 50, 16);
2789            let mut buf = Buffer::empty(area);
2790            let mut modal = InfoModal::default();
2791            modal.open();
2792            let mut panel = DataTableInfo::new(
2793                &state,
2794                InfoContext {
2795                    format: None,
2796                    facts: None,
2797                    facts_tab: None,
2798                    footer_expected: false,
2799                    declared_types: false,
2800                },
2801                &mut modal,
2802                &theme,
2803            );
2804            (&mut panel).render(area, &mut buf);
2805            let text: Vec<String> = (0..area.height)
2806                .map(|y| {
2807                    (0..area.width)
2808                        .map(|x| buf[(x, y)].symbol().to_string())
2809                        .collect()
2810                })
2811                .collect();
2812            (buf, text)
2813        };
2814        let find = |text: &[String], needle: &str| {
2815            let y = text
2816                .iter()
2817                .position(|row| row.contains(needle))
2818                .unwrap_or_else(|| panic!("{needle:?} not drawn: {text:#?}"));
2819            // Cells, not bytes: the frame and the rail are multibyte.
2820            let x = text[y][..text[y].find(needle).unwrap()].chars().count();
2821            (x as u16, y as u16)
2822        };
2823
2824        let (buf, text) = paint();
2825        let (x, y) = find(&text, "Schema  types inferred");
2826        // The rule's title in the plain accent: the rail marks focus, not the rule.
2827        assert_eq!(buf[(x, y)].fg, theme.accent, "{text:#?}");
2828        let (_, id_row) = find(&text, " id ");
2829        assert!(text[id_row as usize].contains(g.rail), "{text:#?}");
2830        let (x, y) = find(&text, "Resources");
2831        assert!(!text[y as usize].contains(g.rail), "{text:#?}");
2832        assert_ne!(
2833            buf[(x, y)].fg,
2834            theme.accent,
2835            "an inactive tab is not accented"
2836        );
2837
2838        // One frame: its corners on the first and last rows and nowhere else,
2839        // the footer on the last row inside it.
2840        for row in &text[1..text.len() - 1] {
2841            assert!(
2842                !row.contains(g.border.top_left) && !row.contains(g.border.bottom_left),
2843                "a second border inside the panel: {text:#?}"
2844            );
2845        }
2846        assert!(text[text.len() - 2].contains("Esc"), "{text:#?}");
2847    }
2848
2849    #[test]
2850    fn the_tabs_on_offer_depend_on_the_dataset() {
2851        assert_eq!(
2852            InfoTab::visible(offer(false, false)),
2853            [InfoTab::Schema, InfoTab::Resources]
2854        );
2855        assert_eq!(
2856            InfoTab::visible(offer(true, true)),
2857            [
2858                InfoTab::Schema,
2859                InfoTab::Resources,
2860                InfoTab::Partitions,
2861                InfoTab::Notes
2862            ]
2863        );
2864        assert_eq!(
2865            InfoTab::visible(offer(false, true)),
2866            [InfoTab::Schema, InfoTab::Resources, InfoTab::Notes],
2867            "notes without partitions still sit last"
2868        );
2869    }
2870
2871    #[test]
2872    fn the_format_tab_sits_beside_the_schema() {
2873        let offered = TabsOffered {
2874            format: true,
2875            ..offer(false, true)
2876        };
2877        assert_eq!(
2878            InfoTab::visible(offered),
2879            [
2880                InfoTab::Schema,
2881                InfoTab::Format,
2882                InfoTab::Resources,
2883                InfoTab::Notes
2884            ]
2885        );
2886        assert_eq!(InfoTab::Format.prev(offered), InfoTab::Schema);
2887        assert_eq!(InfoTab::Format.index(offer(false, false)), 0, "not offered");
2888    }
2889
2890    #[test]
2891    fn a_clock_shows_hours_only_when_there_are_some() {
2892        assert_eq!(clock(3.25), "0:03.250");
2893        assert_eq!(clock(62.0), "1:02.000");
2894        assert_eq!(clock(3723.0005), "1:02:03.001");
2895    }
2896
2897    /// A value breaks between words; indentation stays, the spaces at a break go, and
2898    /// only a word wider than the room is split.
2899    #[test]
2900    fn metadata_values_wrap_on_word_boundaries() {
2901        assert_eq!(
2902            wrap_words("Broadcast WAV coding history", 12),
2903            ["Broadcast", "WAV coding", "history"]
2904        );
2905        assert_eq!(
2906            wrap_words("    {% if x %}   y", 10),
2907            ["    {% if", "x %}   y"]
2908        );
2909        assert_eq!(
2910            wrap_words("a 0123456789abcdef", 6),
2911            ["a 0123", "456789", "abcdef"]
2912        );
2913        // Measured in columns: three double-width characters are six.
2914        assert_eq!(wrap_words("日本語 text", 7), ["日本語", "text"]);
2915        assert_eq!(wrap_words("", 5), [""]);
2916        // Spaces at a break or past the room leave no blank line.
2917        assert_eq!(wrap_words("abc   ", 4), ["abc"]);
2918        assert_eq!(wrap_words("          x", 5), ["x"]);
2919    }
2920
2921    /// Each value is drawn whole: its own newlines kept, wrapped under the key, a short
2922    /// array listed and a long one counted.
2923    #[test]
2924    fn metadata_values_wrap_whole_under_their_key() {
2925        use crate::model_files::MetaValue;
2926        let meta = vec![
2927            (
2928                "a".to_string(),
2929                MetaValue::Text("line one\nsecond line that is long".to_string()),
2930            ),
2931            (
2932                "tokens".to_string(),
2933                MetaValue::List {
2934                    of: "strings",
2935                    len: 151_936,
2936                    items: vec![],
2937                },
2938            ),
2939            (
2940                "tags".to_string(),
2941                MetaValue::List {
2942                    of: "strings",
2943                    len: 2,
2944                    items: vec!["x".to_string(), "y".to_string()],
2945                },
2946            ),
2947        ];
2948        let lines = metadata_lines(&meta, 20);
2949        let key = |s: &str| format!("{s:<6}  ");
2950        let blank = " ".repeat(8);
2951        assert_eq!(
2952            lines,
2953            [
2954                (key("a"), "line one".to_string()),
2955                (blank.clone(), "second line".to_string()),
2956                (blank.clone(), "that is long".to_string()),
2957                (key("tokens"), "[151,936".to_string()),
2958                (blank.clone(), "strings]".to_string()),
2959                (key("tags"), "[\"x\", \"y\"]".to_string()),
2960            ]
2961        );
2962        // A value of megabytes is drawn to its first 64 KiB, and says what is left.
2963        let huge = vec![(
2964            "tokenizer.huggingface.json".to_string(),
2965            MetaValue::Text("x".repeat(VALUE_SHOWN_BYTES + 2048)),
2966        )];
2967        let lines = metadata_lines(&huge, 80);
2968        let last = &lines.last().unwrap().1;
2969        assert!(last.ends_with("2.0 KiB more"), "{last}");
2970        let drawn: usize = lines[..lines.len() - 1].iter().map(|(_, v)| v.len()).sum();
2971        assert_eq!(drawn, VALUE_SHOWN_BYTES);
2972        assert_eq!(short_count(8_030_261_248), "8.0B");
2973        assert_eq!(short_count(950), "950");
2974    }
2975
2976    #[test]
2977    fn tab_navigation_wraps_through_what_is_on_offer() {
2978        // Nothing optional: two tabs, back and forth.
2979        assert_eq!(
2980            InfoTab::Schema.next(offer(false, false)),
2981            InfoTab::Resources
2982        );
2983        assert_eq!(
2984            InfoTab::Resources.next(offer(false, false)),
2985            InfoTab::Schema
2986        );
2987        assert_eq!(
2988            InfoTab::Schema.prev(offer(false, false)),
2989            InfoTab::Resources
2990        );
2991
2992        // Both optional tabs present.
2993        assert_eq!(
2994            InfoTab::Resources.next(offer(true, true)),
2995            InfoTab::Partitions
2996        );
2997        assert_eq!(InfoTab::Partitions.next(offer(true, true)), InfoTab::Notes);
2998        assert_eq!(InfoTab::Notes.next(offer(true, true)), InfoTab::Schema);
2999        assert_eq!(InfoTab::Schema.prev(offer(true, true)), InfoTab::Notes);
3000
3001        // Notes only.
3002        assert_eq!(InfoTab::Resources.next(offer(false, true)), InfoTab::Notes);
3003        assert_eq!(InfoTab::Notes.prev(offer(false, true)), InfoTab::Resources);
3004    }
3005
3006    /// A tab that is no longer on offer must not strand the cursor: it reads as the
3007    /// first tab, so moving on from it goes somewhere real.
3008    #[test]
3009    fn a_tab_that_is_no_longer_offered_falls_back_to_the_first() {
3010        assert_eq!(InfoTab::Notes.index(offer(false, false)), 0);
3011        assert_eq!(InfoTab::Notes.next(offer(false, false)), InfoTab::Resources);
3012        assert_eq!(InfoTab::Partitions.index(offer(false, false)), 0);
3013        assert_eq!(
3014            InfoTab::Partitions.prev(offer(false, false)),
3015            InfoTab::Resources
3016        );
3017    }
3018
3019    /// The window's three promises, checked over every shape that fits in a terminal.
3020    ///
3021    /// Review after review found defects in this arithmetic while it lived inside the
3022    /// render, and the test that was meant to guard it re-implemented the same
3023    /// arithmetic — so the two drifted and it could never fail. This calls the real
3024    /// function and asserts what the panel actually needs.
3025    #[test]
3026    fn the_notes_window_always_shows_the_selected_note_and_wastes_no_room() {
3027        let shapes: Vec<Vec<usize>> = vec![
3028            vec![2, 2, 2, 2, 2, 2],
3029            vec![2],
3030            vec![3, 2, 4, 2],
3031            vec![2, 9, 2],
3032            vec![5, 5, 5],
3033            vec![1, 1, 1, 1, 1, 1, 1, 1],
3034            vec![4, 2, 2, 7, 2],
3035        ];
3036        let span = |h: &[usize], a: usize, b: usize| {
3037            h[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1)
3038        };
3039        for heights in &shapes {
3040            for show in 1..=30usize {
3041                for selected in 0..heights.len() {
3042                    for stored in 0..heights.len() {
3043                        let (first, last) = notes_window(heights, selected, stored, show);
3044                        let at = format!(
3045                            "heights {heights:?}, show {show}, selected {selected}, stored {stored}"
3046                        );
3047
3048                        assert!(first <= selected, "the cursor is above the window at {at}");
3049                        assert!(selected < last, "the cursor is below the window at {at}");
3050
3051                        let used = span(heights, first, last);
3052                        if last - first > 1 {
3053                            assert!(used <= show, "{used} rows in {show} at {at}");
3054                        }
3055
3056                        // Nothing more would fit below, and nothing more would fit above.
3057                        if last < heights.len() {
3058                            assert!(
3059                                span(heights, first, last + 1) > show,
3060                                "another note below would have fitted at {at}"
3061                            );
3062                        }
3063                        if first > 0 {
3064                            assert!(
3065                                span(heights, first - 1, last) > show,
3066                                "another note above would have fitted at {at}"
3067                            );
3068                        }
3069                    }
3070                }
3071            }
3072        }
3073    }
3074
3075    /// A note taller than the whole panel is still drawn, because leaving it out would
3076    /// put it out of reach.
3077    #[test]
3078    fn a_note_taller_than_the_panel_is_still_the_window() {
3079        let (first, last) = notes_window(&[2, 9, 2], 1, 0, 4);
3080        assert_eq!((first, last), (1, 2), "just the note that does not fit");
3081    }
3082
3083    /// The panel is the only thing that decides how many notes fit, and the cursor can
3084    /// always reach the last of them.
3085    #[test]
3086    fn the_notes_cursor_reaches_every_note() {
3087        let mut modal = InfoModal::new();
3088        assert!(!modal.notes_move(1, 0), "nothing to move through");
3089        for expected in 1..5 {
3090            assert!(modal.notes_move(1, 5));
3091            assert_eq!(modal.notes_selected_index, expected);
3092        }
3093        assert!(!modal.notes_move(1, 5), "and stops at the last");
3094        for expected in (0..4).rev() {
3095            assert!(modal.notes_move(-1, 5));
3096            assert_eq!(modal.notes_selected_index, expected);
3097        }
3098        assert!(!modal.notes_move(-1, 5), "and at the first");
3099    }
3100
3101    #[test]
3102    fn wrapping_measures_columns_not_characters() {
3103        assert_eq!(wrap_to("one two three", 9), ["one two", "three"]);
3104        assert_eq!(wrap_to("", 10), [""], "an empty line is still a line");
3105        assert_eq!(
3106            wrap_to("supercalifragilistic", 5),
3107            ["supercalifragilistic"],
3108            "a word longer than the panel is left whole rather than broken"
3109        );
3110        // Double-width characters take two columns each, so four of them fill eight.
3111        assert_eq!(wrap_to("日本語表 x", 8), ["日本語表", "x"]);
3112    }
3113
3114    #[test]
3115    fn test_format_bytes() {
3116        assert_eq!(format_bytes(0), "0 B");
3117        assert_eq!(format_bytes(500), "500 B");
3118        assert_eq!(format_bytes(1536), "1.5 KiB");
3119        assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
3120    }
3121
3122    #[test]
3123    fn test_format_int() {
3124        assert_eq!(format_int(0), "0");
3125        assert_eq!(format_int(1234), "1,234");
3126        assert_eq!(format_int(1_234_567), "1,234,567");
3127    }
3128}