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;