Skip to main content

datui_cli/
keys.rs

1//! The key registry: every key of every screen, in one table.
2//!
3//! Each [`Key`] is what is typed, a short label (for hints), a one-line description
4//! (the in-app help), an optional longer description (the manpage and the docs),
5//! and the key the help's Enter presses for it. Keys sit in task [`Group`]s on a
6//! [`Screen`]; [`GLOBAL`] holds the keys every screen takes.
7//!
8//! What shows keys reads them from here: the help overlay `?` opens in `datui-lib`,
9//! `docs/reference/keyboard-shortcuts.md` ([`render_markdown`]) and `datui-keys(7)`.
10//! A key added to the app is an entry here; a test in `datui-lib` presses each
11//! entry's key in its screen and fails when nothing takes it.
12
13/// Where the keys are typed: one in-app help screen each.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum Context {
16    Table,
17    Home,
18    Documentation,
19    Query,
20    Find,
21    GoToColumn,
22    Inspector,
23    Info,
24    ValueCounts,
25    SortFilter,
26    PivotMelt,
27    Chart,
28    Describe,
29    Distribution,
30    DistributionDetail,
31    Correlation,
32    CorrelationDetail,
33    DataQuality,
34    Export,
35    Copy,
36    Views,
37    FormatPicker,
38    Retype,
39    Combine,
40    TablePicker,
41    Sample,
42    Hex,
43    /// The keys every screen takes: [`GLOBAL`].
44    Global,
45    /// The help overlay: [`HELP`].
46    Help,
47    /// A question datui asks before acting: [`QUESTION`].
48    Question,
49}
50
51/// One screen's keys.
52#[derive(Debug, Clone, Copy)]
53pub struct Screen {
54    pub context: Context,
55    /// Its heading in the help, the reference and the manpage: `Table`.
56    pub title: &'static str,
57    /// How the screen is reached, as markdown.
58    pub reached: &'static str,
59    /// Its keys, by task, in the order the help shows them.
60    pub groups: &'static [Group],
61}
62
63/// Keys that do one kind of task: `Explore`, `Shape`, `Analyze`, `Output`, or a
64/// screen's own (`Fields`, `Picker`).
65#[derive(Debug, Clone, Copy)]
66pub struct Group {
67    pub name: &'static str,
68    pub keys: &'static [Key],
69}
70
71/// One key, or a few that do one thing in turn (`n / N`).
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct Key {
74    /// What is typed, as the help writes it: `↑ / ↓ (j/k)`, `Ctrl+O`, `(type)`.
75    pub keys: &'static str,
76    /// A word or two for a hint beside the key: `Filter`, `Next match`.
77    pub label: &'static str,
78    /// Other words a hint may give the key where what it does turns on the screen's
79    /// state: Enter's `Show rows` beside `Read rows`. A hint says one of these or
80    /// [`Key::label`], never a word of its own.
81    pub also: &'static [&'static str],
82    /// One line: the in-app help, and the man page and docs without [`Key::more`].
83    pub line: &'static str,
84    /// The whole description, where it says more than the line: the manpage and the
85    /// docs print it in place of the line.
86    pub more: Option<&'static str>,
87    pub run: Run,
88}
89
90/// What Enter on a key's help line presses, in the screen help was opened from.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Run {
93    /// The first key of [`Key::keys`].
94    First,
95    /// This key, as [`chord`] reads it.
96    Key(&'static str),
97    /// Nothing: typing, the mouse, a range of digits.
98    Never,
99}
100
101/// A key entry: `keys`, a hint `label` and the help's `line`.
102pub const fn k(keys: &'static str, label: &'static str, line: &'static str) -> Key {
103    Key {
104        keys,
105        label,
106        also: &[],
107        line,
108        more: None,
109        run: Run::First,
110    }
111}
112
113impl Key {
114    /// With the whole description for the manpage and the docs.
115    pub const fn more(self, more: &'static str) -> Self {
116        Key {
117            more: Some(more),
118            ..self
119        }
120    }
121
122    /// With other words a hint may say for it; see [`Key::also`].
123    pub const fn also(self, also: &'static [&'static str]) -> Self {
124        Key { also, ..self }
125    }
126
127    /// Whether a hint may give this key `label`.
128    pub fn says(&self, label: &str) -> bool {
129        self.label == label || self.also.contains(&label)
130    }
131
132    /// Enter in the help presses `spec` rather than the first key.
133    pub const fn run(self, spec: &'static str) -> Self {
134        Key {
135            run: Run::Key(spec),
136            ..self
137        }
138    }
139
140    /// Enter in the help presses nothing for this entry.
141    pub const fn no_run(self) -> Self {
142        Key {
143            run: Run::Never,
144            ..self
145        }
146    }
147
148    /// The description the manpage and the docs print.
149    pub fn long(&self) -> &'static str {
150        self.more.unwrap_or(self.line)
151    }
152
153    /// The key Enter on this line presses, if any.
154    pub fn action(&self) -> Option<Chord> {
155        match self.run {
156            Run::First => chord(first_key(self.keys)),
157            Run::Key(spec) => chord(spec),
158            Run::Never => None,
159        }
160    }
161}
162
163/// A key as the app receives it, without the terminal library: `datui-lib` turns it
164/// into a key event.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct Chord {
167    pub code: Code,
168    pub ctrl: bool,
169    pub alt: bool,
170    pub shift: bool,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Code {
175    Char(char),
176    Enter,
177    Esc,
178    Tab,
179    BackTab,
180    Backspace,
181    Delete,
182    Insert,
183    Up,
184    Down,
185    Left,
186    Right,
187    PageUp,
188    PageDown,
189    Home,
190    End,
191    F(u8),
192}
193
194/// Every key a `keys` text names, in order: `↑ / ↓ (j/k)` is ↑, ↓, j and k. Text that
195/// is not a key (`(type)`, `Click`) names none.
196pub fn chords(keys: &str) -> Vec<Chord> {
197    let spaced = keys.replace(['(', ')'], " / ");
198    spaced
199        .split(" / ")
200        .flat_map(|part| part.split(", "))
201        .flat_map(|part| part.split(" or "))
202        .map(str::trim)
203        .flat_map(|token| {
204            // `PgUp/PgDn`, `h/l`; a lone `/` is the slash key.
205            if token.len() > 1 && token.contains('/') {
206                token
207                    .split('/')
208                    .filter(|t| !t.is_empty())
209                    .collect::<Vec<_>>()
210            } else {
211                vec![token]
212            }
213        })
214        .filter_map(chord)
215        .collect()
216}
217
218/// The first key a `keys` text names: `↑` of `↑ / ↓ (j/k)`, `Ctrl+D` of
219/// `Ctrl+D/Ctrl+U`.
220pub fn first_key(keys: &str) -> &str {
221    let mut end = keys.len();
222    for sep in [" / ", ", ", " (", " or ", " "] {
223        if let Some(at) = keys.find(sep)
224            && at > 0
225        {
226            end = end.min(at);
227        }
228    }
229    let first = &keys[..end];
230    // `PgUp/PgDn`, `Ctrl+D/Ctrl+U`; a lone `/` is the slash key.
231    match first.find('/') {
232        Some(at) if at > 0 && first.len() > 1 => &first[..at],
233        _ => first,
234    }
235}
236
237/// Read one key: `j`, `G`, `Enter`, `Ctrl+O`, `Shift+Tab`, `F1`, `↑`. `None` for text
238/// that is not one key.
239pub fn chord(spec: &str) -> Option<Chord> {
240    let mut chord = Chord {
241        code: Code::Esc,
242        ctrl: false,
243        alt: false,
244        shift: false,
245    };
246    let mut rest = spec;
247    loop {
248        if let Some(r) = rest.strip_prefix("Ctrl+").filter(|r| !r.is_empty()) {
249            chord.ctrl = true;
250            rest = r;
251        } else if let Some(r) = rest.strip_prefix("Alt+").filter(|r| !r.is_empty()) {
252            chord.alt = true;
253            rest = r;
254        } else if let Some(r) = rest.strip_prefix("Shift+").filter(|r| !r.is_empty()) {
255            chord.shift = true;
256            rest = r;
257        } else {
258            break;
259        }
260    }
261    chord.code = match rest {
262        "Enter" => Code::Enter,
263        "Esc" => Code::Esc,
264        "Tab" if chord.shift => Code::BackTab,
265        "Tab" => Code::Tab,
266        "Space" => Code::Char(' '),
267        "Backspace" => Code::Backspace,
268        "Delete" | "Del" => Code::Delete,
269        "Insert" => Code::Insert,
270        "PgUp" | "PageUp" => Code::PageUp,
271        "PgDn" | "PageDown" => Code::PageDown,
272        "Home" => Code::Home,
273        "End" => Code::End,
274        "↑" | "↑↓" => Code::Up,
275        "↓" => Code::Down,
276        "←" | "←→" => Code::Left,
277        "→" => Code::Right,
278        _ => {
279            let mut chars = rest.chars();
280            match (chars.next(), chars.next()) {
281                // Ctrl+O is the letter o with Ctrl; a capital alone comes with Shift,
282                // as a terminal reports it.
283                (Some(c), None) if c.is_ascii_alphabetic() && chord.ctrl => {
284                    Code::Char(c.to_ascii_lowercase())
285                }
286                (Some(c), None) if !c.is_whitespace() => {
287                    if c.is_ascii_uppercase() {
288                        chord.shift = true;
289                    }
290                    Code::Char(c)
291                }
292                (Some('F'), Some(_)) => Code::F(rest[1..].parse().ok().filter(|n| *n <= 12)?),
293                _ => return None,
294            }
295        }
296    };
297    Some(chord)
298}
299
300/// The keys every screen takes. Every screen's help lists them last.
301pub const GLOBAL: Group = Group {
302    name: "Everywhere",
303    keys: &[
304        k("? / F1", "Help", "This screen's keys (F1 in text fields)")
305            .more("This screen's keys. In a text field ? types, and F1 opens the help")
306            .run("F1"),
307        k("Ctrl+O", "Home", "The home screen; abandons a load")
308            .more("Go to the home screen, with the dataset you left selected. Its filter is kept and selected, so typing replaces it. During a load, cancels the load"),
309        k(
310            "Ctrl+Q / Ctrl+C",
311            "Quit",
312            "Quit (Ctrl+C in a text field too)",
313        )
314        .more("Quit from anywhere; Ctrl+C quits from a text field too, where Alt+W copies"),
315        k("Click a field", "Act", "Focus a form's field and act as Space")
316            .more("Focus a form's field and act as Space does: a checkbox flips, a choice steps, a picker opens, a button runs, and a text field takes the cursor. In a list of sorts, filters or columns, the first click on a row focuses it and the second acts on it. A click on a picker's line chooses it, a click on a tab switches to it, and a click on a key in the footer presses it. A click outside a dialog does nothing. With display.mouse = false (or --mouse false) the mouse belongs to the terminal; in most terminals Shift+drag selects text either way")
317            .no_run(),
318        k("Right-click a field", "Back", "Step a choice back, as ← does; else focus it").no_run(),
319    ],
320};
321
322/// The keys of the help overlay itself, for its footer and the reference.
323pub const HELP: Group = Group {
324    name: "Help",
325    keys: &[
326        k("↑ / ↓ (j/k)", "Move", "Move between the keys"),
327        k("Enter", "Run", "Close the help and press the key")
328            .more("Close the help and press the selected line's key on the screen the help was opened from"),
329        k("/", "Filter", "Narrow to the keys whose text matches"),
330        k("Esc", "Close", "Clear the filter, then close").also(&["Clear"]),
331    ],
332};
333
334/// The keys of a question datui asks before acting: overwrite a file, delete a view,
335/// read a whole dataset.
336pub const QUESTION: Group = Group {
337    name: "Question",
338    keys: &[
339        k("← / →", "Switch", "Choose the other answer").also(&["Pick"]),
340        k(
341            "Enter",
342            "Confirm",
343            "Give the answer chosen, or close an error",
344        )
345        .also(&["Close"]),
346        k("Esc", "Cancel", "Leave it as it was").also(&["No"]),
347    ],
348};
349
350/// Where the whole reference is, the help's last line.
351pub const REFERENCE_URL: &str =
352    "https://derekwisong.github.io/datui/reference/keyboard-shortcuts.html";
353
354/// The q language in a few lines, for the query screen's help and `datui-query(7)`:
355/// an example and what it shows.
356pub const Q_SUMMARY: &[(&str, &str)] = &[
357    (
358        "select [cols] [by groups] [where conds]",
359        "Each part optional",
360    ),
361    (
362        "select a, b where a > 10, b < 5",
363        "In where, , is and; | is or",
364    ),
365    (
366        "select avg price, n:count id by region",
367        "Aggregate by group",
368    ),
369    (
370        "total:a+b  col[\"first name\"]",
371        "Name a column; a name with spaces",
372    ),
373    ("1/c+a is 1/(c+a)", "Right to left: 100 < (a+b)*2"),
374    (
375        "date.year  2024.01.31  city.contains[\"York\"]",
376        "Accessors and dates",
377    ),
378    (
379        "name in [\"Ann\", \"Bo\"]  item like \"*ham*\"",
380        "Membership and patterns",
381    ),
382];
383
384/// The analysis tools' sample keys, the same on each.
385const SAMPLE_KEYS: &[Key] = &[
386    k("s", "Sample", "Choose the sample every tool reads").more(
387        "Choose the sample every tool reads: which rows, how they are picked, how many (typed, like 50000, 50k or 2m), the seed. Enter applies the form",
388    ),
389    k(
390        "v",
391        "Sample rows",
392        "View the sample's rows as a table; Esc comes back",
393    ),
394    k(
395        "r",
396        "Resample",
397        "Draw another sample (when the result is a sample)",
398    ),
399    k("a", "All rows", "Read every row instead, after confirming"),
400    k("t", "New rows", "Read again with rows that arrived since")
401        .also(&["Refresh"])
402        .more(
403        "While following a file, read again with the rows that arrived since the results were read",
404    ),
405];
406
407/// Every screen, as the reference lists them.
408pub const SCREENS: &[Screen] = &[
409    Screen {
410        context: Context::Table,
411        title: "Table",
412        reached: "Where a dataset opens.",
413        groups: &[
414            Group {
415                name: "Explore",
416                keys: &[
417                    k("↑ / ↓ (j/k)", "Move", "Move the row cursor"),
418                    k("← / → (h/l)", "Column", "Move the column cursor")
419                        .more("Move the column cursor, frozen columns included. The columns scroll only when the cursor would leave the screen"),
420                    k("Shift+← / Shift+→", "Page columns", "A page of columns left or right")
421                        .more("A page of columns left or right, the cursor on the page's first column"),
422                    k("{ / }", "First, last", "First column, last column"),
423                    k("PgUp / PgDn", "Page", "A page up or down (Ctrl+B / Ctrl+F too)"),
424                    k("Ctrl+D / Ctrl+U", "Half page", "Half a page down or up"),
425                    k("Home / End", "Top, end", "First or last row (G = End)"),
426                    k(":", "Command line", "Go to a row, or run SQL or q")
427                        .more("Open the command line. Digits go to that row (the prefix shows row:); anything else runs as SQL or q, whichever the prefix shows, and Ctrl+T switches. With a query in effect, the line opens holding the query's text"),
428                    k("g", "Go to column", "Go to a column by name"),
429                    k("/ (f)", "Find", "Find text, a regex, or letters in order")
430                        .more("Find text, a regex, or letters in order in the view. Matches on screen light up as you type. Enter moves the cursor, row and column, to the first match at or after the current row. f opens it too"),
431                    k("n / N", "Next", "Next or previous match")
432                        .more("Next / previous match from the cursor's cell, wrapping around the view"),
433                    k("Enter", "Drill", "Drill down to a group's rows, or inspect")
434                        .more("On a row of a by query or a SQL GROUP BY, drill down to its rows (Esc comes back); elsewhere, inspect the row"),
435                    k("Space", "Inspect", "Inspect the row: every field, whole")
436                        .more("Inspect the row: every field, each value whole and exact (Esc or Space closes)"),
437                ],
438            },
439            Group {
440                name: "Shape",
441                keys: &[
442                    k("[ / ]", "Sort", "Sort by the cursor's column, up or down")
443                        .more("Sort by the cursor's column, [ ascending and ] descending, replacing the sort in effect; the same key again on that column removes it. The s sidebar adds secondary sorts"),
444                    k("s", "Sort & Filter", "Sort & Filter sidebar, on the cursor's column")
445                        .more("Open the Sort & Filter sidebar (tabs: Columns, Filters), on the cursor's column"),
446                    k("+ / -", "Filter", "Keep (+) or drop (-) rows with this cell's value")
447                        .more("Filter on the cursor's cell: + keeps the rows with its value, - drops them (on a null cell, the nulls). Each adds a filter to the Sort & Filter sidebar, joined with \"and\"; the value is the cell's exactly as stored"),
448                    k("r", "Reverse", "Reverse the sort, or the row order")
449                        .more("Reverse sort order (sorted columns carry a direction mark in the header); with no sort, reverse the row order"),
450                    k("H / L", "Move column", "Move the cursor's column left or right")
451                        .more("Move the cursor's column one place left or right, the cursor with it; a frozen column moves among the frozen ones. R puts the order back"),
452                    k("p", "Pivot & Melt", "Pivot or melt"),
453                    k("S", "Sample", "Draw a sample into memory; the view works on it")
454                        .more("Draw a sample of the source into memory; the query, filters and sort then run over the sample. Rows show as they arrive (Esc stops and keeps them), and the footer says, for example, sample 100,000 of 36.8M. Analysis, charts and export read the sample. S again edits it; the Method No sample, or R, removes it"),
455                    k("R", "Reset", "Clear query, filters, sort, layout, view")
456                        .more("Reset table: clear the sample, query, filters, sort, column order, hidden columns and widths, frozen columns, pivot/melt, drill-down and the applied view"),
457                ],
458            },
459            Group {
460                name: "Analyze",
461                keys: &[
462                    k("F", "Counts", "Value counts of the column; a number as a histogram")
463                        .more("Value counts of the cursor's column: each value's rows, percent and a bar, with a summary. A number column opens as a histogram; c there shows the list of its values"),
464                    k("a", "Analysis", "Describe, distributions, correlation, quality")
465                        .more("Open Analysis. In a Data Quality evidence drill a is disabled; Esc returns to the observation"),
466                    k("c", "Chart", "Chart the view"),
467                    k("i", "Info", "Schema, resources, partitions, notes")
468                    .also(&["Notes"])
469                        .more("Open the Info panel (tabs: Schema, Resources, Partitions, Notes). H on its Schema tab reads a CSV's first row as data, or as column names"),
470                ],
471            },
472            Group {
473                name: "Output",
474                keys: &[
475                    k("e", "Export", "Export the view to a file"),
476                    k("y", "Copy", "Copy a cell, row, view or table")
477                        .more("Copy to the clipboard (cell, row, view or table); a cell is the cursor's"),
478                    k("v", "Views", "The saved views list"),
479                    k("V", "Apply view", "Apply the best-matching view")
480                        .more("Apply the best-matching view (or open the list)"),
481                ],
482            },
483            Group {
484                name: "Display",
485                keys: &[
486                    k("#", "Row numbers", "Row numbers on or off")
487                        .more("Row numbers on or off. Each row keeps its place in the source as its number through a sort or a filter (in a text file, its line number). On by default for text and logs; display.row_numbers sets it"),
488                    k(",", "Grouping", "Number formatting (digit grouping) on or off"),
489                    k("D", "Types", "The type row under the headers on or off"),
490                    k("< / >", "Width", "The cursor's column 4 cells narrower or wider"),
491                    k("= / w", "Fit, auto", "Fit the column to the rows on screen; automatic")
492                        .more("Fit the cursor's column to the rows on screen; w puts it back to automatic width"),
493                    k("b", "Format spec", "Read a binary file with another format spec")
494                        .more("On a binary file read through a format spec, read it again with another spec. Clears the query, filters and sort"),
495                    k("T", "Table", "Open another table of the file")
496                        .more("In a file of several tables (a workbook's worksheets, a database's tables, a format spec's record types, a Hugging Face cache's splits), open another table in place of this one, as --table does. Clears the query, filters and sort, and applies a saved view for that table. On a file of one table, says so"),
497                    k("t", "Follow", "Follow the file as it grows")
498                    .also(&["Pause", "Resume"])
499                        .more("Follow the file as it grows (CSV, TSV, PSV, NDJSON). The file is read again, so the query, filters and sort are cleared. While following, t pauses and resumes, and Esc stops"),
500                ],
501            },
502            Group {
503                name: "Go",
504                keys: &[
505                    k("q", "Back", "Home when opened from there, else quit")
506                        .more("Back to the home screen when the dataset was opened from it; otherwise quit"),
507                    k("Q", "Quit", "Quit"),
508                    k("Esc", "Back", "Leave a drill-down; stop a find, sample, follow")
509                    .also(&["Stop"])
510                        .more("Leave a drill-down; stop a find, a sample being drawn (its rows so far stay) or a follow"),
511                ],
512            },
513            Group {
514                name: "Mouse",
515                keys: &[
516                    k("Click", "Point", "The cursor to the cell; on a header, its column")
517                        .no_run(),
518                    k("Double-click", "Enter", "Enter on the row").no_run(),
519                    k("Double-click a header", "Sort", "Sort by it: up, down, off, as [ / ] do")
520                        .more("Double-click a column's header to sort by it, as [ and ] do: ascending, then descending, then back to no sort, replacing the sort in effect")
521                        .no_run(),
522                    k("Double-click a header's edge", "Fit", "Fit the column to the rows, as = does")
523                        .more("Double-click the gap right of a column's header to fit the column to the rows on screen, as = does; the column cursor moves to it, and nothing is sorted")
524                        .no_run(),
525                    k("Wheel", "Scroll", "Scroll three rows a notch, as ↑ / ↓ do")
526                        .more("Scroll three rows a notch, as ↑ / ↓ do; the same in help, the inspector and the sidebars")
527                        .no_run(),
528                    k("Shift+wheel", "Across", "← / →, the column cursor")
529                        .more("Move the column cursor, as ← / → do (a sideways wheel too)")
530                        .no_run(),
531                    k("Click a key", "Press", "Press a key the footer shows")
532                        .more("Press a key shown in the footer. A click on the filters and sort presses s, and a click on the query presses :")
533                        .no_run(),
534                    k("Drag a header", "Move column", "Move the column there, as H / L do")
535                        .more("Drag a column's header onto another column to move it there, as H / L do; a rule on the header marks where it lands")
536                        .no_run(),
537                    k("Drag a header's edge", "Width", "Set the column's width, as < / > do")
538                        .more("Drag the gap right of a column's header to set its width by hand, as < / > do, from 4 to 240 cells")
539                        .no_run(),
540                    k("Right-click", "Menu", "A menu of the keys that act on the cell")
541                        .more("Right-click a cell: the cursor goes there and a menu lists the keys that act on it (+, -, F, [, ], y, Space), each with its key. ↑ / ↓ and Enter, or a click, run a line as its key does; Esc or a click elsewhere closes it")
542                        .no_run(),
543                ],
544            },
545        ],
546    },
547    Screen {
548        context: Context::Home,
549        title: "Home screen",
550        reached: "`datui` with no path, or <kbd>Ctrl</kbd>+<kbd>O</kbd> from anywhere.",
551        groups: &[
552            Group {
553                name: "Explore",
554                keys: &[
555                    k("↑ / ↓", "Move", "Move the selection (Ctrl+P / Ctrl+N too)")
556                        .more("Move the selection (Ctrl+P / Ctrl+N too). The home screen opens with no row picked, so nothing is read, and the search box names the row it starts on. The first ↑ or ↓ picks that row, and PgUp, PgDn, Home, End, Ctrl+↑ / Ctrl+↓ and the wheel move from it. A click picks the row clicked, and typing picks the best match"),
557                    k("Ctrl+↑ / Ctrl+↓", "Section", "Previous or next section"),
558                    k("PgUp / PgDn", "Page", "A screenful, stopping at the first and last"),
559                    k("Home / End", "First, last", "The first or last row")
560                        .more("The first or last row. The filter has no cursor; typing always edits its end"),
561                    k("← / →", "Fold", "Fold or unfold; → goes inside or shows more")
562                        .more("Fold or unfold a section. → on a directory or a file of tables goes inside it, and on a file a format spec reads as record types it lists them, one row each. When a directory or RECENT shows only its first rows, → on its more row shows them all, and ← on a row past the first ones shortens it again; on any other row, ← folds"),
563                    k("Space", "Fold", "Fold the section (types once filtering)")
564                        .more("While the filter is empty: fold or unfold the section header under the cursor. With a filter typed, it types"),
565                    k("Tab", "Sort", "Cycle the sort")
566                    .also(&["Complete"])
567                        .more("Cycle the sort; the footer names the order in effect when it has room"),
568                ],
569            },
570            Group {
571                name: "Go",
572                keys: &[
573                    k("Enter", "Open", "What the footer names: Open, Inside, Look")
574                    .also(&["Up", "Open all", "Inside", "Look", "Fold", "Show all", "Show", "Hex", "About"])
575                        .more("What the footer says on this row: \"Open all\" reads a whole directory as one table, \"Inside\" steps into it, \"Open\" loads a file, \"Look\" finds out first. A catalog bookmark, indented under its dataset, opens whole. On a section header, fold or unfold it; on a more row, show the rest; on `..`, go up a level; on the hidden-files row, show them. With no row picked yet, Enter acts on the row the search box names"),
576                    k("Backspace", "Up", "Delete a filter character, or up a level")
577                        .more("Delete a filter character; on an empty filter, up a level (from a bucket, back to its cloud source; from the top of a catalog's remote dataset, back here)"),
578                    k("Esc", "Back", "Path prompt, filter, directory, then the table")
579                    .also(&["Cancel"])
580                        .more("Back out one layer: the path prompt, the filter (onto the first dataset), the directory (back to the row it was entered from), then to the open table"),
581                ],
582            },
583            Group {
584                name: "Find",
585                keys: &[
586                    k("(type)", "Filter", "Narrow by name or column, fuzzy")
587                        .more("Narrow by name or column; fuzzy, so \"sal\" finds \"sales\". What you open often ranks first. Typing also searches below the directory you are inside and the listed bucket names; matches appear under \"Found\". A paste goes in whole")
588                        .no_run(),
589                    k("~", "Path", "Type a path or URL (on an empty filter)")
590                        .more("While the filter is empty, type a path or URL by hand. The list shows the directory being typed, narrowed by the name after the last /, and the first name that matches is picked. The prompt takes characters, Backspace, and Ctrl+U to clear. Tab completes the one name left, the start the names share, or a name picked further down. ↑ / ↓ pick a name, and ↑ from the first goes back to the path as typed. Enter opens the picked name or the typed path: a file opens, and a directory is entered. Esc closes. A pasted path goes in whole, a line break in it as a space; one starting with ~ pasted on an empty filter opens the prompt with it. s3://, gs:// and az:// complete from buckets and prefixes already known. With a filter typed, ~ types into it"),
591                    k("Ctrl+U", "Clear", "Clear the filter or path input"),
592                    k("Ctrl+R", "Refresh", "List again what is on screen"),
593                ],
594            },
595            Group {
596                name: "Manage",
597                keys: &[
598                    k("Ctrl+A", "All files", "Show or hide files datui cannot read")
599                        .more("Show or hide files datui cannot read; inside a SQLite database, its internal tables"),
600                    k("Ctrl+X", "Hex", "The file under the cursor as bytes")
601                        .more("Show the local file under the cursor as bytes, in the hex view, whatever datui would read it as"),
602                    k("Ctrl+D", "Catalog", "Add the row to catalog.toml; on its rows, forget it")
603                    .also(&["Add", "Forget"])
604                        .more("Add the dataset or directory under the cursor to catalog.toml, listed under My datasets; on a row from catalog.toml, forget it. A heading stands for the directory it lists. Only catalog.toml is written; another catalog is hidden with home.hide"),
605                    k("Ctrl+E", "Docs", "The row's documentation, full screen")
606                        .more("Open the Documentation view of a catalog row, of a place inside one, or of a file whose format spec documents it: description, publisher, license, links, record types, columns with units and value legends, bookmarks. A catalog's description, link and column notes take precedence over the spec's. Here Ctrl+E is not readline's end of line: the filter is always edited at its end"),
607                    k("Delete", "Forget", "Forget a recent, place or catalog.toml row; hide")
608                    .also(&["Hide"])
609                        .more("Forget the highlighted recent entry, or a whole place after confirming, or a row from catalog.toml, or hide a cloud source. On the Example datasets heading, hide them until datui cache clear, after confirming"),
610                    k("Shift+Delete", "Forget all", "Forget every recent entry, after confirming"),
611                ],
612            },
613            Group {
614                name: "Mouse",
615                keys: &[
616                    k("Click", "Select", "Select the row; double-click is Enter").no_run(),
617                    k("Wheel", "Scroll", "Move the selection three rows")
618                        .more("Move the selection three rows, stopping at the ends")
619                        .no_run(),
620                ],
621            },
622        ],
623    },
624    Screen {
625        context: Context::Documentation,
626        title: "Documentation",
627        reached: "<kbd>Ctrl</kbd>+<kbd>E</kbd> on the home screen, on a catalog row or a file a format spec documents.",
628        groups: &[Group {
629            name: "Read",
630            keys: &[
631                k("↑ / ↓ (j/k)", "Move", "Move the cursor a line"),
632                k("PgUp / PgDn", "Page", "A page"),
633                k("g / G", "Ends", "The first or last line (Home / End too)"),
634                k("Enter / Space / →", "Values", "Open or close the column's value legend")
635                    .more("Open or close the value legend of the column under the cursor: a catalog's values, or a spec's enum"),
636                k("o", "Open", "Open the line's link in the browser, after asking")
637                    .more("Open the link on the cursor's line in the system browser. Only http and https links open, and only after a question showing the whole URL (Enter opens, Esc does not). Off over SSH or without a display: y copies the link instead"),
638                k("y", "Copy", "Copy the line's link or value, whole")
639                    .more("Copy the link or value on the cursor's line, whole, however it is cut on screen"),
640                k("Esc / q / ←", "Back", "Back to the home screen"),
641            ],
642        }],
643    },
644    Screen {
645        context: Context::Query,
646        title: "Command line",
647        reached: "<kbd>:</kbd> at the table.",
648        groups: &[
649            Group {
650                name: "Run",
651                keys: &[
652                    k("(digits)", "Row", "Digits go to that row (row: shows it)")
653                        .more("Digits alone go to that row, and the prefix says row: (:0 Enter is the top)")
654                        .no_run(),
655                    k("Enter", "Run", "Go to the row, or run the query")
656                    .also(&["Go"])
657                        .more("Go to the row, or run the query in the language the prefix shows, sql: or q:. Reopened with a query in effect, the line holds the query's text, selected: typing replaces it, and the arrows edit it"),
658                    k("Ctrl+J", "Run", "Run, the same as Enter"),
659                    k("Ctrl+T", "Language", "SQL or q; the line keeps its text")
660                        .more("Switch between SQL and q, keeping what is typed; the choice is remembered. [query] default_mode sets the first"),
661                    k("Esc", "Close", "Close"),
662                ],
663            },
664            Group {
665                name: "Edit",
666                keys: &[
667                    k("Tab", "Complete", "Complete a column name (SQL: or df)")
668                        .more("Complete a column name, or df in SQL; again for the next match. The line under the input lists the names that fit"),
669                    k("Alt+Enter", "New line", "SQL: start a new line"),
670                    k("↑ / ↓", "History", "Earlier and later queries (Ctrl+P / Ctrl+N)")
671                        .more("Earlier and later queries from the history (Ctrl+P / Ctrl+N too; SQL and q keep their own). In SQL over several lines, they move between lines first"),
672                    k("Ctrl+U / Ctrl+K", "Delete", "Delete to the start or end of the line"),
673                    k("Ctrl+Z / Ctrl+R", "Undo", "Undo, redo"),
674                ],
675            },
676        ],
677    },
678    Screen {
679        context: Context::Find,
680        title: "Find",
681        reached: "<kbd>/</kbd> (or <kbd>f</kbd>) at the table.",
682        groups: &[
683            Group {
684                name: "Find",
685                keys: &[
686                    k("(text)", "Pattern", "Text, a regex, or letters in order")
687                        .more("What to find: text (any case until a capital is typed), a regex with Ctrl+R, or letters in order with Ctrl+T. Matches in the rows on screen light up as you type, and the line says how many are on screen")
688                        .no_run(),
689                    k("Enter", "Next", "Go to the first match at or after the row")
690                        .more("Find: the cursor moves, row and column, to the first match at or after the current row, reading beyond the rows already read when it must (the footer counts the rows read; Esc stops). On an empty field, clear the find"),
691                    k("Ctrl+G", "Keep matches", "Keep only the rows that match, as a filter")
692                        .more("Keep only the rows with a match, as a filter: the footer and the Sort & Filter sidebar show it, and removing it there (or R) brings the rows back"),
693                    k("Ctrl+R", "Regex", "Regex on or off"),
694                    k("Ctrl+T", "Letters in order", "Letters in order on or off: smth finds Smith"),
695                    k("Ctrl+L", "Column", "Only the cursor's column, or every column")
696                        .more("Only the column cursor's column, or every column shown"),
697                    k("↑ / ↓", "History", "Earlier patterns (Ctrl+P / Ctrl+N too)"),
698                    k("Esc", "Cancel", "Cancel"),
699                ],
700            },
701            Group {
702                // Keys of the table, not the prompt: Enter here would type them.
703                name: "At the table",
704                keys: &[
705                    k("n / N", "Next", "Next or previous match")
706                        .more("Next / previous match from the cursor's cell. After the last match the find wraps to the first, and the footer says so. Presses typed while a find is reading run in turn; Esc stops them all").no_run(),
707                    k("/", "Find again", "Find again, the last pattern ready to edit").no_run(),
708                    k("Esc", "Clear", "Clear the find (or stop one still reading)").no_run(),
709                ],
710            },
711        ],
712    },
713    Screen {
714        context: Context::GoToColumn,
715        title: "Go to column",
716        reached: "<kbd>g</kbd> at the table.",
717        groups: &[Group {
718            name: "Go",
719            keys: &[
720                k("(type)", "Narrow", "Narrow to the names that contain it").no_run(),
721                k("↑ / ↓", "Move", "Move"),
722                k("Enter", "Go", "Move the column cursor to it")
723                    .more("Go. The column cursor moves to it. A column already whole on screen stays where it is; another becomes the first after the frozen ones, or lands on the last page when it is there"),
724                k("Backspace", "Delete", "Delete a character (Ctrl+W a word, Ctrl+U all)"),
725                k("Esc", "Cancel", "Close without moving"),
726            ],
727        }],
728    },
729    Screen {
730        context: Context::Inspector,
731        title: "Inspector",
732        reached: "<kbd>Space</kbd> at the table.",
733        groups: &[
734            Group {
735                name: "Fields",
736                keys: &[
737                    k("↑ / ↓ (j/k)", "Field", "Move between fields"),
738                    k("Home / End", "First, last", "First and last field"),
739                    k("PgUp / PgDn", "Page", "A page of fields"),
740                    k("← / → (h/l)", "Row", "Previous and next row")
741                        .more("Previous and next row; the table's cursor moves with it"),
742                    k("Tab / Shift+Tab", "Value", "Into the value, to scroll and find in it")
743                        .more("Into the value, to scroll and find in it. The panes keep their sizes: they are split by what they hold, not by where the cursor is"),
744                    k("Enter", "Open", "Open a struct, list or JSON; read a field")
745                    .also(&["Rows", "Read", "Retry"])
746                        .more("On a group's row, its rows, as at the table. Else open a struct, a list, or text holding a JSON object or array; or read a field the table's rows do not hold (hidden and binary columns)"),
747                    k("r", "Read", "On a group's row, read a field the rows lack")
748                        .more("On a group's row, read a field the rows do not hold"),
749                    k("/", "Find", "Find a field by name, then by value")
750                        .more("Find a field by name, then by value: type to narrow, Enter or ↓ keeps the list narrowed, Esc clears it"),
751                    k("f", "Nulls", "Nulls shown or hidden (comparing: only diffs)")
752                    .also(&["Differ", "Nulls: hidden", "Nulls: shown"])
753                        .more("Nulls: shown or hidden (null and empty fields); comparing, only the fields that differ"),
754                    k("s", "Order", "Order: the table's, A-Z, or nulls last"),
755                    k("c", "Compare", "Compare with the next row, or the pinned one")
756                        .more("Compare: a column for the next row, or the pinned one"),
757                    k("m", "Pin", "Pin this row to compare others with; again to unpin"),
758                    k("Esc / Space", "Close", "Close; Esc clears a find, then Compare, first")
759                    .also(&["Clear", "No compare"])
760                        .more("Close. Esc backs out one level at a time: a find first, then Compare, then the inspector"),
761                ],
762            },
763            Group {
764                name: "Output",
765                keys: &[
766                    k("y", "Copy", "Copy the value as its view shows it")
767                    .also(&["Copy base64", "Copy JSON", "Copy text"]),
768                    k("Y", "Copy row", "Copy the whole row as one JSON object"),
769                    k("e", "View", "The value's next view, where it has more than one")
770                    .also(&["JSON", "Raw", "Escaped", "Hex", "Text"]),
771                    k("w", "Wrap", "Word wrap or hard wrap for long text")
772                    .also(&["Hard wrap", "Word wrap"]),
773                    k("o", "Open", "Open the value in another program"),
774                ],
775            },
776            Group {
777                name: "Value",
778                keys: &[
779                    k("↑ / ↓ (j/k)", "Scroll", "Scroll a line"),
780                    k("PgUp / PgDn", "Page", "Scroll a page"),
781                    k("Home / End", "Top/End", "Top and end, however long the value")
782                        .more("Top and end, instantly, however long the value"),
783                    k("/", "Find", "Find in the value; n and N go on")
784                        .more("Find in the value; n and N go to the next and previous match"),
785                    k("n / N", "Next", "The next or previous match in the value"),
786                    k("e, w, y, o", "As fields", "As in the fields"),
787                    k("Esc / Tab", "Fields", "Back to the fields; Esc clears a find first")
788                        .more("Back to the fields (Shift+Tab too); Esc clears a find first"),
789                ],
790            },
791            Group {
792                name: "Find",
793                keys: &[
794                    k("(type)", "Find", "The name or value to find")
795                        .also(&["Text"])
796                        .no_run(),
797                    k("Enter", "Done", "Keep the list narrowed; in a value, find")
798                        .also(&["Find"]),
799                    k("Esc", "Clear", "Clear the find"),
800                ],
801            },
802            Group {
803                name: "Nested",
804                keys: &[
805                    k("Enter / → (l)", "Open", "Open the focused item"),
806                    k("↑ / ↓ (j/k)", "Field", "Move between fields, keys or items").also(&["Key", "Item"]),
807                    k("Tab / Shift+Tab", "Value", "Into the item's value; not in an empty level"),
808                    k("PgUp / PgDn", "Page", "A page of the level"),
809                    k("Esc / ← (h)", "Back", "Up a level; at the row, Esc closes"),
810                    k("y", "Copy", "Copy the item: text, or JSON indented")
811                        .more("Copy the focused item: text as itself, a JSON object or array indented"),
812                    k("e", "View", "The item's next view").also(&["JSON", "Raw", "Escaped", "Hex", "Text"]),
813                ],
814            },
815        ],
816    },
817    Screen {
818        context: Context::Info,
819        title: "Info panel",
820        reached: "<kbd>i</kbd> at the table.",
821        groups: &[Group {
822            name: "Panel",
823            keys: &[
824                k("← / → (h/l)", "Tabs", "Previous or next tab (Shift+Tab / Tab too)")
825                    .more("Previous or next tab, from anywhere in the panel; Shift+Tab and Tab do the same. The panel is a viewer, not a form: its body always has the keys"),
826                k("↑ / ↓ (j/k)", "Move", "Move the cursor, or scroll the list")
827                    .also(&["Scroll"])
828                    .more("Schema, Notes or Documentation tab: move the cursor. Model, Audio, MIDI, Metadata and format tabs: scroll the list"),
829                k("PgUp / PgDn", "Page", "Scroll the list a page")
830                    .more("Model, Audio, MIDI, Metadata and format tabs: scroll the list a page"),
831                k("Home / End", "Top, end", "The top or the end of the list")
832                    .more("Model, Audio, MIDI, Metadata and format tabs: the top or the end of the list"),
833                k("Enter", "Open", "Schema: the type; Notes: the offer; a table: open it")
834                    .also(&["Values"])
835                    .more("Schema tab: change the column's type, with the names and formats a format spec's type takes. Notes tab: take the offer on the note, where it has one. Documentation tab: open or close the value legend of the column under the cursor. Excel or SQLite tab: open the worksheet or table under the cursor in place of this one, as T does"),
836                k("o", "Open", "Documentation tab: open the line's link")
837                    .more("Documentation tab: open the link on the cursor's line in the system browser. Only http and https links open, and only after a question showing the whole URL. Off over SSH or without a display"),
838                k("y", "Copy", "Documentation tab: copy the line's link or value")
839                    .more("Documentation tab: copy the link or value on the cursor's line, whole, however it is cut on screen"),
840                k("H", "Header", "Schema tab, CSV: first row as data or names")
841                    .more("Schema tab, CSV, TSV, PSV: read the first row as data, under column_1, column_2, …; again to read it as column names. Reads the file again, so the query, filters and sort are cleared, and the panel closes"),
842                k("c", "Count", "Count every row, where the count is an estimate")
843                    .more("A dataset of more files than read.exact_count_files shows a row count estimated from a sample of its footers; c reads every footer for the exact count. The footer shows its progress, and Esc at the table stops it"),
844                k("x", "Hex", "Show the file's bytes in the hex view"),
845                k("Esc / i", "Close", "Close the panel"),
846            ],
847        }],
848    },
849    Screen {
850        context: Context::ValueCounts,
851        title: "Value counts",
852        reached: "<kbd>F</kbd> at the table.",
853        groups: &[
854            Group {
855                name: "Explore",
856                keys: &[
857                    k("↑ / ↓ (j/k)", "Move", "Move"),
858                    k("PgUp / PgDn", "Page", "A page"),
859                    k("Home / End", "First, last", "First and last row"),
860                    k("← / → (h/l)", "Column", "The previous or next column")
861                        .more("The previous or next column; the table's column cursor moves with it"),
862                    k("Enter", "Drill", "The rows holding the value")
863                    .also(&["Rows"])
864                        .more("The rows holding the value, as a drill-down; Esc there comes back here"),
865                    k("s", "Sort", "Sort by count or by value"),
866                    k("c", "Histogram", "A number's histogram, or the list of its values")
867                    .also(&["List"])
868                        .more("Switch a number column between its histogram, binned from the counts, and the list of its values. A number column opens as its histogram; the header says which is shown"),
869                    k("a", "All rows", "Count every row, when the counts are of a sample"),
870                    k("t", "New rows", "Count again with the rows that arrived")
871                    .also(&["Refresh"])
872                        .more("While following a file, count again with the rows that arrived since; the footer says how many"),
873                ],
874            },
875            Group {
876                name: "Output",
877                keys: &[
878                    k("y", "Copy", "Copy the counts as TSV")
879                        .more("Copy the counts as TSV: every value, its count, percent and cumulative percent"),
880                    k("e", "Export", "Export the counts to a file"),
881                    k("Esc", "Back", "Back to the table")
882                    .also(&["Stop"])
883                        .more("Back to the table; while every row is being counted, stop that and keep the sample"),
884                ],
885            },
886        ],
887    },
888    Screen {
889        context: Context::SortFilter,
890        title: "Sort and filter",
891        reached: "<kbd>s</kbd> at the table.",
892        groups: &[
893            Group {
894                name: "Sidebar",
895                keys: &[
896                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous field")
897                        .more("Next or previous field, wrapping; a field the form does not offer right now is skipped. The arrows move from the moment the dialog opens"),
898                    k("← / →", "Change", "Tabs on the tab bar; flip a sort; and/or")
899                    .also(&["Tabs", "And/Or"])
900                        .more("On the tab bar: switch Sort & Filter / Columns. On a sort: flip its direction. On a filter: and / or. On a column: step its sort (none, ascending, descending). h/l too"),
901                    k("Space", "Act", "Flip a sort, edit a filter, add one")
902                    .also(&["Flip", "Edit", "Add"])
903                        .more("On a sort: flip its direction. On a filter: edit it (column, operator, value). On \"add sort\": pick a column to sort by, last and ascending. On \"add filter\": a new filter, starting on the table's column cursor. On a column: step its sort"),
904                    k("Enter", "Apply", "Apply and close, from any row")
905                        .more("Apply everything staged and close, from any row (in the filter editor Enter takes the step; Ctrl+J applies)"),
906                    k("Ctrl+J", "Apply", "Apply from anywhere, mid-edit too")
907                        .more("Apply from anywhere, including mid-edit (the row in progress is saved). Ctrl+Enter does the same, on a terminal that tells it from Enter"),
908                    k("Esc", "Cancel", "Close a picker or editor, then the sidebar")
909                        .more("Close an open picker or filter editor; otherwise cancel and close, discarding what is staged. Reopening shows what is applied"),
910                ],
911            },
912            Group {
913                name: "In effect",
914                keys: &[
915                    k("[ / ]", "Move", "Move the sort or filter earlier or later")
916                        .more("Move the focused sort earlier or later in the sort order, or the focused filter in the list"),
917                    k("d / Del", "Remove", "Remove the sort or filter"),
918                    k("C", "Clear", "Remove every sort and filter"),
919                ],
920            },
921            Group {
922                name: "Columns",
923                keys: &[
924                    k("(type)", "Narrow", "Narrow the list, with the find field focused")
925                    .also(&["Find"])
926                        .more("Narrow the column list, when the find field is focused. ↓ from find goes to the list, on the table's column cursor")
927                        .no_run(),
928                    k("PgUp / PgDn, Home / End", "Page", "A page of columns; the first or last")
929                        .more("A page of columns, or the first or last; ↓ stops at the last column"),
930                    k("Space", "Sort", "Cycle the sort: none, ascending, descending")
931                        .more("Cycle the column's sort: none → ascending → descending (← steps back). Each column carries its own direction"),
932                    k("1-9", "Sort place", "Put the column at that place in the sort")
933                        .more("Put the column at that place in the sort order; 0 removes it (a digit past the end of the order says so)")
934                        .run("1"),
935                    k("Del", "Unsort", "Remove the column from the sort"),
936                    k("[ / ]", "Sort order", "Earlier or later in the sort order"),
937                    k("+ / - (= / _)", "Position", "Move the column's display position"),
938                    k("L", "Freeze", "Freeze columns up to this one at the left")
939                        .more("Freeze this column and every column up to it at the left edge; on a column already frozen, pull the boundary back"),
940                    k("v", "Hide", "Show or hide the column")
941                        .more("Show or hide the column (it keeps its place, dimmed)"),
942                    k("< / > (, / .)", "Width", "4 cells narrower or wider")
943                        .more("Make the column 4 cells narrower or wider. A number column is never narrower than its numbers"),
944                    k("f", "Fit", "Fit to the rows on screen")
945                        .more("Fit the column to the rows on screen, its name up to the automatic limit"),
946                    k("w", "Auto width", "Back to the automatic width"),
947                    k("C", "Clear", "Clear staged sort, order, locks, hidden, widths")
948                        .more("Clear the staged sort, order, locks, hidden columns and widths"),
949                ],
950            },
951            Group {
952                name: "Filter editor",
953                keys: &[
954                    k("(type)", "Narrow", "Narrow the column or operator list")
955                        .more("Narrow the column or operator list. The operators are those the column's type takes")
956                        .no_run(),
957                    k("Enter", "Next", "Choose the step; from the value, save the row")
958                    .also(&["Save", "Add"])
959                        .more("Pick the column (type to narrow, Enter chooses), the operator the same way, then type the value and Enter saves the row. Tab and → also choose at the column and operator steps, and so does Space until typing narrows the list (then it types a space); Shift+Tab steps back; ↑ / ↓ move in the lists"),
960                    k("Esc", "Back", "End the edit, and only the edit"),
961                ],
962            },
963        ],
964    },
965    Screen {
966        context: Context::PivotMelt,
967        title: "Pivot and melt",
968        reached: "<kbd>p</kbd> at the table: the form on the left, a live preview of the result on the right (above and below on a narrow terminal).",
969        groups: &[
970            Group {
971                name: "Form",
972                keys: &[
973                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous field")
974                        .more("Next or previous field, wrapping; a field the form does not offer right now is skipped. The arrows move from the moment the builder opens"),
975                    k("← / →", "Change", "Pivot / Melt on the first row; step a choice")
976                        .more("On the first row: switch Pivot and Melt. On the aggregation, strategy or type: the previous or next value. On a single column row: the previous or next column. In a text field: move the cursor. h/l too, outside text fields. The preview follows each change"),
977                    k("Space", "Open", "Open a column picker; next value of a choice")
978                        .more("On a column row: open its picker, scoped to that row; typing narrows it. On a choice: its next value, wrapping"),
979                    k("Enter", "Apply", "Apply the reshape the preview shows")
980                        .more("Apply the reshape the preview shows, to the whole view, from any field"),
981                    k("Esc", "Close", "Close without applying; stop a pivot")
982                        .more("Close without applying; while a pivot is computed, stop it and keep the builder"),
983                ],
984            },
985            Group {
986                name: "Picker",
987                keys: &[
988                    k("(type)", "Narrow", "Narrow the column list").no_run(),
989                    k("↑ / ↓", "Move", "Move; typing narrows"),
990                    k("Enter", "Choose", "Choose; on a several-choice row, done"),
991                    k("Space", "Toggle", "Choose; toggle a column on a several-choice row")
992                        .more("Choose, until typing narrows the list (then it types a space). On a several-choice row: toggle a column in or out"),
993                    k("Esc", "Back", "Back out of the picker, undoing its toggles")
994                        .more("Back out of the picker; the toggles made since it opened are undone (Enter keeps them)"),
995                ],
996            },
997        ],
998    },
999    Screen {
1000        context: Context::Chart,
1001        title: "Chart",
1002        reached: "<kbd>c</kbd> at the table: a chart chosen from the cursor column's type.",
1003        groups: &[
1004            Group {
1005                name: "Shelves",
1006                keys: &[
1007                    k("1-7", "Type", "Chart type, in order: Line to Heatmap")
1008                        .more("Switch the chart type: Line, Scatter, Bar, Histogram, Box, KDE, Heatmap ([ / ] step). The shelves keep what the new type takes. On Rows, digits type a sample size instead")
1009                        .run("1"),
1010                    k("[ / ]", "Type", "Previous or next chart type"),
1011                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous row")
1012                        .more("Next or previous row of the panel, wrapping (j/k too). A shelf the type does not use is dimmed and skipped"),
1013                    k("Space / Enter", "Act", "Open a shelf's picker, toggle, or step the row")
1014                    .also(&["Pick", "Toggle", "Read"])
1015                        .more("On X, Y or Color: open its picker. On the line under Color: pick the values that get a series, by rows. On an option: toggle it or take its next value. The panel applies as it changes, so Enter acts as Space does, except on Rows: Space switches between Sample and Every row, and Enter reads what the row says"),
1016                    k("← / → (h/l)", "Change", "Step the type, bucket, aggregate or option")
1017                    .also(&["Type", "Bucket", "Aggregate", "Percentile", "Adjust", "Switch", "Order", "Range", "Cumulative"])
1018                        .more("Step the type, the time bucket (day, week, month, quarter, year), the aggregate (count, distinct, sum, mean, median, stdev, quantile, min, max, first, last) and a quantile's percentile, cumulative, bins, range or order; on Rows, switch between Sample and Every row (read on Enter); on a shelf that takes one column, the previous or next column; on the line under Color, turn Other (every value without a series) on or off; flip a toggle"),
1019                    k("+ / -", "Adjust", "Bins or bandwidth"),
1020                    k("0-9", "Size", "On Rows: type a sample size, like 50k or 2m")
1021                        .more("On Rows: type a sample size, like 50000, 50,000, 50k, 250k or 2m. Backspace edits, Enter reads it, Esc puts the row back as it was. A size of at least the table's rows is Every row. Nothing is read until Enter or focus leaves the row")
1022                        .no_run(),
1023                    k("g", "Grid", "Grid on or off")
1024                        .more("Grid on or off at the labeled ticks: Line, Scatter, Histogram, Box and KDE. [analysis] chart_grid sets where it starts"),
1025                    k("Esc", "Back", "Back to the table")
1026                    .also(&["Undo"])
1027                        .more("Back to the table; on Rows with a change waiting, put the row back first"),
1028                ],
1029            },
1030            Group {
1031                name: "Plot",
1032                keys: &[
1033                    k("x", "Crosshair", "Line, Scatter: a crosshair reads out the values")
1034                        .more("Line and Scatter: the plot takes the keys, and a crosshair reads out x and every series' value under the plot. ← / → (h/l) step to the next point or column, Home/End go to the ends; x, Tab or Esc hand the keys back to the panel. A click on the plot puts the crosshair there"),
1035                    k("e", "Export", "Export the chart to PNG, SVG or PDF")
1036                        .more("Export the chart to PNG, SVG or PDF, with a title, notes and source. Needs the chart's shelves filled first"),
1037                    k("t", "New rows", "Draw again with the rows that arrived")
1038                    .also(&["Refresh"])
1039                        .more("While following a file, draw again with the rows that arrived since; the footer says how many"),
1040                ],
1041            },
1042            Group {
1043                name: "Crosshair",
1044                keys: &[
1045                    k("← / →", "Cursor", "Move the crosshair along the plot"),
1046                    k("Tab", "Panel", "Back to the panel, the crosshair kept"),
1047                    k("Esc", "Back", "The crosshair off, back to the panel"),
1048                ],
1049            },
1050            Group {
1051                name: "Picker",
1052                keys: &[
1053                    k("↑ / ↓", "Move", "Move; typing narrows"),
1054                    k("Enter / Space", "Choose", "Choose (Y, Color values: Space toggles one)")
1055                    .also(&["Toggle", "Done"])
1056                        .more("Choose; on a line or scatter chart's Y and on the Color values, Space toggles one in or out (up to 10, fewer on a terminal of fewer colors). Elsewhere Space chooses until typing narrows the list, then types a space"),
1057                    k("Tab / Shift+Tab", "Next", "Choose and move to the next or previous row"),
1058                    k("Esc", "Back", "Back out of the picker alone"),
1059                ],
1060            },
1061            Group {
1062                name: "Export dialog",
1063                keys: &[
1064                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Path, format, style, size, legend, words")
1065                        .more("Next or previous field: Path, Format, Style, Size, Width, Height, Legend, the marks (Opacity and Point size for a scatter, Line width for a line, Y from zero for a line), Title, Description, Notes, Source, Byline, Recipe"),
1066                    k("← / →", "Change", "Step the format, style, size, legend or marks")
1067                        .more("Step the format (PNG, SVG, PDF), the style (Light, Dark, Transparent), the size (Slide 16:9, Document, Square, Single column, Double column, Custom), the legend (line ends, a corner, off), a scatter's point opacity (Auto, 100%, 50%, 20%) and size (Small, Medium, Large), a line's width (Thin, Normal, Bold), Y from zero, or the recipe (Include, Omit). Typing a width or height makes the size Custom"),
1068                    k("Ctrl+P / Ctrl+N", "History", "Earlier or later paths in the path field"),
1069                    k("Enter", "Export", "Export, from anywhere in the dialog")
1070                        .more("Export, from anywhere in the dialog. A path ending .png, .svg or .pdf takes that format; any other gets the format's extension after it. An existing file asks Overwrite / No, starting on No; ←/→ (h/l) or Tab pick, Enter confirms, and declining returns to the filled dialog"),
1071                    k("Esc", "Cancel", "Back to the chart"),
1072                ],
1073            },
1074        ],
1075    },
1076    Screen {
1077        context: Context::Describe,
1078        title: "Analysis: Describe",
1079        reached: "<kbd>a</kbd> at the table.",
1080        groups: &[
1081            Group {
1082                name: "Explore",
1083                keys: &[
1084                    k("Tab / Shift+Tab", "Focus", "Between the results and the tools")
1085                    .also(&["Tools", "Result"]),
1086                    k("↑ / ↓ (j/k)", "Move", "Rows, or the sidebar's tools")
1087                    .also(&["Tools", "Rows"]),
1088                    k("← / → (h/l)", "Scroll", "Scroll the statistics")
1089                    .also(&["Columns"])
1090                        .more("Scroll the statistics; the header counts those out of view"),
1091                    k("Home / End", "First, last", "First or last row"),
1092                    k("PgUp / PgDn", "Page", "A page"),
1093                    k("Enter", "Open", "Open the sidebar's tool")
1094                    .also(&["Detail"])
1095                        .more("Open the sidebar's tool and move into its pane. The first run on a dataset starts in the Sample form, where Enter runs it; later tools reuse that sample. Results last until the view changes: closing and reopening shows them again"),
1096                    k("Esc", "Back", "Cancel a run; the tools; then close")
1097                    .also(&["Cancel", "Tools", "Close"])
1098                        .more("Cancel a run in progress; otherwise from the results back to the tools, and from the tools close the analysis view"),
1099                ],
1100            },
1101            Group {
1102                name: "Sample",
1103                keys: SAMPLE_KEYS,
1104            },
1105        ],
1106    },
1107    Screen {
1108        context: Context::Distribution,
1109        title: "Analysis: Distribution",
1110        reached: "Distribution in the Analysis sidebar.",
1111        groups: &[
1112            Group {
1113                name: "Explore",
1114                keys: &[
1115                    k("↑ / ↓ (j/k)", "Move", "Rows, or the sidebar's tools")
1116                    .also(&["Tools", "Rows"]),
1117                    k("← / → (h/l)", "Scroll", "Scroll the statistics")
1118                    .also(&["Columns"])
1119                        .more("Scroll the statistics; the header counts those out of view"),
1120                    k("Home / End", "First, last", "First or last row"),
1121                    k("PgUp / PgDn", "Page", "A page"),
1122                    k("Tab / Shift+Tab", "Focus", "Between the results and the tools")
1123                    .also(&["Tools", "Result"]),
1124                    k("Enter", "Detail", "Q-Q plot and histogram for the column")
1125                    .also(&["Open"])
1126                        .more("Open the selected column's detail, a Q-Q plot and a histogram; with the sidebar focused, open a tool"),
1127                    k("Esc", "Back", "Cancel a run; the tools; then close")
1128                    .also(&["Cancel", "Tools", "Close"])
1129                        .more("Cancel a run in progress; otherwise from the results back to the tools, and from the tools close the analysis view"),
1130                ],
1131            },
1132            Group {
1133                name: "Sample",
1134                keys: SAMPLE_KEYS,
1135            },
1136        ],
1137    },
1138    Screen {
1139        context: Context::DistributionDetail,
1140        title: "Analysis: Distribution detail",
1141        reached: "<kbd>Enter</kbd> on a column in Distribution.",
1142        groups: &[Group {
1143            name: "Detail",
1144            keys: &[
1145                k("↑ / ↓ (j/k)", "Distribution", "Compare the values with another family"),
1146                k("Home / End", "First, last", "The first or last family"),
1147                k("s", "Scale", "Histogram scale: linear or log"),
1148                k("Esc", "Back", "Back to the distribution table"),
1149            ],
1150        }],
1151    },
1152    Screen {
1153        context: Context::Correlation,
1154        title: "Analysis: Correlation",
1155        reached: "Correlation Matrix in the Analysis sidebar.",
1156        groups: &[
1157            Group {
1158                name: "Explore",
1159                keys: &[
1160                    k("Tab / Shift+Tab", "Focus", "Between the matrix and the tools")
1161                    .also(&["Tools", "Result"]),
1162                    k("↑ / ↓ (j/k)", "Move", "Matrix rows, or the sidebar's tools")
1163                    .also(&["Tools", "Rows"]),
1164                    k("← / → (h/l)", "Column", "Matrix columns")
1165                    .also(&["Columns"]),
1166                    k("Home / End", "Ends", "The first or last pair")
1167                        .more("Jump to the first pair (the first row's second column) or the last (the last row's next-to-last column); the matrix opens on the first"),
1168                    k("PgUp / PgDn", "Page", "A page"),
1169                    k("Enter", "Detail", "The pair's detail (not on the diagonal)")
1170                    .also(&["Open"])
1171                        .more("On a cell, open the pair's detail; in the sidebar, open a tool. Does nothing on a diagonal cell"),
1172                    k("m", "Method", "Pearson or Spearman, named in the title")
1173                        .more("Method: Pearson r or Spearman ρ, named in the title (one run computes both, so switching reads nothing)"),
1174                    k("Esc", "Back", "Cancel a run; the tools; then close")
1175                    .also(&["Cancel", "Tools", "Close"])
1176                        .more("Cancel a run in progress; otherwise from the matrix back to the tools, and from the tools close the analysis view"),
1177                ],
1178            },
1179            Group {
1180                name: "Sample",
1181                keys: SAMPLE_KEYS,
1182            },
1183        ],
1184    },
1185    Screen {
1186        context: Context::CorrelationDetail,
1187        title: "Analysis: Correlation detail",
1188        reached: "<kbd>Enter</kbd> on a pair in the correlation matrix.",
1189        groups: &[Group {
1190            name: "Detail",
1191            keys: &[
1192                k("m", "Method", "Pearson or Spearman"),
1193                k("Esc", "Back", "Back to the correlation matrix")
1194                    .more("Return to the correlation matrix. Resampling (r) works from the matrix, not from inside this detail"),
1195            ],
1196        }],
1197    },
1198    Screen {
1199        context: Context::DataQuality,
1200        title: "Analysis: Data Quality",
1201        reached: "Data Quality in the Analysis sidebar.",
1202        groups: &[
1203            Group {
1204                name: "Setup",
1205                keys: &[
1206                    k("↑ / ↓, Tab", "Move", "Move between rows")
1207                    .also(&["Row"]),
1208                    k("← / →", "Choose", "Change a short list's choice")
1209                    .also(&["Change"]),
1210                    k("Space", "Open", "Open the row's form or list")
1211                    .also(&["Sample", "Choose", "Time roles", "Intervals", "Expected", "Intent"])
1212                        .more("Open the row: the Sample form, a list (type to narrow; Enter chooses, and Space until typing narrows it), Time roles, Intervals, Column intent or Expected"),
1213                    k("s", "Sample", "The Sample form")
1214                        .more("The Sample form; its Enter applies to Setup and returns there"),
1215                    k("p", "Access", "The access plan: what a run reads"),
1216                    k("d", "Release", "Release kept rows and the local copy")
1217                    .also(&["Release rows"])
1218                        .more("Release the rows runs kept and a full scan's local copy, named on the Read rule; the next run that would reuse them reads again"),
1219                    k("Enter", "Run", "Run, from any row")
1220                        .more("Run, from any row. A full read asks first; the report on screen or in the session cache is shown, not read again"),
1221                    k("Esc", "Discard", "Discard every staged edit")
1222                    .also(&["Back"]),
1223                ],
1224            },
1225            Group {
1226                name: "Setup lists",
1227                keys: &[
1228                    k("↑ / ↓", "Move", "Choose the role, pair, column or row")
1229                    .also(&["Role", "Field", "Column", "Pair"])
1230                        .more("Time roles: choose the role. Intervals: choose the pair. Column intent: choose the column. Expected: Windows, From, Before (Tab too)"),
1231                    k("← / →", "Choose", "Time roles: its column; Expected: windows")
1232                    .also(&["Column", "Windows"])
1233                        .more("Time roles: choose the role's column. Expected: choose the windows. Intervals: measure the pair or not"),
1234                    k("Space", "Toggle", "Intervals: measure or not; intent: its form")
1235                    .also(&["Declare"])
1236                        .more("Intervals: measure the pair or not. Column intent: declare the column's intent in a form"),
1237                    k("Enter", "Done", "Done"),
1238                    k("Esc", "Undo", "Put the list back as it was")
1239                    .also(&["Cancel"]),
1240                ],
1241            },
1242            Group {
1243                name: "Forms",
1244                keys: &[
1245                    k("Tab / Shift+Tab", "Next", "Next or previous field"),
1246                    k("← / →", "Change", "Change the field's choice")
1247                        .also(&["Format", "Reading"]),
1248                    k("Space", "Toggle", "Toggle a checkbox"),
1249                    k("Enter", "Apply", "Apply the form; export the report")
1250                        .also(&["Export"]),
1251                    k("Esc", "Cancel", "Close the form unchanged"),
1252                ],
1253            },
1254            Group {
1255                name: "Popups",
1256                keys: &[
1257                    k("↑ / ↓", "Scroll", "Scroll a popup, or move in its list")
1258                        .also(&["Move"]),
1259                    k("(type)", "Narrow", "Narrow a popup's list").no_run(),
1260                    k("Enter", "Close", "Close a popup, or act on its finding")
1261                        .also(&["Read", "Choose", "Fewer checks", "All checks", "Show rows", "Read rows"])
1262                        .more("Close a popup, or act on what it shows: a finding's rows (shown when the run kept them, else read), its checks all or fewer, a list's choice"),
1263                    k("Esc", "Back", "Close a popup or a list unchanged")
1264                        .also(&["Cancel", "Close"]),
1265                ],
1266            },
1267            Group {
1268                name: "Report",
1269                keys: &[
1270                    k("← / → (h/l)", "Page", "Previous or next page"),
1271                    k("1 - 5", "Page", "Overview, Columns, Segments, Trends, Intervals")
1272                        .run("1"),
1273                    k("↑ / ↓ (j/k)", "Move", "Move, or scroll a tall finding")
1274                    .also(&["Bar", "Tools"]),
1275                    k("PgUp / PgDn", "Page", "A page; Home / End the ends")
1276                        .more("Page; Home/End jump to either end"),
1277                    k("Enter", "Details", "Open a finding, then its rows")
1278                    .also(&["Open", "Inspect", "Columns", "Segments", "Trends", "Show rows", "Read rows", "Set grain", "Time roles", "Intervals"])
1279                        .more("Open a finding, then its rows. On an empty page, open the setting it needs"),
1280                    k("c / t", "Only", "Overview: one column's or type's findings")
1281                    .also(&["Column", "Type"])
1282                        .more("Overview: only one column's or one type's findings"),
1283                    k("o", "Order", "Overview: ranked, by rows, by rate")
1284                    .also(&["Ranked", "By rows", "By rate"]),
1285                    k("e", "Setup", "Setup"),
1286                    k("s", "Sample", "Setup, with the Sample form open"),
1287                    k("v", "Sample rows", "View the sample's rows; Esc returns"),
1288                    k("p", "Access", "The access plan: what a run reads"),
1289                    k("r", "Resample", "On a sample, run again with a new seed"),
1290                    k("x", "Export", "Export the report to JSON or Markdown")
1291                        .more("Export the report to JSON or Markdown; nothing is read"),
1292                    k("Tab / Shift+Tab", "Focus", "Between the result and the tools")
1293                    .also(&["Result"]),
1294                    k("Esc", "Back", "Back one level: all findings, the tools, close")
1295                    .also(&["Cancel", "All findings", "Tools", "Close"])
1296                        .more("Back one level: all findings again, then the tools, then close"),
1297                ],
1298            },
1299            Group {
1300                name: "Segments",
1301                keys: &[
1302                    k("Enter", "Details", "The segment's columns beside its comparison")
1303                        .more("A segment's columns beside the one it is compared with, largest change first"),
1304                    k("o", "Order", "Largest change first, or back in order")
1305                    .also(&["In order", "By change"]),
1306                    k("b", "Baseline", "Compare with the highlighted segment"),
1307                ],
1308            },
1309            Group {
1310                name: "Trends",
1311                keys: &[
1312                    k("Enter", "Details", "A line's bars, with rate and interval")
1313                    .also(&["Trends"])
1314                        .more("A line's bars: span, segments, rows sampled of counted, rate, 95% interval, and the bar before. ↑ / ↓ there: the next bar"),
1315                    k("m", "Measure", "Next measure"),
1316                    k("w", "Coarser", "Stage a coarser window in Setup")
1317                        .more("Stage a coarser window in Setup, for segments the sample reached thinly or not at all"),
1318                    k("g", "Gaps", "The expected windows with no rows")
1319                        .more("The expected windows with no rows: empty by exact count, not sampled, or out of scope"),
1320                    k("Esc", "Back", "Back to Trends"),
1321                ],
1322            },
1323            Group {
1324                name: "Intervals",
1325                keys: &[
1326                    k("Enter", "Details", "An interval's detail; again, its rows")
1327                    .also(&["Show rows", "Read rows"])
1328                        .more("An interval's detail: its ends, the rows with both, missing and unread ends, negative and zero durations, percentiles and breaches. Enter there shows the rows behind the count under the cursor: a sample's from the rows the run kept"),
1329                    k("Esc", "Back", "Back to the list"),
1330                ],
1331            },
1332        ],
1333    },
1334    Screen {
1335        context: Context::Export,
1336        title: "Export",
1337        reached: "<kbd>e</kbd> at the table.",
1338        groups: &[
1339            Group {
1340                name: "Form",
1341                keys: &[
1342                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous field")
1343                        .more("Next or previous field, wrapping; the fields follow the format. The dialog opens on Path, and the arrows move from there"),
1344                    k("← / →", "Change", "Step the format or compression; move the cursor")
1345                    .also(&["Format"])
1346                        .more("On Format or Compression: the previous or next value, along the Format row (h/l too). On Header or Source file: toggle. In Path and Delimiter: move the cursor"),
1347                    k("Space", "Act", "Toggle a checkbox; next format or compression")
1348                    .also(&["Toggle"])
1349                        .more("Toggle a checkbox (Header, Source file); on Format or Compression, the next value, wrapping"),
1350                    k("Ctrl+P / Ctrl+N", "History", "Earlier or later paths in the path field")
1351                        .more("In the path field: the paths exported to before, earlier or later (↑ and ↓ move between fields)"),
1352                    k("Enter", "Export", "Export, from anywhere in the form")
1353                        .more("Export, from anywhere in the form. On a blank path the form says \"Enter a file path.\" instead of exporting"),
1354                    k("Esc", "Cancel", "Close without exporting"),
1355                ],
1356            },
1357            Group {
1358                name: "File exists",
1359                keys: &[
1360                    k("← / → (h/l)", "Pick", "Pick Overwrite or No (Tab too)"),
1361                    k("Enter", "Confirm", "Confirm the one picked"),
1362                    k("Esc", "No", "Decline: back to the filled form"),
1363                ],
1364            },
1365        ],
1366    },
1367    Screen {
1368        context: Context::Copy,
1369        title: "Copy",
1370        reached: "<kbd>y</kbd> at the table.",
1371        groups: &[
1372            Group {
1373                name: "Form",
1374                keys: &[
1375                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous row"),
1376                    k("← / →", "Change", "Step the scope, column or format")
1377                        .more("The previous or next scope, column or format (h/l too); on Header: toggle"),
1378                    k("Space", "Act", "Next scope or format; pick a column; toggle")
1379                    .also(&["Toggle", "Pick"])
1380                        .more("On Scope or Format: the next value, wrapping. On Column: open its picker. On Header: toggle"),
1381                    k("Enter", "Copy", "Copy, from anywhere in the form")
1382                        .more("Copy, from anywhere in the form. On the Cell scope with no column picked, Enter copies nothing and shows pick a column to copy in the warning color"),
1383                    k("Esc", "Cancel", "Close a picker, then the dialog")
1384                        .more("Close a picker, then the dialog, without copying"),
1385                ],
1386            },
1387            Group {
1388                name: "Picker",
1389                keys: &[
1390                    k("(type)", "Narrow", "Narrow the list").no_run(),
1391                    k("↑ / ↓", "Move", "Move the cursor (j/k narrow)")
1392                        .more("Move the cursor (in the picker, j and k type; only ↑ / ↓ move)"),
1393                    k("Enter / Space", "Choose", "Choose")
1394                        .more("Choose. Space chooses until typing narrows the list, then types a space, so a name of several words narrows"),
1395                    k("Tab / Shift+Tab", "Next", "Choose and move on"),
1396                    k("Esc", "Back", "Back to the form, the choice unchanged"),
1397                ],
1398            },
1399        ],
1400    },
1401    Screen {
1402        context: Context::Views,
1403        title: "Views",
1404        reached: "<kbd>v</kbd> at the table.",
1405        groups: &[
1406            Group {
1407                name: "List",
1408                keys: &[
1409                    k("↑ / ↓ (j/k)", "Move", "Move in the list"),
1410                    k("Enter", "Apply", "Apply the selected view"),
1411                    k("s", "Save", "Save the current state as a view")
1412                        .more("Save the current state as a view (an untouched table has nothing to save, and s says so)"),
1413                    k("e", "Edit", "Edit the selected view"),
1414                    k("d", "Delete", "Delete the selected view, after confirming"),
1415                    k("i", "Score", "How the view's score was computed")
1416                        .more("Show how the selected view's score was computed (Esc closes the score view)"),
1417                    k("Esc", "Close", "Close"),
1418                ],
1419            },
1420            Group {
1421                name: "Save and edit",
1422                keys: &[
1423                    k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous row")
1424                        .more("Next or previous row. In the description ↑ / ↓ move between its lines, and leave it from the first or last"),
1425                    k("Enter", "Save", "Save (in the description, Enter types)")
1426                        .more("Save, from any row. In the description Enter types: Ctrl+J saves from there"),
1427                    k("Ctrl+J", "Save", "Save from anywhere, the description too")
1428                        .more("Save from anywhere, the description included; so does Ctrl+Enter, on a terminal that tells it from Enter"),
1429                    k("PgUp / PgDn", "Lines", "Five lines in the description"),
1430                    k("Space", "Toggle", "Expand Matching; toggle schema match")
1431                    .also(&["Collapse", "Expand"])
1432                        .more("Expand or collapse Matching; toggle schema match"),
1433                    k("Esc", "Cancel", "Back to the list, discarding edits"),
1434                ],
1435            },
1436            Group {
1437                name: "Delete",
1438                keys: &[
1439                    k("← / → (h/l)", "Pick", "Pick Delete or No (Tab too); it starts on No"),
1440                    k("Enter", "Confirm", "Confirm the one picked"),
1441                    k("Esc", "No", "Keep the view"),
1442                ],
1443            },
1444        ],
1445    },
1446    Screen {
1447        context: Context::FormatPicker,
1448        title: "Format picker",
1449        reached: "<kbd>b</kbd> at a table read through a format spec.",
1450        groups: &[Group {
1451            name: "Pick",
1452            keys: &[
1453                k("(type)", "Narrow", "Narrow to the names that contain it").no_run(),
1454                k("↑ / ↓", "Move", "Move"),
1455                k("Enter / Space", "Read", "Read the file again with the spec")
1456                    .more("Read the file again with the spec chosen. The query, filters and sort are cleared"),
1457                k("Backspace", "Delete", "Delete a character (Ctrl+W a word, Ctrl+U all)"),
1458                k("Esc", "Cancel", "Close and keep the format"),
1459            ],
1460        }],
1461    },
1462    Screen {
1463        context: Context::Retype,
1464        title: "Column type",
1465        reached: "<kbd>Enter</kbd> on the Info panel's Schema tab, or Change type in the cell menu.",
1466        groups: &[Group {
1467            name: "Pick",
1468            keys: &[
1469                k("(type)", "Narrow", "Narrow the list; a format typed is the format").no_run()
1470                    .more("Narrow the list to the names that contain it. In the format list, a strftime format typed that no line holds is the format"),
1471                k("↑ / ↓", "Move", "Move"),
1472                k("Enter", "Choose", "The type, or its format")
1473                    .more("Apply the type: the column is read as it at once, and a value that does not fit becomes null. A date, time or datetime asks for its format next, each line showing what it makes of the column's first value. as read removes the type"),
1474                k("Backspace", "Delete", "Delete a character (Ctrl+W a word, Ctrl+U all)"),
1475                k("Esc", "Back", "Back to the types, or close")
1476                    .also(&["Cancel"]),
1477            ],
1478        }],
1479    },
1480    Screen {
1481        context: Context::Combine,
1482        title: "Combine into datetime",
1483        reached: "Combine into datetime in the cell menu, on a text, date or time column.",
1484        groups: &[
1485            Group {
1486            name: "Fields",
1487            keys: &[
1488                k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous field"),
1489                k("Space", "Pick", "Pick a column, or step the kind"),
1490                k("← / →", "Change", "Step the kind: datetime, date or time"),
1491                k("Enter", "Make", "Make the column")
1492                    .more("Make the column, placed before the first column it is made from, as a format spec's derived column is. A date, a time and a UTC offset make a datetime in UTC"),
1493                k("Esc", "Cancel", "Cancel"),
1494            ],
1495        },
1496        Group {
1497            name: "Picker",
1498            keys: &[
1499                k("(type)", "Narrow", "Narrow to the columns that contain it").no_run(),
1500                k("↑ / ↓", "Move", "Move"),
1501                k("Enter", "Choose", "Choose the column"),
1502                k("Esc", "Back", "Back to the fields, the choice unchanged"),
1503            ],
1504        }],
1505    },
1506    Screen {
1507        context: Context::TablePicker,
1508        title: "Table picker",
1509        reached: "<kbd>T</kbd> at a table from a file of several tables.",
1510        groups: &[Group {
1511            name: "Pick",
1512            keys: &[
1513                k("(type)", "Narrow", "Narrow to the names that contain it").no_run(),
1514                k("↑ / ↓", "Move", "Move"),
1515                k("Enter", "Open", "Open the table in place of this one")
1516                    .more("Open the table chosen in place of this one. The query, filters and sort are cleared"),
1517                k("Backspace", "Delete", "Delete a character (Ctrl+W a word, Ctrl+U all)"),
1518                k("Esc", "Cancel", "Close and keep the table"),
1519            ],
1520        }],
1521    },
1522    Screen {
1523        context: Context::Sample,
1524        title: "Sample",
1525        reached: "<kbd>S</kbd> at the table.",
1526        groups: &[Group {
1527            name: "Form",
1528            keys: &[
1529                k("Tab / Shift+Tab (↑ / ↓)", "Next", "Next or previous row")
1530                    .also(&["Row", "Tools", "Sample"]),
1531                k("← / →", "Change", "Step Rows from, Method, Per value of")
1532                    .more("Step Rows from, Method and Per value of; Method's No sample takes the view's sample away"),
1533                k("(type)", "Edit", "Type a size (50k, 2m), a seed, rows, values")
1534                    .more("Type into the focused row: the size (50000, 50k, 2m), the seed, a row range, partition values or file numbers")
1535                    .no_run(),
1536                    k("PgUp / PgDn", "Files", "Page through the files, where there are many"),
1537                k("Enter", "Draw", "Draw the sample; again past a memory warning")
1538                    .also(&["Clear", "Draw anyway", "Run", "Apply"])
1539                    .more("Draw the sample: the table shows its rows as they arrive, and the query, filters and sort run over them. When the estimate is more than the memory available now (or analysis.sample_memory_limit), the form says so; Enter again draws anyway"),
1540                k("Esc", "Cancel", "Close; the view's sample stays as it was")
1541                    .also(&["Back"]),
1542            ],
1543        }],
1544    },
1545    Screen {
1546        context: Context::Hex,
1547        title: "Hex view",
1548        reached: "`datui --hex FILE`, <kbd>Ctrl</kbd>+<kbd>X</kbd> on the home screen, or <kbd>x</kbd> in the Info panel.",
1549        groups: &[
1550            Group {
1551                name: "Explore",
1552                keys: &[
1553                    k("← / → (h/l)", "Byte", "A byte"),
1554                    k("↑ / ↓ (j/k)", "Row", "A row"),
1555                    k("w / b", "Group", "The next group of four, or back one"),
1556                    k("0 / $", "Row ends", "The start or end of the row"),
1557                    k("g / G", "File ends", "Start or end of the file (Home / End too)")
1558                        .more("The start or end of the file (Home and End too)"),
1559                    k("PgUp / PgDn", "Page", "A page (Ctrl+B / F; Ctrl+U / D half)")
1560                        .more("A page (Ctrl+B/F too; Ctrl+U/D half a page)"),
1561                    k(":", "Go to", "Go to an offset: 4096, 0x1000, +16, e-8")
1562                    .also(&["Offset"])
1563                        .more("Go to an offset: 4096 or 0x1000; +16 or -16 from the cursor; e-8 for the eighth byte from the end"),
1564                ],
1565            },
1566            Group {
1567                name: "Find",
1568                keys: &[
1569                    k("f", "Find", "Find text or bytes (de ad, 0x1acf, ?? any)")
1570                        .more("Find bytes. Text is found as its UTF-8 bytes (Ctrl+U in the prompt: as UTF-16 little-endian). 0x1acffc1d, or two or more hex pairs (de ad be ef), is a byte pattern, where ?? matches any byte. Text in double quotes is text, even when it looks like hex. A match may span rows"),
1571                    k("n / N", "Next, previous", "Next or previous match, round the end")
1572                    .also(&["Next", "Prev"])
1573                        .more("The next or previous match, wrapping around the end of the file"),
1574                    k("R", "Stride", "Bytes per row from the matches' spacing")
1575                    .also(&["Use stride"])
1576                        .more("When the matches after the one found are all the same distance apart, make that the bytes per row"),
1577                    k("Esc", "Stop", "Stop a find that is reading")
1578                    .also(&["Cancel"]),
1579                ],
1580            },
1581            Group {
1582                name: "Prompt",
1583                keys: &[
1584                    k("Enter", "Find", "Find, go to the offset or set the row size")
1585                        .also(&["Go", "Set"]),
1586                    k("Ctrl+U", "UTF-16", "Find the text as UTF-16 as well"),
1587                    k("Esc", "Cancel", "Close the prompt"),
1588                ],
1589            },
1590            Group {
1591                name: "Display",
1592                keys: &[
1593                    k("r", "Row size", "Bytes per row; empty: as many as fit")
1594                        .more("Bytes per row, so that records line up; empty goes back to as many as fit. --hex-width N sets it on the command line"),
1595                    k("#", "Offsets", "Offsets in decimal or hex")
1596                    .also(&["Hex", "Decimal"]),
1597                    k("i / Enter", "Inspector", "Show or hide the byte inspector")
1598                        .more("Show or hide the byte inspector. It sits beside the bytes when there is room and a row holds 16 bytes, and over them otherwise"),
1599                    k("v", "Mark", "Mark a range from the cursor; again to unmark")
1600                    .also(&["Unmark"])
1601                        .more("Mark from the cursor; move to mark a range, and the status line counts it. v again, or Esc, unmarks"),
1602                ],
1603            },
1604            Group {
1605                name: "Go",
1606                keys: &[
1607                    k("B", "Format spec", "Read the file with a format spec instead")
1608                    .also(&["Format"])
1609                        .more("Read the file with a format spec instead: type to narrow the list of specs; Enter chooses, and Space until typing narrows it"),
1610                    k("Esc", "Back", "Back to the Info panel or home")
1611                        .more("Back to the table, when opened from the Info panel; back home, when opened from there"),
1612                    k("q", "Back", "Home, when opened from there; else quit")
1613                    .also(&["Home", "Quit"]),
1614                ],
1615            },
1616        ],
1617    },
1618];
1619
1620/// The screen for `context`.
1621pub fn screen(context: Context) -> &'static Screen {
1622    SCREENS
1623        .iter()
1624        .find(|s| s.context == context)
1625        .expect("every context has a screen")
1626}
1627
1628/// The entry for `keys` on `context`'s screen, or in `group` of it when named: what
1629/// the footer's hints read their labels from.
1630pub fn lookup(context: Context, group: Option<&str>, keys: &str) -> Option<&'static Key> {
1631    let groups: &'static [Group] = match context {
1632        Context::Global => std::slice::from_ref(&GLOBAL),
1633        Context::Help => std::slice::from_ref(&HELP),
1634        Context::Question => std::slice::from_ref(&QUESTION),
1635        _ => screen(context).groups,
1636    };
1637    let entries = || {
1638        groups
1639            .iter()
1640            .filter(move |g| group.is_none_or(|name| g.name == name))
1641            .flat_map(|g| g.keys.iter())
1642    };
1643    // As written, or the entry whose keys include every key named: a hint may offer
1644    // `d` of `d / Del`, or `↑ / ↓` of `↑ / ↓ (j/k)`.
1645    let wanted = chords(keys);
1646    entries().find(|k| k.keys == keys).or_else(|| {
1647        (!wanted.is_empty())
1648            .then(|| entries().find(|k| wanted.iter().all(|c| chords(k.keys).contains(c))))
1649            .flatten()
1650    })
1651}
1652
1653/// Every key entry with its screen and group, in reference order; [`GLOBAL`], [`HELP`]
1654/// and [`QUESTION`] first.
1655pub fn entries() -> impl Iterator<Item = (Option<&'static Screen>, &'static Group, &'static Key)> {
1656    [&GLOBAL, &HELP, &QUESTION]
1657        .into_iter()
1658        .flat_map(|group| group.keys.iter().map(move |key| (None, group, key)))
1659        .chain(SCREENS.iter().flat_map(|screen| {
1660            screen
1661                .groups
1662                .iter()
1663                .flat_map(move |group| group.keys.iter().map(move |key| (Some(screen), group, key)))
1664        }))
1665}
1666
1667/// Escape text for a markdown table cell.
1668fn cell(text: &str) -> String {
1669    text.replace('|', "\\|")
1670        .replace('<', "&lt;")
1671        .replace('>', "&gt;")
1672}
1673
1674/// A key label as markdown: code, so `[` and `*` read as themselves.
1675fn key_cell(label: &str) -> String {
1676    if label.contains('`') {
1677        format!("`` {} ``", label.replace('|', "\\|"))
1678    } else {
1679        format!("`{}`", label.replace('|', "\\|"))
1680    }
1681}
1682
1683fn markdown_table(out: &mut String, keys: &[Key]) {
1684    out.push_str("\n| Key | Action |\n|---|---|\n");
1685    for key in keys {
1686        out.push_str(&format!(
1687            "| {} | {} |\n",
1688            key_cell(key.keys),
1689            cell(key.long())
1690        ));
1691    }
1692}
1693
1694/// The keyboard reference: the keys every screen takes, then each screen's.
1695pub fn render_markdown() -> String {
1696    let mut out = String::new();
1697    out.push_str("\n## Every screen\n\nThese keys work on every screen.\n");
1698    markdown_table(&mut out, GLOBAL.keys);
1699    out.push_str("\n## Help\n\n<kbd>?</kbd> or <kbd>F1</kbd>.\n");
1700    markdown_table(&mut out, HELP.keys);
1701    out.push_str("\n## Question\n\nWhen datui asks before acting.\n");
1702    markdown_table(&mut out, QUESTION.keys);
1703    for screen in SCREENS {
1704        out.push_str(&format!("\n## {}\n\n{}\n", screen.title, screen.reached));
1705        for group in screen.groups {
1706            out.push_str(&format!("\n### {} · {}\n", screen.title, group.name));
1707            markdown_table(&mut out, group.keys);
1708        }
1709    }
1710    out
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715    use super::*;
1716
1717    #[test]
1718    fn first_keys_are_read() {
1719        for (keys, first) in [
1720            ("↑ / ↓ (j/k)", "↑"),
1721            ("Ctrl+D / Ctrl+U", "Ctrl+D"),
1722            ("PgUp/PgDn", "PgUp"),
1723            ("/", "/"),
1724            ("+ / - (= / _)", "+"),
1725            ("e, w, y, o", "e"),
1726            ("Enter (d / D)", "Enter"),
1727            ("↑ / ↓, Tab", "↑"),
1728            ("Click a key", "Click"),
1729        ] {
1730            assert_eq!(first_key(keys), first, "{keys}");
1731        }
1732    }
1733
1734    #[test]
1735    fn chords_are_read() {
1736        let c = chord("Ctrl+O").unwrap();
1737        assert!(c.ctrl && !c.shift && c.code == Code::Char('o'));
1738        assert_eq!(chord("G").unwrap().code, Code::Char('G'));
1739        assert!(chord("G").unwrap().shift);
1740        assert_eq!(chord("Shift+Tab").unwrap().code, Code::BackTab);
1741        assert_eq!(chord("F1").unwrap().code, Code::F(1));
1742        assert_eq!(chord("↓").unwrap().code, Code::Down);
1743        assert_eq!(chord("Space").unwrap().code, Code::Char(' '));
1744        assert_eq!(chord("+").unwrap().code, Code::Char('+'));
1745        assert!(chord("Click").is_none());
1746        assert!(chord("(type)").is_none());
1747        assert!(chord("1-9").is_none());
1748        let codes: Vec<Code> = chords("↑ / ↓ (j/k)").iter().map(|c| c.code).collect();
1749        assert_eq!(
1750            codes,
1751            [Code::Up, Code::Down, Code::Char('j'), Code::Char('k')]
1752        );
1753        assert_eq!(chords("PgUp/PgDn").len(), 2);
1754        assert_eq!(chords("/")[0].code, Code::Char('/'));
1755        assert!(chords("(type)").is_empty());
1756    }
1757
1758    /// Every entry's Enter presses a key, unless it says it presses none; every line
1759    /// is one line, short enough for half the help; every label is three words at most.
1760    #[test]
1761    fn every_entry_is_whole() {
1762        let mut bad = Vec::new();
1763        for (screen, group, key) in entries() {
1764            let at = format!(
1765                "{} · {} · {}",
1766                screen.map_or("Global", |s| s.title),
1767                group.name,
1768                key.keys
1769            );
1770            if key.run != Run::Never && key.action().is_none() {
1771                bad.push(format!("{at}: Enter presses nothing; .run() or .no_run()"));
1772            }
1773            if key.line.contains('\n') || key.line.chars().count() > 52 {
1774                bad.push(format!("{at}: line longer than 52: {}", key.line));
1775            }
1776            for label in std::iter::once(&key.label).chain(key.also) {
1777                if label.is_empty() || label.split(' ').count() > 3 {
1778                    bad.push(format!("{at}: label {label:?}"));
1779                }
1780            }
1781            if key.line.ends_with('.') || key.more.is_some_and(|m| m.ends_with('.')) {
1782                bad.push(format!("{at}: ends with a period"));
1783            }
1784            if key.more == Some(key.line) {
1785                bad.push(format!("{at}: more repeats the line"));
1786            }
1787        }
1788        assert!(bad.is_empty(), "{}", bad.join("\n"));
1789    }
1790
1791    #[test]
1792    fn every_context_has_one_screen() {
1793        use std::collections::HashSet;
1794        let contexts: HashSet<_> = SCREENS.iter().map(|s| s.context).collect();
1795        assert_eq!(contexts.len(), SCREENS.len(), "a context is listed twice");
1796        for screen in SCREENS {
1797            assert!(!screen.groups.is_empty(), "{} has no keys", screen.title);
1798            let mut names = HashSet::new();
1799            for group in screen.groups {
1800                assert!(
1801                    names.insert(group.name),
1802                    "{}: {} twice",
1803                    screen.title,
1804                    group.name
1805                );
1806                assert!(!group.keys.is_empty());
1807            }
1808        }
1809    }
1810}