Skip to main content

datui_lib/widgets/
documentation.rs

1//! The Documentation view: what a catalog says of one dataset, and what the format spec
2//! that reads a file says of it, as a page to read.
3//!
4//! `Ctrl+E` on a home row opens it full screen; the Info panel's Documentation tab
5//! draws the same page for the open dataset. A catalog's word comes first: its
6//! description, its links, and a column's description, unit and legend each stand over
7//! the spec's. Fields are `label  value` lines, each link is a line of its own that is
8//! cut with `…` rather than wrapped (`y` copies it whole), and a column's value legend
9//! opens under it with `Enter`.
10
11use std::collections::HashSet;
12use std::sync::Arc;
13
14use ratatui::buffer::Buffer;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Paragraph, Widget};
19use unicode_width::UnicodeWidthStr;
20
21use crate::catalog::{ColumnNote, Dataset};
22use crate::formats::SpecDocs;
23use crate::glyphs;
24use crate::render::context::RenderContext;
25use crate::widgets::ui::{HintBar, SectionRule, Surface};
26
27/// The widest a line of prose runs on a wide terminal: a reading surface keeps its
28/// measure.
29const MEASURE: u16 = 110;
30
31/// What a page documents: a catalog's dataset, what the format spec that reads the file
32/// says of it, or both.
33#[derive(Debug, Clone, Default)]
34pub struct Documented {
35    /// The dataset, and the label of the catalog that lists it.
36    pub catalog: Option<(String, Arc<Dataset>)>,
37    /// What the format spec that reads the file says of it.
38    pub spec: Option<Arc<SpecDocs>>,
39    /// The file's name: the page's title when no catalog names it.
40    pub name: String,
41}
42
43impl Documented {
44    /// The page of a catalog's dataset, of what a spec says, or of both; `None` with
45    /// neither.
46    pub fn new(
47        catalog: Option<(String, Arc<Dataset>)>,
48        spec: Option<Arc<SpecDocs>>,
49        name: String,
50    ) -> Option<Self> {
51        (catalog.is_some() || spec.is_some()).then_some(Self {
52            catalog,
53            spec,
54            name,
55        })
56    }
57
58    /// The page's title: the catalog's name for the dataset, else the file's.
59    pub fn title(&self) -> &str {
60        match &self.catalog {
61            Some((_, entry)) => &entry.name,
62            None => &self.name,
63        }
64    }
65
66    /// Each column's note, in the spec's order, then the columns only the catalog
67    /// notes. Where both note a column, the catalog's description, unit and legend each
68    /// stand over the spec's when it gives one.
69    pub fn columns(&self) -> Vec<(String, ColumnNote)> {
70        let catalog: &[(String, ColumnNote)] = self
71            .catalog
72            .as_ref()
73            .map_or(&[], |(_, entry)| &entry.columns);
74        let spec: &[(String, ColumnNote)] = self.spec.as_ref().map_or(&[], |s| &s.columns);
75        let noted = |name: &str| catalog.iter().find(|(n, _)| n == name).map(|(_, n)| n);
76        let mut out: Vec<(String, ColumnNote)> = spec
77            .iter()
78            .map(|(name, note)| {
79                let note = match noted(name) {
80                    Some(over) => layered(over, note),
81                    None => note.clone(),
82                };
83                (name.clone(), note)
84            })
85            .collect();
86        out.extend(
87            catalog
88                .iter()
89                .filter(|(name, _)| !spec.iter().any(|(n, _)| n == name))
90                .cloned(),
91        );
92        out
93    }
94}
95
96/// `over`'s description, unit and legend, each in place of `under`'s when it gives one.
97fn layered(over: &ColumnNote, under: &ColumnNote) -> ColumnNote {
98    let pick = |a: &String, b: &String| if a.is_empty() { b } else { a }.clone();
99    ColumnNote {
100        description: pick(&over.description, &under.description),
101        unit: pick(&over.unit, &under.unit),
102        values: if over.values.is_empty() {
103            under.values.clone()
104        } else {
105            over.values.clone()
106        },
107        ty: pick(&over.ty, &under.ty),
108    }
109}
110
111/// A section of named fields of a spec's header or footer, each with its note.
112fn field_section(out: &mut Vec<DocLine>, title: &'static str, fields: &[(String, ColumnNote)]) {
113    if fields.is_empty() {
114        return;
115    }
116    out.push(DocLine::Blank);
117    out.push(DocLine::Section(title, fields.len()));
118    for (name, note) in fields {
119        out.push(DocLine::Column {
120            name: name.clone(),
121            about: note.about(),
122            values: 0,
123        });
124    }
125}
126
127/// One line of the page, before it is laid out to a width.
128#[derive(Debug, Clone, PartialEq)]
129pub enum DocLine {
130    /// The dataset's description, wrapped.
131    About(String),
132    /// `label  value`, the value wrapped under itself.
133    Field(&'static str, String),
134    /// `label  url` on one line, never wrapped: cut with `…` when too wide.
135    Link(&'static str, String),
136    /// A section title on a rule, with a count.
137    Section(&'static str, usize),
138    /// A column: its name, what it means, and how many codes its legend has.
139    Column {
140        name: String,
141        about: String,
142        values: usize,
143    },
144    /// One code of an open legend.
145    Legend(String, String),
146    /// A record type of a format spec: its name, and what picks it, its columns and
147    /// what it is.
148    RecordType(String, String),
149    /// A bookmark: its name and where it goes.
150    Bookmark(String, String),
151    Blank,
152}
153
154impl DocLine {
155    /// Whether the cursor stops here.
156    fn focusable(&self) -> bool {
157        !matches!(self, DocLine::Blank | DocLine::Section(..))
158    }
159
160    /// What `y` copies on this line: a link's URL, a bookmark's path, a field's value.
161    pub fn copy_text(&self) -> Option<&str> {
162        match self {
163            DocLine::Link(_, url) => Some(url),
164            DocLine::Field(_, value) => Some(value),
165            DocLine::Bookmark(_, path) => Some(path),
166            DocLine::Legend(code, _) => Some(code),
167            DocLine::Column { name, .. } => Some(name),
168            DocLine::RecordType(name, _) => Some(name),
169            DocLine::About(text) => Some(text),
170            DocLine::Section(..) | DocLine::Blank => None,
171        }
172    }
173}
174
175/// The page's lines for `doc`, with the legends of `expanded` columns open. `measured`
176/// is the size something has measured, if any.
177pub fn lines(doc: &Documented, expanded: &HashSet<String>, measured: Option<u64>) -> Vec<DocLine> {
178    let mut out = Vec::new();
179    let entry = doc.catalog.as_ref().map(|(_, entry)| entry.as_ref());
180    let spec = doc.spec.as_deref();
181    // The catalog's word, else the spec's.
182    let first_said = |of_entry: Option<&str>, of_spec: Option<&str>| {
183        [of_entry, of_spec]
184            .into_iter()
185            .flatten()
186            .find(|text| !text.is_empty())
187            .unwrap_or_default()
188            .to_string()
189    };
190    let description = first_said(
191        entry.map(|e| e.description.as_str()),
192        spec.map(|s| s.description.as_str()),
193    );
194    if !description.is_empty() {
195        out.push(DocLine::About(description));
196        out.push(DocLine::Blank);
197    }
198    if let Some((catalog, entry)) = &doc.catalog {
199        catalog_fields(&mut out, entry, catalog, measured);
200    }
201    if let Some(spec) = spec {
202        out.push(DocLine::Field("format spec", spec.spec.clone()));
203        if let Some(file) = &spec.file {
204            out.push(DocLine::Field("spec file", crate::home::display_path(file)));
205        }
206    }
207    let documentation = first_said(
208        entry.map(|e| e.documentation.as_str()),
209        spec.map(|s| s.documentation.as_str()),
210    );
211    let links: Vec<(&'static str, String)> = [
212        (
213            "homepage",
214            entry.map(|e| e.homepage.clone()).unwrap_or_default(),
215        ),
216        ("documentation", documentation),
217    ]
218    .into_iter()
219    .filter(|(_, url)| !url.is_empty())
220    .collect();
221    if !links.is_empty() {
222        out.push(DocLine::Blank);
223        out.push(DocLine::Section("LINKS", links.len()));
224        for (label, url) in links {
225            out.push(DocLine::Link(label, url));
226        }
227    }
228    if let Some(spec) = spec {
229        field_section(&mut out, "HEADER", &spec.header);
230    }
231    if let Some(spec) = spec.filter(|s| !s.record_types.is_empty()) {
232        let middot = glyphs::get().middot;
233        out.push(DocLine::Blank);
234        out.push(DocLine::Section("RECORD TYPES", spec.record_types.len()));
235        for record in &spec.record_types {
236            let columns = match record.columns {
237                1 => "1 column".to_string(),
238                n => format!("{n} columns"),
239            };
240            let mut about = format!("{} {middot} {columns}", record.picked_by);
241            if !record.description.is_empty() {
242                about = format!("{about} {middot} {}", record.description);
243            }
244            out.push(DocLine::RecordType(record.name.clone(), about));
245        }
246    }
247    let columns = doc.columns();
248    if !columns.is_empty() {
249        out.push(DocLine::Blank);
250        out.push(DocLine::Section("COLUMNS", columns.len()));
251        for (name, note) in &columns {
252            out.push(DocLine::Column {
253                name: name.clone(),
254                about: note.about(),
255                values: note.values.len(),
256            });
257            if expanded.contains(name) {
258                for (code, meaning) in &note.values {
259                    let code = if code.is_empty() { "blank" } else { code };
260                    out.push(DocLine::Legend(code.to_string(), meaning.clone()));
261                }
262            }
263        }
264    }
265    if let Some(spec) = spec {
266        field_section(&mut out, "FOOTER", &spec.footer);
267    }
268    if let Some(entry) = entry.filter(|e| !e.bookmarks.is_empty()) {
269        out.push(DocLine::Blank);
270        out.push(DocLine::Section("BOOKMARKS", entry.bookmarks.len()));
271        for (name, path) in &entry.bookmarks {
272            out.push(DocLine::Bookmark(name.clone(), path.clone()));
273        }
274    }
275    out
276}
277
278/// What the catalog says of where `entry` is and whose it is.
279fn catalog_fields(out: &mut Vec<DocLine>, entry: &Dataset, catalog: &str, measured: Option<u64>) {
280    out.push(DocLine::Field("catalog", catalog.to_string()));
281    for (label, value) in [("publisher", &entry.publisher), ("license", &entry.license)] {
282        if !value.is_empty() {
283            out.push(DocLine::Field(label, value.clone()));
284        }
285    }
286    out.push(DocLine::Field("format", format_of(entry)));
287    match (&entry.path, &entry.url) {
288        (Some(_), _) => out.push(DocLine::Field(
289            "path",
290            crate::home::display_path(&entry.location()),
291        )),
292        (None, Some(url)) => {
293            out.push(DocLine::Link("url", url.clone()));
294            out.push(DocLine::Field("login", crate::home::login_of(entry)));
295        }
296        (None, None) => {}
297    }
298    match (measured, entry.size) {
299        (Some(size), _) => out.push(DocLine::Field("size", crate::discover::format_size(size))),
300        (None, Some(hint)) => out.push(DocLine::Field(
301            "size",
302            format!("~{}", crate::discover::format_size(hint)),
303        )),
304        (None, None) => {}
305    }
306}
307
308/// What the dataset is, in a word: its file format, or `directory`.
309fn format_of(entry: &Dataset) -> String {
310    let location = entry.location();
311    let text = location.to_string_lossy();
312    if text.ends_with('/') || (entry.path.is_some() && location.is_dir()) {
313        return "directory".to_string();
314    }
315    crate::FileFormat::from_path(&location)
316        .map(|f| f.name().to_string())
317        .unwrap_or_else(|| {
318            if crate::catalog::is_object_store_dataset(&text) {
319                "directory".to_string()
320            } else {
321                "file".to_string()
322            }
323        })
324}
325
326/// The view's state: the page shown, where the cursor is, which legends are open.
327#[derive(Debug, Clone, Default)]
328pub struct DocState {
329    pub doc: Option<Documented>,
330    /// What has measured the file, if anything has: shown in place of the hint.
331    pub measured: Option<u64>,
332    /// Index into [`Self::lines`] of the line the cursor is on.
333    pub cursor: usize,
334    /// First visual row drawn.
335    pub scroll: usize,
336    /// Columns whose legends are open.
337    pub expanded: HashSet<String>,
338    /// Rows the last frame had room for, for paging.
339    pub view_height: usize,
340    /// Whether `o` opens a link here: set by the app when it opens the page.
341    pub links_open: bool,
342}
343
344impl DocState {
345    /// Show `doc`, from the top, every legend closed.
346    pub fn open(&mut self, doc: Documented, measured: Option<u64>) {
347        *self = Self {
348            doc: Some(doc),
349            measured,
350            ..Self::default()
351        };
352        self.cursor = self.first_focusable();
353    }
354
355    pub fn close(&mut self) {
356        *self = Self::default();
357    }
358
359    pub fn is_open(&self) -> bool {
360        self.doc.is_some()
361    }
362
363    pub fn lines(&self) -> Vec<DocLine> {
364        match &self.doc {
365            Some(doc) => lines(doc, &self.expanded, self.measured),
366            None => Vec::new(),
367        }
368    }
369
370    fn first_focusable(&self) -> usize {
371        self.lines()
372            .iter()
373            .position(DocLine::focusable)
374            .unwrap_or(0)
375    }
376
377    /// Move the cursor `delta` focusable lines, stopping at either end.
378    pub fn move_cursor(&mut self, delta: isize) {
379        let lines = self.lines();
380        let focusable: Vec<usize> = (0..lines.len()).filter(|&i| lines[i].focusable()).collect();
381        if focusable.is_empty() {
382            return;
383        }
384        let at = focusable
385            .iter()
386            .position(|&i| i >= self.cursor)
387            .unwrap_or(focusable.len() - 1) as isize;
388        let to = (at + delta).clamp(0, focusable.len() as isize - 1) as usize;
389        self.cursor = focusable[to];
390    }
391
392    /// Open or close the legend of the column the cursor is on. False when it is not on
393    /// a column with one.
394    pub fn toggle_legend(&mut self) -> bool {
395        let lines = self.lines();
396        // On a code of an open legend, the legend's column.
397        let column = lines[..=self.cursor.min(lines.len().saturating_sub(1))]
398            .iter()
399            .rev()
400            .find_map(|line| match line {
401                DocLine::Column { name, values, .. } if *values > 0 => Some(name.clone()),
402                DocLine::Legend(..) => None,
403                _ => Some(String::new()),
404            })
405            .filter(|name| !name.is_empty());
406        let on_legend_or_column = matches!(
407            lines.get(self.cursor),
408            Some(DocLine::Legend(..)) | Some(DocLine::Column { values: 1.., .. })
409        );
410        let Some(column) = column.filter(|_| on_legend_or_column) else {
411            return false;
412        };
413        if !self.expanded.remove(&column) {
414            self.expanded.insert(column.clone());
415        }
416        // Back onto the column's own line when its legend closes under the cursor.
417        let lines = self.lines();
418        if let Some(at) = lines
419            .iter()
420            .position(|l| matches!(l, DocLine::Column { name, values: 1.., .. } if *name == column))
421            && !self.expanded.contains(&column)
422        {
423            self.cursor = at;
424        }
425        true
426    }
427
428    /// The link on the cursor's line, as the page has it: what `o` offers to open.
429    /// Only a Link line's; a value or a bookmark is never opened.
430    pub fn link(&self) -> Option<String> {
431        match self.lines().get(self.cursor) {
432            Some(DocLine::Link(_, url)) => Some(url.clone()),
433            _ => None,
434        }
435    }
436
437    /// Whether the footer offers `o`: on a link, where a browser would show it.
438    pub fn offers_open(&self) -> bool {
439        self.links_open && self.link().is_some()
440    }
441
442    /// What `y` copies at the cursor.
443    pub fn copy_text(&self) -> Option<String> {
444        self.lines()
445            .get(self.cursor)
446            .and_then(DocLine::copy_text)
447            .map(str::to_string)
448    }
449}
450
451/// `text` cut to `width` columns with the ellipsis when it does not fit.
452fn cut(text: &str, width: usize) -> String {
453    if text.width() <= width {
454        return text.to_string();
455    }
456    let ellipsis = glyphs::get().ellipsis;
457    let room = width.saturating_sub(ellipsis.width());
458    let mut out = String::new();
459    for c in text.chars() {
460        if out.width() + c.to_string().width() > room {
461            break;
462        }
463        out.push(c);
464    }
465    out.push_str(ellipsis);
466    out
467}
468
469/// `text` wrapped at word boundaries to `width` columns.
470fn wrap(text: &str, width: usize) -> Vec<String> {
471    let width = width.max(8);
472    let mut rows = Vec::new();
473    let mut row = String::new();
474    for word in text.split_whitespace() {
475        let candidate = if row.is_empty() {
476            word.to_string()
477        } else {
478            format!("{row} {word}")
479        };
480        if candidate.width() <= width || row.is_empty() {
481            row = candidate;
482            while row.width() > width {
483                // One word longer than the row: cut it where the row ends.
484                let head: String = row.chars().take(width).collect();
485                let rest: String = row.chars().skip(width).collect();
486                rows.push(head);
487                row = rest;
488            }
489        } else {
490            rows.push(std::mem::take(&mut row));
491            row = word.to_string();
492        }
493    }
494    if !row.is_empty() || rows.is_empty() {
495        rows.push(row);
496    }
497    rows
498}
499
500/// Lay `lines` out at `width`: the visual rows of each, and its first row's index.
501fn layout(
502    lines: &[DocLine],
503    width: usize,
504    cursor: usize,
505    ctx: &RenderContext,
506) -> (Vec<Line<'static>>, Vec<usize>) {
507    let g = glyphs::get();
508    let key_w = lines
509        .iter()
510        .filter_map(|l| match l {
511            DocLine::Field(label, _) | DocLine::Link(label, _) => Some(label.len()),
512            _ => None,
513        })
514        .max()
515        .unwrap_or(8)
516        + 2;
517    let col_w = lines
518        .iter()
519        .filter_map(|l| match l {
520            DocLine::Column { name, .. }
521            | DocLine::Bookmark(name, _)
522            | DocLine::RecordType(name, _) => Some(name.width()),
523            _ => None,
524        })
525        .max()
526        .unwrap_or(0)
527        .min(width / 3)
528        + 2;
529    let label = Style::default().fg(ctx.label);
530    let plain = Style::default().fg(ctx.text_primary);
531    let dim = Style::default().fg(ctx.dimmed);
532    // One column for the rail, as every list in datui reserves it.
533    let inner = width.saturating_sub(1);
534    let mut rows: Vec<Line<'static>> = Vec::new();
535    let mut starts = Vec::with_capacity(lines.len());
536    for (i, line) in lines.iter().enumerate() {
537        starts.push(rows.len());
538        let focused = i == cursor;
539        let rail = if focused { g.rail } else { " " };
540        let rail = Span::styled(rail, Style::default().fg(ctx.accent));
541        let key_style = if focused {
542            Style::default().fg(ctx.accent)
543        } else {
544            label
545        };
546        let mut push = |spans: Vec<Span<'static>>, first: bool| {
547            let mut all = vec![if first { rail.clone() } else { Span::raw(" ") }];
548            all.extend(spans);
549            let mut line = Line::from(all);
550            if focused {
551                line = line.patch_style(ctx.highlight_style());
552            }
553            rows.push(line);
554        };
555        match line {
556            DocLine::Blank => rows.push(Line::from("")),
557            DocLine::Section(..) => rows.push(Line::from("")),
558            DocLine::About(text) => {
559                for (n, row) in wrap(text, inner).into_iter().enumerate() {
560                    push(vec![Span::styled(row, plain)], n == 0);
561                }
562            }
563            DocLine::Field(key, value) => {
564                let room = inner.saturating_sub(key_w);
565                for (n, row) in wrap(value, room).into_iter().enumerate() {
566                    let head = if n == 0 {
567                        format!("{key:<key_w$}")
568                    } else {
569                        " ".repeat(key_w)
570                    };
571                    push(
572                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
573                        n == 0,
574                    );
575                }
576            }
577            DocLine::Link(key, url) => {
578                let room = inner.saturating_sub(key_w);
579                push(
580                    vec![
581                        Span::styled(format!("{key:<key_w$}"), key_style),
582                        Span::styled(cut(url, room), plain.add_modifier(Modifier::UNDERLINED)),
583                    ],
584                    true,
585                );
586            }
587            DocLine::Column {
588                name,
589                about,
590                values,
591            } => {
592                let marker = if *values == 0 {
593                    String::new()
594                } else {
595                    let open = if i + 1 < lines.len() && matches!(lines[i + 1], DocLine::Legend(..))
596                    {
597                        g.expanded
598                    } else {
599                        g.collapsed
600                    };
601                    format!("  {}{values} values", open)
602                };
603                let room = inner.saturating_sub(col_w);
604                let text = format!("{about}{marker}");
605                for (n, row) in wrap(&text, room).into_iter().enumerate() {
606                    let head = if n == 0 {
607                        format!("{:<col_w$}", cut(name, col_w - 2))
608                    } else {
609                        " ".repeat(col_w)
610                    };
611                    push(
612                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
613                        n == 0,
614                    );
615                }
616            }
617            DocLine::RecordType(name, about) => {
618                let room = inner.saturating_sub(col_w);
619                for (n, row) in wrap(about, room).into_iter().enumerate() {
620                    let head = if n == 0 {
621                        format!("{:<col_w$}", cut(name, col_w - 2))
622                    } else {
623                        " ".repeat(col_w)
624                    };
625                    push(
626                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
627                        n == 0,
628                    );
629                }
630            }
631            DocLine::Legend(code, meaning) => {
632                let pad = col_w + 2;
633                let code_w = 6usize.max(code.width() + 2);
634                let room = inner.saturating_sub(pad + code_w);
635                for (n, row) in wrap(meaning, room).into_iter().enumerate() {
636                    let head = if n == 0 {
637                        format!("{}{code:<code_w$}", " ".repeat(pad))
638                    } else {
639                        " ".repeat(pad + code_w)
640                    };
641                    push(
642                        vec![Span::styled(head, dim), Span::styled(row, plain)],
643                        n == 0,
644                    );
645                }
646            }
647            DocLine::Bookmark(name, path) => {
648                let room = inner.saturating_sub(col_w);
649                push(
650                    vec![
651                        Span::styled(format!("{:<col_w$}", cut(name, col_w - 2)), key_style),
652                        Span::styled(cut(path, room), plain),
653                    ],
654                    true,
655                );
656            }
657        }
658    }
659    (rows, starts)
660}
661
662/// Draw the page in `area` (no frame): what the Info tab and the full-screen view share.
663/// Keeps the cursor's line in view.
664pub fn render_page(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
665    if area.width < 10 || area.height == 0 {
666        return;
667    }
668    let area = Rect {
669        width: area.width.min(MEASURE),
670        ..area
671    };
672    let lines = state.lines();
673    state.cursor = state.cursor.min(lines.len().saturating_sub(1));
674    let (rows, starts) = layout(&lines, area.width as usize, state.cursor, ctx);
675    let height = area.height as usize;
676    state.view_height = height;
677    // The cursor's whole line in view, and the section title above a first line.
678    let first = starts.get(state.cursor).copied().unwrap_or(0);
679    let last = starts
680        .get(state.cursor + 1)
681        .copied()
682        .unwrap_or(rows.len())
683        .saturating_sub(1);
684    if first < state.scroll {
685        state.scroll = first;
686    }
687    if last >= state.scroll + height {
688        state.scroll = last + 1 - height.min(last + 1);
689    }
690    if state.scroll > 0 && state.cursor == state.first_focusable() {
691        state.scroll = 0;
692    }
693    let shown: Vec<Line> = rows
694        .iter()
695        .skip(state.scroll)
696        .take(height)
697        .cloned()
698        .collect();
699    Paragraph::new(shown).render(area, buf);
700    // Section titles are drawn over their placeholder rows, as rules.
701    for (i, line) in lines.iter().enumerate() {
702        let DocLine::Section(title, count) = line else {
703            continue;
704        };
705        let Some(row) = starts[i].checked_sub(state.scroll).filter(|r| *r < height) else {
706            continue;
707        };
708        let chip = count.to_string();
709        SectionRule {
710            title,
711            chip: Some(&chip),
712        }
713        .render(
714            Rect {
715                x: area.x + 1,
716                y: area.y + row as u16,
717                width: area.width.saturating_sub(1),
718                height: 1,
719            },
720            buf,
721            ctx,
722        );
723    }
724}
725
726/// The full-screen view: the page in a frame titled with the dataset's name, and the
727/// keys that work on it.
728pub fn render_view(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
729    let Some(title) = state.doc.as_ref().map(|d| d.title().to_string()) else {
730        return;
731    };
732    let mut footer = HintBar::from_ctx(ctx);
733    let lines = state.lines();
734    match lines.get(state.cursor) {
735        Some(DocLine::Column { values: 1.., .. }) | Some(DocLine::Legend(..)) => {
736            footer = footer.hint("Enter", "Values");
737        }
738        _ => {}
739    }
740    if state.offers_open() {
741        footer = footer.hint("o", "Open");
742    }
743    footer = footer.hint("y", "Copy").hint("Esc", "Back");
744    let title = format!("Documentation {} {title}", glyphs::get().trail);
745    let inner = Surface::new(&title).footer(&footer).render(area, buf, ctx);
746    render_page(state, inner, buf, ctx);
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752
753    fn noaa() -> Arc<Dataset> {
754        Arc::new(
755            crate::catalog::bundled()
756                .datasets
757                .into_iter()
758                .find(|d| d.id == "noaa")
759                .unwrap(),
760        )
761    }
762
763    fn page(entry: Arc<Dataset>) -> Documented {
764        Documented {
765            catalog: Some(("Example datasets".into(), entry)),
766            ..Documented::default()
767        }
768    }
769
770    fn screen(state: &mut DocState, width: u16, height: u16) -> Vec<String> {
771        let ctx = RenderContext::for_test();
772        let area = Rect::new(0, 0, width, height);
773        let mut buf = Buffer::empty(area);
774        render_view(state, area, &mut buf, &ctx);
775        (0..height)
776            .map(|y| {
777                (0..width)
778                    .map(|x| buf[(x, y)].symbol().to_string())
779                    .collect::<String>()
780                    .trim_end()
781                    .to_string()
782            })
783            .collect()
784    }
785
786    #[test]
787    fn a_link_is_one_line_cut_never_wrapped() {
788        let mut state = DocState::default();
789        state.open(page(noaa()), None);
790        let rows = screen(&mut state, 50, 40);
791        let text = rows.join("\n");
792        assert!(text.contains("publisher"), "{text}");
793        let docs: Vec<&String> = rows
794            .iter()
795            .filter(|r| r.contains("documentation"))
796            .collect();
797        assert_eq!(docs.len(), 1, "{text}");
798        assert!(docs[0].contains(glyphs::get().ellipsis), "{text}");
799        assert!(!text.contains("readme.txt"), "{text}");
800        assert!(text.contains("LINKS"), "{text}");
801    }
802
803    #[test]
804    fn y_copies_the_whole_link_and_enter_opens_a_legend() {
805        let mut state = DocState::default();
806        state.open(page(noaa()), None);
807        while !matches!(
808            state.lines()[state.cursor],
809            DocLine::Link("documentation", _)
810        ) {
811            state.move_cursor(1);
812        }
813        assert_eq!(
814            state.copy_text().as_deref(),
815            Some("https://www.ncei.noaa.gov/pub/data/ghcn/daily/readme.txt")
816        );
817        while !matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "ELEMENT")
818        {
819            state.move_cursor(1);
820        }
821        assert!(state.toggle_legend());
822        let text = screen(&mut state, 100, 60).join("\n");
823        assert!(text.contains("PRCP"), "{text}");
824        state.move_cursor(3);
825        assert!(state.toggle_legend(), "closes from inside the legend");
826        assert!(!screen(&mut state, 100, 60).join("\n").contains("PRCP"));
827        assert!(
828            matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "ELEMENT")
829        );
830    }
831
832    const ORDERS: &str = r#"
833name = "acme.orders"
834description = "Order entry capture"
835documentation = "https://example.com/orders.pdf"
836match = { glob = "*.ord" }
837
838[records]
839framing = "length_prefixed"
840size = "len"
841type = "kind"
842fields = [{ name = "len", type = "u2" }, { name = "kind", type = "str", size = 1 }]
843
844[[variants]]
845name = "add"
846when = "A"
847description = "An order added to the book"
848fields = [
849  { name = "price", type = "u4", scale = 4, description = "Limit price", unit = "USD" },
850  { name = "side", type = "u1", enum = { 1 = "BUY", 2 = "SELL" } },
851]
852
853[[variants]]
854name = "exec"
855when = ["E", "C"]
856fields = [{ name = "shares", type = "u4", description = "Shares executed" }]
857"#;
858
859    fn spec_page(text: &str) -> Documented {
860        let spec = crate::formats::Spec::parse(text, None).unwrap();
861        Documented::new(None, spec.docs().map(Arc::new), "day.ord".into()).unwrap()
862    }
863
864    #[test]
865    fn a_spec_read_from_a_file_names_its_file() {
866        let file = dirs::home_dir().unwrap().join("specs").join("orders.toml");
867        let spec = crate::formats::Spec::parse(ORDERS, Some(&file)).unwrap();
868        let doc = Documented::new(None, spec.docs().map(Arc::new), "day.ord".into()).unwrap();
869        let page = lines(&doc, &HashSet::new(), None);
870        let at = |line: &DocLine| page.iter().position(|l| l == line);
871        let name = at(&DocLine::Field("format spec", "acme.orders".into())).unwrap();
872        let shown = crate::home::display_path(&file);
873        assert!(shown.starts_with('~'), "{shown}");
874        assert_eq!(at(&DocLine::Field("spec file", shown)), Some(name + 1));
875        // Parsed from text, there is no file to name.
876        let parsed = lines(&spec_page(ORDERS), &HashSet::new(), None);
877        assert!(
878            !parsed
879                .iter()
880                .any(|l| matches!(l, DocLine::Field("spec file", _)))
881        );
882    }
883
884    #[test]
885    fn a_variant_specs_page_lists_record_types_columns_and_legends() {
886        let mut state = DocState::default();
887        state.open(spec_page(ORDERS), None);
888        let m = glyphs::get().middot;
889        let lines = state.lines();
890        assert_eq!(lines[0], DocLine::About("Order entry capture".into()));
891        assert!(lines.contains(&DocLine::Field("format spec", "acme.orders".into())));
892        assert!(lines.contains(&DocLine::Link(
893            "documentation",
894            "https://example.com/orders.pdf".into()
895        )));
896        assert!(lines.contains(&DocLine::Section("RECORD TYPES", 2)));
897        assert!(lines.contains(&DocLine::RecordType(
898            "add".into(),
899            format!("kind = \"A\" {m} 4 columns {m} An order added to the book")
900        )));
901        assert!(lines.contains(&DocLine::RecordType(
902            "exec".into(),
903            format!("kind in (\"E\", \"C\") {m} 3 columns")
904        )));
905        assert!(lines.contains(&DocLine::Section("COLUMNS", 3)));
906        assert!(lines.contains(&DocLine::Column {
907            name: "price".into(),
908            about: "Limit price (USD)".into(),
909            values: 0,
910        }));
911        // The enum is the column's legend, opened with Enter.
912        while !matches!(&state.lines()[state.cursor], DocLine::Column { name, .. } if name == "side")
913        {
914            state.move_cursor(1);
915        }
916        assert!(state.toggle_legend());
917        assert!(
918            state
919                .lines()
920                .contains(&DocLine::Legend("2".into(), "SELL".into()))
921        );
922        let text = screen(&mut state, 100, 40).join("\n");
923        assert!(text.contains("Documentation"), "{text}");
924        assert!(text.contains("day.ord"), "{text}");
925        assert!(text.contains("RECORD TYPES"), "{text}");
926        assert!(!text.contains("catalog"), "{text}");
927    }
928
929    #[test]
930    fn a_delimited_specs_page_lists_its_column_notes() {
931        let doc = spec_page(
932            r#"
933name = "acme.log"
934kind = "delimited"
935description = "Instrument log"
936
937[columns]
938temp = { description = "Air temperature", unit = "deg F" }
939"#,
940        );
941        let lines = lines(&doc, &HashSet::new(), None);
942        assert!(
943            !lines
944                .iter()
945                .any(|l| matches!(l, DocLine::Section("RECORD TYPES", _)))
946        );
947        assert!(lines.contains(&DocLine::Column {
948            name: "temp".into(),
949            about: "Air temperature (deg F)".into(),
950            values: 0,
951        }));
952    }
953
954    /// A declared type stands beside the unit, as the type row pairs them.
955    #[test]
956    fn a_typed_column_shows_its_type_beside_its_unit() {
957        let doc = spec_page(
958            r#"
959name = "acme.log"
960kind = "delimited"
961
962[columns]
963Latitude = { type = "f64", unit = "deg", description = "GPS latitude" }
964LogIdx = { type = "i64" }
965"#,
966        );
967        let lines = lines(&doc, &HashSet::new(), None);
968        assert!(lines.contains(&DocLine::Column {
969            name: "Latitude".into(),
970            about: "f64 · deg  GPS latitude".into(),
971            values: 0,
972        }));
973        assert!(lines.contains(&DocLine::Column {
974            name: "LogIdx".into(),
975            about: "i64".into(),
976            values: 0,
977        }));
978    }
979
980    #[test]
981    fn a_catalogs_word_stands_over_the_specs() {
982        let catalog = crate::catalog::parse(
983            r#"
984label = "Mine"
985
986[orders]
987name = "Orders"
988path = "/data/day.ord"
989description = "Orders from the lab"
990documentation = "https://example.com/lab.txt"
991columns.price = { description = "Price the lab quotes" }
992columns.venue = { description = "Where it traded" }
993"#,
994            "mine",
995            crate::catalog::Origin::Mine,
996            None,
997        )
998        .unwrap();
999        let entry = Arc::new(catalog.datasets.into_iter().next().unwrap());
1000        let spec = crate::formats::Spec::parse(ORDERS, None).unwrap();
1001        let doc = Documented::new(
1002            Some(("Mine".into(), entry)),
1003            spec.docs().map(Arc::new),
1004            "day.ord".into(),
1005        )
1006        .unwrap();
1007        assert_eq!(doc.title(), "Orders");
1008        let lines = lines(&doc, &HashSet::new(), None);
1009        assert_eq!(lines[0], DocLine::About("Orders from the lab".into()));
1010        assert!(lines.contains(&DocLine::Field("catalog", "Mine".into())));
1011        assert!(lines.contains(&DocLine::Field("format spec", "acme.orders".into())));
1012        let links: Vec<&DocLine> = lines
1013            .iter()
1014            .filter(|l| matches!(l, DocLine::Link("documentation", _)))
1015            .collect();
1016        assert_eq!(
1017            links,
1018            [&DocLine::Link(
1019                "documentation",
1020                "https://example.com/lab.txt".into()
1021            )]
1022        );
1023        assert!(lines.contains(&DocLine::Section("RECORD TYPES", 2)));
1024        let columns: Vec<(String, String)> = lines
1025            .iter()
1026            .filter_map(|l| match l {
1027                DocLine::Column { name, about, .. } => Some((name.clone(), about.clone())),
1028                _ => None,
1029            })
1030            .collect();
1031        assert_eq!(
1032            columns,
1033            [
1034                (
1035                    "price".to_string(),
1036                    "Price the lab quotes (USD)".to_string()
1037                ),
1038                ("side".into(), String::new()),
1039                ("shares".into(), "Shares executed".into()),
1040                ("venue".into(), "Where it traded".into()),
1041            ]
1042        );
1043    }
1044
1045    #[test]
1046    fn a_catalog_note_keeps_the_specs_legend_and_unit() {
1047        let over = ColumnNote {
1048            description: "Side of the book".into(),
1049            ..ColumnNote::default()
1050        };
1051        let under = ColumnNote {
1052            description: "Side".into(),
1053            unit: "flag".into(),
1054            values: vec![("1".into(), "BUY".into())],
1055            ty: String::new(),
1056        };
1057        assert_eq!(
1058            layered(&over, &under),
1059            ColumnNote {
1060                description: "Side of the book".into(),
1061                ..under.clone()
1062            }
1063        );
1064        let legend = ColumnNote {
1065            values: vec![("B".into(), "Buy".into())],
1066            ..ColumnNote::default()
1067        };
1068        assert_eq!(layered(&legend, &under).values, legend.values);
1069        assert_eq!(layered(&legend, &under).description, "Side");
1070    }
1071
1072    #[test]
1073    fn header_and_footer_fields_are_documented_in_their_own_sections() {
1074        let doc = spec_page(
1075            r#"
1076name = "acme.tape"
1077match = { glob = "*.tape" }
1078
1079[header]
1080fields = [
1081  { name = "magic", type = "str", size = 4 },
1082  { type = "pad", size = 4 },
1083  { name = "trade_date", type = "u4", description = "Session date" },
1084  { name = "tick", type = "u4", unit = "ns" },
1085]
1086
1087[records]
1088fields = [{ name = "px", type = "u4", description = "Price" }]
1089
1090[footer]
1091fields = [{ name = "rows", type = "u4", description = "Records written" }]
1092"#,
1093        );
1094        let lines = lines(&doc, &HashSet::new(), None);
1095        let at = |line: &DocLine| lines.iter().position(|l| l == line).unwrap();
1096        let column = |name: &str, about: &str| DocLine::Column {
1097            name: name.into(),
1098            about: about.into(),
1099            values: 0,
1100        };
1101        let header = at(&DocLine::Section("HEADER", 2));
1102        assert_eq!(
1103            lines[header + 1..header + 3],
1104            [column("trade_date", "Session date"), column("tick", "ns")]
1105        );
1106        let columns = at(&DocLine::Section("COLUMNS", 1));
1107        let footer = at(&DocLine::Section("FOOTER", 1));
1108        assert!(header < columns && columns < footer);
1109        assert_eq!(lines[footer + 1], column("rows", "Records written"));
1110        // Nothing documented in the footer, no FOOTER section.
1111        let bare = spec_page(
1112            "name = \"a.b\"\n[header]\nfields = [{ name = \"v\", type = \"u1\", description = \"Version\" }]\n[records]\nfields = [{ name = \"x\", type = \"u1\" }]\n[footer]\nfields = [{ name = \"n\", type = \"u4\" }]\n",
1113        );
1114        let lines = super::lines(&bare, &HashSet::new(), None);
1115        assert!(lines.contains(&DocLine::Section("HEADER", 1)));
1116        assert!(
1117            !lines
1118                .iter()
1119                .any(|l| matches!(l, DocLine::Section("FOOTER", _)))
1120        );
1121    }
1122
1123    #[test]
1124    fn the_cursor_stays_in_view_and_bookmarks_are_listed() {
1125        let mut state = DocState::default();
1126        state.open(page(noaa()), None);
1127        state.move_cursor(isize::MAX / 2);
1128        let text = screen(&mut state, 80, 16).join("\n");
1129        assert!(text.contains("Central Park, NY"), "{text}");
1130    }
1131}