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