Skip to main content

data_beans/interactive/
mod.rs

1//! Full-screen terminal views (feature `tui`), and line prompts for when
2//! there is no terminal.
3//!
4//! - [`ui`]: the shared pieces for building a view. The palette (terminal
5//!   foreground plus one accent), [`ui::panel`], [`ui::header`],
6//!   [`ui::help_line`], [`ui::input_line`], the [`ui::Screen`] trait driven by
7//!   [`ui::run_screen`], and histograms: [`ui::Scale`], [`ui::Binning`],
8//!   [`ui::Binned`], [`ui::HistPlot`], and [`ui::MirrorPlot`] for two series
9//!   around one zero line.
10//! - [`qc_tui`]: `qc`'s view: cell QC rules moved on their histograms.
11//! - [`list_tui`]: a list to browse, flat (names) or a tree (a file's
12//!   hierarchy): filter, mark, write, copy.
13//! - [`plan_tui`]: what a command is about to write, with its settings on
14//!   keys: Enter writes, `q` does not.
15//! - [`stat_tui`]: table-and-histogram explorer over plain name and value
16//!   columns, optionally marking entries and handing them back.
17//!
18//! Open a view only when [`tui_available`]; otherwise fall back to text.
19//!
20//! The views run in a legume-tui session ([`TOOL`]): a command opens it
21//! first thing and hands it to [`ui::Tui`] or [`ui::run_screen_in`]; where
22//! the session is hosted, the command keeps running when the connection
23//! drops. [`ui::run_screen`] runs a view in a plain session of its own
24//! (`legume_tui::Session::plain`).
25
26pub mod list_tui;
27pub mod plan_tui;
28pub mod qc_tui;
29pub mod stat_tui;
30pub mod ui;
31
32use std::io::{self, IsTerminal};
33
34/// Set, to anything but empty or `0`, to run every command straight through
35/// as `--batch-process` does: for a script or pipeline started from a
36/// terminal, which its commands would otherwise take for the user's.
37pub const BATCH_ENV: &str = "DATA_BEANS_BATCH";
38
39/// Whether a full-screen view can run: stdin and stdout are a terminal, and
40/// one that can draw it (`TERM` is not `dumb`).
41pub fn tui_available() -> bool {
42    can_draw(
43        io::stdin().is_terminal() && io::stdout().is_terminal(),
44        std::env::var("TERM").ok().as_deref(),
45    )
46}
47
48fn can_draw(tty: bool, term: Option<&str>) -> bool {
49    tty && term != Some("dumb")
50}
51
52/// Why a command runs without its view.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum NoView {
55    /// `--batch-process`, or [`BATCH_ENV`].
56    Batch,
57    /// No terminal to draw it on.
58    NoTerminal,
59}
60
61/// The one rule every command's view follows: batch when asked (`flag`, or
62/// `env`, the value of [`BATCH_ENV`]), else a view where there is a
63/// terminal (`terminal`).
64fn decide(flag: bool, env: Option<&str>, terminal: bool) -> Result<(), NoView> {
65    let asked = flag || env.is_some_and(|v| !v.is_empty() && v != "0");
66    match (asked, terminal) {
67        (true, _) => Err(NoView::Batch),
68        (false, false) => Err(NoView::NoTerminal),
69        (false, true) => Ok(()),
70    }
71}
72
73fn decide_here(flag: bool) -> Result<(), NoView> {
74    decide(
75        flag,
76        std::env::var(BATCH_ENV).ok().as_deref(),
77        tui_available(),
78    )
79}
80
81/// The line said when `command`'s view is skipped for want of a terminal.
82fn no_terminal_line(command: &str) -> String {
83    format!(
84        "{command}: no terminal to draw a view on (stdin or stdout is not one, or \
85         TERM=dumb): running straight through; --batch-process or {BATCH_ENV}=1 \
86         does this without this line"
87    )
88}
89
90/// Whether `command` opens its view, for a command that runs without one
91/// too (`qc`, `stat`): not when batch is asked (`batch`, its
92/// `--batch-process`, or [`BATCH_ENV`]), and only on a terminal. A view
93/// skipped for want of a terminal (output redirected, say) is said in one
94/// line on stderr, so the switch is never silent; asked for, it is quiet.
95pub fn opens_view(command: &str, batch: bool) -> bool {
96    match decide_here(batch) {
97        Ok(()) => true,
98        Err(NoView::Batch) => false,
99        Err(NoView::NoTerminal) => {
100            eprintln!("{}", no_terminal_line(command));
101            false
102        }
103    }
104}
105
106/// Whether `command` picks its rows or columns in the explorer, for a
107/// command that takes them on the command line too (`columns`, `subset-rows`,
108/// ...): only when the command line `selected` none. Then batch asked
109/// (`batch`, or [`BATCH_ENV`]) or no terminal is an error naming
110/// `options`, what selects them instead.
111pub fn picks(command: &str, batch: bool, selected: bool, options: &str) -> anyhow::Result<bool> {
112    if selected {
113        return Ok(false);
114    }
115    match decide_here(batch) {
116        Ok(()) => Ok(true),
117        Err(why) => anyhow::bail!("{}", pick_refused(command, options, why)),
118    }
119}
120
121fn pick_refused(command: &str, options: &str, why: NoView) -> String {
122    match why {
123        NoView::Batch => {
124            format!("{command}: give {options}; run in batch, it has no view to pick them in")
125        }
126        NoView::NoTerminal => {
127            format!("{command}: give {options}, or run it on a terminal to pick them in a view")
128        }
129    }
130}
131
132/// The tool data-beans' sessions belong to: they are named
133/// `data-beans-qc-1`, `data-beans-stat-2`, and `data-beans attach`
134/// finds them.
135pub const TOOL: legume_tui::Tool = legume_tui::Tool::new("data-beans");
136
137/// The detach and switch keys for a title bar ([`ui::title_bar`]), each
138/// with what it does in a word (`detach`, `switch`); none when the views
139/// run plain.
140pub fn session_keys() -> Vec<(String, String)> {
141    legume_tui::help_keys()
142        .into_iter()
143        .map(|k| (k.key.to_lowercase(), k.short))
144        .collect()
145}
146
147/// The paragraph a command's long help ends with about its session:
148/// `lead` first, for a command whose view is optional (when it runs as a
149/// session), then how it runs, the detach and switch keys, how to return,
150/// and `more` at the end. Its views are always opened on something
151/// named, so a new start never offers the live sessions; `data-beans
152/// attach` does.
153pub fn session_help(lead: Option<&str>, more: &str) -> String {
154    let help = legume_tui::cli::SessionHelp::new()
155        .what_goes_on("the command goes on")
156        .resume_hint(Some(
157            "Starting it again starts a new one (naming the detached ones); \
158             `data-beans attach` with no name",
159        ))
160        .more(more);
161    let help = match lead {
162        Some(lead) => help.lead(lead),
163        None => help,
164    };
165    legume_tui::cli::session_help(&TOOL, help)
166}
167
168#[cfg(test)]
169#[path = "tests/session.rs"]
170mod tests;