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::formats::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 crate::export::export_modal::ExportFormat;
20use crate::render::context::RenderContext;
21use crate::table::DataTableState;
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. `heights` are each note's rows (a blank
32/// line between notes), `stored` the last scroll, `show` the rows available.
33/// Guarantees:
34///
35/// - `first <= selected < last`: the cursor's note is always drawn;
36/// - the range fits `show`, unless it is one note too tall alone (drawn as far as it
37///   goes, else unreachable);
38/// - `last` is as large as possible, never leaving rows blank while a whole note is
39///   hidden.
40fn notes_window(heights: &[usize], selected: usize, stored: usize, show: usize) -> (usize, usize) {
41    if heights.is_empty() {
42        return (0, 0);
43    }
44    let selected = selected.min(heights.len() - 1);
45    // Rows that notes `a..b` take, counting the blank line between adjacent ones.
46    let span = |a: usize, b: usize| heights[a..b].iter().sum::<usize>() + (b - a).saturating_sub(1);
47    // Start no later than the selected note, and far enough back that it still fits.
48    let mut first = stored.min(selected);
49    while first < selected && span(first, selected + 1) > show {
50        first += 1;
51    }
52    // Then take as many following notes as the rest of the panel holds.
53    let mut last = selected + 1;
54    while last < heights.len() && span(first, last + 1) <= show {
55        last += 1;
56    }
57    // And give back any room left at the top, so the panel is never part empty while a
58    // whole note is hidden above it.
59    while first > 0 && span(first - 1, last) <= show {
60        first -= 1;
61    }
62    (first, last)
63}
64
65/// A note's lines: its summary and the line saying what it is based on, both wrapped to
66/// the panel. Selection changes only the marker, never a height.
67fn note_rows(note: &crate::notes::Note, selected: bool, width: usize) -> Vec<NoteRow> {
68    let mut rows = Vec::new();
69    let marker = if selected {
70        crate::glyphs::get().prompt
71    } else {
72        "  "
73    };
74    let mut first = true;
75    for line in wrap_to(&note.summary, width.saturating_sub(2)) {
76        rows.push(NoteRow {
77            text: format!("{}{line}", if first { marker } else { "  " }),
78            dim: false,
79        });
80        first = false;
81    }
82    for line in wrap_to(&note.scope, width.saturating_sub(4)) {
83        rows.push(NoteRow {
84            text: format!("    {line}"),
85            dim: true,
86        });
87    }
88    rows
89}
90
91/// Break `text` on spaces so no line passes `width` columns, measured in columns
92/// (double-width names). A word longer than the panel stays whole (the terminal clips
93/// it): wrapping protects the note's height.
94pub(crate) fn wrap_to(text: &str, width: usize) -> Vec<String> {
95    use unicode_width::UnicodeWidthStr;
96    if width == 0 {
97        return vec![text.to_string()];
98    }
99    let mut lines = Vec::new();
100    let mut line = String::new();
101    for word in text.split_whitespace() {
102        let room = if line.is_empty() {
103            width
104        } else {
105            width.saturating_sub(line.width() + 1)
106        };
107        if word.width() > room && !line.is_empty() {
108            lines.push(std::mem::take(&mut line));
109        }
110        if !line.is_empty() {
111            line.push(' ');
112        }
113        line.push_str(word);
114    }
115    if !line.is_empty() || lines.is_empty() {
116        lines.push(line);
117    }
118    lines
119}
120
121/// `n` comma-grouped, for the panel's own labels.
122pub(crate) fn group_u64(n: u64) -> String {
123    let mut out = String::new();
124    crate::numfmt::NumberFormat::CHROME.write_u64(n, &mut out);
125    out
126}
127
128/// `n` and the noun for it: `1 tensor`, `291 tensors`.
129pub(crate) fn count_of(n: u64, one: &str, many: &str) -> String {
130    format!("{} {}", group_u64(n), if n == 1 { one } else { many })
131}
132
133/// A parameter count as a model card says it: `8.0B`, `124.4M`, `950`.
134pub(crate) fn short_count(n: u64) -> String {
135    const STEPS: [(u64, &str); 4] = [
136        (1_000_000_000_000, "T"),
137        (1_000_000_000, "B"),
138        (1_000_000, "M"),
139        (1_000, "K"),
140    ];
141    for (size, suffix) in STEPS {
142        if n >= size {
143            return format!("{:.1}{suffix}", n as f64 / size as f64);
144        }
145    }
146    n.to_string()
147}
148
149/// Break one line into lines of at most `width` columns between words, dropping
150/// break spaces and keeping leading indentation. Only a word wider than `width` is
151/// broken, at the edge, so a long URL or hash shows whole.
152fn wrap_words(text: &str, width: usize) -> Vec<String> {
153    use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
154    let mut lines = Vec::new();
155    let mut line = String::new();
156    let mut line_width = 0;
157    // A line a break started: spaces at its start are the break's, not indentation.
158    let mut broken = false;
159    let mut rest = text;
160    while !rest.is_empty() {
161        let space = rest.starts_with(' ');
162        let end = rest
163            .find(|c: char| (c == ' ') != space)
164            .unwrap_or(rest.len());
165        let (token, after) = rest.split_at(end);
166        rest = after;
167        let w = token.width();
168        if space {
169            if broken && line.is_empty() {
170                continue;
171            }
172            if line_width + w <= width {
173                line.push_str(token);
174                line_width += w;
175            } else {
176                // Indentation wider than the room starts no line of its own.
177                if !line.is_empty() {
178                    lines.push(std::mem::take(&mut line));
179                }
180                line_width = 0;
181                broken = true;
182            }
183            continue;
184        }
185        if line_width + w > width && !line.is_empty() && w <= width {
186            lines.push(std::mem::take(&mut line).trim_end().to_string());
187            line_width = 0;
188            broken = true;
189        }
190        for c in token.chars() {
191            let cw = c.width().unwrap_or(0);
192            if line_width + cw > width && !line.is_empty() {
193                lines.push(std::mem::take(&mut line));
194                line_width = 0;
195                broken = true;
196            }
197            line.push(c);
198            line_width += cw;
199        }
200    }
201    // Spaces dropped at a break leave no blank line after the text.
202    if !(broken && line.is_empty()) || lines.is_empty() {
203        lines.push(line);
204    }
205    lines
206}
207
208/// One metadata value as text: an array that was listed, or how long it is.
209pub(crate) fn meta_text(value: &crate::formats::model_files::MetaValue) -> String {
210    use crate::formats::model_files::MetaValue;
211    match value {
212        MetaValue::Text(text) => text.clone(),
213        MetaValue::List { of, len, items } if items.len() as u64 == *len && *len > 0 => {
214            let quote = *of == "strings";
215            let items: Vec<String> = items
216                .iter()
217                .map(|i| if quote { format!("{i:?}") } else { i.clone() })
218                .collect();
219            format!("[{}]", items.join(", "))
220        }
221        MetaValue::List { of, len, .. } => format!("[{} {of}]", group_u64(*len)),
222    }
223}
224
225/// The most of one metadata value a detail tab draws: a chat template shows whole, a
226/// GGUF's embedded `tokenizer.json` (megabytes) would be rewrapped every frame.
227pub(crate) const VALUE_SHOWN_BYTES: usize = 64 * 1024;
228
229/// Metadata as drawn lines: the key on a value's first line, each value split at its
230/// newlines and wrapped to the rest of `width`; past [`VALUE_SHOWN_BYTES`] a line says
231/// how much more.
232pub(crate) fn metadata_lines(
233    metadata: &[(String, crate::formats::model_files::MetaValue)],
234    width: usize,
235) -> Vec<(String, String)> {
236    use unicode_width::UnicodeWidthStr;
237    let longest = metadata.iter().map(|(k, _)| k.width()).max().unwrap_or(0);
238    // Two columns between key and value; the key takes no more than two fifths.
239    let key_width = longest.min(width * 2 / 5).max(1);
240    let value_width = width.saturating_sub(key_width + 2).max(1);
241    let mut out = Vec::new();
242    for (key, value) in metadata {
243        let key_cell = format!(
244            "{:<w$}  ",
245            crate::glyphs::fit(key, key_width),
246            w = key_width
247        );
248        let blank = " ".repeat(key_width + 2);
249        // Borrowed, not copied: this runs every frame.
250        let listed;
251        let text = match value {
252            crate::formats::model_files::MetaValue::Text(text) => text.as_str(),
253            other => {
254                listed = meta_text(other);
255                listed.as_str()
256            }
257        };
258        let cut = text.floor_char_boundary(VALUE_SHOWN_BYTES);
259        let (text, more) = (&text[..cut], text.len() - cut);
260        let mut first = true;
261        for raw in text.split('\n') {
262            // Tabs as a space and other control characters dropped, so the widths
263            // measured here are the widths drawn.
264            let clean: String = raw
265                .chars()
266                .filter_map(|c| match c {
267                    '\t' => Some(' '),
268                    c if c.is_control() => None,
269                    c => Some(c),
270                })
271                .collect();
272            for line in wrap_words(&clean, value_width) {
273                let k = if first {
274                    key_cell.clone()
275                } else {
276                    blank.clone()
277                };
278                first = false;
279                out.push((k, line));
280            }
281        }
282        if more > 0 {
283            let g = crate::glyphs::get();
284            out.push((
285                blank,
286                format!("{} {} more", g.ellipsis, crate::numfmt::bytes(more as u64)),
287            ));
288        }
289    }
290    out
291}
292
293/// A length of time as a clock: `0:03.250`, `1:02:03.250`.
294pub(crate) fn clock(seconds: f64) -> String {
295    let ms = (seconds.max(0.0) * 1000.0).round() as u64;
296    let (h, m, s, ms) = (ms / 3_600_000, ms / 60_000 % 60, ms / 1000 % 60, ms % 1000);
297    if h > 0 {
298        format!("{h}:{m:02}:{s:02}.{ms:03}")
299    } else {
300        format!("{m}:{s:02}.{ms:03}")
301    }
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
305pub enum InfoTab {
306    #[default]
307    Schema,
308    /// A delimited spec's metadata line, as key and value.
309    Metadata,
310    /// What the file says besides its rows ([`crate::formats::text_formats::Detail`]): a model's
311    /// totals, a VCD header. Titled by the detail.
312    Format,
313    Resources,
314    Partitions,
315    Notes,
316    /// What the catalog that lists the dataset says of it: the page `Ctrl+E` shows on
317    /// home.
318    Documentation,
319}
320
321/// Which of the optional tabs the dataset on screen offers.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
323pub struct TabsOffered {
324    /// A delimited spec read a metadata line.
325    pub metadata: bool,
326    /// The file said something besides its rows.
327    pub format: bool,
328    pub partitions: bool,
329    pub notes: bool,
330    /// A catalog lists the dataset. Not the state's to say: the App sets it.
331    pub documentation: bool,
332}
333
334impl TabsOffered {
335    /// What `state` offers; `facts_tab` is the format's tab the file facts fill, when
336    /// they will (see [`InfoContext::facts_tab`]).
337    pub fn of(state: &DataTableState, facts_tab: Option<&'static str>) -> Self {
338        Self {
339            metadata: state
340                .delimited_read()
341                .is_some_and(|read| read.metadata.is_some()),
342            format: state.format_detail().is_some() || facts_tab.is_some(),
343            partitions: state
344                .partition_columns()
345                .map(|v| !v.is_empty())
346                .unwrap_or(false),
347            notes: state.has_notes(),
348            documentation: false,
349        }
350    }
351}
352
353impl InfoTab {
354    /// The tabs on offer, in order: the file's own tab when it says something (beside the
355    /// schema), Partitions for partitioned data, Notes when datui has notes.
356    pub fn visible(offered: TabsOffered) -> Vec<InfoTab> {
357        let mut tabs = vec![InfoTab::Schema];
358        if offered.documentation {
359            tabs.push(InfoTab::Documentation);
360        }
361        if offered.metadata {
362            tabs.push(InfoTab::Metadata);
363        }
364        if offered.format {
365            tabs.push(InfoTab::Format);
366        }
367        tabs.push(InfoTab::Resources);
368        if offered.partitions {
369            tabs.push(InfoTab::Partitions);
370        }
371        if offered.notes {
372            tabs.push(InfoTab::Notes);
373        }
374        tabs
375    }
376
377    pub fn title(self) -> &'static str {
378        match self {
379            InfoTab::Schema => "Schema",
380            InfoTab::Metadata => "Metadata",
381            InfoTab::Format => "Format",
382            InfoTab::Resources => "Resources",
383            InfoTab::Partitions => "Partitions",
384            InfoTab::Notes => "Notes",
385            InfoTab::Documentation => "Documentation",
386        }
387    }
388
389    /// Next tab, wrapping. A tab that is not on offer starts from the first.
390    pub fn next(self, offered: TabsOffered) -> Self {
391        let tabs = Self::visible(offered);
392        let at = self.index(offered);
393        tabs[(at + 1) % tabs.len()]
394    }
395
396    pub fn prev(self, offered: TabsOffered) -> Self {
397        let tabs = Self::visible(offered);
398        let at = self.index(offered);
399        tabs[(at + tabs.len() - 1) % tabs.len()]
400    }
401
402    /// Where this tab sits among the ones on offer; 0 when it is not among them.
403    pub fn index(self, offered: TabsOffered) -> usize {
404        Self::visible(offered)
405            .iter()
406            .position(|tab| *tab == self)
407            .unwrap_or(0)
408    }
409}
410
411/// Modal state for the Info panel: focus, tab, schema table selection/scroll.
412#[derive(Default)]
413pub struct InfoModal {
414    pub active_tab: InfoTab,
415    pub schema_selected_index: usize,
416    pub schema_scroll_offset: usize,
417    pub schema_table_state: ratatui::widgets::TableState,
418    /// Last visible height for schema table (data rows), set during render.
419    pub schema_visible_height: usize,
420    /// The note the cursor is on, and the first note drawn.
421    pub notes_selected_index: usize,
422    /// The first row of the notes list on screen; the render keeps the selected note
423    /// inside the window.
424    pub notes_scroll_offset: usize,
425    /// The first line of a detail tab's list on screen. The render clamps it. One for
426    /// all: a dataset has one detail tab at most.
427    pub detail_scroll: usize,
428    /// The list lines a detail tab last had room for; set during render.
429    pub detail_visible: usize,
430    /// The cursor in a detail tab listing the file's tables (worksheets, SQLite tables),
431    /// where Enter opens one; the render clamps it and keeps it in view.
432    pub detail_selected: usize,
433}
434
435impl InfoModal {
436    pub fn new() -> Self {
437        Self::default()
438    }
439
440    pub fn open(&mut self) {
441        self.open_on(InfoTab::Schema);
442    }
443
444    /// Open with `tab` in front. The accented `i` chip promises unread notes;
445    /// arriving on the Schema tab instead made the reader hunt for them.
446    pub fn open_on(&mut self, tab: InfoTab) {
447        self.active_tab = tab;
448        self.schema_selected_index = 0;
449        self.schema_scroll_offset = 0;
450        self.schema_table_state.select(Some(0));
451        self.notes_selected_index = 0;
452        self.notes_scroll_offset = 0;
453        self.detail_scroll = 0;
454        self.detail_selected = 0;
455    }
456
457    /// Switch to the next of the tabs on offer.
458    pub fn switch_tab(&mut self, offered: TabsOffered) {
459        self.active_tab = self.active_tab.next(offered);
460        if self.active_tab == InfoTab::Schema {
461            self.schema_selected_index = 0;
462            self.schema_scroll_offset = 0;
463            self.schema_table_state.select(Some(0));
464        }
465    }
466
467    /// Switch to the previous of the tabs on offer.
468    pub fn switch_tab_prev(&mut self, offered: TabsOffered) {
469        self.active_tab = self.active_tab.prev(offered);
470        if self.active_tab == InfoTab::Schema {
471            self.schema_selected_index = 0;
472            self.schema_scroll_offset = 0;
473            self.schema_table_state.select(Some(0));
474        }
475    }
476
477    /// Scroll a detail tab's list by `delta` lines; the render keeps it
478    /// in range.
479    pub fn detail_scroll_by(&mut self, delta: isize) {
480        self.detail_scroll = self.detail_scroll.saturating_add_signed(delta);
481    }
482
483    /// Move the cursor through the notes; true if changed. Only the index moves: the render
484    /// scrolls to the selection, so note heights never limit the cursor.
485    pub fn notes_move(&mut self, delta: isize, total: usize) -> bool {
486        if total == 0 {
487            return false;
488        }
489        let last = total - 1;
490        let next = (self.notes_selected_index as isize + delta).clamp(0, last as isize) as usize;
491        if next == self.notes_selected_index {
492            return false;
493        }
494        self.notes_selected_index = next;
495        true
496    }
497
498    /// Scroll and selection for schema table. `total_rows` = schema len,
499    /// `visible_height` = rows shown. Returns true if state changed.
500    pub fn schema_table_down(&mut self, total_rows: usize, visible_height: usize) -> bool {
501        if total_rows == 0 {
502            return false;
503        }
504        let max_idx = total_rows.saturating_sub(1);
505        if self.schema_selected_index >= max_idx {
506            return false;
507        }
508        self.schema_selected_index += 1;
509        let visible_end = self.schema_scroll_offset + visible_height;
510        if visible_height > 0 && self.schema_selected_index >= visible_end {
511            self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
512        }
513        let local = self
514            .schema_selected_index
515            .saturating_sub(self.schema_scroll_offset);
516        self.schema_table_state.select(Some(local));
517        true
518    }
519
520    pub fn schema_table_up(&mut self, total_rows: usize, _visible_height: usize) -> bool {
521        if total_rows == 0 || self.schema_selected_index == 0 {
522            return false;
523        }
524        self.schema_selected_index -= 1;
525        if self.schema_selected_index < self.schema_scroll_offset {
526            self.schema_scroll_offset = self.schema_selected_index;
527        }
528        let local = self
529            .schema_selected_index
530            .saturating_sub(self.schema_scroll_offset);
531        self.schema_table_state.select(Some(local));
532        true
533    }
534
535    /// Sync table state from selected_index/offset (e.g. after tab switch or total_rows change).
536    pub fn sync_schema_table_state(&mut self, total_rows: usize, visible_height: usize) {
537        if total_rows == 0 {
538            self.schema_table_state.select(None);
539            return;
540        }
541        let max_idx = total_rows.saturating_sub(1);
542        self.schema_selected_index = self.schema_selected_index.min(max_idx);
543        if self.schema_scroll_offset + visible_height <= self.schema_selected_index
544            && visible_height > 0
545        {
546            self.schema_scroll_offset = self.schema_selected_index + 1 - visible_height;
547        }
548        if self.schema_selected_index < self.schema_scroll_offset {
549            self.schema_scroll_offset = self.schema_selected_index;
550        }
551        let local = self
552            .schema_selected_index
553            .saturating_sub(self.schema_scroll_offset);
554        self.schema_table_state.select(Some(local));
555    }
556}
557
558/// What the open file says beyond its rows: its size and, where its reader has a facts
559/// read (`crate::formats::readers::Reader::facts`), its tab and the footer behind it. Read on a
560/// worker once per dataset: a stat or footer read on a dead mount hangs its thread.
561#[derive(Debug, Clone)]
562pub enum FileFacts {
563    /// Asked for; the worker has not answered.
564    Reading,
565    /// What the worker found.
566    Read {
567        /// `None` for a directory, whose own size is not the data's.
568        size: Option<u64>,
569        /// A footer that gives the Schema tab's Compression column; `None` for a
570        /// format without one.
571        footer: Option<Footer>,
572        /// The format's tab, made from what the read found.
573        detail: Option<Arc<crate::formats::text_formats::Detail>>,
574    },
575    /// The read failed, and why. Kept for the dataset rather than asked again: a file
576    /// that could not be read a moment ago is not worth a read per frame.
577    Failed(String),
578}
579
580impl FileFacts {
581    /// Stat `path` and, with `facts`, read its format's facts. Blocking: call on a worker.
582    /// Failures are short for the panel's line; the full error is logged.
583    pub(crate) fn read(
584        path: &Path,
585        facts: Option<crate::formats::readers::Facts>,
586    ) -> std::result::Result<Self, String> {
587        let io = |e: std::io::Error| {
588            log::warn!(target: "datui", "file size of {}: {e}", path.display());
589            match e.kind() {
590                std::io::ErrorKind::NotFound => "file not found".to_string(),
591                std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
592                // The OS's own words, without the errno the log already has.
593                _ => {
594                    let said = e.to_string();
595                    match said.rsplit_once(" (os error") {
596                        Some((words, _)) => words.to_string(),
597                        None => said,
598                    }
599                }
600            }
601        };
602        let meta = std::fs::metadata(path).map_err(io)?;
603        if meta.is_dir() {
604            return Ok(Self::Read {
605                size: None,
606                footer: None,
607                detail: None,
608            });
609        }
610        let read = match facts {
611            Some(facts) => (facts.read)(path).map_err(|e| {
612                log::warn!(target: "datui", "footer of {}: {e}", path.display());
613                "unreadable footer".to_string()
614            })?,
615            None => crate::formats::readers::FormatFacts::default(),
616        };
617        Ok(Self::Read {
618            size: Some(meta.len()),
619            footer: read.footer,
620            detail: read.detail,
621        })
622    }
623}
624
625/// Context for the info panel: the format and what the file says. Open costs belong to
626/// the dataset, which the panel already has.
627pub struct InfoContext<'a> {
628    pub format: Option<ExportFormat>,
629    /// The file declares its columns' types, as its format's descriptor says.
630    pub declared_types: bool,
631    /// `None` when there is no one file on this machine to ask: a remote source, a
632    /// glob, or a dataset opened from several paths.
633    pub facts: Option<&'a FileFacts>,
634    /// The format's tab the facts fill (one local file with a facts read), offered, named
635    /// and sized before they land so nothing moves.
636    pub facts_tab: Option<&'static str>,
637    /// The facts read a footer that gives the Compression column, whose room is kept
638    /// while it is read.
639    pub footer_expected: bool,
640}
641
642impl<'a> InfoContext<'a> {
643    /// Where the column types came from, as the Schema rule's chip says it.
644    pub fn schema_source(&self) -> &'static str {
645        if self.declared_types {
646            "types declared"
647        } else {
648            "types inferred"
649        }
650    }
651
652    /// The footer the facts read, once it has landed.
653    pub fn footer(&self) -> Option<&'a Footer> {
654        match self.facts? {
655            FileFacts::Read { footer, .. } => footer.as_ref(),
656            FileFacts::Reading | FileFacts::Failed(_) => None,
657        }
658    }
659
660    /// The format's tab the facts made, once it has landed.
661    fn facts_detail(&self) -> Option<&'a crate::formats::text_formats::Detail> {
662        match self.facts? {
663            FileFacts::Read { detail, .. } => detail.as_deref(),
664            FileFacts::Reading | FileFacts::Failed(_) => None,
665        }
666    }
667
668    /// Whether the worker has yet to answer.
669    fn reading(&self) -> bool {
670        matches!(self.facts, Some(FileFacts::Reading))
671    }
672}
673
674pub struct DataTableInfo<'a> {
675    pub state: &'a DataTableState,
676    pub ctx: InfoContext<'a>,
677    pub modal: &'a mut InfoModal,
678    pub theme: &'a RenderContext,
679    /// The dataset is one local file, which `x` shows as hex.
680    pub hex: bool,
681    /// The dataset is delimited text, whose first row `H` reads the other way.
682    pub header_toggle: bool,
683    /// What the columns mean, from the catalog that lists the dataset.
684    pub codebook: Option<&'a crate::home::codebook::Codebook>,
685    /// The Documentation tab's page, when a catalog lists the dataset.
686    pub documentation: Option<&'a mut crate::widgets::documentation::DocState>,
687    /// The row count from a sample of the dataset's footers, until it is counted.
688    pub estimate: Option<crate::formats::schema_union::RowEstimate>,
689}
690
691/// The Resources tab's `Read:` value: how the open reads the data, and that a remote
692/// file was downloaded first. `None` for a frame no open found, such as Python's.
693fn read_line(state: &DataTableState) -> Option<String> {
694    let mode = state.read_mode()?.label();
695    Some(if state.fetched() {
696        format!("download {} {mode}", crate::glyphs::get().arrow_right)
697    } else {
698        mode.to_string()
699    })
700}
701
702/// The Schema tab's first line: the size, or that it is not known yet. `None` rather
703/// than an uncounted state's number, which is only how far the buffer reached (`Rows
704/// (total): 70` beside a counting spinner).
705fn rows_and_columns(rows: Option<usize>, columns: usize) -> String {
706    let middot = crate::glyphs::get().middot;
707    match rows {
708        Some(rows) => format!(
709            "Rows (total): {} {middot} Columns: {}",
710            format_int(rows),
711            columns
712        ),
713        None => format!("Rows (total): counting... {middot} Columns: {columns}"),
714    }
715}
716
717/// [`rows_and_columns`] for a count estimated from a sample of footers, with how many
718/// were read and the key that counts them all.
719fn estimated_rows_and_columns(
720    estimate: crate::formats::schema_union::RowEstimate,
721    columns: usize,
722) -> String {
723    let middot = crate::glyphs::get().middot;
724    format!(
725        "Rows (total): ~{} (est. from {} of {} files; c counts) {middot} Columns: {columns}",
726        crate::home::discover::format_rows(estimate.rows as usize),
727        format_int(estimate.sampled),
728        format_int(estimate.files),
729    )
730}
731
732impl<'a> DataTableInfo<'a> {
733    pub fn new(
734        state: &'a DataTableState,
735        ctx: InfoContext<'a>,
736        modal: &'a mut InfoModal,
737        theme: &'a RenderContext,
738    ) -> Self {
739        Self {
740            state,
741            ctx,
742            modal,
743            theme,
744            hex: false,
745            header_toggle: false,
746            codebook: None,
747            documentation: None,
748            estimate: None,
749        }
750    }
751
752    /// The codebook, when it has a note for one of the columns on screen.
753    fn codebook_here(&self) -> Option<&'a crate::home::codebook::Codebook> {
754        self.codebook
755            .filter(|book| book.covers(self.state.schema().iter_names().map(|n| n.as_str())))
756    }
757
758    fn render_schema_tab(&mut self, area: Rect, buf: &mut Buffer) {
759        let summary = self.render_schema_summary(area, buf);
760        let mut rest = Rect {
761            y: area.y + summary,
762            height: area.height.saturating_sub(summary),
763            ..area
764        };
765        if rest.height == 0 {
766            return;
767        }
768        // The selected column's note in full below the table: a blank row and three of
769        // text, kept whichever column is selected so nothing moves.
770        let note_rows = if self.codebook_here().is_some() && rest.height >= 10 {
771            4
772        } else {
773            0
774        };
775        rest.height -= note_rows;
776        let used = self.render_schema_table(rest, buf);
777        if note_rows > 0 {
778            // Right under the table's last row, where the eye already is.
779            self.render_column_note(
780                Rect {
781                    y: rest.y + used + 1,
782                    height: note_rows - 1,
783                    ..rest
784                },
785                buf,
786            );
787        }
788    }
789
790    /// What the codebook says of the selected column: its meaning and unit, then its
791    /// codes.
792    fn render_column_note(&self, area: Rect, buf: &mut Buffer) {
793        let Some(book) = self.codebook_here() else {
794            return;
795        };
796        let Some((name, _)) = self
797            .state
798            .schema()
799            .get_at_index(self.modal.schema_selected_index)
800        else {
801            return;
802        };
803        let width = area.width as usize;
804        let rows = area.height as usize;
805        let mut lines: Vec<String> = Vec::new();
806        match book.column(name.as_str()) {
807            Some(column) => {
808                let about = column.about();
809                if !about.is_empty() {
810                    lines.extend(wrap_to(&format!("{name}: {about}"), width));
811                }
812                if !column.values.is_empty() {
813                    let sep = format!(" {} ", crate::glyphs::get().middot);
814                    let codes: Vec<&str> = column
815                        .values
816                        .keys()
817                        .map(|k| if k.is_empty() { "blank" } else { k.as_str() })
818                        .collect();
819                    let left = rows.saturating_sub(lines.len()).max(1);
820                    let mut wrapped = wrap_to(&format!("Codes: {}", codes.join(&sep)), width);
821                    if wrapped.len() > left {
822                        // The last line that fits carries the rest, cut with the ellipsis.
823                        let rest = wrapped[left - 1..].join(" ");
824                        wrapped.truncate(left - 1);
825                        wrapped.push(crate::glyphs::fit(&rest, width));
826                    }
827                    lines.extend(wrapped);
828                }
829            }
830            None => lines.push(format!("{name}: not documented")),
831        }
832        for (i, line) in lines.iter().take(rows).enumerate() {
833            Paragraph::new(crate::glyphs::fit(line, width))
834                .style(Style::default().fg(self.theme.text_secondary))
835                .render(
836                    Rect {
837                        y: area.y + i as u16,
838                        height: 1,
839                        ..area
840                    },
841                    buf,
842                );
843        }
844    }
845
846    fn render_schema_summary(&self, area: Rect, buf: &mut Buffer) -> u16 {
847        let ncols = self.state.schema().len();
848        let mut lines = vec![];
849        // `num_rows_if_valid`, not `num_rows`: see `rows_and_columns`.
850        lines.push(match self.estimate {
851            Some(estimate) => estimated_rows_and_columns(estimate, ncols),
852            None => rows_and_columns(self.state.num_rows_if_valid(), ncols),
853        });
854        let by_type = columns_by_type(self.state.schema().as_ref());
855        if !by_type.is_empty() {
856            lines.push(by_type);
857        }
858        // A file of several tables (an NMEA log's sentence types) names the others.
859        let others = self.state.other_tables();
860        if !others.is_empty() {
861            let sep = format!(" {} ", crate::glyphs::get().middot);
862            lines.push(format!("Other tables (--table): {}", others.join(&sep)));
863        }
864        if let Some(book) = self.codebook_here()
865            && !book.source.is_empty()
866        {
867            lines.push(crate::glyphs::fit(
868                &format!("Documentation: {}", book.source),
869                area.width as usize,
870            ));
871        }
872        for (i, s) in lines.iter().enumerate() {
873            Paragraph::new(s.as_str()).render(
874                Rect {
875                    x: area.x,
876                    y: area.y + i as u16,
877                    width: area.width,
878                    height: 1,
879                },
880                buf,
881            );
882        }
883        lines.len() as u16
884    }
885
886    /// Draw the schema table; returns the rows it drew, rule and header included.
887    fn render_schema_table(&mut self, area: Rect, buf: &mut Buffer) -> u16 {
888        // A dataset of many files says which footers its columns came from; one file
889        // says only whether its format declared them.
890        let dataset = self.state.dataset_schema();
891        let src = match dataset {
892            Some(dataset) => dataset.origin.to_string(),
893            // A model's, an audio file's or MIDI's columns are datui's own.
894            None if self.state.format_detail().is_some_and(|d| d.own_columns) => {
895                "types declared".to_string()
896            }
897            None => self.ctx.schema_source().to_string(),
898        };
899        // Per column, how many read footers carry it; only multi-file datasets vary (a single
900        // file's fact is in the block title).
901        let presence = dataset.map(|dataset| {
902            let readable = dataset.files.saturating_sub(dataset.unreadable.len());
903            let present_by_name: HashMap<&str, usize> = dataset
904                .columns
905                .iter()
906                .map(|c| (c.name.as_str(), c.present_in))
907                .collect();
908            (readable, present_by_name)
909        });
910        let has_files = presence.is_some();
911        let compression = self.ctx.footer().map(|m| {
912            crate::formats::parquet_footer::column_compression(
913                m.as_ref(),
914                self.state.schema().as_ref(),
915            )
916        });
917        // Kept for a file whose footer is still out, so the columns do not re-proportion
918        // when it lands.
919        let has_comp =
920            self.ctx.footer_expected || compression.as_ref().is_some_and(|c| !c.is_empty());
921        // A delimited spec's unit row: each column's unit, beside its type.
922        let has_units = !self.state.units().is_empty();
923        let book = self.codebook_here();
924        let mut header_cells = vec!["Column", "Type"];
925        if has_units {
926            header_cells.push("Unit");
927        }
928        if book.is_some() {
929            header_cells.push("About");
930        }
931        if has_files {
932            header_cells.push("Files");
933        }
934        if has_comp {
935            header_cells.push("Compression");
936        }
937        let header = Row::new(header_cells).bold();
938
939        let total_rows = self.state.schema().len();
940        // The body always has the keys: the tabs switch from anywhere, so the
941        // rule is accented and the row carries the rail.
942        let body_focused = true;
943        // A noun on the rule, and where its types came from in the chip, as every
944        // section rule says a fact about its section.
945        SectionRule {
946            title: "Schema",
947            chip: Some(&src),
948        }
949        .render(Rect { height: 1, ..area }, buf, self.theme);
950        let inner = Rect {
951            y: area.y + 1,
952            height: area.height.saturating_sub(1),
953            ..area
954        };
955        let visible_height = inner.height as usize;
956
957        // One header row, plus one for the out-of-view count when columns overflow, so hidden
958        // columns are stated before scrolling.
959        let fits = total_rows <= visible_height.saturating_sub(1);
960        let data_height = visible_height.saturating_sub(1 + usize::from(!fits));
961        self.modal.schema_visible_height = data_height;
962        self.modal.sync_schema_table_state(total_rows, data_height);
963
964        let offset = self.modal.schema_scroll_offset;
965        let take = data_height.min(total_rows.saturating_sub(offset));
966        let mut rows: Vec<Vec<String>> = vec![];
967        for (idx, (name, dtype)) in self.state.schema().iter().enumerate() {
968            if idx < offset {
969                continue;
970            }
971            if idx >= offset + take {
972                break;
973            }
974            let name_str: &str = name.as_ref();
975            let mut cells = vec![name.to_string(), dtype.to_string()];
976            if has_units {
977                cells.push(self.state.unit_of(name_str).unwrap_or_default().to_string());
978            }
979            if let Some(book) = book {
980                cells.push(book.column(name_str).map(|c| c.about()).unwrap_or_default());
981            }
982            if let Some((readable, present_by_name)) = &presence {
983                // A column the footers never named — one built by a query or added
984                // from the file names — has no per-file fact to state.
985                let files_str = match present_by_name.get(name_str) {
986                    Some(present) if present >= readable => {
987                        format!("all {}", format_int(*readable))
988                    }
989                    Some(present) => {
990                        format!("{} of {}", format_int(*present), format_int(*readable))
991                    }
992                    None => crate::glyphs::get().dash.to_string(),
993                };
994                cells.push(files_str);
995            }
996            if has_comp {
997                let comp_str = match compression.as_ref().map(|c| c.get(name_str)) {
998                    Some(Some((codec, ratio))) => {
999                        format!("{} {:.1}{}", codec, ratio, crate::glyphs::get().times)
1000                    }
1001                    // Blank until the footer lands, rather than a dash that says it did.
1002                    None if self.ctx.reading() => String::new(),
1003                    _ => crate::glyphs::get().dash.to_string(),
1004                };
1005                cells.push(comp_str);
1006            }
1007            rows.push(cells);
1008        }
1009
1010        let widths: Vec<Constraint> = if book.is_some() {
1011            // The note takes what the name and type leave.
1012            let mut weights = vec![3, 2];
1013            weights.extend(has_units.then_some(2));
1014            weights.push(7);
1015            weights.extend(has_files.then_some(2));
1016            weights.extend(has_comp.then_some(3));
1017            weights.into_iter().map(Constraint::Fill).collect()
1018        } else if has_units {
1019            // Name and type as wide as each other, the rest narrower.
1020            let mut weights = vec![3, 3, 2];
1021            weights.extend(has_files.then_some(2));
1022            weights.extend(has_comp.then_some(3));
1023            weights.into_iter().map(Constraint::Fill).collect()
1024        } else {
1025            match (has_files, has_comp) {
1026                (true, true) => vec![
1027                    Constraint::Percentage(25),
1028                    Constraint::Percentage(30),
1029                    Constraint::Percentage(20),
1030                    Constraint::Percentage(25),
1031                ],
1032                (true, false) => vec![
1033                    Constraint::Percentage(35),
1034                    Constraint::Percentage(40),
1035                    Constraint::Percentage(25),
1036                ],
1037                (false, true) => vec![
1038                    Constraint::Percentage(30),
1039                    Constraint::Percentage(40),
1040                    Constraint::Percentage(30),
1041                ],
1042                (false, false) => vec![Constraint::Percentage(50), Constraint::Percentage(50)],
1043            }
1044        };
1045        // Rail and tint while focused; accent alone otherwise, the rail's column kept so
1046        // focus moves nothing.
1047        let g = crate::glyphs::get();
1048        let (highlight, symbol) = if body_focused {
1049            (self.theme.highlight_style(), g.selector)
1050        } else {
1051            (Style::default().fg(self.theme.accent), g.selector_blank)
1052        };
1053        let symbol = Span::styled(symbol, Style::default().fg(self.theme.accent));
1054        // A note longer than its column ends in the ellipsis rather than mid-word: the
1055        // whole of it is under the table.
1056        if book.is_some() {
1057            let about = 2 + usize::from(has_units);
1058            let room = Rect {
1059                width: inner
1060                    .width
1061                    .saturating_sub(crate::glyphs::cell_width(g.selector) as u16),
1062                ..inner
1063            };
1064            let cols = Layout::horizontal(widths.clone()).spacing(1).split(room);
1065            if let Some(col) = cols.get(about) {
1066                for cells in &mut rows {
1067                    if let Some(cell) = cells.get_mut(about) {
1068                        *cell = crate::glyphs::fit(cell, col.width as usize);
1069                    }
1070                }
1071            }
1072        }
1073        let rows: Vec<Row> = rows.into_iter().map(Row::new).collect();
1074        let table = Table::new(rows, widths)
1075            .header(header)
1076            .column_spacing(1)
1077            .row_highlight_style(highlight)
1078            .highlight_symbol(symbol)
1079            .highlight_spacing(HighlightSpacing::Always);
1080        let table_area = Rect {
1081            height: inner.height.saturating_sub(u16::from(!fits)),
1082            ..inner
1083        };
1084        StatefulWidget::render(table, table_area, buf, &mut self.modal.schema_table_state);
1085
1086        if !fits && inner.height > 0 {
1087            let above = offset;
1088            let below = total_rows.saturating_sub(offset + take);
1089            let counted = match (above, below) {
1090                (0, 0) => None,
1091                (0, n) => Some(format!("{} below", format_int(n))),
1092                (n, 0) => Some(format!("{} above", format_int(n))),
1093                (a, b) => Some(format!("{} above, {} below", format_int(a), format_int(b))),
1094            };
1095            if let Some(text) = counted {
1096                Paragraph::new(text)
1097                    .style(Style::default().fg(self.theme.dimmed))
1098                    .alignment(ratatui::layout::Alignment::Right)
1099                    .render(
1100                        Rect {
1101                            y: inner.y + inner.height - 1,
1102                            height: 1,
1103                            ..inner
1104                        },
1105                        buf,
1106                    );
1107            }
1108        }
1109        // The rule, the header, the rows and the count under them.
1110        (2 + take + usize::from(!fits)).min(area.height as usize) as u16
1111    }
1112
1113    fn render_resources_tab(&self, area: Rect, buf: &mut Buffer) {
1114        // One past the longest label, "Buffer (Rows):", with room to spare.
1115        const LABEL_WIDTH: u16 = 17;
1116        let label_constraint = Constraint::Length(LABEL_WIDTH);
1117        let value_constraint = Constraint::Min(1);
1118        let mut y = area.y;
1119        let h = area.height;
1120        let w = area.width;
1121
1122        fn label_value_row(label: &str, value: &str, area: Rect, buf: &mut Buffer, label_w: u16) {
1123            let chunks = Layout::default()
1124                .direction(Direction::Horizontal)
1125                .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1126                .split(area);
1127            Paragraph::new(label).render(chunks[0], buf);
1128            Paragraph::new(value).render(chunks[1], buf);
1129        }
1130
1131        if y >= area.y + h {
1132            return;
1133        }
1134        let size_chunks = Layout::default()
1135            .direction(Direction::Horizontal)
1136            .constraints([label_constraint, value_constraint])
1137            .split(Rect {
1138                y,
1139                width: w,
1140                height: 1,
1141                ..area
1142            });
1143        // Drawn from what the worker left; reading the file here would hang the frame
1144        // on a mount that has stopped answering.
1145        let file_size = match self.ctx.facts {
1146            None | Some(FileFacts::Read { size: None, .. }) => Span::raw(crate::glyphs::get().dash),
1147            Some(FileFacts::Read {
1148                size: Some(size), ..
1149            }) => Span::raw(crate::numfmt::bytes(*size)),
1150            Some(FileFacts::Reading) => {
1151                Span::styled("reading...", Style::default().fg(self.theme.dimmed))
1152            }
1153            Some(FileFacts::Failed(why)) => Span::styled(
1154                crate::glyphs::fit(why, size_chunks[1].width as usize),
1155                Style::default().fg(self.theme.error),
1156            ),
1157        };
1158        Paragraph::new("File size:").render(size_chunks[0], buf);
1159        Paragraph::new(Line::from(file_size)).render(size_chunks[1], buf);
1160        y += 1;
1161
1162        if y >= area.y + h {
1163            return;
1164        }
1165        let fmt = self
1166            .ctx
1167            .format
1168            .map(|f| f.as_str())
1169            .unwrap_or(crate::glyphs::get().dash);
1170        label_value_row(
1171            "Format:",
1172            fmt,
1173            Rect {
1174                y,
1175                width: w,
1176                height: 1,
1177                ..area
1178            },
1179            buf,
1180            LABEL_WIDTH,
1181        );
1182        y += 1;
1183
1184        // How the open reads it: whether scrolling reads the file or memory.
1185        if let Some(read) = read_line(self.state) {
1186            if y >= area.y + h {
1187                return;
1188            }
1189            label_value_row(
1190                "Read:",
1191                &read,
1192                Rect {
1193                    y,
1194                    width: w,
1195                    height: 1,
1196                    ..area
1197                },
1198                buf,
1199                LABEL_WIDTH,
1200            );
1201            y += 1;
1202        }
1203
1204        if y >= area.y + h {
1205            return;
1206        }
1207        let buf_rows = self.state.buffered_rows();
1208        let max_rows = self.state.max_buffered_rows();
1209        let row_area = Rect {
1210            y,
1211            width: w,
1212            height: 1,
1213            ..area
1214        };
1215        let row_chunks = Layout::default()
1216            .direction(Direction::Horizontal)
1217            .constraints([label_constraint, value_constraint])
1218            .split(row_area);
1219        Paragraph::new("Buffer (Rows):").render(row_chunks[0], buf);
1220        // Values start in the value column, as every other row's do.
1221        if max_rows > 0 {
1222            let label = format!("{} / {}", format_int(buf_rows), format_int(max_rows));
1223            Paragraph::new(label).render(row_chunks[1], buf);
1224        } else {
1225            Paragraph::new(format_int(buf_rows)).render(row_chunks[1], buf);
1226        }
1227        y += 1;
1228
1229        if y >= area.y + h {
1230            return;
1231        }
1232        let buf_mb = self
1233            .state
1234            .buffered_memory_bytes()
1235            .map(|b| b / (1024 * 1024));
1236        let max_mb = self.state.max_buffered_mb();
1237        let mb_area = Rect {
1238            y,
1239            width: w,
1240            height: 1,
1241            ..area
1242        };
1243        let mb_chunks = Layout::default()
1244            .direction(Direction::Horizontal)
1245            .constraints([label_constraint, value_constraint])
1246            .split(mb_area);
1247        Paragraph::new("Buffer (MB):").render(mb_chunks[0], buf);
1248        if max_mb > 0 {
1249            let label = match buf_mb {
1250                Some(m) => format!("{:.1} / {} MiB", m as f64, max_mb),
1251                None => crate::glyphs::get().dash.to_string(),
1252            };
1253            Paragraph::new(label).render(mb_chunks[1], buf);
1254        } else {
1255            let value = buf_mb
1256                .map(|m| format!("{:.1} MiB", m as f64))
1257                .unwrap_or_else(|| {
1258                    self.state
1259                        .buffered_memory_bytes()
1260                        .map(|b| crate::numfmt::bytes(b as u64))
1261                        .unwrap_or_else(|| crate::glyphs::get().dash.to_string())
1262                });
1263            Paragraph::new(value).render(mb_chunks[1], buf);
1264        }
1265        y += 1;
1266
1267        self.render_measurements(area, buf, &mut y, LABEL_WIDTH);
1268    }
1269
1270    /// What the open cost, under its heading at the tab's foot. Only what was measured: no
1271    /// zeros for figures datui cannot stand behind (see [`crate::loading::measurements`] and
1272    /// `docs/user-guide/dataset-info.md`).
1273    fn render_measurements(&self, area: Rect, buf: &mut Buffer, y: &mut u16, label_w: u16) {
1274        let meter = self.state.measurements();
1275        let mut rows: Vec<(&str, String)> = [
1276            ("Listing:", "files", "file", meter.listing()),
1277            ("Footers:", "footers read", "footer read", meter.footers()),
1278            ("Last page:", "files read", "file read", meter.last_page()),
1279        ]
1280        .into_iter()
1281        .filter_map(|(label, unit, singular, cost)| {
1282            Some((label, measurement_line(&cost?, unit, singular)))
1283        })
1284        .collect();
1285        if let Some(total) = meter.total() {
1286            rows.push(("Total:", total_line(&total)));
1287        }
1288        if rows.is_empty() {
1289            return;
1290        }
1291        let bottom = area.y + area.height;
1292        // The heading with at least one row, or neither: blank at `y`, heading at `y + 1`,
1293        // first row at `y + 2` must all fit.
1294        if *y + 2 >= bottom {
1295            return;
1296        }
1297        *y += 1;
1298        SectionRule {
1299            title: "Measurements",
1300            chip: None,
1301        }
1302        .render(
1303            Rect {
1304                y: *y,
1305                width: area.width,
1306                height: 1,
1307                ..area
1308            },
1309            buf,
1310            self.theme,
1311        );
1312        *y += 1;
1313        for (label, line) in rows {
1314            if *y >= bottom {
1315                return;
1316            }
1317            let chunks = Layout::default()
1318                .direction(Direction::Horizontal)
1319                .constraints([Constraint::Length(label_w), Constraint::Min(1)])
1320                .split(Rect {
1321                    y: *y,
1322                    width: area.width,
1323                    height: 1,
1324                    ..area
1325                });
1326            Paragraph::new(label).render(chunks[0], buf);
1327            Paragraph::new(line).render(chunks[1], buf);
1328            *y += 1;
1329        }
1330    }
1331
1332    /// A delimited spec's metadata line: its title, then each key and value. A line
1333    /// that is not key=value pairs is shown as it is.
1334    fn render_metadata_tab(&mut self, area: Rect, buf: &mut Buffer) {
1335        let Some(read) = self.state.delimited_read().cloned() else {
1336            return;
1337        };
1338        let Some(metadata) = &read.metadata else {
1339            return;
1340        };
1341        if area.height == 0 || area.width < 8 {
1342            return;
1343        }
1344        let mut lines = Vec::new();
1345        if let Some(title) = &metadata.title {
1346            lines.push((title.clone(), Style::default()));
1347        }
1348        if let Some(file) = &read.facts_from {
1349            lines.push((format!("From {file}"), Style::default()));
1350        }
1351        let shown: Vec<(String, crate::formats::model_files::MetaValue)> =
1352            if metadata.pairs.is_empty() {
1353                let line = read.delimited().metadata_line.unwrap_or(1);
1354                vec![(
1355                    format!("line {line}"),
1356                    crate::formats::model_files::MetaValue::Text(metadata.raw.clone()),
1357                )]
1358            } else {
1359                metadata
1360                    .pairs
1361                    .iter()
1362                    .map(|(k, v)| {
1363                        (
1364                            k.clone(),
1365                            crate::formats::model_files::MetaValue::Text(v.clone()),
1366                        )
1367                    })
1368                    .collect()
1369            };
1370        self.render_detail(area, buf, &lines, "Metadata", &shown);
1371    }
1372
1373    /// A file's lines, its warnings, then its list: signals, tags, metadata, tracks.
1374    fn render_format_tab(&mut self, area: Rect, buf: &mut Buffer) {
1375        let state = self.state;
1376        let Some(detail) = state.format_detail().or_else(|| self.ctx.facts_detail()) else {
1377            // The facts that fill it are still out, or could not be read.
1378            let (said, style) = match self.ctx.facts {
1379                Some(FileFacts::Failed(why)) => {
1380                    (why.clone(), Style::default().fg(self.theme.error))
1381                }
1382                _ => (
1383                    "reading...".to_string(),
1384                    Style::default().fg(self.theme.dimmed),
1385                ),
1386            };
1387            Paragraph::new(crate::glyphs::fit(&said, area.width as usize))
1388                .style(style)
1389                .render(
1390                    Rect {
1391                        height: area.height.min(1),
1392                        ..area
1393                    },
1394                    buf,
1395                );
1396            return;
1397        };
1398        let warn = Style::default().fg(self.theme.warning);
1399        let lines: Vec<(String, Style)> = detail
1400            .lines
1401            .iter()
1402            .map(|line| (line.clone(), Style::default()))
1403            .chain(detail.warnings.iter().map(|line| (line.clone(), warn)))
1404            .collect();
1405        let pick = !detail.tables.is_empty();
1406        self.render_detail_list(area, buf, &lines, detail.list_title, &detail.list, pick);
1407    }
1408
1409    /// A detail tab: head lines, a blank, a rule titled `title`, then the key/value list
1410    /// scrolled by `detail_scroll`, each value wrapped whole.
1411    fn render_detail(
1412        &mut self,
1413        area: Rect,
1414        buf: &mut Buffer,
1415        lines: &[(String, Style)],
1416        title: &str,
1417        list: &[(String, crate::formats::model_files::MetaValue)],
1418    ) {
1419        self.render_detail_list(area, buf, lines, title, list, false);
1420    }
1421
1422    /// [`Self::render_detail`]; with `pick`, the list has a cursor on one entry
1423    /// (`detail_selected`), drawn with the rail, which the scroll follows.
1424    fn render_detail_list(
1425        &mut self,
1426        area: Rect,
1427        buf: &mut Buffer,
1428        lines: &[(String, Style)],
1429        title: &str,
1430        list: &[(String, crate::formats::model_files::MetaValue)],
1431        pick: bool,
1432    ) {
1433        if area.height == 0 || area.width < 8 {
1434            return;
1435        }
1436        let width = area.width as usize;
1437        let mut y = area.y;
1438        let bottom = area.y + area.height;
1439        // Wrapped rather than cut: a warning cut off mid-sentence says less than nothing.
1440        for (line, style) in lines {
1441            for part in wrap_to(line, width) {
1442                if y >= bottom {
1443                    return;
1444                }
1445                Paragraph::new(crate::glyphs::fit(&part, width))
1446                    .style(*style)
1447                    .render(
1448                        Rect {
1449                            y,
1450                            height: 1,
1451                            ..area
1452                        },
1453                        buf,
1454                    );
1455                y += 1;
1456            }
1457        }
1458        // A blank line, then the rule, then at least one line of the list, or none: an
1459        // empty list has no rule to count it.
1460        if list.is_empty() || y + 2 >= bottom {
1461            return;
1462        }
1463        y += 1;
1464        let count = group_u64(list.len() as u64);
1465        SectionRule {
1466            title,
1467            chip: Some(&count),
1468        }
1469        .render(
1470            Rect {
1471                y,
1472                height: 1,
1473                ..area
1474            },
1475            buf,
1476            self.theme,
1477        );
1478        y += 1;
1479
1480        // A list with a cursor keeps a column for its rail, so the cursor arriving
1481        // moves nothing.
1482        let gutter = u16::from(pick);
1483        let rows = metadata_lines(list, width.saturating_sub(gutter as usize));
1484        let room = (bottom - y) as usize;
1485        let fits = rows.len() <= room;
1486        // The last row says what is out of view when not everything fits.
1487        let shown = if fits { room } else { room.saturating_sub(1) };
1488        self.modal.detail_visible = shown;
1489        let max_scroll = rows.len().saturating_sub(shown);
1490        // The lines of the entry under the cursor: an entry's first line names it,
1491        // the lines a long value wraps onto leave its key blank.
1492        let picked = pick.then(|| {
1493            let starts: Vec<usize> = (0..rows.len())
1494                .filter(|&i| !rows[i].0.trim().is_empty())
1495                .collect();
1496            let at = self
1497                .modal
1498                .detail_selected
1499                .min(starts.len().saturating_sub(1));
1500            self.modal.detail_selected = at;
1501            let start = starts.get(at).copied().unwrap_or(0);
1502            let end = starts.get(at + 1).copied().unwrap_or(rows.len());
1503            if start < self.modal.detail_scroll {
1504                self.modal.detail_scroll = start;
1505            } else if end > self.modal.detail_scroll + shown {
1506                self.modal.detail_scroll = end.saturating_sub(shown).min(start);
1507            }
1508            start..end
1509        });
1510        self.modal.detail_scroll = self.modal.detail_scroll.min(max_scroll);
1511        let first = self.modal.detail_scroll;
1512        let key_style = Style::default().fg(self.theme.text_secondary);
1513        let rail = crate::glyphs::get().rail;
1514        for (i, (key, value)) in rows.iter().enumerate().skip(first).take(shown) {
1515            let row = Rect {
1516                y,
1517                height: 1,
1518                ..area
1519            };
1520            let on = picked.as_ref().is_some_and(|p| p.contains(&i));
1521            let (key_style, value_style) = if on {
1522                let hl = self.theme.highlight_style();
1523                (key_style.patch(hl), Style::default().patch(hl))
1524            } else {
1525                (key_style, Style::default())
1526            };
1527            if on && picked.as_ref().is_some_and(|p| p.start == i) {
1528                buf.set_string(row.x, row.y, rail, Style::default().fg(self.theme.accent));
1529            }
1530            Paragraph::new(Line::from(vec![
1531                Span::styled(key.clone(), key_style),
1532                Span::styled(value.clone(), value_style),
1533            ]))
1534            .render(
1535                Rect {
1536                    x: row.x + gutter,
1537                    width: row.width.saturating_sub(gutter),
1538                    ..row
1539                },
1540                buf,
1541            );
1542            y += 1;
1543        }
1544        if !fits && shown > 0 {
1545            let above = first;
1546            let below = rows.len().saturating_sub(first + shown);
1547            let text = match (above, below) {
1548                (0, n) => format!("{} below", group_chrome(n)),
1549                (n, 0) => format!("{} above", group_chrome(n)),
1550                (a, b) => format!("{} above, {} below", group_chrome(a), group_chrome(b)),
1551            };
1552            Paragraph::new(text)
1553                .style(Style::default().fg(self.theme.dimmed))
1554                .alignment(ratatui::layout::Alignment::Right)
1555                .render(
1556                    Rect {
1557                        y: bottom - 1,
1558                        height: 1,
1559                        ..area
1560                    },
1561                    buf,
1562                );
1563        }
1564    }
1565
1566    /// What datui noticed: each note's summary and its basis line. Whole notes only (a
1567    /// claim without its basis, or the reverse, misleads), as [`notes_window`] ensures.
1568    /// Plain styling: observations, not alarms.
1569    fn render_notes_tab(&mut self, area: Rect, buf: &mut Buffer) {
1570        let notes = self.state.notes();
1571        if area.height == 0 || area.width <= 4 || notes.is_empty() {
1572            return;
1573        }
1574        let selected = self.modal.notes_selected_index.min(notes.len() - 1);
1575        let width = area.width as usize;
1576        let blocks: Vec<Vec<NoteRow>> = notes
1577            .iter()
1578            .enumerate()
1579            .map(|(index, note)| note_rows(note, index == selected, width))
1580            .collect();
1581        let heights: Vec<usize> = blocks.iter().map(Vec::len).collect();
1582        let dim = Style::default().fg(self.theme.dimmed);
1583
1584        // Try the whole panel first; reserve a count row only when notes are left out.
1585        let full = area.height as usize;
1586        let (first, last) = notes_window(&heights, selected, self.modal.notes_scroll_offset, full);
1587        let all_shown = first == 0 && last == heights.len();
1588        // A row for the hidden count only when something is hidden and the note can spare it,
1589        // and for the read-as-text offer, without which the note is half a note.
1590        let offer = notes[selected]
1591            .read_as_text
1592            .as_ref()
1593            .map(|column| format!("Enter  read {column} as text"));
1594        let reserve = (!all_shown || offer.is_some()) && heights[selected] < full;
1595        let show = if reserve { full - 1 } else { full };
1596        if heights[selected] > show {
1597            // The selected note cannot show summary and basis: say so ("this one": another note
1598            // may fit).
1599            let count = notes.len();
1600            Paragraph::new(Line::from(Span::styled(
1601                format!(
1602                    "{} {} {} selected: no room",
1603                    group_chrome(count),
1604                    if count == 1 { "note" } else { "notes" },
1605                    crate::glyphs::get().middot
1606                ),
1607                dim,
1608            )))
1609            .render(Rect { height: 1, ..area }, buf);
1610            return;
1611        }
1612        let (first, last) = if reserve {
1613            notes_window(&heights, selected, self.modal.notes_scroll_offset, show)
1614        } else {
1615            (first, last)
1616        };
1617        self.modal.notes_scroll_offset = first;
1618
1619        let mut y = area.y;
1620        let bottom = area.y + show as u16;
1621        for (offset, block) in blocks[first..last].iter().enumerate() {
1622            if offset > 0 && y < bottom {
1623                y += 1;
1624            }
1625            for row in block {
1626                if y >= bottom {
1627                    break;
1628                }
1629                let at = Rect {
1630                    y,
1631                    height: 1,
1632                    ..area
1633                };
1634                if row.dim {
1635                    Paragraph::new(Line::from(Span::styled(row.text.clone(), dim))).render(at, buf);
1636                } else {
1637                    Paragraph::new(row.text.as_str()).render(at, buf);
1638                }
1639                y += 1;
1640            }
1641        }
1642
1643        // The offer and the hidden count share the last row: the short count first, the offer
1644        // gets the rest, so neither overwrites the other.
1645        let (above, below) = (first, notes.len() - last);
1646        let hidden = match (reserve, above, below) {
1647            (false, _, _) | (_, 0, 0) => None,
1648            (_, 0, n) => Some(format!("{} below", group_chrome(n))),
1649            (_, n, 0) => Some(format!("{} above", group_chrome(n))),
1650            (_, a, b) => Some(format!(
1651                "{} above, {} below",
1652                group_chrome(a),
1653                group_chrome(b)
1654            )),
1655        };
1656        if !reserve || (hidden.is_none() && offer.is_none()) {
1657            return;
1658        }
1659        let last_row = Rect {
1660            y: area.y + area.height - 1,
1661            height: 1,
1662            ..area
1663        };
1664        // A space between them, so they never read as one phrase when both are there.
1665        use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
1666        let taken = hidden
1667            .as_ref()
1668            .map(|text| (text.width() as u16).saturating_add(1))
1669            .unwrap_or(0);
1670        if let Some(offer) = offer.as_ref() {
1671            let room = last_row.width.saturating_sub(taken) as usize;
1672            // Cut with a mark, never silently: a truncated offer can read as a different one.
1673            let offer = if offer.width() > room {
1674                // Cut by width rather than by word: the spacing after the key name is
1675                // part of how the line reads, and wrapping would close it up.
1676                let mark = crate::glyphs::get().ellipsis;
1677                let mut kept = String::new();
1678                for ch in offer.chars() {
1679                    if kept.width() + ch.width().unwrap_or(0) + mark.width() > room {
1680                        break;
1681                    }
1682                    kept.push(ch);
1683                }
1684                Some(format!("{kept}{mark}"))
1685            } else {
1686                Some(offer.clone())
1687            };
1688            // Below about a word there is no offer left to make, only the mark.
1689            if let Some(offer) = offer.filter(|_| room >= 8) {
1690                Paragraph::new(Line::from(Span::styled(offer, dim))).render(
1691                    Rect {
1692                        width: room as u16,
1693                        ..last_row
1694                    },
1695                    buf,
1696                );
1697            }
1698        }
1699        if let Some(hidden) = hidden {
1700            Paragraph::new(Line::from(Span::styled(hidden, dim)))
1701                .right_aligned()
1702                .render(last_row, buf);
1703        }
1704    }
1705
1706    fn render_partitioned_data_tab(&self, area: Rect, buf: &mut Buffer) {
1707        let y = area.y;
1708        let w = area.width;
1709
1710        let Some(partition_columns) = self.state.partition_columns() else {
1711            Paragraph::new("Partition columns: unknown").render(
1712                Rect {
1713                    y,
1714                    width: w,
1715                    height: 1,
1716                    ..area
1717                },
1718                buf,
1719            );
1720            return;
1721        };
1722
1723        if partition_columns.is_empty() {
1724            Paragraph::new("Partition columns: none").render(
1725                Rect {
1726                    y,
1727                    width: w,
1728                    height: 1,
1729                    ..area
1730                },
1731                buf,
1732            );
1733            return;
1734        }
1735
1736        let line = format!("Partition columns: {}", partition_columns.join(", "));
1737        Paragraph::new(line).render(
1738            Rect {
1739                y,
1740                width: w,
1741                height: 1,
1742                ..area
1743            },
1744            buf,
1745        );
1746    }
1747}
1748
1749/// How long a stretch took without rounding it away: milliseconds to two places under a
1750/// second (seconds would print `0.00s` for most), floor `0.00 ms` under five
1751/// microseconds.
1752fn format_took(took: std::time::Duration) -> String {
1753    let ms = took.as_secs_f64() * 1000.0;
1754    // Round to the printed places before choosing the unit, so 999.997 ms prints `1.00s`,
1755    // not `1000.00 ms`.
1756    if (ms * 100.0).round() < 100_000.0 {
1757        format!("{ms:.2} ms")
1758    } else {
1759        format!("{:.2}s", took.as_secs_f64())
1760    }
1761}
1762
1763/// What datui asked for over a network, where it did the asking.
1764fn wire_line(wire: crate::loading::measurements::OverTheWire) -> String {
1765    let mut line = format!(
1766        ", {} request{}",
1767        format_int(wire.requests),
1768        if wire.requests == 1 { "" } else { "s" }
1769    );
1770    // Bytes only where datui counted them, so an unweighed stretch never prints `0 B`.
1771    if let Some(bytes) = wire.bytes {
1772        line.push_str(&format!(", {}", crate::numfmt::bytes(bytes)));
1773    }
1774    line
1775}
1776
1777/// One measurement as a line: time, count of what it counted, and requests and bytes
1778/// where datui made them. `unit` varies: footer passes count footers, which may
1779/// exceed the dataset's files.
1780fn measurement_line(
1781    cost: &crate::loading::measurements::Cost,
1782    unit: &str,
1783    singular: &str,
1784) -> String {
1785    let mut line = format_took(cost.took);
1786    // Without a count, just the time: no number that is not the dataset's size.
1787    if let Some(files) = cost.files {
1788        let unit = if files == 1 { singular } else { unit };
1789        line.push_str(&format!(", {} {unit}", format_int(files)));
1790    }
1791    if let Some(wire) = cost.over_the_wire {
1792        line.push_str(&wire_line(wire));
1793    }
1794    line
1795}
1796
1797/// The total as a line: time and requests, no file count (see
1798/// [`crate::loading::measurements::Meter::total`]).
1799fn total_line(total: &crate::loading::measurements::Total) -> String {
1800    let mut line = format_took(total.took);
1801    if let Some(wire) = total.over_the_wire {
1802        line.push_str(&wire_line(wire));
1803    }
1804    line
1805}
1806
1807/// Comma-group a count for the info panel. Thin alias over the shared chrome
1808/// formatter, kept so call sites and tests read the same as before.
1809fn format_int(n: usize) -> String {
1810    crate::numfmt::group_chrome(n)
1811}
1812
1813fn columns_by_type(schema: &Schema) -> String {
1814    let mut counts: HashMap<String, usize> = HashMap::new();
1815    for (_, dtype) in schema.iter() {
1816        let k = dtype.to_string();
1817        *counts.entry(k).or_default() += 1;
1818    }
1819    let mut pairs: Vec<_> = counts.into_iter().collect();
1820    pairs.sort_by(|a, b| a.0.cmp(&b.0));
1821    pairs
1822        .into_iter()
1823        .map(|(k, v)| format!("{}: {}", k, v))
1824        .collect::<Vec<_>>()
1825        .join(&format!(" {} ", crate::glyphs::get().middot))
1826}
1827
1828impl<'a> Widget for &mut DataTableInfo<'a> {
1829    fn render(self, area: Rect, buf: &mut Buffer) {
1830        let ctx = self.theme;
1831        let offered = TabsOffered {
1832            documentation: self.documentation.is_some(),
1833            ..TabsOffered::of(self.state, self.ctx.facts_tab)
1834        };
1835        let tab = self.modal.active_tab;
1836
1837        // The panel's own keys, said where they work and only while they work:
1838        // nothing here may live only in `?`.
1839        let g = crate::glyphs::get();
1840        let scrolls = match tab {
1841            InfoTab::Schema => true,
1842            InfoTab::Notes => offered.notes,
1843            InfoTab::Metadata => offered.metadata,
1844            InfoTab::Format => offered.format,
1845            InfoTab::Documentation => offered.documentation,
1846            _ => false,
1847        };
1848        // A tab whose list is the file's tables has a cursor, and Enter opens one.
1849        let tables = tab == InfoTab::Format
1850            && offered.format
1851            && self
1852                .state
1853                .format_detail()
1854                .is_some_and(|d| !d.tables.is_empty());
1855        let mut footer = HintBar::from_ctx(ctx)
1856            .screen(datui_cli::keys::Context::Info)
1857            .key("← / →")
1858            .weight(3);
1859        if tables {
1860            footer = footer.key("Enter").weight(2).key("↑ / ↓").weight(2);
1861        } else if scrolls {
1862            footer = footer.key_as("↑ / ↓", "Scroll").weight(2);
1863        }
1864        if tab == InfoTab::Documentation && offered.documentation {
1865            footer = footer.key_as("Enter", "Values").weight(1);
1866            if self
1867                .documentation
1868                .as_deref()
1869                .is_some_and(|d| d.offers_open())
1870            {
1871                footer = footer.key("o").weight(1);
1872            }
1873            footer = footer.key("y").weight(1);
1874        }
1875        if tab == InfoTab::Schema && self.header_toggle {
1876            footer = footer.key("H").weight(-1);
1877        }
1878        if self.hex {
1879            footer = footer.key("x").weight(0);
1880        }
1881        let footer = footer.key("Esc").weight(4);
1882        // A frame of three rows has one inside it: the body's, so a panel too
1883        // short for a note still says so rather than showing only keys.
1884        let surface = Surface::new("Info");
1885        let surface = if area.height > 3 {
1886            surface.footer(&footer)
1887        } else {
1888            surface
1889        };
1890        let content = surface.render(area, buf, ctx);
1891        if content.height == 0 || content.width < 4 {
1892            return;
1893        }
1894        // Short of height, the blank row under the tabs goes first, then the tab
1895        // line: the body is what the panel is for.
1896        let tab_rows = u16::from(content.height >= 4);
1897        let gap = u16::from(content.height >= 6);
1898
1899        // Tab line: the active tab is accented; the bar never takes focus (tabs switch from
1900        // anywhere), so no rail, slot kept.
1901        let tabs = InfoTab::visible(offered);
1902        let active = tabs[tab.index(offered)];
1903        let current = tab.index(offered);
1904        let mut spans = Vec::new();
1905        let mut clicks = Vec::new();
1906        for (i, t) in tabs.iter().enumerate() {
1907            let is_active = *t == active;
1908            if i > 0 {
1909                spans.push(Span::styled(
1910                    format!(" {}", g.rule),
1911                    Style::default().fg(ctx.dimmed),
1912                ));
1913            }
1914            spans.push(Span::raw(" "));
1915            let style = if is_active {
1916                Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
1917            } else {
1918                Style::default().fg(ctx.text_secondary)
1919            };
1920            // The Format tab is named by the file's detail (VCD, Model, Audio), or by
1921            // the format's descriptor while the facts that fill it are read.
1922            let title = match (t, self.state.format_detail()) {
1923                (InfoTab::Format, Some(detail)) => detail.tab,
1924                (InfoTab::Format, None) => self.ctx.facts_tab.unwrap_or(t.title()),
1925                _ => t.title(),
1926            };
1927            // A click steps the tabs there, as ← / → do from anywhere here.
1928            clicks.push((
1929                spans.len(),
1930                crate::app::pointer::Hit::Option {
1931                    field: None,
1932                    index: i,
1933                    current,
1934                },
1935            ));
1936            spans.push(Span::styled(title, style));
1937        }
1938        let tab_area = Rect {
1939            height: tab_rows,
1940            ..content
1941        };
1942        let line = Line::from(spans);
1943        if tab_rows > 0 {
1944            crate::app::pointer::record_spans(tab_area, &line, clicks);
1945        }
1946        Paragraph::new(line).render(tab_area, buf);
1947
1948        // A blank row under the tabs rather than a rule: the tab line is state,
1949        // not a section.
1950        let body = Rect {
1951            y: content.y + tab_rows + gap,
1952            height: content.height - tab_rows - gap,
1953            ..content
1954        };
1955        match tab {
1956            InfoTab::Schema => self.render_schema_tab(body, buf),
1957            InfoTab::Resources => self.render_resources_tab(body, buf),
1958            InfoTab::Metadata if offered.metadata => self.render_metadata_tab(body, buf),
1959            InfoTab::Format if offered.format => self.render_format_tab(body, buf),
1960            InfoTab::Partitions if offered.partitions => {
1961                self.render_partitioned_data_tab(body, buf)
1962            }
1963            InfoTab::Notes if offered.notes => self.render_notes_tab(body, buf),
1964            InfoTab::Documentation if offered.documentation => {
1965                if let Some(page) = self.documentation.as_deref_mut() {
1966                    crate::widgets::documentation::render_page(page, body, buf, ctx);
1967                }
1968            }
1969            InfoTab::Metadata
1970            | InfoTab::Format
1971            | InfoTab::Partitions
1972            | InfoTab::Notes
1973            | InfoTab::Documentation => self.render_schema_tab(body, buf),
1974        }
1975    }
1976}
1977
1978#[cfg(test)]
1979mod tests;