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::formats::SpecDocs;
22use crate::glyphs;
23use crate::home::catalog::{ColumnNote, Dataset};
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::numfmt::bytes(size))),
300        (None, Some(hint)) => out.push(DocLine::Field(
301            "size",
302            format!("~{}", crate::numfmt::bytes(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::home::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` wrapped at word boundaries to `width` columns.
452fn wrap(text: &str, width: usize) -> Vec<String> {
453    let width = width.max(8);
454    let mut rows = Vec::new();
455    let mut row = String::new();
456    for word in text.split_whitespace() {
457        let candidate = if row.is_empty() {
458            word.to_string()
459        } else {
460            format!("{row} {word}")
461        };
462        if candidate.width() <= width || row.is_empty() {
463            row = candidate;
464            while row.width() > width {
465                // One word longer than the row: cut it where the row ends.
466                let head: String = row.chars().take(width).collect();
467                let rest: String = row.chars().skip(width).collect();
468                rows.push(head);
469                row = rest;
470            }
471        } else {
472            rows.push(std::mem::take(&mut row));
473            row = word.to_string();
474        }
475    }
476    if !row.is_empty() || rows.is_empty() {
477        rows.push(row);
478    }
479    rows
480}
481
482/// Lay `lines` out at `width`: the visual rows of each, and its first row's index.
483fn layout(
484    lines: &[DocLine],
485    width: usize,
486    cursor: usize,
487    ctx: &RenderContext,
488) -> (Vec<Line<'static>>, Vec<usize>) {
489    let g = glyphs::get();
490    let key_w = lines
491        .iter()
492        .filter_map(|l| match l {
493            DocLine::Field(label, _) | DocLine::Link(label, _) => Some(label.len()),
494            _ => None,
495        })
496        .max()
497        .unwrap_or(8)
498        + 2;
499    let col_w = lines
500        .iter()
501        .filter_map(|l| match l {
502            DocLine::Column { name, .. }
503            | DocLine::Bookmark(name, _)
504            | DocLine::RecordType(name, _) => Some(name.width()),
505            _ => None,
506        })
507        .max()
508        .unwrap_or(0)
509        .min(width / 3)
510        + 2;
511    let label = Style::default().fg(ctx.label);
512    let plain = Style::default().fg(ctx.text_primary);
513    let dim = Style::default().fg(ctx.dimmed);
514    // One column for the rail, as every list in datui reserves it.
515    let inner = width.saturating_sub(1);
516    let mut rows: Vec<Line<'static>> = Vec::new();
517    let mut starts = Vec::with_capacity(lines.len());
518    for (i, line) in lines.iter().enumerate() {
519        starts.push(rows.len());
520        let focused = i == cursor;
521        let rail = if focused { g.rail } else { " " };
522        let rail = Span::styled(rail, Style::default().fg(ctx.accent));
523        let key_style = if focused {
524            Style::default().fg(ctx.accent)
525        } else {
526            label
527        };
528        let mut push = |spans: Vec<Span<'static>>, first: bool| {
529            let mut all = vec![if first { rail.clone() } else { Span::raw(" ") }];
530            all.extend(spans);
531            let mut line = Line::from(all);
532            if focused {
533                line = line.patch_style(ctx.highlight_style());
534            }
535            rows.push(line);
536        };
537        match line {
538            DocLine::Blank => rows.push(Line::from("")),
539            DocLine::Section(..) => rows.push(Line::from("")),
540            DocLine::About(text) => {
541                for (n, row) in wrap(text, inner).into_iter().enumerate() {
542                    push(vec![Span::styled(row, plain)], n == 0);
543                }
544            }
545            DocLine::Field(key, value) => {
546                let room = inner.saturating_sub(key_w);
547                for (n, row) in wrap(value, room).into_iter().enumerate() {
548                    let head = if n == 0 {
549                        format!("{key:<key_w$}")
550                    } else {
551                        " ".repeat(key_w)
552                    };
553                    push(
554                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
555                        n == 0,
556                    );
557                }
558            }
559            DocLine::Link(key, url) => {
560                let room = inner.saturating_sub(key_w);
561                push(
562                    vec![
563                        Span::styled(format!("{key:<key_w$}"), key_style),
564                        Span::styled(
565                            glyphs::fit(url, room),
566                            plain.add_modifier(Modifier::UNDERLINED),
567                        ),
568                    ],
569                    true,
570                );
571            }
572            DocLine::Column {
573                name,
574                about,
575                values,
576            } => {
577                let marker = if *values == 0 {
578                    String::new()
579                } else {
580                    let open = if i + 1 < lines.len() && matches!(lines[i + 1], DocLine::Legend(..))
581                    {
582                        g.expanded
583                    } else {
584                        g.collapsed
585                    };
586                    format!("  {}{values} values", open)
587                };
588                let room = inner.saturating_sub(col_w);
589                let text = format!("{about}{marker}");
590                for (n, row) in wrap(&text, room).into_iter().enumerate() {
591                    let head = if n == 0 {
592                        format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
593                    } else {
594                        " ".repeat(col_w)
595                    };
596                    push(
597                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
598                        n == 0,
599                    );
600                }
601            }
602            DocLine::RecordType(name, about) => {
603                let room = inner.saturating_sub(col_w);
604                for (n, row) in wrap(about, room).into_iter().enumerate() {
605                    let head = if n == 0 {
606                        format!("{:<col_w$}", glyphs::fit(name, col_w - 2))
607                    } else {
608                        " ".repeat(col_w)
609                    };
610                    push(
611                        vec![Span::styled(head, key_style), Span::styled(row, plain)],
612                        n == 0,
613                    );
614                }
615            }
616            DocLine::Legend(code, meaning) => {
617                let pad = col_w + 2;
618                let code_w = 6usize.max(code.width() + 2);
619                let room = inner.saturating_sub(pad + code_w);
620                for (n, row) in wrap(meaning, room).into_iter().enumerate() {
621                    let head = if n == 0 {
622                        format!("{}{code:<code_w$}", " ".repeat(pad))
623                    } else {
624                        " ".repeat(pad + code_w)
625                    };
626                    push(
627                        vec![Span::styled(head, dim), Span::styled(row, plain)],
628                        n == 0,
629                    );
630                }
631            }
632            DocLine::Bookmark(name, path) => {
633                let room = inner.saturating_sub(col_w);
634                push(
635                    vec![
636                        Span::styled(
637                            format!("{:<col_w$}", glyphs::fit(name, col_w - 2)),
638                            key_style,
639                        ),
640                        Span::styled(glyphs::fit(path, room), plain),
641                    ],
642                    true,
643                );
644            }
645        }
646    }
647    (rows, starts)
648}
649
650/// Draw the page in `area` (no frame): what the Info tab and the full-screen view share.
651/// Keeps the cursor's line in view.
652pub fn render_page(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
653    if area.width < 10 || area.height == 0 {
654        return;
655    }
656    let area = Rect {
657        width: area.width.min(MEASURE),
658        ..area
659    };
660    let lines = state.lines();
661    state.cursor = state.cursor.min(lines.len().saturating_sub(1));
662    let (rows, starts) = layout(&lines, area.width as usize, state.cursor, ctx);
663    let height = area.height as usize;
664    state.view_height = height;
665    // The cursor's whole line in view, and the section title above a first line.
666    let first = starts.get(state.cursor).copied().unwrap_or(0);
667    let last = starts
668        .get(state.cursor + 1)
669        .copied()
670        .unwrap_or(rows.len())
671        .saturating_sub(1);
672    if first < state.scroll {
673        state.scroll = first;
674    }
675    if last >= state.scroll + height {
676        state.scroll = last + 1 - height.min(last + 1);
677    }
678    if state.scroll > 0 && state.cursor == state.first_focusable() {
679        state.scroll = 0;
680    }
681    let shown: Vec<Line> = rows
682        .iter()
683        .skip(state.scroll)
684        .take(height)
685        .cloned()
686        .collect();
687    Paragraph::new(shown).render(area, buf);
688    // Section titles are drawn over their placeholder rows, as rules.
689    for (i, line) in lines.iter().enumerate() {
690        let DocLine::Section(title, count) = line else {
691            continue;
692        };
693        let Some(row) = starts[i].checked_sub(state.scroll).filter(|r| *r < height) else {
694            continue;
695        };
696        let chip = count.to_string();
697        SectionRule {
698            title,
699            chip: Some(&chip),
700        }
701        .render(
702            Rect {
703                x: area.x + 1,
704                y: area.y + row as u16,
705                width: area.width.saturating_sub(1),
706                height: 1,
707            },
708            buf,
709            ctx,
710        );
711    }
712}
713
714/// The full-screen view: the page in a frame titled with the dataset's name, and the
715/// keys that work on it.
716pub fn render_view(state: &mut DocState, area: Rect, buf: &mut Buffer, ctx: &RenderContext) {
717    let Some(title) = state.doc.as_ref().map(|d| d.title().to_string()) else {
718        return;
719    };
720    let mut footer = HintBar::from_ctx(ctx).screen(datui_cli::keys::Context::Documentation);
721    let lines = state.lines();
722    match lines.get(state.cursor) {
723        Some(DocLine::Column { values: 1.., .. }) | Some(DocLine::Legend(..)) => {
724            footer = footer.key("Enter");
725        }
726        _ => {}
727    }
728    if state.offers_open() {
729        footer = footer.key("o");
730    }
731    footer = footer.key("y").key("Esc");
732    let title = format!("Documentation {} {title}", glyphs::get().trail);
733    let inner = Surface::new(&title).footer(&footer).render(area, buf, ctx);
734    render_page(state, inner, buf, ctx);
735}
736
737#[cfg(test)]
738mod tests;