diff --git a/Cargo.toml b/Cargo.toml index 882db11..e941218 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,9 @@ tensor = ["legume-numeric/tensor"] ndarray = ["dep:ndarray", "legume-numeric/ndarray", "zarrs/ndarray", "zarrs/transpose"] aux = [] # Full-screen terminal views (ratatui): the CLI's interactive sessions, and -# `data_beans::interactive` for downstream crates building their own. -tui = ["dep:ratatui"] +# `data_beans::interactive` for downstream crates building their own. The +# views run in legume-tui sessions, which a dropped connection leaves running. +tui = ["dep:ratatui", "dep:legume-tui"] alg = ["aux"] sim = ["alg"] # Pulls in libhdf5 — opt-in so installs on hosts without HDF5 headers still @@ -87,3 +88,10 @@ num_cpus = "1.16.0" sysinfo = { version = "0.38", default-features = false, features = ["system"] } statrs = { version = "0.18", default-features = false } ratatui = { version = "0.30", default-features = false, features = ["crossterm"], optional = true } +# Detachable sessions for the views; no pictures, so no graphics. +legume-tui = { version = "0.3.2", optional = true, default-features = false, features = ["clap", "logs"] } + +[dev-dependencies] +# The crate's harness, driving the views through pseudo-terminals in +# tests/detach_session.rs. +legume-tui = { version = "0.3.2", default-features = false, features = ["testing"] } diff --git a/src/aux/logging.rs b/src/aux/logging.rs index 950e49c..6fb6f49 100644 --- a/src/aux/logging.rs +++ b/src/aux/logging.rs @@ -8,10 +8,9 @@ //! every existing caller (`data_beans::aux::logging::new_progress_bar`, senna, //! graph-embedding-util) keeps working unchanged, and adds [`init_logger`], //! which wraps `env_logger` in `indicatif_log_bridge` so `log` output renders -//! above the bars instead of corrupting them, and [`hold_logs`], which keeps -//! records back while a full-screen view owns the terminal. - -use std::sync::Mutex; +//! above the bars instead of corrupting them. With the `tui` feature the +//! logger sits behind legume-tui's log gate, so a view's +//! `legume_tui::logs::hold` keeps records back while it owns the terminal. pub use legume_numeric::matrix::progress::{new_progress_bar, new_spinner, MULTI_PROGRESS}; @@ -20,114 +19,26 @@ pub use legume_numeric::matrix::progress::{new_progress_bar, new_spinner, MULTI_ /// `legume_numeric::matrix::common_io::{VERBOSE,QUIET}_LOG_FILTER`; an external `RUST_LOG` /// still overrides the default filter. pub fn init_logger(verbose: bool) { - let default_filter = if verbose { + init_logger_with(if verbose { legume_numeric::matrix::common_io::VERBOSE_LOG_FILTER } else { legume_numeric::matrix::common_io::QUIET_LOG_FILTER - }; + }); +} + +/// [`init_logger`] with `default_filter` (`error`, `info`) where no +/// `RUST_LOG` is set. For the `data-beans` binary's own default; not part +/// of the library's surface. +#[doc(hidden)] +pub fn init_logger_with(default_filter: &str) { let logger = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_filter)) .build(); let max_level = logger.filter(); let wrapped = indicatif_log_bridge::LogWrapper::new(MULTI_PROGRESS.clone(), logger); - let _ = - log::set_boxed_logger(Box::new(Holding(wrapped))).map(|()| log::set_max_level(max_level)); -} - -/// Records held back while a full-screen view owns the terminal: level, -/// target and message. `None` when not holding. -static HELD: Mutex>> = Mutex::new(None); - -/// The logger [`init_logger`] installs: forwards records, or keeps them in -/// [`HELD`] while [`hold_logs`] is on. -struct Holding(L); - -impl log::Log for Holding { - fn enabled(&self, metadata: &log::Metadata) -> bool { - self.0.enabled(metadata) - } - - fn log(&self, record: &log::Record) { - if let Some(held) = HELD.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { - if self.0.enabled(record.metadata()) { - let entry = ( - record.level(), - record.target().into(), - record.args().to_string(), - ); - held.push(entry); - } - return; - } - self.0.log(record); - } - - fn flush(&self) { - self.0.flush(); - } -} - -/// Hold log records back (`true`) while a full-screen view owns the -/// terminal, where a printed line would scroll it and tear the picture; on -/// `false`, write what was held, in order. Only affects the logger that -/// [`init_logger`] installed. -pub fn hold_logs(on: bool) { - let held = { - let mut slot = HELD.lock().unwrap_or_else(|e| e.into_inner()); - if on { - slot.get_or_insert_with(Vec::new); - return; - } - slot.take() - }; - for (level, target, message) in held.into_iter().flatten() { - log::logger().log( - &log::Record::builder() - .level(level) - .target(&target) - .args(format_args!("{message}")) - .build(), - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use log::Log; - use std::sync::atomic::{AtomicUsize, Ordering}; - - struct Counting(AtomicUsize); - - impl Log for Counting { - fn enabled(&self, _: &log::Metadata) -> bool { - true - } - fn log(&self, _: &log::Record) { - self.0.fetch_add(1, Ordering::Relaxed); - } - fn flush(&self) {} - } - - #[test] - fn held_records_wait_for_release() { - let logger = Holding(Counting(AtomicUsize::new(0))); - let record = |msg| { - logger.log( - &log::Record::builder() - .level(log::Level::Warn) - .args(format_args!("{msg}")) - .build(), - ) - }; - hold_logs(true); - record("held"); - assert_eq!(logger.0 .0.load(Ordering::Relaxed), 0, "nothing written"); - let held = HELD.lock().unwrap().as_ref().map(|h| h.len()); - assert_eq!(held, Some(1)); - hold_logs(false); - assert!(HELD.lock().unwrap().is_none(), "released"); - record("through"); - assert_eq!(logger.0 .0.load(Ordering::Relaxed), 1); - } + #[cfg(feature = "tui")] + let installed = log::set_logger(legume_tui::logs::gate(wrapped)); + #[cfg(not(feature = "tui"))] + let installed = log::set_boxed_logger(Box::new(wrapped)); + let _ = installed.map(|()| log::set_max_level(max_level)); } diff --git a/src/handlers/analysis.rs b/src/handlers/analysis.rs index 3284f9e..0d3346a 100644 --- a/src/handlers/analysis.rs +++ b/src/handlers/analysis.rs @@ -1,7 +1,8 @@ -use crate::handlers::explore::{open_data, side_stats, values_reader}; +use crate::handlers::explore::{off_screen, open_data, open_session, side_stats, values_reader}; use crate::hdf5_io::*; -use crate::interactive::stat_tui::{explore, Purpose, Side, StatExplorer}; +use crate::interactive::stat_tui::{explore_in, Purpose, Side, StatExplorer}; use crate::interactive::tui_available; +use crate::interactive::ui::Tui; use crate::qc::*; use crate::sparse_io::*; use crate::sparse_io_vector::*; @@ -115,6 +116,9 @@ pub struct RunStatArgs { Not available with --column-group-file." )] pub interactive: bool, + + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, } /// Compute statistics across sparse matrix data @@ -123,6 +127,14 @@ pub struct RunStatArgs { /// data files. Statistics can be stratified by column groups if a group membership /// file is provided. pub fn run_stat(cmd_args: &RunStatArgs) -> anyhow::Result<()> { + let grouped = cmd_args.stat_dim == Some(StatDim::Row) && cmd_args.column_group_file.is_some(); + let explore_after = !grouped && wants_explorer(cmd_args); + // The explorer's session first: hosted, the statistics are computed + // (and written) in the session, not here. + let session = explore_after + .then(|| open_session("stat", &cmd_args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); let output = cmd_args.output.clone(); dirname(&output).as_deref().map(mkdir).transpose()?; @@ -207,31 +219,32 @@ pub fn run_stat(cmd_args: &RunStatArgs) -> anyhow::Result<()> { StatDim::Row => Side::Rows, StatDim::Column => Side::Columns, }; - let explore_after = wants_explorer(cmd_args); // The explorer replaces printing to stdout; a file output is still written. let save = !(explore_after && cmd_args.output.eq_ignore_ascii_case("stdout")); let select_rows = rows_matching(cmd_args, &data)?; let select_rows = select_rows.as_deref(); - let first = side_stats( - &data, - side, - select_rows, - cmd_args.block_size, - save.then_some(&*cmd_args.output), - )?; - if explore_after { + let output = save.then_some(&*cmd_args.output); + // A file written before the explorer is up is written off its screen, + // so a `kill` meanwhile lets the write finish. + let first = off_screen(tui.as_mut().filter(|_| output.is_some()), || { + side_stats(&data, side, select_rows, cmd_args.block_size, output) + })?; + if let Some(tui) = tui.as_mut() { let title = cmd_args.data_files.join(", "); let data = &data; let loader = Box::new(move |side| side_stats(data, side, select_rows, cmd_args.block_size, None)); - explore(StatExplorer::new( - &title, - side, - first, - Some(loader), - Some(values_reader(data)), - Purpose::Explore, - ))?; + explore_in( + tui, + StatExplorer::new( + &title, + side, + first, + Some(loader), + Some(values_reader(data)), + Purpose::Explore, + ), + )?; } Ok(()) } diff --git a/src/handlers/builders.rs b/src/handlers/builders.rs index 051d33b..0415689 100644 --- a/src/handlers/builders.rs +++ b/src/handlers/builders.rs @@ -60,6 +60,7 @@ pub(super) fn run_squeeze_if_needed( auto_cutoff: false, output: None, row_align: RowAlignMode::Common, + session: Default::default(), }; run_squeeze(&squeeze_args)?; } diff --git a/src/handlers/explore.rs b/src/handlers/explore.rs index 8dbccf6..c07ab4f 100644 --- a/src/handlers/explore.rs +++ b/src/handlers/explore.rs @@ -3,9 +3,10 @@ use crate::hdf5_io::resolve_backend_file; use crate::interactive::stat_tui::{ - explore, Dataset, Picked, Purpose, Side, StatExplorer, Values, ValuesReader, + explore_in, Dataset, Picked, Purpose, Side, StatExplorer, Values, ValuesReader, }; -use crate::interactive::tui_available; +use crate::interactive::ui::Tui; +use crate::interactive::{tui_available, TOOL}; use crate::qc::*; use crate::sparse_io::*; use crate::sparse_io_vector::*; @@ -15,6 +16,29 @@ use legume_numeric::matrix::traits::RunningStatOps; use log::info; use std::sync::Arc; +/// The session of interactive command `screen` (`data-beans--N`), +/// opened before any of its work. Hosted, this process goes no further: +/// it relays the terminal and exits with the command, which runs again in +/// the session's host. Always on something named, so a new one starts. +pub fn open_session( + screen: &str, + args: &legume_tui::cli::SessionArgs, +) -> anyhow::Result { + Ok(TOOL.session(screen, &args.options(true))?) +} + +/// Run `f`, a command's work after its views, off the screen of `tui` when +/// there is one: a `kill` meanwhile lets it finish. +pub fn off_screen( + tui: Option<&mut Tui<'_>>, + f: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + match tui { + Some(tui) => tui.work(f)?, + None => f(), + } +} + /// Open `files` as one matrix. Several files keep their column names /// distinct by appending each file's name. pub fn open_data(files: &[Box], preload: bool) -> anyhow::Result { @@ -101,6 +125,7 @@ pub fn values_reader(data: &SparseIoVec) -> ValuesReader<'_> { /// command labelled `verb`. Returns the marked indices (ascending), or /// `None` when the user quits without finishing. pub fn pick_entries( + tui: &mut Tui<'_>, data_file: &str, side: Side, verb: &'static str, @@ -118,7 +143,7 @@ pub fn pick_entries( Some(values_reader(&data)), Purpose::Pick { verb }, ); - let picked = explore(explorer)?.map(|p| p.side(side)); + let picked = explore_in(tui, explorer)?.map(|p| p.side(side)); match &picked { Some(entries) => info!("picked {} entries", entries.len()), None => info!("nothing picked; cancelled"), @@ -131,6 +156,7 @@ pub fn pick_entries( /// labelled `verb`. Returns the marks of both sides, or `None` when the user /// quits without finishing. pub fn pick_rows_and_columns( + tui: &mut Tui<'_>, data_file: &str, verb: &'static str, ) -> anyhow::Result> { @@ -141,14 +167,17 @@ pub fn pick_rows_and_columns( let first = side_stats(&data, Side::Columns, None, None, None)?; let data = &data; let loader = Box::new(move |side| side_stats(data, side, None, None, None)); - let picked = explore(StatExplorer::new( - data_file, - Side::Columns, - first, - Some(loader), - Some(values_reader(data)), - Purpose::PickBoth { verb }, - ))?; + let picked = explore_in( + tui, + StatExplorer::new( + data_file, + Side::Columns, + first, + Some(loader), + Some(values_reader(data)), + Purpose::PickBoth { verb }, + ), + )?; match &picked { Some(p) => info!( "picked {} rows and {} columns", diff --git a/src/handlers/inspection.rs b/src/handlers/inspection.rs index 2267e69..00d1c64 100644 --- a/src/handlers/inspection.rs +++ b/src/handlers/inspection.rs @@ -1,6 +1,7 @@ -use crate::handlers::explore::pick_entries; +use crate::handlers::explore::{off_screen, open_session, pick_entries}; use crate::hdf5_io::*; use crate::interactive::stat_tui::Side; +use crate::interactive::ui::Tui; use crate::sparse_io::*; use crate::utilities::io_helpers::{ parse_index_spec, read_col_names, MAX_COLUMN_NAME_IDX, MAX_ROW_NAME_IDX, @@ -46,6 +47,9 @@ pub struct TakeColumnsArgs { /// output `parquet` file #[arg(short, long, default_value = "stdout")] pub output: Box, + + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, } #[derive(Args, Debug)] @@ -72,6 +76,9 @@ pub struct TakeRowsArgs { /// output `parquet` file #[arg(short, long, default_value = "stdout")] pub output: Box, + + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, } #[derive(Args, Debug)] @@ -186,6 +193,12 @@ pub fn show_info(cmd_args: &InfoArgs) -> anyhow::Result<()> { } pub fn take_columns(args: &TakeColumnsArgs) -> anyhow::Result<()> { + // The picker's session first: hosted, the rest runs in the session. + let session = args + .interactive + .then(|| open_session("columns", &args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); let columns = args.column_indices.clone(); let column_name_file = args.name_file.clone(); let column_names_arg = args.column_names.clone(); @@ -194,74 +207,82 @@ pub fn take_columns(args: &TakeColumnsArgs) -> anyhow::Result<()> { let output = args.output.clone(); - let picked = if args.interactive { - match pick_entries(&args.data_file, Side::Columns, "take")? { + let picked = match tui.as_mut() { + Some(tui) => match pick_entries(tui, &args.data_file, Side::Columns, "take")? { Some(picked) => Some(picked), None => return Ok(()), - } - } else { - None + }, + None => None, }; - let data = open_sparse_matrix(&data_file, &backend)?; - let row_names = data.row_names()?; - - let (data, column_names) = if let Some(columns) = picked { - let names = data.column_names()?; - let column_names: Vec> = columns.iter().map(|&i| names[i].clone()).collect(); - (data.read_columns_dmatrix(columns)?, column_names) - } else if let Some(columns) = columns { - let columns = parse_index_spec(&columns)?; - let n_columns = data.num_columns().unwrap_or(0); - let columns: Vec = columns.into_iter().filter(|&i| i < n_columns).collect(); - - if columns.is_empty() { - return Err(anyhow::anyhow!("invalid indexes")); - } - - let _names = data.column_names()?; - let column_names: Vec> = columns.iter().map(|&i| _names[i].clone()).collect(); - - (data.read_columns_dmatrix(columns)?, column_names) - } else if let Some(column_file) = column_name_file { - let col_names_to_match = read_col_names(column_file, MAX_COLUMN_NAME_IDX)?; - let all_names = data.column_names()?; - let (matched_indices, column_names) = - match_by_substring(&all_names, &col_names_to_match, "column")?; - (data.read_columns_dmatrix(matched_indices)?, column_names) - } else if let Some(col_names_to_match) = column_names_arg { - let all_names = data.column_names()?; - let (matched_indices, column_names) = - match_by_substring(&all_names, &col_names_to_match, "column")?; - (data.read_columns_dmatrix(matched_indices)?, column_names) - } else { - return Err(anyhow::anyhow!( - "either `column-indices`, `name-file`, `column-names`, or `--interactive` must be provided" - )); - }; + // Written off the screen: a `kill` meanwhile lets it finish. + off_screen(tui.as_mut(), || { + let data = open_sparse_matrix(&data_file, &backend)?; + let row_names = data.row_names()?; - if let Ok(ext) = file_ext(&output) { - if ext.as_ref() == "parquet" { - data.to_parquet_with_names( - &output, - (Some(&row_names), Some("row_name")), - Some(&column_names), - )?; - return Ok(()); + let (data, column_names) = if let Some(columns) = picked { + let names = data.column_names()?; + let column_names: Vec> = columns.iter().map(|&i| names[i].clone()).collect(); + (data.read_columns_dmatrix(columns)?, column_names) + } else if let Some(columns) = columns { + let columns = parse_index_spec(&columns)?; + let n_columns = data.num_columns().unwrap_or(0); + let columns: Vec = columns.into_iter().filter(|&i| i < n_columns).collect(); + + if columns.is_empty() { + return Err(anyhow::anyhow!("invalid indexes")); + } + + let _names = data.column_names()?; + let column_names: Vec> = columns.iter().map(|&i| _names[i].clone()).collect(); + + (data.read_columns_dmatrix(columns)?, column_names) + } else if let Some(column_file) = column_name_file { + let col_names_to_match = read_col_names(column_file, MAX_COLUMN_NAME_IDX)?; + let all_names = data.column_names()?; + let (matched_indices, column_names) = + match_by_substring(&all_names, &col_names_to_match, "column")?; + (data.read_columns_dmatrix(matched_indices)?, column_names) + } else if let Some(col_names_to_match) = column_names_arg { + let all_names = data.column_names()?; + let (matched_indices, column_names) = + match_by_substring(&all_names, &col_names_to_match, "column")?; + (data.read_columns_dmatrix(matched_indices)?, column_names) + } else { + return Err(anyhow::anyhow!( + "either `column-indices`, `name-file`, `column-names`, or `--interactive` must be provided" + )); + }; + + if let Ok(ext) = file_ext(&output) { + if ext.as_ref() == "parquet" { + data.to_parquet_with_names( + &output, + (Some(&row_names), Some("row_name")), + Some(&column_names), + )?; + return Ok(()); + } } - } - write_named_tsv( - &output, - &data, - (Some(&row_names), Some("row_name")), - Some(&column_names), - )?; + write_named_tsv( + &output, + &data, + (Some(&row_names), Some("row_name")), + Some(&column_names), + )?; - Ok(()) + Ok(()) + }) } pub fn take_rows(args: &TakeRowsArgs) -> anyhow::Result<()> { + // The picker's session first: hosted, the rest runs in the session. + let session = args + .interactive + .then(|| open_session("rows", &args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); let rows = args.row_indices.clone(); let row_name_file = args.name_file.clone(); let row_names_arg = args.row_names.clone(); @@ -270,70 +291,72 @@ pub fn take_rows(args: &TakeRowsArgs) -> anyhow::Result<()> { let output = args.output.clone(); - let picked = if args.interactive { - match pick_entries(&args.data_file, Side::Rows, "take")? { + let picked = match tui.as_mut() { + Some(tui) => match pick_entries(tui, &args.data_file, Side::Rows, "take")? { Some(picked) => Some(picked), None => return Ok(()), - } - } else { - None - }; - - let data_backend = open_sparse_matrix(&data_file, &backend)?; - - let (data, row_names) = if let Some(rows) = picked { - let names = data_backend.row_names()?; - let row_names: Vec> = rows.iter().map(|&i| names[i].clone()).collect(); - (data_backend.read_rows_dmatrix(rows)?, row_names) - } else if let Some(rows) = rows { - let rows = parse_index_spec(&rows)?; - let n_rows = data_backend.num_rows().unwrap_or(0); - let rows: Vec = rows.into_iter().filter(|&i| i < n_rows).collect(); - - if rows.is_empty() { - return Err(anyhow::anyhow!("invalid indexes")); - } - - let _names = data_backend.row_names()?; - let row_names: Vec> = rows.iter().map(|&i| _names[i].clone()).collect(); - - (data_backend.read_rows_dmatrix(rows)?, row_names) - } else if let Some(row_name_file) = row_name_file { - let row_names_to_match = read_col_names(row_name_file, MAX_ROW_NAME_IDX)?; - let all_names = data_backend.row_names()?; - let (matched_indices, row_names) = - match_by_substring(&all_names, &row_names_to_match, "row")?; - (data_backend.read_rows_dmatrix(matched_indices)?, row_names) - } else if let Some(row_names_to_match) = row_names_arg { - let all_names = data_backend.row_names()?; - let (matched_indices, row_names) = - match_by_substring(&all_names, &row_names_to_match, "row")?; - (data_backend.read_rows_dmatrix(matched_indices)?, row_names) - } else { - return Err(anyhow::anyhow!( - "either `row-indices`, `name-file`, `row-names`, or `--interactive` must be provided" - )); + }, + None => None, }; - let data_t = data.transpose(); - let column_names = data_backend.column_names()?; - - if let Ok(ext) = file_ext(&output) { - if ext.as_ref() == "parquet" { - data_t.to_parquet_with_names( - &output, - (Some(&column_names), Some("column_name")), - Some(&row_names), - )?; - return Ok(()); + // Written off the screen: a `kill` meanwhile lets it finish. + off_screen(tui.as_mut(), || { + let data_backend = open_sparse_matrix(&data_file, &backend)?; + + let (data, row_names) = if let Some(rows) = picked { + let names = data_backend.row_names()?; + let row_names: Vec> = rows.iter().map(|&i| names[i].clone()).collect(); + (data_backend.read_rows_dmatrix(rows)?, row_names) + } else if let Some(rows) = rows { + let rows = parse_index_spec(&rows)?; + let n_rows = data_backend.num_rows().unwrap_or(0); + let rows: Vec = rows.into_iter().filter(|&i| i < n_rows).collect(); + + if rows.is_empty() { + return Err(anyhow::anyhow!("invalid indexes")); + } + + let _names = data_backend.row_names()?; + let row_names: Vec> = rows.iter().map(|&i| _names[i].clone()).collect(); + + (data_backend.read_rows_dmatrix(rows)?, row_names) + } else if let Some(row_name_file) = row_name_file { + let row_names_to_match = read_col_names(row_name_file, MAX_ROW_NAME_IDX)?; + let all_names = data_backend.row_names()?; + let (matched_indices, row_names) = + match_by_substring(&all_names, &row_names_to_match, "row")?; + (data_backend.read_rows_dmatrix(matched_indices)?, row_names) + } else if let Some(row_names_to_match) = row_names_arg { + let all_names = data_backend.row_names()?; + let (matched_indices, row_names) = + match_by_substring(&all_names, &row_names_to_match, "row")?; + (data_backend.read_rows_dmatrix(matched_indices)?, row_names) + } else { + return Err(anyhow::anyhow!( + "either `row-indices`, `name-file`, `row-names`, or `--interactive` must be provided" + )); + }; + + let data_t = data.transpose(); + let column_names = data_backend.column_names()?; + + if let Ok(ext) = file_ext(&output) { + if ext.as_ref() == "parquet" { + data_t.to_parquet_with_names( + &output, + (Some(&column_names), Some("column_name")), + Some(&row_names), + )?; + return Ok(()); + } } - } - write_named_tsv( - &output, - &data_t, - (Some(&column_names), Some("column_name")), - Some(&row_names), - )?; - Ok(()) + write_named_tsv( + &output, + &data_t, + (Some(&column_names), Some("column_name")), + Some(&row_names), + )?; + Ok(()) + }) } diff --git a/src/handlers/merging.rs b/src/handlers/merging.rs index a8fe370..f594d94 100644 --- a/src/handlers/merging.rs +++ b/src/handlers/merging.rs @@ -424,6 +424,7 @@ pub fn run_merge_backend(args: &MergeBackendArgs) -> anyhow::Result<()> { auto_cutoff: false, output: None, row_align: RowAlignMode::Common, + session: Default::default(), }; run_squeeze(&squeeze_args)?; @@ -727,6 +728,7 @@ pub fn run_merge_mtx(args: &MergeMtxArgs) -> anyhow::Result<()> { auto_cutoff: false, output: None, row_align: RowAlignMode::Common, + session: Default::default(), }; run_squeeze(&squeeze_args)?; diff --git a/src/handlers/transformation.rs b/src/handlers/transformation.rs index 5137615..3249e95 100644 --- a/src/handlers/transformation.rs +++ b/src/handlers/transformation.rs @@ -39,6 +39,9 @@ pub struct SubsetArgs { /// `.zarr.zip` #[arg(short, long, required = true)] pub output: Box, + + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, } #[derive(Args, Debug)] @@ -58,6 +61,9 @@ pub struct SubsetColumnsArgs { #[arg(short = 'I', long, default_value_t = false)] pub interactive: bool, + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, + /// delimiter for base-key extraction (e.g., '@' to match "ACGT-1@batch" with "ACGT-1") #[arg(short = 'd', long, default_value = "@")] pub delimiter: char, @@ -112,6 +118,9 @@ pub struct SubsetRowsArgs { #[arg(short = 'I', long, default_value_t = false)] pub interactive: bool, + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, + /// delimiter for base-key extraction (e.g., '@' to match "gene@batch" with "gene") #[arg(short = 'd', long, default_value = "@")] pub delimiter: char, @@ -271,6 +280,9 @@ pub struct RunSqueezeArgs { - union: Keep rows present in ANY file (union, fills missing with zeros)" )] pub row_align: RowAlignMode, + + #[command(flatten)] + pub session: legume_tui::cli::SessionArgs, } #[derive(clap::ValueEnum, Clone, Debug, PartialEq)] diff --git a/src/handlers/transformation/squeeze.rs b/src/handlers/transformation/squeeze.rs index 4f125d6..78a3ee9 100644 --- a/src/handlers/transformation/squeeze.rs +++ b/src/handlers/transformation/squeeze.rs @@ -1,9 +1,11 @@ use super::{RowAlignMode, RunSqueezeArgs}; +use crate::handlers::explore::{off_screen, open_session}; use crate::handlers::merging::{ find_aligned_rows, generate_unique_batch_names, run_merge_backend, MergeBackendArgs, }; use crate::hdf5_io::*; -use crate::interactive::cutoff_tui::{choose_cutoffs, AxisView, CutoffPicker}; +use crate::interactive::cutoff_tui::{choose_cutoffs, choose_cutoffs_in, AxisView, CutoffPicker}; +use crate::interactive::ui::Tui; use crate::interactive::{confirm, prompt_user_action, tui_available, UserAction}; use crate::qc::*; use crate::sparse_io::*; @@ -17,13 +19,27 @@ use log::info; /// /// If --output is specified: Squeezes all files and merges into single output file. /// Otherwise, modifies files in-place (with confirmation in interactive mode). +/// +/// With --interactive, every file is squeezed in one session, opened before +/// any of the work. Once the picker has been up, a `kill` lets the squeeze +/// under way finish and leaves the files after it untouched. pub fn run_squeeze(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { + let session = cmd_args + .interactive + .then(|| open_session("squeeze", &cmd_args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); + // If output specified with multiple files, squeeze to temp and merge if cmd_args.output.is_some() && cmd_args.data_files.len() > 1 { - return run_squeeze_and_merge(cmd_args); + return run_squeeze_and_merge(cmd_args, tui.as_mut()); } for data_file_arg in &cmd_args.data_files { + if tui.as_ref().is_some_and(Tui::stop_requested) { + info!("Stopped before {}", data_file_arg); + break; + } info!("Processing file: {}", data_file_arg); let (backend, data_file) = resolve_backend_file(data_file_arg, None)?; @@ -60,7 +76,7 @@ pub fn run_squeeze(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { let in_place = cmd_args.output.is_none().then_some(data_file_arg.as_ref()); let Some((row_nnz_cutoff, col_nnz_cutoff)) = - settle_cutoffs(cmd_args, data.as_ref(), &data_file, in_place)? + settle_cutoffs(cmd_args, data.as_ref(), &data_file, in_place, tui.as_mut())? else { info!("Skipping {}", data_file_arg); continue; @@ -74,35 +90,39 @@ pub fn run_squeeze(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { // Stage now (only when we'll actually perform the squeeze) drop(data); - if cmd_args.output.is_some() { - info!("Staging {} -> {}", data_file, target_file); - materialize_writable_backend(&data_file, &target_file)?; - } - let data = open_sparse_matrix(&target_file, &backend)?; - - // Perform squeeze - squeeze_by_nnz( - data.as_ref(), - SqueezeCutoffs { - row: row_nnz_cutoff, - column: col_nnz_cutoff, - }, - cmd_args.block_size, - cmd_args.preload, - )?; - - let data = open_sparse_matrix(&target_file, &backend)?; - - info!( - "after squeeze -- data: {} rows x {} columns", - data.num_rows().unwrap(), - data.num_columns().unwrap() - ); - drop(data); - - if let Some(output_prefix) = &effective_output { - finalize_zarr_output(&target_file, output_prefix)?; - } + // Off the screen, so a `kill` meanwhile lets it finish. + off_screen(tui.as_mut(), || { + if cmd_args.output.is_some() { + info!("Staging {} -> {}", data_file, target_file); + materialize_writable_backend(&data_file, &target_file)?; + } + let data = open_sparse_matrix(&target_file, &backend)?; + + // Perform squeeze + squeeze_by_nnz( + data.as_ref(), + SqueezeCutoffs { + row: row_nnz_cutoff, + column: col_nnz_cutoff, + }, + cmd_args.block_size, + cmd_args.preload, + )?; + + let data = open_sparse_matrix(&target_file, &backend)?; + + info!( + "after squeeze -- data: {} rows x {} columns", + data.num_rows().unwrap(), + data.num_columns().unwrap() + ); + drop(data); + + if let Some(output_prefix) = &effective_output { + finalize_zarr_output(&target_file, output_prefix)?; + } + Ok(()) + })?; } Ok(()) @@ -112,7 +132,10 @@ pub fn run_squeeze(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { /// /// For Common mode: Squeeze each file first, then find common rows, subset, and merge. /// For Union mode: Merge first with union of all rows, then squeeze the merged result. -fn run_squeeze_and_merge(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { +fn run_squeeze_and_merge( + cmd_args: &RunSqueezeArgs, + mut tui: Option<&mut Tui<'_>>, +) -> anyhow::Result<()> { let output_prefix = cmd_args.output.as_ref().unwrap(); info!( "Squeeze and merge mode: {} files -> {}", @@ -124,7 +147,14 @@ fn run_squeeze_and_merge(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { let (row_nnz_cutoff, col_nnz_cutoff) = if needs_stats(cmd_args) { let (backend, data_file) = resolve_backend_file(&cmd_args.data_files[0], None)?; let data = open_sparse_matrix(&data_file, &backend)?; - let Some(cutoffs) = settle_cutoffs(cmd_args, data.as_ref(), &data_file, None)? else { + let Some(cutoffs) = settle_cutoffs( + cmd_args, + data.as_ref(), + &data_file, + None, + tui.as_deref_mut(), + )? + else { info!("Operation cancelled"); return Ok(()); }; @@ -138,10 +168,11 @@ fn run_squeeze_and_merge(cmd_args: &RunSqueezeArgs) -> anyhow::Result<()> { return Ok(()); } - match cmd_args.row_align { + // Off the screen, so a `kill` meanwhile lets it finish. + off_screen(tui, || match cmd_args.row_align { RowAlignMode::Union => run_merge_then_squeeze(cmd_args, row_nnz_cutoff, col_nnz_cutoff), RowAlignMode::Common => run_squeeze_then_merge(cmd_args, row_nnz_cutoff, col_nnz_cutoff), - } + }) } /// Union mode: Merge first with union of all rows, then squeeze the merged result. @@ -543,20 +574,26 @@ fn needs_stats(cmd_args: &RunSqueezeArgs) -> bool { /// these are the explicit cutoffs as given. Otherwise the nnz statistics are /// collected, auto/interactive fill unset cutoffs from the histogram trough, /// the histogram is printed/saved as asked, and `--interactive` lets the user -/// adjust (confirming first when squeezing `in_place`). +/// adjust (confirming first when squeezing `in_place`) on the screen of +/// `tui`, the scans and printing off it. fn settle_cutoffs( cmd_args: &RunSqueezeArgs, data: &dyn SparseIo>, data_file: &str, in_place: Option<&str>, + mut tui: Option<&mut Tui<'_>>, ) -> anyhow::Result> { let save = cmd_args.save_histogram.as_deref(); if !needs_stats(cmd_args) { return Ok(Some((cmd_args.row_nnz_cutoff, cmd_args.column_nnz_cutoff))); } - let row_nnz = collect_row_stat(data, cmd_args.block_size)?.count_positives(); - let col_nnz = collect_column_stat(data, cmd_args.block_size)?.count_positives(); + let (row_nnz, col_nnz) = off_screen(tui.as_deref_mut(), || { + Ok(( + collect_row_stat(data, cmd_args.block_size)?.count_positives(), + collect_column_stat(data, cmd_args.block_size)?.count_positives(), + )) + })?; // Suggest cutoffs at the trough of the log(1+nnz) histogram let row_suggest = suggest_nnz_cutoff(&row_nnz); @@ -585,11 +622,13 @@ fn settle_cutoffs( }; if cmd_args.show_histogram || save.is_some() { - display_nnz_histogram(data_file, row, col, cmd_args.show_histogram, save)?; + off_screen(tui.as_deref_mut(), || { + display_nnz_histogram(data_file, row, col, cmd_args.show_histogram, save) + })?; } if cmd_args.interactive { - return pick_cutoffs(data_file, row, col, in_place); + return pick_cutoffs(data_file, row, col, in_place, tui); } // Headless auto mode (no histogram shown): report what was applied @@ -602,13 +641,15 @@ fn settle_cutoffs( /// Let the user settle both cutoffs: a full-screen picker on a terminal, /// else the printed histogram and line prompts. `in_place` names the file an -/// in-place squeeze would overwrite, so the user confirms that first. -/// `None` means cancel. +/// in-place squeeze would overwrite, so the user confirms that first. The +/// picker runs on the screen of `tui` when there is one. `None` means +/// cancel. fn pick_cutoffs( data_file: &str, row: NnzAxis<'_>, col: NnzAxis<'_>, in_place: Option<&str>, + tui: Option<&mut Tui<'_>>, ) -> anyhow::Result> { let picked = if tui_available() { let picker = CutoffPicker::new( @@ -617,7 +658,10 @@ fn pick_cutoffs( AxisView::new("Columns", col.nnz, col.cutoff, col.suggest), in_place, ); - choose_cutoffs(picker)? + match tui { + Some(tui) => choose_cutoffs_in(tui, picker)?, + None => choose_cutoffs(picker)?, + } } else { prompt_cutoffs(data_file, row, col, in_place)? }; diff --git a/src/handlers/transformation/subset.rs b/src/handlers/transformation/subset.rs index 9ab4cf1..bcaf439 100644 --- a/src/handlers/transformation/subset.rs +++ b/src/handlers/transformation/subset.rs @@ -1,8 +1,9 @@ use super::{run_squeeze, ReorderRowsArgs, RowAlignMode, RunSqueezeArgs}; use super::{SubsetArgs, SubsetColumnsArgs, SubsetRowsArgs}; -use crate::handlers::explore::{pick_entries, pick_rows_and_columns}; +use crate::handlers::explore::{off_screen, open_session, pick_entries, pick_rows_and_columns}; use crate::hdf5_io::*; use crate::interactive::stat_tui::Side; +use crate::interactive::ui::Tui; use crate::sparse_io::*; use crate::utilities::io_helpers::{ read_col_names, read_row_names, MAX_COLUMN_NAME_IDX, MAX_ROW_NAME_IDX, @@ -73,6 +74,7 @@ fn build_squeeze_args(output_file: Box, args_cols: usize, args_rows: usize) auto_cutoff: false, output: None, row_align: RowAlignMode::Common, + session: Default::default(), } } @@ -83,103 +85,112 @@ fn build_squeeze_args(output_file: Box, args_cols: usize, args_rows: usize) /// a file containing column names. Optionally, the output can be squeezed to /// remove rows/columns with too few non-zero entries. pub fn subset_columns(args: &SubsetColumnsArgs) -> anyhow::Result<()> { + // The picker's session first: hosted, the rest runs in the session. + let session = args + .interactive + .then(|| open_session("subset-columns", &args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); // Pick before staging, so cancelling copies nothing. - let columns_indices = if args.interactive { - match pick_entries(&args.data_file, Side::Columns, "subset")? { + let columns_indices = match tui.as_mut() { + Some(tui) => match pick_entries(tui, &args.data_file, Side::Columns, "subset")? { Some(picked) => Some(picked), None => return Ok(()), - } - } else { - args.column_indices.clone() + }, + None => args.column_indices.clone(), }; - let column_name_file = args.name_file.clone(); - let zip = if args.interactive { - zip_like_input(&args.data_file, &args.output) - } else { - args.zip - }; - let (_backend, effective_output, output_file, mut data) = - stage_for_edit(&args.data_file, &args.output, zip)?; - - let original_ncol = data.num_columns().unwrap_or(0); - info!("original data: {} columns", original_ncol); - - let selected_columns = if let Some(idx) = columns_indices { - info!("using {} column indices directly", idx.len()); - idx - } else if let Some(column_file) = column_name_file { - let col_names = read_col_names(column_file, MAX_COLUMN_NAME_IDX)?; - if col_names.is_empty() { - return Err(anyhow::anyhow!("Empty column file")); - } - info!("read {} column names from file", col_names.len()); - - let data_col_names = data - .column_names() - .expect("column names not found in data file"); - - let membership = Membership::from_pairs( - col_names + // Written off the screen: a `kill` meanwhile lets it finish. + off_screen(tui.as_mut(), || { + let column_name_file = args.name_file.clone(); + + let zip = if args.interactive { + zip_like_input(&args.data_file, &args.output) + } else { + args.zip + }; + let (_backend, effective_output, output_file, mut data) = + stage_for_edit(&args.data_file, &args.output, zip)?; + + let original_ncol = data.num_columns().unwrap_or(0); + info!("original data: {} columns", original_ncol); + + let selected_columns = if let Some(idx) = columns_indices { + info!("using {} column indices directly", idx.len()); + idx + } else if let Some(column_file) = column_name_file { + let col_names = read_col_names(column_file, MAX_COLUMN_NAME_IDX)?; + if col_names.is_empty() { + return Err(anyhow::anyhow!("Empty column file")); + } + info!("read {} column names from file", col_names.len()); + + let data_col_names = data + .column_names() + .expect("column names not found in data file"); + + let membership = Membership::from_pairs( + col_names + .iter() + .enumerate() + .map(|(i, name)| (name.clone(), i.to_string().into_boxed_str())), + args.allow_prefix && !args.exact_names, + ) + .with_delimiter(args.delimiter); + + let (matched, stats) = membership.match_keys(&data_col_names); + + info!( + "Column matching: {} exact + {} base_key + {} prefix = {}/{} matched", + stats.exact, + stats.base_key, + stats.prefix, + stats.total_matched(), + data_col_names.len() + ); + + if matched.is_empty() { + let file_sample: Vec<_> = col_names.iter().take(3).collect(); + let data_sample: Vec<_> = data_col_names.iter().take(3).collect(); + info!("column names from file (sample): {:?}", file_sample); + info!("column names in data (sample): {:?}", data_sample); + return Err(anyhow::anyhow!( + "Found empty columns - no matching column names" + )); + } + + let idx: Vec = data_col_names .iter() .enumerate() - .map(|(i, name)| (name.clone(), i.to_string().into_boxed_str())), - args.allow_prefix && !args.exact_names, - ) - .with_delimiter(args.delimiter); - - let (matched, stats) = membership.match_keys(&data_col_names); + .filter_map(|(i, name)| matched.get(name).map(|_| i)) + .collect(); - info!( - "Column matching: {} exact + {} base_key + {} prefix = {}/{} matched", - stats.exact, - stats.base_key, - stats.prefix, - stats.total_matched(), - data_col_names.len() - ); - - if matched.is_empty() { - let file_sample: Vec<_> = col_names.iter().take(3).collect(); - let data_sample: Vec<_> = data_col_names.iter().take(3).collect(); - info!("column names from file (sample): {:?}", file_sample); - info!("column names in data (sample): {:?}", data_sample); + info!("subsetting to {} columns", idx.len()); + idx + } else { return Err(anyhow::anyhow!( - "Found empty columns - no matching column names" + "either `column-indices`, `name-file`, or `--interactive` must be provided" )); + }; + + data.subset_columns_rows(Some(&selected_columns), None)?; + info!("after subset: {} columns", selected_columns.len()); + drop(data); + + if args.do_squeeze { + info!("Squeeze the backend data {}", output_file); + let squeeze_args = build_squeeze_args( + output_file.clone(), + args.column_nnz_cutoff, + args.row_nnz_cutoff, + ); + run_squeeze(&squeeze_args)?; } - let idx: Vec = data_col_names - .iter() - .enumerate() - .filter_map(|(i, name)| matched.get(name).map(|_| i)) - .collect(); - - info!("subsetting to {} columns", idx.len()); - idx - } else { - return Err(anyhow::anyhow!( - "either `column-indices`, `name-file`, or `--interactive` must be provided" - )); - }; - - data.subset_columns_rows(Some(&selected_columns), None)?; - info!("after subset: {} columns", selected_columns.len()); - drop(data); - - if args.do_squeeze { - info!("Squeeze the backend data {}", output_file); - let squeeze_args = build_squeeze_args( - output_file.clone(), - args.column_nnz_cutoff, - args.row_nnz_cutoff, - ); - run_squeeze(&squeeze_args)?; - } - - finalize_zarr_output(&output_file, &effective_output)?; - info!("done"); - Ok(()) + finalize_zarr_output(&output_file, &effective_output)?; + info!("done"); + Ok(()) + }) } /// Subset rows from a sparse matrix data file @@ -189,99 +200,108 @@ pub fn subset_columns(args: &SubsetColumnsArgs) -> anyhow::Result<()> { /// a file containing row names. Optionally, the output can be squeezed to /// remove rows/columns with too few non-zero entries. pub fn subset_rows(args: &SubsetRowsArgs) -> anyhow::Result<()> { + // The picker's session first: hosted, the rest runs in the session. + let session = args + .interactive + .then(|| open_session("subset-rows", &args.session)) + .transpose()?; + let mut tui = session.as_ref().map(Tui::new); // Pick before staging, so cancelling copies nothing. - let row_indices = if args.interactive { - match pick_entries(&args.data_file, Side::Rows, "subset")? { + let row_indices = match tui.as_mut() { + Some(tui) => match pick_entries(tui, &args.data_file, Side::Rows, "subset")? { Some(picked) => Some(picked), None => return Ok(()), - } - } else { - args.row_indices.clone() - }; - let row_name_file = args.name_file.clone(); - - let zip = if args.interactive { - zip_like_input(&args.data_file, &args.output) - } else { - args.zip + }, + None => args.row_indices.clone(), }; - let (_backend, effective_output, output_file, mut data) = - stage_for_edit(&args.data_file, &args.output, zip)?; - - let original_nrow = data.num_rows().unwrap_or(0); - info!("original data: {} rows", original_nrow); - - let selected_rows = if let Some(idx) = row_indices { - info!("using {} row indices directly", idx.len()); - idx - } else if let Some(row_file) = row_name_file { - let row_names = read_row_names(row_file, MAX_ROW_NAME_IDX)?; - if row_names.is_empty() { - return Err(anyhow::anyhow!("Empty row file")); - } - info!("read {} row names from file", row_names.len()); - let data_row_names = data.row_names().expect("row names not found in data file"); - - let membership = Membership::from_pairs( - row_names + // Written off the screen: a `kill` meanwhile lets it finish. + off_screen(tui.as_mut(), || { + let row_name_file = args.name_file.clone(); + + let zip = if args.interactive { + zip_like_input(&args.data_file, &args.output) + } else { + args.zip + }; + let (_backend, effective_output, output_file, mut data) = + stage_for_edit(&args.data_file, &args.output, zip)?; + + let original_nrow = data.num_rows().unwrap_or(0); + info!("original data: {} rows", original_nrow); + + let selected_rows = if let Some(idx) = row_indices { + info!("using {} row indices directly", idx.len()); + idx + } else if let Some(row_file) = row_name_file { + let row_names = read_row_names(row_file, MAX_ROW_NAME_IDX)?; + if row_names.is_empty() { + return Err(anyhow::anyhow!("Empty row file")); + } + info!("read {} row names from file", row_names.len()); + + let data_row_names = data.row_names().expect("row names not found in data file"); + + let membership = Membership::from_pairs( + row_names + .iter() + .enumerate() + .map(|(i, name)| (name.clone(), i.to_string().into_boxed_str())), + args.allow_prefix && !args.exact_names, + ) + .with_delimiter(args.delimiter); + + let (matched, stats) = membership.match_keys(&data_row_names); + + info!( + "Row matching: {} exact + {} base_key + {} prefix = {}/{} matched", + stats.exact, + stats.base_key, + stats.prefix, + stats.total_matched(), + data_row_names.len() + ); + + if matched.is_empty() { + let file_sample: Vec<_> = row_names.iter().take(3).collect(); + let data_sample: Vec<_> = data_row_names.iter().take(3).collect(); + info!("row names from file (sample): {:?}", file_sample); + info!("row names in data (sample): {:?}", data_sample); + return Err(anyhow::anyhow!("Found empty rows - no matching row names")); + } + + let idx: Vec = data_row_names .iter() .enumerate() - .map(|(i, name)| (name.clone(), i.to_string().into_boxed_str())), - args.allow_prefix && !args.exact_names, - ) - .with_delimiter(args.delimiter); - - let (matched, stats) = membership.match_keys(&data_row_names); - - info!( - "Row matching: {} exact + {} base_key + {} prefix = {}/{} matched", - stats.exact, - stats.base_key, - stats.prefix, - stats.total_matched(), - data_row_names.len() - ); + .filter_map(|(i, name)| matched.get(name).map(|_| i)) + .collect(); - if matched.is_empty() { - let file_sample: Vec<_> = row_names.iter().take(3).collect(); - let data_sample: Vec<_> = data_row_names.iter().take(3).collect(); - info!("row names from file (sample): {:?}", file_sample); - info!("row names in data (sample): {:?}", data_sample); - return Err(anyhow::anyhow!("Found empty rows - no matching row names")); + info!("subsetting to {} rows", idx.len()); + idx + } else { + return Err(anyhow::anyhow!( + "either `row-indices`, `name-file`, or `--interactive` must be provided" + )); + }; + + data.subset_columns_rows(None, Some(&selected_rows))?; + info!("after subset: {} rows", selected_rows.len()); + drop(data); + + if args.do_squeeze { + info!("Squeeze the backend data {}", output_file); + let squeeze_args = build_squeeze_args( + output_file.clone(), + args.column_nnz_cutoff, + args.row_nnz_cutoff, + ); + run_squeeze(&squeeze_args)?; } - let idx: Vec = data_row_names - .iter() - .enumerate() - .filter_map(|(i, name)| matched.get(name).map(|_| i)) - .collect(); - - info!("subsetting to {} rows", idx.len()); - idx - } else { - return Err(anyhow::anyhow!( - "either `row-indices`, `name-file`, or `--interactive` must be provided" - )); - }; - - data.subset_columns_rows(None, Some(&selected_rows))?; - info!("after subset: {} rows", selected_rows.len()); - drop(data); - - if args.do_squeeze { - info!("Squeeze the backend data {}", output_file); - let squeeze_args = build_squeeze_args( - output_file.clone(), - args.column_nnz_cutoff, - args.row_nnz_cutoff, - ); - run_squeeze(&squeeze_args)?; - } - - finalize_zarr_output(&output_file, &effective_output)?; - info!("done"); - Ok(()) + finalize_zarr_output(&output_file, &effective_output)?; + info!("done"); + Ok(()) + }) } /// Subset rows and columns picked in the explorer @@ -291,30 +311,36 @@ pub fn subset_rows(args: &SubsetRowsArgs) -> anyhow::Result<()> { /// same format as the input with just those. A side with nothing marked is /// kept whole. pub fn subset(args: &SubsetArgs) -> anyhow::Result<()> { + // The picker's session first: hosted, the rest runs in the session. + let session = open_session("subset", &args.session)?; + let mut tui = Tui::new(&session); // Pick before staging, so cancelling copies nothing. - let Some(picked) = pick_rows_and_columns(&args.data_file, "subset")? else { + let Some(picked) = pick_rows_and_columns(&mut tui, &args.data_file, "subset")? else { return Ok(()); }; - let zip = zip_like_input(&args.data_file, &args.output); - let (_backend, effective_output, output_file, mut data) = - stage_for_edit(&args.data_file, &args.output, zip)?; - - let rows = (!picked.rows.is_empty()).then_some(picked.rows); - let columns = (!picked.columns.is_empty()).then_some(picked.columns); - info!( - "subsetting to {} rows x {} columns", - rows.as_ref() - .map_or(data.num_rows().unwrap_or(0), |r| r.len()), - columns - .as_ref() - .map_or(data.num_columns().unwrap_or(0), |c| c.len()) - ); - data.subset_columns_rows(columns.as_ref(), rows.as_ref())?; - drop(data); + // Written off the screen: a `kill` meanwhile lets it finish. + off_screen(Some(&mut tui), || { + let zip = zip_like_input(&args.data_file, &args.output); + let (_backend, effective_output, output_file, mut data) = + stage_for_edit(&args.data_file, &args.output, zip)?; + + let rows = (!picked.rows.is_empty()).then_some(picked.rows); + let columns = (!picked.columns.is_empty()).then_some(picked.columns); + info!( + "subsetting to {} rows x {} columns", + rows.as_ref() + .map_or(data.num_rows().unwrap_or(0), |r| r.len()), + columns + .as_ref() + .map_or(data.num_columns().unwrap_or(0), |c| c.len()) + ); + data.subset_columns_rows(columns.as_ref(), rows.as_ref())?; + drop(data); - finalize_zarr_output(&output_file, &effective_output)?; - info!("done"); - Ok(()) + finalize_zarr_output(&output_file, &effective_output)?; + info!("done"); + Ok(()) + }) } /// Reorder rows in a sparse matrix data file diff --git a/src/interactive/cutoff_tui.rs b/src/interactive/cutoff_tui.rs index 1afdd34..1aa1799 100644 --- a/src/interactive/cutoff_tui.rs +++ b/src/interactive/cutoff_tui.rs @@ -14,8 +14,8 @@ use ratatui::widgets::{Block, BorderType, Clear, LineGauge, Paragraph, Wrap}; use ratatui::Frame; use super::ui::{ - header, help_line, input_line, median, panel, run_screen, Binned, HistPlot, Scale, Screen, - ACCENTED, DIM, HIGHLIGHT, PLAIN, + header, help_line, input_line, median, panel, run_screen, title_bar, Binned, HistPlot, Scale, + Screen, Tui, ACCENTED, DIM, HIGHLIGHT, PLAIN, }; use crate::qc::{below_nnz_cutoff, pct}; @@ -227,6 +227,8 @@ pub struct CutoffPicker { /// Set once the user is done: the row and column cutoffs, or `None` to /// cancel. decision: Option>, + /// The detach and switch keys, for the title bar. + session_keys: Vec<(String, String)>, } impl CutoffPicker { @@ -245,6 +247,7 @@ impl CutoffPicker { in_place_target: in_place_target.map(str::to_string), mode: Mode::Browse, decision: None, + session_keys: super::session_keys(), } } @@ -298,6 +301,10 @@ impl Screen for CutoffPicker { self.decision = Some(None); } + fn status(&self) -> Option { + Some(format!("picking nnz cutoffs for {}", self.title)) + } + fn handle_key(&mut self, key: KeyEvent) { let shift = key.modifiers.contains(KeyModifiers::SHIFT); let f = self.focus; @@ -367,7 +374,12 @@ impl Screen for CutoffPicker { .areas(frame.area()); let scales = format!("x {} · y {}", self.x_scale.name(), self.y_scale.name()); - frame.render_widget(header("squeeze", &self.title, &scales), top); + title_bar( + frame, + top, + header("squeeze", &self.title, &scales), + &self.session_keys, + ); for (i, area) in [row_area, col_area].into_iter().enumerate() { self.axes[i].render(frame, area, self.focus == i, self.y_scale); } @@ -405,6 +417,16 @@ pub fn choose_cutoffs(mut picker: CutoffPicker) -> anyhow::Result, + mut picker: CutoffPicker, +) -> anyhow::Result> { + tui.run(&mut picker)?; + Ok(picker.decision.flatten()) +} + #[cfg(test)] #[path = "tests/cutoff_tui.rs"] mod tests; diff --git a/src/interactive/mod.rs b/src/interactive/mod.rs index 55c756e..535dbe3 100644 --- a/src/interactive/mod.rs +++ b/src/interactive/mod.rs @@ -12,6 +12,12 @@ //! columns, optionally marking entries and handing them back. //! //! Open a view only when [`tui_available`]; otherwise fall back to text. +//! +//! The views run in a legume-tui session ([`TOOL`]): a command opens it +//! first thing and hands it to [`ui::Tui`] or [`ui::run_screen_in`]; where +//! the session is hosted, the command keeps running when the connection +//! drops. [`ui::run_screen`] runs a view in a plain session of its own +//! (`legume_tui::Session::plain`). pub mod cutoff_tui; pub mod stat_tui; @@ -25,6 +31,42 @@ pub fn tui_available() -> bool { io::stdin().is_terminal() && io::stdout().is_terminal() } +/// The tool data-beans' sessions belong to: they are named +/// `data-beans-squeeze-1`, `data-beans-stat-2`, and `data-beans attach` +/// finds them. +pub const TOOL: legume_tui::Tool = legume_tui::Tool::new("data-beans"); + +/// The detach and switch keys for a title bar ([`ui::title_bar`]), each +/// with what it does in a word (`detach`, `switch`); none when the views +/// run plain. +pub fn session_keys() -> Vec<(String, String)> { + legume_tui::help_keys() + .into_iter() + .map(|k| (k.key.to_lowercase(), k.short)) + .collect() +} + +/// The paragraph a command's long help ends with about its session: +/// `lead` first, for a command whose view is optional (when it runs as a +/// session), then how it runs, the detach and switch keys, how to return, +/// and `more` at the end. Its views are always opened on something +/// named, so a new start never offers the live sessions; `data-beans +/// attach` does. +pub fn session_help(lead: Option<&str>, more: &str) -> String { + let help = legume_tui::cli::SessionHelp::new() + .what_goes_on("the command goes on") + .resume_hint(Some( + "Starting it again starts a new one (naming the detached ones); \ + `data-beans attach` with no name", + )) + .more(more); + let help = match lead { + Some(lead) => help.lead(lead), + None => help, + }; + legume_tui::cli::session_help(&TOOL, help) +} + /// User action after viewing histogram or other interactive prompts #[derive(Debug, Clone)] pub enum UserAction { @@ -87,3 +129,7 @@ pub fn confirm(message: &str) -> anyhow::Result { Ok(matches!(choice.as_str(), "y" | "yes")) } + +#[cfg(test)] +#[path = "tests/session.rs"] +mod tests; diff --git a/src/interactive/stat_tui.rs b/src/interactive/stat_tui.rs index 288de31..1aa5b0a 100644 --- a/src/interactive/stat_tui.rs +++ b/src/interactive/stat_tui.rs @@ -21,8 +21,8 @@ use ratatui::Frame; use regex::{Regex, RegexBuilder}; use super::ui::{ - header, help_line, input_line, median, panel, run_screen, Binned, HistPlot, Scale, Screen, - ACCENTED, DIM, HIGHLIGHT, PLAIN, + header, help_line, input_line, median, panel, run_screen, title_bar, Binned, HistPlot, Scale, + Screen, Tui, ACCENTED, DIM, HIGHLIGHT, PLAIN, }; use crate::qc::fmt_stat; @@ -244,6 +244,8 @@ pub struct StatExplorer<'a> { quit: bool, /// Marked entries handed back by Enter when picking. picked: Option, + /// The detach and switch keys, for the title bar. + session_keys: Vec<(String, String)>, } impl<'a> StatExplorer<'a> { @@ -291,6 +293,7 @@ impl<'a> StatExplorer<'a> { mode: Mode::Browse, quit: false, picked: None, + session_keys: super::session_keys(), }; explorer.reorder(); explorer.refilter(None); @@ -894,6 +897,14 @@ impl Screen for StatExplorer<'_> { self.quit = true; } + fn status(&self) -> Option { + let doing = match self.purpose { + Purpose::Explore => "exploring", + Purpose::Pick { verb } | Purpose::PickBoth { verb } => verb, + }; + Some(format!("{doing} {} of {}", self.side.name(), self.title)) + } + fn pending_work(&self) -> Option { Some(match self.pending.as_ref()? { Work::OtherSide => format!("computing {} statistics ...", self.side.other().name()), @@ -979,7 +990,12 @@ impl Screen for StatExplorer<'_> { self.y_scale.name() ); let badge = self.purpose.verb().unwrap_or("stat"); - frame.render_widget(header(badge, &self.title, &extra), top); + title_bar( + frame, + top, + header(badge, &self.title, &extra), + &self.session_keys, + ); let in_values = matches!(self.mode, Mode::Values | Mode::Typing(Input::SaveValues, _)); if in_values { @@ -1026,6 +1042,16 @@ pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result> Ok(explorer.picked) } +/// [`explore`] on the screen of `tui`, in its session: a `kill` quits it +/// as `q` does, handing nothing back. +pub fn explore_in( + tui: &mut Tui<'_>, + mut explorer: StatExplorer<'_>, +) -> anyhow::Result> { + tui.run(&mut explorer)?; + Ok(explorer.picked) +} + #[cfg(test)] #[path = "tests/stat_tui.rs"] mod tests; diff --git a/src/interactive/tests/cutoff_tui.rs b/src/interactive/tests/cutoff_tui.rs index e77cd46..7f35688 100644 --- a/src/interactive/tests/cutoff_tui.rs +++ b/src/interactive/tests/cutoff_tui.rs @@ -191,3 +191,20 @@ fn x_and_y_cycle_scales_for_both_axes() { .collect(); assert!(text.contains("x sqrt · y linear")); } + +#[test] +fn the_session_keys_stay_on_screen_at_80_columns() { + let mut p = picker(Some("target")); + p.session_keys = vec![ + ("ctrl+alt+d".into(), "detach".into()), + ("ctrl+alt+s".into(), "switch".into()), + ]; + let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap(); + term.draw(|f| p.render(f)).unwrap(); + let top: String = term.backend().buffer().content()[..80] + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(top.contains("ctrl+alt+d detach"), "{top:?}"); + assert!(top.contains("ctrl+alt+s switch"), "{top:?}"); +} diff --git a/src/interactive/tests/session.rs b/src/interactive/tests/session.rs new file mode 100644 index 0000000..221efad --- /dev/null +++ b/src/interactive/tests/session.rs @@ -0,0 +1,80 @@ +use super::ui::{modes_of, Screen}; +use super::*; +use legume_tui::{Keyboard, Modes, Mouse}; +use ratatui::crossterm::event::{KeyEvent, KeyboardEnhancementFlags}; +use ratatui::Frame; + +/// A view that asks for the mouse and for chords as told. +struct Asks { + mouse: bool, + chords: bool, +} + +impl Screen for Asks { + fn render(&mut self, _: &mut Frame) {} + fn handle_key(&mut self, _: KeyEvent) {} + fn interrupt(&mut self) {} + fn done(&self) -> bool { + true + } + fn takes_mouse(&self) -> bool { + self.mouse + } + fn reports_chords(&self) -> bool { + self.chords + } +} + +#[test] +fn a_views_modes_are_the_sessions() { + let none = modes_of(&Asks { + mouse: false, + chords: false, + }); + assert_eq!(none, Modes::new()); + + let both = modes_of(&Asks { + mouse: true, + chords: true, + }); + assert_eq!(both.mouse, Mouse::Buttons, "buttons and the wheel only"); + assert_eq!( + both.keyboard_enhancement, + Keyboard::On(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + assert!(!both.bracketed_paste && !both.focus); +} + +#[test] +fn outside_a_session_the_title_bar_lists_no_keys() { + assert!(session_keys().is_empty(), "no detach key to list"); +} + +#[test] +fn the_session_paragraph_names_the_keys_and_the_subcommands() { + let more = "The title bar shows the detach and switch keys."; + let text = session_help(None, more); + let flat = text.split_whitespace().collect::>().join(" "); + for said in [ + "detachable session", + "the command goes on", + "ctrl+alt+d", + "ctrl+alt+s", + "`data-beans attach` with no name offers the live sessions", + "--no-detach", + more, + ] { + assert!(flat.contains(said), "{said:?} not in {text:?}"); + } + assert!(text.lines().all(|l| l.chars().count() <= 70), "{text}"); +} + +#[test] +fn an_optional_views_paragraph_says_so_first() { + let lead = "With --interactive, its view runs as a session."; + let text = session_help(Some(lead), "More."); + let flat = text.split_whitespace().collect::>().join(" "); + assert!(flat.starts_with(lead), "{text}"); + assert!(flat.ends_with("More."), "{text}"); + assert!(text.lines().all(|l| l.chars().count() <= 70), "{text}"); +} diff --git a/src/interactive/tests/stat_tui.rs b/src/interactive/tests/stat_tui.rs index 28f83b4..4783387 100644 --- a/src/interactive/tests/stat_tui.rs +++ b/src/interactive/tests/stat_tui.rs @@ -428,3 +428,29 @@ fn picking_both_sides_allows_marks_on_one_side_only() { assert_eq!(picked.columns, [4]); assert!(picked.rows.is_empty(), "rows never shown, so kept whole"); } + +/// The session keys a hosted view shows. +fn hosted_keys() -> Vec<(String, String)> { + vec![ + ("ctrl+alt+d".into(), "detach".into()), + ("ctrl+alt+s".into(), "switch".into()), + ] +} + +#[test] +fn the_session_keys_stay_on_screen_at_80_columns() { + let mut e = StatExplorer::new( + "a-long-input-name-that-fills-the-title-bar-on-its-own.zarr", + Side::Rows, + rows(), + None, + Some(reader()), + Purpose::Pick { verb: "subset" }, + ); + e.session_keys = hosted_keys(); + let text = screen_text(&mut e, 80, 24); + let top: String = text.chars().take(80).collect(); + assert!(top.contains("ctrl+alt+d detach"), "{top:?}"); + assert!(top.contains("ctrl+alt+s switch"), "{top:?}"); + assert!(top.contains("subset"), "the badge is kept: {top:?}"); +} diff --git a/src/interactive/ui.rs b/src/interactive/ui.rs index b366768..8e72a79 100644 --- a/src/interactive/ui.rs +++ b/src/interactive/ui.rs @@ -5,9 +5,10 @@ use ratatui::buffer::Buffer; use std::time::{Duration, Instant}; +use legume_tui::{Keyboard, Modes, Mouse, Round, Session}; use ratatui::crossterm::event::{ self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags, - MouseEvent, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + MouseEvent, }; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -71,21 +72,31 @@ pub trait Screen { fn take_notice(&mut self) -> Option { None } + /// What the view is doing, in one short line, for `data-beans + /// sessions` and the session picker to show. + fn status(&self) -> Option { + None + } } -/// The terminal modes a screen asked for, taken back while its screen is -/// still up: terminals keep each screen's modes apart. -#[derive(Default)] -struct Modes { - mouse: bool, - chords: bool, +/// The terminal modes `screen` asks for, as its session turns them on (and +/// on again after every attach): the mouse as buttons and the wheel in SGR +/// form, not the pointer's every move, which would wake the loop for +/// nothing; keys held with modifiers told apart. +pub fn modes_of(screen: &impl Screen) -> Modes { + Modes::new() + .mouse(if screen.takes_mouse() { + Mouse::Buttons + } else { + Mouse::Off + }) + .keyboard_enhancement(if screen.reports_chords() { + Keyboard::On(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + } else { + Keyboard::Off + }) } -/// Buttons and the wheel (1000) in SGR form (1006); not the pointer's every -/// move, which would wake the loop for nothing. -const MOUSE_ON: &str = "\x1b[?1000h\x1b[?1006h"; -const MOUSE_OFF: &str = "\x1b[?1006l\x1b[?1000l"; - /// Write `text` to the terminal as it is. fn send(text: &str) -> bool { let mut out = std::io::stdout(); @@ -100,132 +111,221 @@ pub fn notify(text: &str) { send(&format!("\x07\x1b]9;{text}\x07")); } -impl Modes { - fn ask(&mut self, screen: &impl Screen) { - let mut out = std::io::stdout(); - if screen.takes_mouse() && !self.mouse { - self.mouse = send(MOUSE_ON); - } - if screen.reports_chords() && !self.chords { - let flags = KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES; - self.chords = - ratatui::crossterm::execute!(out, PushKeyboardEnhancementFlags(flags)).is_ok(); - } - } +/// How often a view's loop asks [`Screen::tick`], keys or not. +pub const TICK: std::time::Duration = std::time::Duration::from_millis(200); - fn release(&mut self) { - let mut out = std::io::stdout(); - if std::mem::take(&mut self.mouse) { - send(MOUSE_OFF); - } - if std::mem::take(&mut self.chords) { - let _ = ratatui::crossterm::execute!(out, PopKeyboardEnhancementFlags); - } - } +/// The terminal a command's views draw on, in the command's session: up +/// from the first view ([`Tui::run`]) until dropped, so every view of the +/// command and the work between them ([`Tui::work`]) share one screen. +/// While it is up, a `data-beans kill` asks the views to stop as Ctrl-C +/// does ([`Tui::stop_requested`]), and work under way finishes first. Log +/// records raised while it is up are held back and written once the +/// normal screen is back, or as work between views runs. +pub struct Tui<'s> { + session: &'s Session, + terminal: Option, + /// The modes the screen has on now. + modes: Modes, + _held: legume_tui::logs::Hold, } -/// How often [`run_screen`] asks [`Screen::tick`], keys or not. -pub const TICK: std::time::Duration = std::time::Duration::from_millis(200); - -/// Holds log records back while alive, writing them when dropped. -struct HeldLogs; +impl<'s> Tui<'s> { + /// Views of `session`, the screen not up yet. + pub fn new(session: &'s Session) -> Self { + Self { + session, + terminal: None, + modes: Modes::new(), + _held: legume_tui::logs::hold(), + } + } -impl HeldLogs { - fn new() -> Self { - crate::aux::logging::hold_logs(true); - HeldLogs + pub fn session(&self) -> &'s Session { + self.session } -} -impl Drop for HeldLogs { - fn drop(&mut self) { - crate::aux::logging::hold_logs(false); + /// Whether `data-beans kill` asked the command to stop: no more views + /// and no more work after the one running. + pub fn stop_requested(&self) -> bool { + self.session.stop_requested() } -} -/// Run `screen` full screen until it is done. The terminal is restored on -/// return and on panic. Log records raised meanwhile are held back and -/// written once the normal screen is back. Blocking work runs on the normal -/// screen, where its own progress output belongs, and the view comes back -/// after it. The mouse and keys held with modifiers are reported only to a -/// screen that asks for them, and events already queued (a turn of the wheel -/// is several) are taken before the screen is drawn again. -pub fn run_screen(screen: &mut S) -> anyhow::Result<()> { - run_screen_with(screen, S::handle_key) -} + /// Run `screen` until it is done, its keys to [`Screen::handle_key`]. + pub fn run(&mut self, screen: &mut S) -> anyhow::Result<()> { + self.run_with(screen, S::handle_key) + } -/// [`run_screen`], with each key (but Ctrl-C) handed to `on_key` rather -/// than [`Screen::handle_key`]: for a caller with rules of its own about -/// which keys a screen sees. -pub fn run_screen_with( - screen: &mut S, - mut on_key: impl FnMut(&mut S, KeyEvent), -) -> anyhow::Result<()> { - ratatui::run(|terminal| -> anyhow::Result<()> { - let held = HeldLogs::new(); - let mut modes = Modes::default(); + /// Run `screen` until it is done, each key (but Ctrl-C) handed to + /// `on_key`. The screen comes up with the first view and stays up + /// after. A stop interrupts the view, as Ctrl-C does. Blocking work + /// ([`Screen::pending_work`]) runs on the normal screen, where its own + /// progress output belongs, and the view comes back after it. The + /// mouse and keys held with modifiers are reported only to a screen + /// that asks for them, and events already queued (a turn of the wheel + /// is several) are taken before the screen is drawn again. After an + /// attach the modes are on again and the view is drawn whole. + pub fn run_with( + &mut self, + screen: &mut S, + mut on_key: impl FnMut(&mut S, KeyEvent), + ) -> anyhow::Result<()> { + let session = self.session; + if session.stop_requested() { + screen.interrupt(); + return Ok(()); + } + let wanted = modes_of(screen); + self.up(wanted)?; + let Some(terminal) = self.terminal.as_mut() else { + unreachable!("the screen is up"); + }; let mut redraw = true; // Ticks keep time while events come: a stream of them must not // keep a view from its background work. let mut next_tick = Instant::now() + TICK; while !screen.done() { + if session.stop_requested() { + screen.interrupt(); + break; + } if redraw { - terminal.draw(|f| screen.render(f))?; - // Asked once the screen is up: its modes are its own. - modes.ask(screen); + session.draw(terminal, |f| screen.render(f))?; + let wanted = modes_of(screen); + if wanted != self.modes { + session.set_modes(wanted)?; + self.modes = wanted; + } + } + if let Some(status) = screen.status() { + session.status(&status); } if let Some(notice) = screen.take_notice() { notify(¬ice); } redraw = false; - if event::poll(next_tick.saturating_duration_since(Instant::now()))? { - loop { - redraw |= match event::read()? { - Event::Key(key) if key.kind != KeyEventKind::Press => false, - Event::Key(key) - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.code == KeyCode::Char('c') => - { - screen.interrupt(); - true - } - Event::Key(key) => { - on_key(screen, key); - true - } - Event::Mouse(m) => screen.mouse(m), - Event::Resize(..) => true, - _ => false, - }; + let wait = next_tick.saturating_duration_since(Instant::now()); + match session.poll(terminal, wait)? { + Round::Event(mut event) => loop { + redraw |= feed(screen, &mut on_key, event); if screen.done() || screen.pending_work().is_some() + || session.stop_requested() || !event::poll(Duration::ZERO)? { break; } - } + event = event::read()?; + }, + // An attach cleared the screen: drawn whole again. + Round::Repainted => redraw = true, + _ => {} } if Instant::now() >= next_tick { redraw |= screen.tick(); next_tick = Instant::now() + TICK; } if let Some(message) = screen.pending_work() { - modes.release(); - ratatui::restore(); - crate::aux::logging::hold_logs(false); - eprintln!("{message}"); - screen.do_work(); - crate::aux::logging::hold_logs(true); - *terminal = ratatui::try_init()?; + session.off_screen(|| { + eprintln!("{message}"); + screen.do_work(); + })?; redraw = true; } } - modes.release(); - // Restore before writing what was held, not after. - ratatui::restore(); - drop(held); Ok(()) - }) + } + + /// The screen, brought up with `modes` if it is not up yet, else + /// switched to them. + fn up(&mut self, modes: Modes) -> anyhow::Result<()> { + if self.terminal.is_none() { + self.terminal = Some(self.session.terminal_with(modes)?); + } else if modes != self.modes { + self.session.set_modes(modes)?; + } + self.modes = modes; + Ok(()) + } + + /// Run `f`, the command's own work before, between or after its + /// views, on the normal screen, its log records written as they come; + /// the screen is taken back after it, to be drawn whole by the next + /// view. A `kill` meanwhile lets `f` finish and is then + /// [`Tui::stop_requested`]. Before the first view the screen stays + /// down: `f` runs where it is, a `kill` waiting for it all the same. + pub fn work(&mut self, f: impl FnOnce() -> T) -> anyhow::Result { + if !self.session.screen_up() { + return Ok(self.session.stoppable(f)); + } + Ok(self.session.off_screen(f)?) + } +} + +impl Drop for Tui<'_> { + fn drop(&mut self) { + if self.terminal.take().is_some() { + self.session.restore(); + } + } +} + +/// Hand `event` to `screen`: Ctrl-C to [`Screen::interrupt`], other key +/// presses to `on_key`. Whether the screen is drawn again. +fn feed( + screen: &mut S, + on_key: &mut impl FnMut(&mut S, KeyEvent), + event: Event, +) -> bool { + match event { + Event::Key(key) if key.kind != KeyEventKind::Press => false, + Event::Key(key) + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') => + { + screen.interrupt(); + true + } + Event::Key(key) => { + on_key(screen, key); + true + } + Event::Mouse(m) => screen.mouse(m), + Event::Resize(..) => true, + _ => false, + } +} + +/// Run `screen` full screen until it is done, in a plain session of its +/// own (`Session::plain`). The terminal is restored on return and +/// on panic. See [`Tui::run_with`] for the rest. +pub fn run_screen(screen: &mut S) -> anyhow::Result<()> { + run_screen_with(screen, S::handle_key) +} + +/// [`run_screen`], with each key (but Ctrl-C) handed to `on_key` rather +/// than [`Screen::handle_key`]: for a caller with rules of its own about +/// which keys a screen sees. +pub fn run_screen_with( + screen: &mut S, + on_key: impl FnMut(&mut S, KeyEvent), +) -> anyhow::Result<()> { + let session = Session::plain(&super::TOOL); + run_screen_in_with(&session, screen, on_key) +} + +/// [`run_screen`] in `session`: hosted, it keeps running when the +/// connection drops and a `kill` stops it as Ctrl-C does. The screen is +/// handed back on return; [`Tui`] keeps it up across several views. +pub fn run_screen_in(session: &Session, screen: &mut S) -> anyhow::Result<()> { + run_screen_in_with(session, screen, S::handle_key) +} + +/// [`run_screen_in`], with each key (but Ctrl-C) handed to `on_key`. +pub fn run_screen_in_with( + session: &Session, + screen: &mut S, + on_key: impl FnMut(&mut S, KeyEvent), +) -> anyhow::Result<()> { + Tui::new(session).run_with(screen, on_key) } /// Title bar: a reverse-video badge naming the view, then plain text. @@ -240,6 +340,24 @@ pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> { ]) } +/// Title bar: `title` (a [`header`]) with the session keys, `keys` +/// ([`super::session_keys`]), at its right end. The keys stay whole however +/// narrow the terminal, the title giving way, so the detach key is always +/// on screen. +pub fn title_bar(frame: &mut Frame, area: Rect, title: Line<'static>, keys: &[(String, String)]) { + if keys.is_empty() { + frame.render_widget(title, area); + return; + } + let pairs: Vec<_> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect(); + let keys = help_line(&pairs); + let width = (keys.width() as u16).min(area.width); + let [left, right] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]).areas(area); + frame.render_widget(title, left); + frame.render_widget(keys, right); +} + /// Rounded panel titled `title`: plain border and accent title when /// `focused`, dim border otherwise. pub fn panel(title: String, focused: bool) -> Block<'static> { diff --git a/src/main.rs b/src/main.rs index 4aedc7d..a9ee0a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,14 +46,22 @@ use crate::handlers::transformation::{ }; use clap::{Parser, Subcommand}; +use interactive::{session_help, TOOL}; fn main() -> anyhow::Result<()> { + // A session's host runs this binary again: it serves the session and + // exits here, before any of data-beans' own work. + legume_tui::serve_if_host(); + let cli = Cli::parse(); if cli.verbose { std::env::set_var("RUST_LOG", "info"); } - env_logger::init(); + // Errors only by default, as env_logger has it; records raised while a + // view's screen is up are held back until the normal screen is back, + // rather than written over the view. + data_beans::aux::logging::init_logger_with("error"); if let Some(n) = cli.n_threads { if n == 0 { @@ -71,7 +79,28 @@ fn main() -> anyhow::Result<()> { // before we finished writing. This is expected, not an error. std::process::exit(0); } - other => other, + Err(e) => { + hold_error(&e); + Err(e) + } + ok => ok, + } +} + +/// Hold `error`, which ends the command, where it will be seen when its +/// session runs detached: drawn on its screen until a terminal attaches +/// and a key is pressed, else left for whoever looks next. Attached or +/// plain, the error is said as usual, on return. +fn hold_error(error: &anyhow::Error) { + let Some(session) = legume_tui::current().filter(|s| s.is_hosted() && !s.attached()) else { + return; + }; + match session.terminal() { + Ok(mut terminal) => { + let _ = session.hold_error(&mut terminal, error); + session.restore(); + } + Err(_) => session.leave(&format!("{error:#}")), } } @@ -174,6 +203,10 @@ fn run(cli: &Cli) -> anyhow::Result<()> { Commands::MergeBackend(args) => { run_merge_backend(args)?; } + Commands::Attach(args) => legume_tui::cli::attach(&TOOL, args)?, + Commands::Sessions => legume_tui::cli::sessions(&TOOL)?, + Commands::Kill(args) => legume_tui::cli::kill(&TOOL, args)?, + Commands::Rename(args) => legume_tui::cli::rename(&TOOL, args)?, } Ok(()) @@ -367,20 +400,34 @@ enum Commands { #[command( about = "Take columns and output dense matrix", visible_aliases = ["take-columns"], - long_about = "Take columns from the sparse matrix,\n\ - and save them to an `output` file as a dense matrix,\n\ - for quick examination.\n\ - Useful for extracting subsets for visualization or analysis." + long_about = format!( + "Take columns from the sparse matrix,\n\ + and save them to an `output` file as a dense matrix,\n\ + for quick examination.\n\ + Useful for extracting subsets for visualization or analysis.\n\ + \n\ + {}", + interactive_session_help(STDOUT_IN_A_SESSION) + ), + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] Columns(TakeColumnsArgs), #[command( about = "Take rows and output dense matrix (transposed)", visible_aliases = ["take-rows"], - long_about = "Take rows from the sparse matrix,\n\ - and save them to an `output` file as a dense matrix,\n\ - for quick examination. For convenience,\n\ - it will output a transposed (`column x selected_row`) matrix." + long_about = format!( + "Take rows from the sparse matrix,\n\ + and save them to an `output` file as a dense matrix,\n\ + for quick examination. For convenience,\n\ + it will output a transposed (`column x selected_row`) matrix.\n\ + \n\ + {}", + interactive_session_help(STDOUT_IN_A_SESSION) + ), + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] Rows(TakeRowsArgs), @@ -400,24 +447,48 @@ enum Commands { #[command( about = "Pick rows and columns in a full-screen table and subset to them", - long_about = "Open the statistics explorer on the columns (Tab switches to the rows).\n\ - Mark what to keep with Space (`a` marks everything a filter shows),\n\ - then press Enter to write a new backend in the input's format.\n\ - A side with nothing marked is kept whole; q quits without writing." + long_about = format!( + "Open the statistics explorer on the columns (Tab switches to the rows).\n\ + Mark what to keep with Space (`a` marks everything a filter shows),\n\ + then press Enter to write a new backend in the input's format.\n\ + A side with nothing marked is kept whole; q quits without writing.\n\ + \n\ + {}", + session_help( + None, + "A `kill` quits the explorer as q does, writing nothing; \ + a write under way finishes first. The title bar shows the detach \ + and switch keys, the footer the others." + ) + ) )] Subset(SubsetArgs), #[command( about = "Subset columns and create new backend", - long_about = "Take columns from the sparse matrix and create a new sparse matrix backend.\n\ - Allows for focused analysis on selected features." + long_about = format!( + "Take columns from the sparse matrix and create a new sparse matrix backend.\n\ + Allows for focused analysis on selected features.\n\ + \n\ + {}", + interactive_session_help("") + ), + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] SubsetColumns(SubsetColumnsArgs), #[command( about = "Subset rows and create new backend", - long_about = "Take rows from the sparse matrix and create a new sparse matrix backend.\n\ - Allows for focused analysis on selected samples/observations." + long_about = format!( + "Take rows from the sparse matrix and create a new sparse matrix backend.\n\ + Allows for focused analysis on selected samples/observations.\n\ + \n\ + {}", + interactive_session_help("") + ), + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] SubsetRows(SubsetRowsArgs), @@ -531,8 +602,21 @@ enum Commands { #[command( about = "Squeeze out sparse rows/columns", - long_about = "Squeeze out rows and columns with too few non-zeros.\n\ - It will overwrite the original (be careful) and save the indices kept." + long_about = format!( + "Squeeze out rows and columns with too few non-zeros.\n\ + It will overwrite the original (be careful) and save the indices kept.\n\ + \n\ + {}", + session_help( + Some("With --interactive, every file is squeezed in one session; \ + the session flags need it."), + "A `kill` cancels the picker as q does; a squeeze under way finishes \ + first, and the files after it are left as they are. \ + The title bar shows the detach and switch keys, the footer the others." + ) + ), + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] Squeeze(RunSqueezeArgs), @@ -545,11 +629,18 @@ enum Commands { #[command( about = "Take matrix statistics", - long_about = "Take basic statistics from a sparse matrix.\n\ - The output file will contain columns of (1) `nnz` - number of non-zero elements,\n\ - (2) `tot` - total sum, (3) `mu` - average `μ`,\n\ - (4) `sig` - standard deviation `σ`.", - visible_alias = "stat" + long_about = format!( + "Take basic statistics from a sparse matrix.\n\ + The output file will contain columns of (1) `nnz` - number of non-zero elements,\n\ + (2) `tot` - total sum, (3) `mu` - average `μ`,\n\ + (4) `sig` - standard deviation `σ`.\n\ + \n\ + {}", + interactive_session_help("") + ), + visible_alias = "stat", + mut_arg("no_detach", |a| a.requires("interactive")), + mut_arg("session", |a| a.requires("interactive")) )] Statistics(RunStatArgs), @@ -566,4 +657,49 @@ enum Commands { visible_alias = "hist" )] Histogram(RunHistogramArgs), + + #[command( + about = "Return to a detached data-beans view", + long_about = legume_tui::cli::about(&TOOL, legume_tui::cli::QUIT_KEY).attach + )] + Attach(legume_tui::cli::AttachArgs), + + #[command( + about = "List the live data-beans sessions", + long_about = legume_tui::cli::about(&TOOL, legume_tui::cli::QUIT_KEY).sessions + )] + Sessions, + + #[command( + about = "Stop a data-beans session", + long_about = "Stop a `data-beans` session as its quit key does: an open view is\n\ + left as by q, so nothing it would write is written, while a squeeze\n\ + or write under way finishes first; files not reached yet are left\n\ + as they are. Waits up to a minute for it to end. --force kills it\n\ + at once, mid-write if it is writing." + )] + Kill(legume_tui::cli::KillArgs), + + #[command( + about = "Give a data-beans session a label", + long_about = legume_tui::cli::about(&TOOL, legume_tui::cli::QUIT_KEY).rename + )] + Rename(legume_tui::cli::RenameArgs), } + +/// The session paragraph of a command whose view opens with --interactive, +/// `more` added after what a `kill` does. +fn interactive_session_help(more: &str) -> String { + session_help( + Some("With --interactive, its view runs as a session; the session flags need it."), + &format!( + "A `kill` quits the view as q does; a write under way finishes first. {more}\ + The title bar shows the detach and switch keys, the footer the others." + ), + ) +} + +/// What becomes of a result written to stdout in a session. +const STDOUT_IN_A_SESSION: &str = "Written to stdout, the result goes to the session's \ + terminal, and the part written while detached is lost; \ + give -o a file to keep it. "; diff --git a/tests/detach_session.rs b/tests/detach_session.rs new file mode 100644 index 0000000..b2f52e6 --- /dev/null +++ b/tests/detach_session.rs @@ -0,0 +1,407 @@ +//! The interactive views as detachable sessions, driven through +//! pseudo-terminals standing in for the user's: `stat --interactive` +//! starts hosted, a detach leaves it running and asks for a name, an +//! attach from another terminal repaints it, a lost client leaves it +//! running, and `data-beans kill` stops it through its own quit path, +//! leaving no process or socket behind. A kill quits `subset` as `q` does +//! (nothing written), lets a write under way finish (also one before the +//! first view), and stops `squeeze` before the files after it. An error +//! that ends a detached session waits on its screen for whoever attaches. +//! `--no-detach`, and a view started inside a session, run plain. + +#![cfg(all(unix, feature = "tui"))] + +use legume_tui::testing::{alive, Private, Term, PROMPT}; +use legume_tui::Size; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +const EXE: &str = env!("CARGO_BIN_EXE_data-beans"); + +/// Wide enough for every footer key, the session keys at the end. +const SIZE: Size = Size { + rows: 40, + cols: 260, + width: 2600, + height: 800, +}; + +/// A private directory for data-beans' sessions; a session a failed test +/// left running is killed with it. +fn private() -> Private { + Private::new(EXE).patience(Duration::from_secs(30)) +} + +fn term(dir: &Private, args: &[&str], env: &[(&str, &str)]) -> Term { + dir.term_sized(args, env, SIZE) +} + +/// A 6 x 8 backend named `name` in `dir` (a `.zarr` directory), built with +/// the tool itself from a small MatrixMarket file. +fn fixture(dir: &Path, name: &str) -> String { + built(dir, name, false) +} + +/// [`fixture`], zipped (`.zarr.zip`) when `zip`. +fn built(dir: &Path, name: &str, zip: bool) -> String { + let mtx = dir.join(format!("{name}.mtx")); + let rows = dir.join(format!("{name}.rows.tsv")); + let cols = dir.join(format!("{name}.cols.tsv")); + let entries = [ + (1, 1, 3), + (2, 1, 1), + (1, 2, 2), + (3, 3, 5), + (4, 4, 1), + (5, 5, 2), + (6, 6, 7), + (1, 7, 1), + (2, 7, 4), + (3, 8, 2), + (4, 8, 1), + (6, 8, 3), + ]; + let mut text = format!( + "%%MatrixMarket matrix coordinate integer general\n6 8 {}\n", + entries.len() + ); + for (i, j, x) in entries { + text += &format!("{i} {j} {x}\n"); + } + std::fs::write(&mtx, text).unwrap(); + let names = |prefix: &str, n: usize| { + (1..=n) + .map(|i| format!("{prefix}{i}\n")) + .collect::() + }; + std::fs::write(&rows, names("R", 6)).unwrap(); + std::fs::write(&cols, names("C", 8)).unwrap(); + let out = dir.join(name); + let built = Command::new(EXE) + .arg("from-mtx") + .arg(&mtx) + .arg("-r") + .arg(&rows) + .arg("-c") + .arg(&cols) + .arg("-o") + .arg(&out) + .args((!zip).then_some("--no-zip")) + .output() + .unwrap(); + assert!(built.status.success(), "{built:?}"); + let ext = if zip { "zarr.zip" } else { "zarr" }; + format!("{}.{ext}", out.display()) +} + +/// Send process `pid` signal `sig` (`STOP`, `CONT`). +fn signal(pid: u32, sig: &str) { + let sent = Command::new("kill") + .arg(format!("-{sig}")) + .arg(pid.to_string()) + .status() + .unwrap(); + assert!(sent.success(), "kill -{sig} {pid}"); +} + +/// What `data-beans info` says of `file`. +fn info(file: &str) -> String { + let out = Command::new(EXE).args(["info", file]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +#[test] +fn stat_detaches_attaches_and_is_killed_through_its_quit_path() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let data = fixture(work.path(), "data"); + + // Started in a terminal, it is hosted: this terminal is its first + // client, and the footer lists the session keys. + let mut first = term(&dir, &["stat", &data, "-i"], &[]); + first.wait_screen("ctrl+alt+d detach"); + first.wait_screen("ctrl+alt+s switch"); + assert!(!first.cooked(), "the client's terminal is raw"); + let listed = dir.wait_listed("exploring columns of"); + assert!(listed.contains("data-beans-stat-1\tattached"), "{listed:?}"); + let pid = dir.program_pid(); + assert_ne!( + pid, + first.child.id(), + "the program runs apart from its client" + ); + + // Detached (ctrl+alt+d): the client ends, its terminal restored, the + // program runs on; Enter leaves it unnamed. + first.type_(b"\x1b\x04"); + first.answer(1, b"\r"); + assert_eq!(first.exit_code(), 0); + assert!(first.cooked(), "terminal restored after detach"); + first.wait_for("Detached data-beans-stat-1."); + dir.wait_listed("data-beans-stat-1\tdetached"); + assert!(alive(pid)); + + // Started again on the same file: a new session, the detached one + // named; dropped at once. + let mut again = term(&dir, &["stat", &data, "-i"], &[]); + again.wait_for("[detached: data-beans-stat-1"); + again.wait_screen("ctrl+alt+d"); + let said = dir.run(&["kill", "stat-2"]); + assert!(said.contains("data-beans-stat-2 stopped"), "{said:?}"); + assert_eq!(again.exit_code(), 0); + + // Attached from another terminal: back in the alternate screen and + // painted whole there, keys reaching the program. + let mut second = term(&dir, &["attach", "stat-1"], &[]); + second.wait_screen("rows (computed on first use)"); + second.wait_screen("ctrl+alt+d"); + assert!(second.alternate(), "back in the alternate screen"); + second.type_(b"/"); + second.wait_screen("filter /"); + second.type_(b"\x1b"); + // Apart from the next key, or the two read as Alt+Tab. + std::thread::sleep(Duration::from_millis(300)); + // Work a key asks for runs on the normal screen in the session, and + // the view comes back after it. + let before = second.seen_len(); + second.type_(b"\t"); + second.wait_for_after(before, "computing rows statistics"); + second.wait_screen("6 rows"); + second.wait_screen("ctrl+alt+d"); + assert!(second.alternate(), "the view back after the work"); + + // The client lost without a word (a dropped connection): it runs on. + second.child.kill().unwrap(); + second.child.wait().unwrap(); + drop(second); + dir.wait_listed("data-beans-stat-1\tdetached"); + assert!(alive(pid)); + + // Killed while detached: it quits as ctrl+c does, leaving nothing. + let said = dir.run(&["kill", "stat-1"]); + assert!(said.contains("data-beans-stat-1 stopped"), "{said:?}"); + dir.gone(pid); + assert!(dir.sockets().is_empty()); + assert!(dir + .run(&["sessions"]) + .contains("no live data-beans sessions")); +} + +#[test] +fn a_kill_quits_subset_without_writing() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let data = fixture(work.path(), "data"); + let out = work.path().join("picked"); + let out = out.to_string_lossy().into_owned(); + + let mut t = term(&dir, &["subset", &data, "-o", &out], &[]); + t.wait_screen("ctrl+alt+d"); + dir.wait_listed("data-beans-subset-1\tattached"); + let pid = dir.program_pid(); + // Marked, not written: the kill discards it as q would. + t.type_(b" "); + t.wait_screen("1 marked"); + let said = dir.run(&["kill"]); + assert!(said.contains("data-beans-subset-1 stopped"), "{said:?}"); + assert_eq!(t.exit_code(), 0); + assert!(t.cooked(), "terminal restored after the program ended"); + dir.gone(pid); + assert!(dir.sockets().is_empty()); + let left: Vec<_> = std::fs::read_dir(work.path()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("picked")) + .collect(); + assert!(left.is_empty(), "written: {left:?}"); +} + +#[test] +fn a_kill_stops_squeeze_before_the_next_file() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let one = fixture(work.path(), "one"); + let two = fixture(work.path(), "two"); + let (one_before, two_before) = (info(&one), info(&two)); + + // In place, two files, one session; logged so the stop is seen. + let files = format!("{one},{two}"); + let mut t = term(&dir, &["-v", "squeeze", &files, "-i", "-r", "2"], &[]); + t.wait_screen("rows/cols"); + dir.wait_listed("picking nnz cutoffs for"); + let pid = dir.program_pid(); + let said = dir.run(&["kill"]); + assert!(said.contains("data-beans-squeeze-1 stopped"), "{said:?}"); + assert_eq!(t.exit_code(), 0); + // Held while the screen was up and written once the normal screen is + // back, where it stays, rather than lost with the alternate screen. + assert!(!t.alternate(), "the normal screen is back"); + let shown = t.screen(); + assert!(shown.contains("Stopped before"), "{shown}"); + dir.gone(pid); + assert!(dir.sockets().is_empty()); + assert_eq!(info(&one), one_before, "the cancelled file is untouched"); + assert_eq!(info(&two), two_before, "the next file is not reached"); +} + +#[test] +fn a_kill_lets_a_write_under_way_finish() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let data = fixture(work.path(), "data"); + // A pipe the test reads only once the kill is in: the write opening + // it waits for the reader, so the kill lands mid-write. + let out = work.path().join("taken.tsv"); + let made = Command::new("mkfifo").arg(&out).status().unwrap(); + assert!(made.success()); + let out_arg = out.to_string_lossy().into_owned(); + + let mut t = term(&dir, &["-v", "columns", &data, "-I", "-o", &out_arg], &[]); + t.wait_screen("ctrl+alt+d"); + dir.wait_listed("data-beans-columns-1\tattached"); + let pid = dir.program_pid(); + t.type_(b" "); + t.wait_screen("1 marked"); + t.type_(b"\r"); + // Logged as the view ends; written as the write starts, off the screen. + t.wait_screen("picked 1 entries"); + + let killer = std::thread::scope(|s| { + let killer = s.spawn(|| dir.run(&["kill"])); + // The kill in before the write can go on. + std::thread::sleep(Duration::from_secs(1)); + assert!(alive(pid), "waiting on the write"); + let written = std::fs::read_to_string(&out).unwrap(); + let lines: Vec<&str> = written.lines().collect(); + assert_eq!(lines.len(), 7, "a header and every row: {written:?}"); + assert!(lines[0].starts_with("row_name\t"), "{written:?}"); + killer.join().unwrap() + }); + assert!( + killer.contains("data-beans-columns-1 stopped"), + "{killer:?}" + ); + assert_eq!(t.exit_code(), 0); + dir.gone(pid); + assert!(dir.sockets().is_empty()); +} + +#[test] +fn a_kill_before_the_explorer_lets_its_output_file_finish() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let data = fixture(work.path(), "data"); + // The statistics file is written before the explorer comes up; a + // pipe holds that write open until the kill is in. + let out = work.path().join("stats.parquet"); + let made = Command::new("mkfifo").arg(&out).status().unwrap(); + assert!(made.success()); + let out_arg = out.to_string_lossy().into_owned(); + + let mut t = term(&dir, &["stat", &data, "-i", "-o", &out_arg], &[]); + dir.wait_listed("data-beans-stat-1\tattached"); + let pid = dir.program_pid(); + + let killer = std::thread::scope(|s| { + let killer = s.spawn(|| dir.run(&["kill"])); + std::thread::sleep(Duration::from_secs(1)); + assert!(alive(pid), "waiting on the write, not ended by the kill"); + let written = std::fs::read(&out).unwrap(); + assert!( + written.starts_with(b"PAR1") && written.ends_with(b"PAR1"), + "a whole parquet file: {} bytes", + written.len() + ); + killer.join().unwrap() + }); + assert!(killer.contains("data-beans-stat-1 stopped"), "{killer:?}"); + assert_eq!(t.exit_code(), 0); + dir.gone(pid); + assert!(dir.sockets().is_empty()); +} + +#[test] +fn no_detach_and_a_view_inside_a_session_run_plain() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let data = fixture(work.path(), "data"); + + // Opted out by the flag, or started under a session's marker (as a + // hosted program starting another view): in this process, no client + // between, no socket, no session keys; q ends it, terminal restored. + for (args, env) in [ + (vec!["stat", &data, "-i", "--no-detach"], vec![]), + ( + vec!["stat", &data, "-i"], + vec![(legume_tui::SESSION_VAR, "data-beans-stat-9")], + ), + ] { + let mut plain = term(&dir, &args, &env); + plain.wait_screen("rows (computed on first use)"); + assert!(!plain.cooked()); + assert!(dir.sockets().is_empty(), "{args:?} {env:?}"); + assert!(dir + .run(&["sessions"]) + .contains("no live data-beans sessions")); + assert!(!plain.text().contains("ctrl+alt+d"), "no session keys"); + assert!(!plain.text().contains(PROMPT)); + plain.type_(b"q"); + assert_eq!(plain.exit_code(), 0); + assert!(plain.cooked(), "terminal restored"); + } +} + +#[test] +fn an_error_that_ends_a_detached_session_waits_for_whoever_attaches() { + let dir = private(); + let work = tempfile::tempdir().unwrap(); + let one = fixture(work.path(), "one"); + // Squeezed in place, a zipped backend is refused: an error once the + // first file is done. + let two = built(work.path(), "two", true); + let files = format!("{one},{two}"); + + let mut t = term(&dir, &["squeeze", &files, "-i", "-r", "2"], &[]); + t.wait_screen("rows/cols"); + dir.wait_listed("picking nnz cutoffs for"); + let pid = dir.program_pid(); + // The squeeze confirmed and the session left while the program is + // stopped, so it reads the confirmation only once nobody is attached + // and the error comes then, however fast the squeeze. Enter leaves it + // unnamed. + t.type_(b"\r"); + t.wait_screen("Squeeze in place?"); + signal(pid, "STOP"); + t.type_(b"y\x1b\x04"); + t.answer(1, b"\r"); + assert_eq!(t.exit_code(), 0); + dir.wait_listed("data-beans-squeeze-1\tdetached"); + signal(pid, "CONT"); + let listed = dir.wait_listed("stopped: In-place squeeze is not supported"); + assert!( + listed.contains("data-beans-squeeze-1\tdetached"), + "{listed:?}" + ); + assert!(alive(pid), "held until seen"); + + // Attached: the error on the screen until a key, then said as the + // program ends, and not again. + let mut back = term(&dir, &["attach"], &[]); + back.wait_screen("In-place squeeze is not supported"); + back.wait_screen("any key quits"); + back.type_(b"x"); + assert_eq!(back.exit_code(), 1, "the program's status"); + assert!(!back.alternate() && back.cooked(), "terminal restored"); + assert!( + back.screen().contains("Error: In-place squeeze"), + "{}", + back.screen() + ); + dir.gone(pid); + assert!(dir.sockets().is_empty()); + let said = dir.run(&["sessions"]); + assert!(said.contains("no live data-beans sessions"), "{said:?}"); + assert!(!said.contains("In-place squeeze"), "said once: {said:?}"); +}