diff --git a/src/cluster/browse.rs b/src/cluster/browse.rs index 23a9d5d..2c0fd9e 100644 --- a/src/cluster/browse.rs +++ b/src/cluster/browse.rs @@ -2,7 +2,8 @@ //! //! Reads the tables `faba cluster`'s blockmodel writes (see [`data`]), //! fitting them first when they are missing or of other settings than those -//! given (see [`plan`]), and opens a view of four linked levels, each a margin of the next over the posterior-mean reads by gene +//! given (see [`plan`]), in a terminal with the settings asked in a form +//! first (see [`ask`]), and opens a view of four linked levels, each a margin of the next over the posterior-mean reads by gene //! role, site and cell role (see [`view`]): every gene module against every //! cell module, the sites of the module in focus, the genes pinned side by //! side (each in its main gene module), and one gene's full pileup over @@ -15,6 +16,7 @@ use crate::site_analysis::miami::genemodel::load_gene_models_where; use clap::Args; use std::path::Path; +mod ask; mod data; mod gene; mod order; @@ -146,10 +148,6 @@ pub fn run_modules(args: &ModulesArgs) -> anyhow::Result<()> { let dir = super::output_dir(&args.cluster); // A cancelled fit is an error: nothing was written. let Some(modality) = ensure_tables(args, &dir, !batch)? else { - eprintln!( - "faba modules: the tables are written in {}; not opening the modules, as asked", - dir.display() - ); return Ok(()); }; // Shared with the thread that writes the report on `e`. @@ -219,10 +217,13 @@ fn plan(args: &ModulesArgs, dir: &Path) -> anyhow::Result { } /// The modality of the block tables in `dir`: those there (see [`plan`]), -/// else those `faba cluster` writes now (the blocks preset, or the -/// settings given), its chains drawn in a view when `view` (and there is a -/// terminal), where `q` cancels the fit, else on a bar on stderr. None -/// when a cancel came too late to stop the fit: the browser is not to open. +/// else those `faba cluster` writes now. With `view` (and a terminal) the +/// settings are asked first (see [`ask`]), prefilled from those given and +/// the preset, and the chains drawn in a view, where `q` cancels the fit; +/// else the settings given are fitted, on a bar on stderr. Esc on the form +/// opens the old tables, as its footer says, if there are any. None, said +/// so, when the form was left by Ctrl+C or with no tables to open, or a +/// cancel came too late to stop the fit: the browser is not to open. fn ensure_tables(args: &ModulesArgs, dir: &Path, view: bool) -> anyhow::Result> { let why = match plan(args, dir)? { Plan::Open(m) => { @@ -234,13 +235,29 @@ fn ensure_tables(args: &ModulesArgs, dir: &Path, view: bool) -> anyhow::Result why, }; - let cluster = args.blockmodel()?; + let interactive = view && data_beans::interactive::tui_available(); + let title = format!("the blockmodel's settings for {}", dir.display()); + let old = tables(dir, args.cluster.modality.as_deref()); + let leave = match &old { + Some(m) => format!("open the old {m} tables without fitting"), + None => "quit without fitting".to_string(), + }; + let cluster = match ask::chosen(args.blockmodel()?, interactive, |c| { + ask::ask(c, &title, &why, &leave) + })? { + ask::Asked::Fit(cluster) => *cluster, + ask::Asked::Back if old.is_some() => return Ok(old), + ask::Asked::Back | ask::Asked::Quit => { + eprintln!("faba modules: not fitting, as asked"); + return Ok(None); + } + }; log::info!( "{why}: running `faba cluster --model {}` in {} first", cluster.model.name(), dir.display() ); - let fitted = if view && data_beans::interactive::tui_available() { + let fitted = if interactive { let title = format!( "fitting the {} model in {}", cluster.model.name(), @@ -250,6 +267,10 @@ fn ensure_tables(args: &ModulesArgs, dir: &Path, view: bool) -> anyhow::Result [(&'static str, String); 4] { + let s = args::blockmodel(&ClusterArgs { + model, + gene_roles: None, + shift: None, + global: None, + profile_strength: None, + ..base.clone() + }); + let shift = if s.shift_sd.is_some() { + Switch::On + } else { + Switch::Off + }; + let global = match s.weights { + Weights::Hdp(_) => Global::Hdp, + Weights::Symmetric => Global::Symmetric, + }; + [ + ("gene-roles", s.gene_roles.to_string()), + ("shift", name(shift)), + ("global", name(global)), + ("profile-strength", s.strength.to_string()), + ] +} + +/// A value-enum's name, as its flag takes it. +fn name(v: impl clap::ValueEnum) -> String { + v.to_possible_value() + .map(|p| p.get_name().to_string()) + .unwrap_or_default() +} + +/// What the form was left with. +pub(super) enum Asked { + /// Ctrl+R: fit with these. + Fit(Box), + /// Esc or `q`: no fit; on to what the footer named. + Back, + /// Ctrl+C: no fit, and nothing after. + Quit, +} + +/// Whether `a` and `b` read as the same number. +fn same_number(a: &str, b: &str) -> bool { + matches!((a.parse::(), b.parse::()), (Ok(x), Ok(y)) if x == y) +} + +/// The form and what came of it. +pub(super) struct FitForm { + /// The settings the form starts from: the inputs and every option not + /// asked are taken from here as they are. + base: ClusterArgs, + /// `faba cluster`'s definition, built: the rows' source and their check. + cmd: Command, + form: Form, + /// Per row: given on the command line, or changed here; a preset + /// switch refills only the rows that are not. + touched: Vec, + at: usize, + /// The row being typed into. + editing: Option, + /// The settings the rows stand for, or why they cannot be fitted. + checked: Result, + title: String, + /// Why the fit is to run. + why: String, + note: Option, + /// What Esc leaves to, as the footer says it. + leave: String, + outcome: Option, +} + +impl FitForm { + /// The form over `base` (a blockmodel's settings), under `title`, the + /// fit asked for because of `why`; Esc leaves to `leave`. + pub(super) fn new(base: &ClusterArgs, title: &str, why: &str, leave: &str) -> Self { + let mut cmd = definition(); + cmd.build(); + let mut form = Form::only(&cmd, &ROWS); + let preset = preset(base, base.model); + let mut touched = Vec::new(); + for f in &mut form.fields { + if let Kind::Choice(values) = &mut f.kind { + // Unset leaves it to the preset, which the row shows; the + // mixture writes no modules. + values.retain(|v| !v.is_empty() && *v != ModelKind::Crp.name()); + } + if f.long == "model" { + f.default = ModelKind::Blocks.name(); + } + if let Some((_, v)) = preset.iter().find(|(l, _)| *l == f.long) { + f.default.clone_from(v); + } + let given = base.given(&f.id); + f.value = if f.long == "model" { + base.model.name() + } else if given { + let words = value_words(base, &f.id).join(","); + // `1.0` given where the default reads `1` is the default. + if same_number(&words, &f.default) { + f.default.clone() + } else { + words + } + } else { + f.default.clone() + }; + touched.push(given); + } + let mut view = FitForm { + base: base.clone(), + cmd, + form, + touched, + at: 0, + editing: None, + checked: Err(String::new()), + title: title.to_string(), + why: why.to_string(), + note: None, + leave: leave.to_string(), + outcome: None, + }; + view.refresh(); + view + } + + /// The value of row `long`, as it stands. + #[cfg(test)] + pub(super) fn value(&self, long: &str) -> &str { + self.form.get(long).map_or("", |f| f.value.as_str()) + } + + /// The settings Ctrl+R took; none when the form was left without. + #[cfg(test)] + pub(super) fn chosen(&self) -> Option { + match &self.outcome { + Some(Asked::Fit(args)) => Some(*args.clone()), + _ => None, + } + } + + /// What the form was left with; none while it is open. + #[cfg(test)] + pub(super) fn outcome(&self) -> Option<&Asked> { + self.outcome.as_ref() + } + + /// The settings `form`'s rows stand for, `faba cluster`'s checks + /// passed; the complaint if not. + fn settings(&self, form: &Form) -> Result { + let mut argv = command_except(&self.base, &ROWS); + for (f, &touched) in form.fields.iter().zip(&self.touched) { + // The model always: `faba cluster`'s default is not ours. + if touched || !f.is_default() || f.long == "model" { + argv.extend(f.line()); + } + } + let m = parse(&self.cmd, &argv)?; + let mut args = ClusterArgs::from_arg_matches(&m).map_err(|e| e.to_string())?; + args.record_given(&m); + args::check(&args).map_err(|e| format!("{e:#}"))?; + Ok(args) + } + + /// Check the rows again, with the text being typed in its row. + fn refresh(&mut self) { + let typed = self.editing.as_ref().and_then(LineInput::text); + self.checked = match typed { + Some(text) => { + let mut form = self.form.clone(); + form.fields[self.at].set_text(text); + self.settings(&form) + } + None => self.settings(&self.form), + }; + } + + /// The model row was changed: the rows its preset sets follow it, + /// unless given or changed here. + fn follow_preset(&mut self) { + let model = match self.form.get("model").map(|f| f.value.as_str()) { + Some(v) if v == ModelKind::Link.name() => ModelKind::Link, + _ => ModelKind::Blocks, + }; + for (long, value) in preset(&self.base, model) { + let Some(i) = self.form.fields.iter().position(|f| f.long == long) else { + continue; + }; + let f = &mut self.form.fields[i]; + if !self.touched[i] { + f.value.clone_from(&value); + } + f.default = value; + } + } + + /// Row `at`'s choice stepped by `d`. + fn cycle(&mut self, d: isize) { + let f = &mut self.form.fields[self.at]; + if !matches!(f.kind, Kind::Choice(_)) { + return; + } + f.cycle(d); + self.touched[self.at] = true; + if f.long == "model" { + self.follow_preset(); + } + } + + /// The text being typed, set in its row. + fn commit(&mut self, text: &str) { + self.editing = None; + self.form.fields[self.at].set_text(text); + self.touched[self.at] = true; + } + + fn key_editing(&mut self, key: KeyEvent) { + let Some(line) = self.editing.as_mut() else { + return; + }; + match line.handle(key) { + Edit::Typing => {} + Edit::Cancelled => self.editing = None, + Edit::Submitted(text) => self.commit(&text), + } + } + + fn key_rows(&mut self, key: KeyEvent) { + let last = self.form.fields.len().saturating_sub(1); + let nav = match key.code { + KeyCode::Up | KeyCode::Char('k') => Some(-1), + KeyCode::Down | KeyCode::Char('j') => Some(1), + KeyCode::PageUp => Some(-10), + KeyCode::PageDown => Some(10), + _ => None, + }; + if let Some(d) = nav { + self.at = moved(self.at, d, last); + return; + } + let choice = matches!(self.form.fields[self.at].kind, Kind::Choice(_)); + match key.code { + KeyCode::Home => self.at = 0, + KeyCode::End => self.at = last, + KeyCode::Left => self.cycle(-1), + KeyCode::Right | KeyCode::Char(' ') => self.cycle(1), + KeyCode::Enter if choice => self.cycle(1), + KeyCode::Enter => { + let mut line = LineInput::new(MAX_TYPED); + line.open(&self.form.fields[self.at].value); + self.editing = Some(line); + } + KeyCode::Char('r') => { + self.form.fields[self.at].reset(); + self.touched[self.at] = false; + if self.form.fields[self.at].long == "model" { + self.follow_preset(); + } + } + KeyCode::Esc | KeyCode::Char('q') => self.outcome = Some(Asked::Back), + _ => {} + } + } + + /// The rows, the one in focus marked, a reason beside the row it + /// blames. + fn rows(&self) -> Vec> { + let complaint = self.checked.as_ref().err(); + let blamed = complaint.and_then(|c| blamed(c, &self.form.fields)); + let typed = self.editing.as_ref().and_then(LineInput::text); + self.form + .fields + .iter() + .enumerate() + .map(|(i, f)| { + let shown = match (typed, &f.kind) { + (Some(t), _) if i == self.at => format!("{t}▏"), + (_, Kind::Choice(_)) => format!("‹ {} ›", f.shown()), + _ => f.shown(), + }; + let said = complaint + .map(String::as_str) + .filter(|_| blamed == Some(f.long.as_str())); + row_line(f, i == self.at, 20, PLAIN, shown, said) + }) + .collect() + } + + /// Whether the fit can start, or why not. + fn status(&self) -> Line<'static> { + if let Some(note) = &self.note { + return Line::styled(note.clone(), ACCENTED); + } + match &self.checked { + Ok(args) => Line::styled( + format!( + "{APPLY_KEYS} fits the {} model with these settings", + args.model.name() + ), + DIM, + ), + Err(why) => Line::styled(format!("cannot fit: {why}"), ACCENTED), + } + } + + fn footer(&self) -> Line<'static> { + if let Some(text) = self.editing.as_ref().and_then(LineInput::text) { + let prompt = format!("--{} ", self.form.fields[self.at].long); + return input_line(&prompt, text, &[("Enter", "set"), ("Esc", "cancel")]); + } + let fit = if self.checked.is_ok() { + "fit" + } else { + "fit (blocked)" + }; + help_line(&[ + ("↑/↓", "move"), + ("←/→/Space", "choice"), + ("Enter", "type"), + ("r", "reset"), + (APPLY_KEYS, fit), + ("Esc", &self.leave), + ("Ctrl+C", "quit"), + ]) + } +} + +impl View for FitForm { + fn stray_enter(&mut self) { + self.note = Some(format!( + "only a plain Enter acts here: Enter types or steps a row; {APPLY_KEYS} fits" + )); + } + + fn takes_apply(&self) -> bool { + true + } +} + +impl Screen for FitForm { + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + let help = crate::tui::wrap_help(self.footer(), area.width); + let [top, body, foot] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(help.len() as u16), + ]) + .areas(area); + frame.render_widget(header("modules", &self.title, &self.why), top); + let changed = self.form.changed(); + let block = panel(format!(" fit settings · {changed} changed "), true); + let inner = block.inner(body); + frame.render_widget(block, body); + let [list, about, status] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(5), + Constraint::Length(2), + ]) + .areas(inner); + let rows = list.height as usize; + let lines = self.rows(); + frame.render_widget(Paragraph::new(window(lines, self.at, rows)), list); + if let Some(f) = self.form.fields.get(self.at) { + let mut text = vec![Line::styled(format!("--{}", f.long), HIGHLIGHT)]; + text.extend(f.long_help.lines().map(|l| Line::raw(l.trim().to_string()))); + frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), about); + } + frame.render_widget( + Paragraph::new(self.status()).wrap(Wrap { trim: true }), + status, + ); + frame.render_widget(Paragraph::new(help), foot); + } + + fn handle_key(&mut self, key: KeyEvent) { + self.note = None; + if is_apply(&key) { + // The text being typed counts: set it, then fit. + if let Some(text) = self.editing.as_ref().and_then(LineInput::text) { + let text = text.to_string(); + self.commit(&text); + self.refresh(); + } + match &self.checked { + Ok(args) => self.outcome = Some(Asked::Fit(Box::new(args.clone()))), + Err(why) => self.note = Some(format!("{APPLY_KEYS} is blocked: {why}")), + } + } else if self.editing.is_some() { + self.key_editing(key); + } else { + self.key_rows(key); + } + self.refresh(); + } + + fn interrupt(&mut self) { + self.outcome = Some(Asked::Quit); + } + + fn done(&self) -> bool { + self.outcome.is_some() + } +} + +/// Ask the settings to fit `base` with in the form, under `title`, the fit +/// asked for because of `why`, Esc leaving to `leave`. +pub(super) fn ask( + base: &ClusterArgs, + title: &str, + why: &str, + leave: &str, +) -> anyhow::Result { + let mut view = FitForm::new(base, title, why, leave); + crate::tui::run_view(&mut view)?; + Ok(view.outcome.unwrap_or(Asked::Quit)) +} + +/// The settings to fit: `args` as they are unless `interactive`, else what +/// `ask` asks. +pub(super) fn chosen( + args: ClusterArgs, + interactive: bool, + ask: impl FnOnce(&ClusterArgs) -> anyhow::Result, +) -> anyhow::Result { + if interactive { + ask(&args) + } else { + Ok(Asked::Fit(Box::new(args))) + } +} diff --git a/src/cluster/browse/tests/ask.rs b/src/cluster/browse/tests/ask.rs new file mode 100644 index 0000000..f0def71 --- /dev/null +++ b/src/cluster/browse/tests/ask.rs @@ -0,0 +1,193 @@ +//! The form `faba modules` asks the fit's settings in: prefilled from the +//! flags and the preset, refilled on a preset switch, checked as typed, and +//! what Ctrl+R and Esc leave. + +use super::model::modules; +use super::*; +use crate::cluster::browse::ask::{self, Asked, FitForm, ROWS}; +use crate::tui::{apply_key, feed, screen_text}; + +/// The form over `words`' settings, as `faba modules` would ask them. +fn form(words: &[&str]) -> FitForm { + let tmp = tempfile::tempdir().unwrap(); + let args = modules(tmp.path(), words).blockmodel().unwrap(); + FitForm::new(&args, "fitting", "no block tables", "open the old tables") +} + +fn press(view: &mut FitForm, code: KeyCode) { + feed(view, KeyEvent::new(code, KeyModifiers::NONE)); +} + +/// Move to the row of `long`. +fn go(view: &mut FitForm, long: &str) { + press(view, KeyCode::Home); + let at = ROWS.iter().position(|r| *r == long).unwrap(); + (0..at).for_each(|_| press(view, KeyCode::Down)); +} + +/// Type `text` over the row of `long`. +fn type_into(view: &mut FitForm, long: &str, text: &str) { + go(view, long); + press(view, KeyCode::Enter); + (0..32).for_each(|_| press(view, KeyCode::Backspace)); + text.chars().for_each(|c| press(view, KeyCode::Char(c))); + press(view, KeyCode::Enter); +} + +#[test] +fn the_form_is_prefilled_from_the_flags_then_the_preset() { + let mut view = form(&["--chains", "2", "--gene-roles", "4"]); + for (long, value) in [ + ("model", "blocks"), + ("chains", "2"), + ("gene-roles", "4"), + ("shift", "on"), + ("profile-strength", "inf"), + ("global", "symmetric"), + ("sweeps", "150"), + ("cell-roles", "8"), + ] { + assert_eq!(view.value(long), value, "--{long}"); + } + let shown = screen_text(&mut view, 100, 30); + for long in ROWS { + assert!(shown.contains(long), "--{long} not shown:\n{shown}"); + } + assert!(shown.contains("Ctrl+R") && shown.contains("Esc"), "{shown}"); +} + +#[test] +fn a_preset_switch_refills_only_the_untouched_rows() { + let mut view = form(&["--gene-roles", "4"]); + type_into(&mut view, "cell-sd", "0.5"); + go(&mut view, "model"); + press(&mut view, KeyCode::Right); + assert_eq!(view.value("model"), "link"); + assert_eq!(view.value("shift"), "off"); + assert_eq!(view.value("profile-strength"), "5"); + assert_eq!(view.value("global"), "hdp"); + // Given on the line, and typed: kept. + assert_eq!(view.value("gene-roles"), "4"); + assert_eq!(view.value("cell-sd"), "0.5"); +} + +#[test] +fn an_invalid_combination_is_said_and_blocks_the_fit() { + let mut view = form(&[]); + go(&mut view, "shift"); + press(&mut view, KeyCode::Right); + assert_eq!(view.value("shift"), "off"); + let shown = screen_text(&mut view, 120, 30); + assert!(shown.contains("need the block shift"), "{shown}"); + feed(&mut view, apply_key()); + assert!(!view.done()); + // The blocked note says why in full, not only beside the row. + let shown = screen_text(&mut view, 120, 30); + let note = shown + .lines() + .find(|l| l.contains("is blocked")) + .unwrap_or(""); + assert!(note.contains("need the block shift"), "{shown}"); + // Back on: the fit can start. + press(&mut view, KeyCode::Right); + feed(&mut view, apply_key()); + assert!(view.done()); +} + +#[test] +fn ctrl_r_fits_with_the_values_edited() { + let mut view = form(&[]); + type_into(&mut view, "chains", "2"); + type_into(&mut view, "profile-strength", "10"); + // Enter never applies: it only types. + assert!(!view.done()); + feed(&mut view, apply_key()); + assert!(view.done()); + let args = view.chosen().unwrap(); + assert_eq!(args.chains, 2); + assert_eq!(args.model, ModelKind::Blocks); + assert_eq!(args.profile_strength.map(|s| s.0), Some(10.0)); + assert!(args.given("chains")); + let saved = crate::cluster::command::command(&args); + let at = saved.iter().position(|w| w == "--chains").unwrap(); + assert_eq!(saved[at + 1], "2", "{saved:?}"); + assert!(saved.contains(&"blocks".to_string()), "{saved:?}"); +} + +#[test] +fn esc_leaves_without_settings() { + let mut view = form(&[]); + press(&mut view, KeyCode::Esc); + assert!(view.done()); + assert!(view.chosen().is_none()); + assert!(matches!(view.outcome(), Some(Asked::Back))); +} + +#[test] +fn ctrl_c_quits_rather_than_going_back() { + let mut view = form(&[]); + view.interrupt(); + assert!(view.done()); + assert!(matches!(view.outcome(), Some(Asked::Quit))); +} + +#[test] +fn the_footer_says_what_esc_leaves_to() { + let mut view = form(&[]); + let shown = screen_text(&mut view, 160, 30); + assert!(shown.contains("open the old tables"), "{shown}"); + assert!(shown.contains("Ctrl+C"), "{shown}"); +} + +#[test] +fn ctrl_r_while_typing_fits_with_the_text() { + let mut view = form(&[]); + go(&mut view, "chains"); + press(&mut view, KeyCode::Enter); + (0..32).for_each(|_| press(&mut view, KeyCode::Backspace)); + press(&mut view, KeyCode::Char('2')); + feed(&mut view, apply_key()); + assert!(view.done()); + assert_eq!(view.chosen().unwrap().chains, 2); +} + +#[test] +fn a_flag_given_at_its_default_is_not_marked_changed() { + let mut view = form(&[ + "--cell-sd", + "1", + "--model", + "link", + "--profile-strength", + "5", + ]); + assert_eq!(view.value("cell-sd"), "1"); + assert_eq!(view.value("profile-strength"), "5"); + let shown = screen_text(&mut view, 120, 30); + assert!(shown.contains("1 changed"), "{shown}"); +} + +#[test] +fn a_held_enter_says_what_plain_enter_does() { + let mut view = form(&[]); + feed( + &mut view, + KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), + ); + let shown = screen_text(&mut view, 120, 30); + assert!(!shown.contains("Enter does nothing"), "{shown}"); + assert!(shown.contains("Enter types"), "{shown}"); +} + +#[test] +fn batch_mode_skips_the_form() { + let tmp = tempfile::tempdir().unwrap(); + let args = modules(tmp.path(), &["--chains", "3"]) + .blockmodel() + .unwrap(); + let kept = ask::chosen(args, false, |_| panic!("the form was shown")).unwrap(); + let Asked::Fit(kept) = kept else { + panic!("not fitted") + }; + assert_eq!(kept.chains, 3); +} diff --git a/src/cluster/browse/tests/mod.rs b/src/cluster/browse/tests/mod.rs index dab2fe2..d29ea54 100644 --- a/src/cluster/browse/tests/mod.rs +++ b/src/cluster/browse/tests/mod.rs @@ -11,6 +11,7 @@ use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; use ratatui::Terminal; +mod ask; mod data; mod gene; mod model; diff --git a/src/cluster/browse/tests/model.rs b/src/cluster/browse/tests/model.rs index abe5444..bec1b11 100644 --- a/src/cluster/browse/tests/model.rs +++ b/src/cluster/browse/tests/model.rs @@ -256,7 +256,7 @@ fn an_older_shared_record_is_still_read() { /// `words` after `faba modules `, parsed as it parses them, what was /// given recorded. -fn modules(dir: &Path, words: &[&str]) -> ModulesArgs { +pub(super) fn modules(dir: &Path, words: &[&str]) -> ModulesArgs { use clap::{Args, FromArgMatches}; let dir = dir.to_string_lossy(); let all = ["faba", &dir].into_iter().chain(words.iter().copied()); diff --git a/src/cluster/command.rs b/src/cluster/command.rs index 1478d91..de0f3c5 100644 --- a/src/cluster/command.rs +++ b/src/cluster/command.rs @@ -13,7 +13,7 @@ use serde_json::{Map, Value}; pub struct Given(FxHashSet); /// `faba cluster`'s options alone, as clap defines them. -fn definition() -> clap::Command { +pub(super) fn definition() -> clap::Command { ClusterArgs::augment_args(clap::Command::new("cluster")) } @@ -70,10 +70,21 @@ fn words(v: &Value) -> Vec { } } +/// The words option `id` (its clap id) has in `args`, as the saved +/// command says them: none when unset. +pub(super) fn value_words(args: &ClusterArgs, id: &str) -> Vec { + fields(args).get(id).map(words).unwrap_or_default() +} + /// `args` as a `faba cluster` command: its inputs as absolute paths, then /// each option given or away from its default, by the flag clap names it, /// with the value this run had, paths among them absolute too. pub(super) fn command(args: &ClusterArgs) -> Vec { + command_except(args, &[]) +} + +/// [`command`] without the options whose long flags are in `skip`. +pub(super) fn command_except(args: &ClusterArgs, skip: &[&str]) -> Vec { let cmd = definition(); let ran = fields(args); let default = cmd @@ -94,6 +105,9 @@ pub(super) fn command(args: &ClusterArgs) -> Vec { let (Some(long), Some(value)) = (arg.get_long(), ran.get(id)) else { continue; }; + if skip.contains(&long) { + continue; + } let changed = default.get(id) != Some(value); let flag = format!("--{long}"); if !arg.get_action().takes_values() { diff --git a/src/pipeline/tui.rs b/src/pipeline/tui.rs index 8622e2d..eacfc34 100644 --- a/src/pipeline/tui.rs +++ b/src/pipeline/tui.rs @@ -23,7 +23,7 @@ use crate::figure::LineInput; use crate::pipeline::args::PipelineArgs; use crate::tui::View; use child::{Failed, Progress, Said, Stopper}; -use form::{Form, OWN}; +use form::Form; use inputs::{Browser, FileRow, Inputs, Picked, Role}; use steps::Steps; @@ -186,7 +186,7 @@ impl App { page: Page::Inputs, inputs: Inputs::new(cwd), steps: Steps::default(), - form: Form::new(&run_cmd, OWN), + form: form::flags(&run_cmd), run_cmd, preview: false, editing: None, diff --git a/src/pipeline/tui/draw.rs b/src/pipeline/tui/draw.rs index 6287507..b9f591e 100644 --- a/src/pipeline/tui/draw.rs +++ b/src/pipeline/tui/draw.rs @@ -352,31 +352,15 @@ fn draw_flags(app: &App, frame: &mut Frame, body: Rect) { if Some(i) == sel { sel_line = lines.len(); } - let changed = !f.is_default(); let style = if dim { DIM } else { PLAIN }; let shown = match f.kind { Kind::Choice(_) => format!("‹ {} ›", f.shown()), _ => f.shown(), }; - let mut spans = vec![ - marker(Some(i) == sel), - Span::styled(if changed { "* " } else { " " }, ACCENTED), - Span::styled(format!("{:<24}", f.long), style), - Span::styled(format!("{shown:<16}"), style), - ]; - if changed { - let d = if f.default.is_empty() { - "(unset)" - } else { - &f.default - }; - spans.push(Span::styled(format!(" (default {d})"), DIM)); - } - if blamed == Some(f.long.as_str()) { - let c = complaint.as_deref().unwrap_or_default(); - spans.push(Span::styled(format!(" {c}"), ACCENTED)); - } - lines.push(Line::from(spans)); + let said = complaint + .as_deref() + .filter(|_| blamed == Some(f.long.as_str())); + lines.push(form::row_line(f, Some(i) == sel, 24, style, shown, said)); flag_of.push(visible.iter().position(|&v| v == i)); } } diff --git a/src/pipeline/tui/form.rs b/src/pipeline/tui/form.rs index 35bfac0..325bbc8 100644 --- a/src/pipeline/tui/form.rs +++ b/src/pipeline/tui/form.rs @@ -1,12 +1,8 @@ -//! The flags of `faba run` as rows of a form, read from its clap -//! definition, and the command line the filled form stands for. -//! -//! Nothing here names a flag of the pipeline apart from [`OWN`] and -//! [`OWNED_HEADINGS`]: every row comes from the command's `Arg`s, so a flag -//! added later shows up on its own. +//! The form of `faba run`'s flags: every flag but those the Inputs and +//! Steps screens own (see [`crate::tui::form`] for the rows). -use crate::script::{emit, many_of, Many}; -use clap::{Arg, ArgAction, Command}; +pub use crate::tui::form::*; +use clap::{Arg, Command}; /// Flags with no help heading that are never passed. The Inputs and Steps /// screens own every flag under [`OWNED_HEADINGS`]. @@ -15,289 +11,17 @@ pub const OWN: &[&str] = &["batch-process", "help", "version", "verbose"]; /// The help headings whose flags the Inputs and Steps screens own. pub const OWNED_HEADINGS: &[&str] = &["Inputs", "Steps"]; -/// What a row holds and how it is changed. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Kind { - /// A switch, given or not. `on` is the value it sets when given. - Flag { on: bool }, - /// One of a fixed set of values; `""` stands for unset. - Choice(Vec), - /// Free text. - Text, +/// Whether the Flags screen shows `a`: not one of [`OWN`], nor under +/// [`OWNED_HEADINGS`]. +fn shown(a: &Arg) -> bool { + let own = a.get_long().is_some_and(|l| OWN.contains(&l)); + let heading = a.get_help_heading().unwrap_or(""); + !own && !OWNED_HEADINGS.contains(&heading) } -/// One flag of the command. -#[derive(Clone, Debug)] -pub struct Field { - /// clap's id for the argument. - pub id: String, - /// The long name, without `--`. - pub long: String, - pub long_help: String, - /// The help heading the flag is listed under; `""` for none. - pub heading: String, - pub kind: Kind, - /// The value clap would use when the flag is not given; `""` for none. - pub default: String, - pub value: String, - /// Hidden from `--help`: shown only with the advanced rows. - pub advanced: bool, - many: Many, -} - -impl Field { - fn from_arg(a: &Arg, own: &[&str]) -> Option { - let long = a.get_long()?.to_string(); - let heading = a.get_help_heading().unwrap_or(""); - if own.contains(&long.as_str()) || OWNED_HEADINGS.contains(&heading) { - return None; - } - let kind = match a.get_action() { - ArgAction::SetTrue => Kind::Flag { on: true }, - ArgAction::SetFalse => Kind::Flag { on: false }, - ArgAction::Set | ArgAction::Append => { - let values: Vec = a - .get_possible_values() - .iter() - .filter(|v| !v.is_hide_set()) - .map(|v| v.get_name().to_string()) - .collect(); - if values.is_empty() { - Kind::Text - } else { - Kind::Choice(values) - } - } - // Counters, help and version: not something to fill in. - _ => return None, - }; - let many = many_of(a); - let sep = match many { - Many::Joined(c) => c.to_string(), - _ => ",".to_string(), - }; - let default = a - .get_default_values() - .iter() - .map(|v| v.to_string_lossy().into_owned()) - .collect::>() - .join(&sep); - let mut kind = kind; - if let Kind::Choice(values) = &mut kind { - // A choice with no default can stay unset. - if default.is_empty() { - values.insert(0, String::new()); - } - } - Some(Field { - id: a.get_id().to_string(), - long_help: a - .get_long_help() - .or_else(|| a.get_help()) - .map(ToString::to_string) - .unwrap_or_default(), - heading: heading.to_string(), - value: default.clone(), - default, - advanced: a.is_hide_set(), - kind, - many, - long, - }) - } - - /// Whether the value is the one clap would use anyway. - #[must_use] - pub fn is_default(&self) -> bool { - self.value.trim() == self.default - } - - /// Back to what clap would use. - pub fn reset(&mut self) { - self.value.clone_from(&self.default); - } - - /// Typed text; empty means back to the default. - pub fn set_text(&mut self, text: &str) { - let t = text.trim(); - if t.is_empty() { - self.reset(); - } else { - self.value = t.to_string(); - } - } - - /// Flip a switch. - pub fn toggle(&mut self) { - if let Kind::Flag { .. } = self.kind { - self.value = if self.value == "true" { - "false" - } else { - "true" - } - .into(); - } - } - - /// Step a choice by `d`, wrapping around. - pub fn cycle(&mut self, d: isize) { - if let Kind::Choice(values) = &self.kind { - let n = values.len() as isize; - let at = values.iter().position(|v| *v == self.value).unwrap_or(0) as isize; - self.value - .clone_from(&values[(at + d).rem_euclid(n) as usize]); - } - } - - /// The value as shown: `on` / `off` for a switch, `(unset)` for nothing. - #[must_use] - pub fn shown(&self) -> String { - match self.kind { - Kind::Flag { .. } => if self.value == "true" { "on" } else { "off" }.into(), - _ if self.value.trim().is_empty() => "(unset)".into(), - _ => self.value.clone(), - } - } - - /// What this row adds to the command line: nothing when it is the - /// default. - #[must_use] - pub fn argv(&self) -> Vec { - if self.is_default() { - return Vec::new(); - } - let flag = format!("--{}", self.long); - match self.kind { - Kind::Flag { on } => { - if (self.value == "true") == on { - vec![flag] - } else { - Vec::new() - } - } - Kind::Choice(_) | Kind::Text => emit(&flag, self.many, values(self.many, &self.value)), - } - } -} - -/// The values typed into a row: split on commas and spaces when the flag -/// takes several, else the whole text. -fn values(many: Many, text: &str) -> Vec { - let text = text.trim(); - if text.is_empty() { - return Vec::new(); - } - match many { - Many::One => vec![text.to_string()], - _ => text - .split(|c: char| c == ',' || c.is_whitespace()) - .filter(|v| !v.is_empty()) - .map(str::to_string) - .collect(), - } -} - -/// Every flag of `faba run` but `own` and those under [`OWNED_HEADINGS`], -/// as rows. -#[derive(Clone, Debug)] -pub struct Form { - pub fields: Vec, -} - -impl Form { - /// The form for `run`, which must be built. - pub fn new(run: &Command, own: &[&str]) -> Self { - let mut form = Form { - fields: run - .get_arguments() - .filter_map(|a| Field::from_arg(a, own)) - .collect(), - }; - // Stable: clap's order inside a heading, each heading contiguous. - let order = form.headings(); - form.fields - .sort_by_key(|f| order.iter().position(|h| *h == f.heading)); - form - } - - pub fn get(&self, long: &str) -> Option<&Field> { - self.fields.iter().find(|f| f.long == long) - } - - /// Flags changed from their defaults. - #[must_use] - pub fn changed(&self) -> usize { - self.fields.iter().filter(|f| !f.is_default()).count() - } - - /// Every changed flag, in form order. - #[must_use] - pub fn argv(&self) -> Vec { - self.fields.iter().flat_map(Field::argv).collect() - } - - /// The headings in form order, `Common` first. - #[must_use] - pub fn headings(&self) -> Vec { - let mut out: Vec = Vec::new(); - for f in &self.fields { - if !out.contains(&f.heading) { - out.push(f.heading.clone()); - } - } - out.sort_by_key(|h| h != "Common"); - out - } - - /// Take the values the command line (or the environment) gave. - pub fn prefill(&mut self, m: &clap::ArgMatches) { - for f in &mut self.fields { - let Some(raw) = crate::script::given_words(m, &f.id) else { - continue; - }; - f.value = match f.kind { - // A switch given on the line is the non-default side. - Kind::Flag { .. } => if f.default == "true" { "false" } else { "true" }.into(), - _ => raw.join(","), - }; - } - } -} - -/// Whether clap takes `argv` (program name first); its complaint if not. -pub fn check(run: &Command, argv: &[String]) -> Result<(), String> { - match run.clone().try_get_matches_from(argv) { - Ok(_) => Ok(()), - Err(e) => Err(complaint(&e.render().to_string())), - } -} - -/// The first line of a clap error, without its `error: ` lead, with the -/// indented lines under it (such as the missing arguments). -fn complaint(rendered: &str) -> String { - let mut lines = rendered.lines(); - let first = lines.next().unwrap_or_default().trim(); - let first = first.strip_prefix("error: ").unwrap_or(first); - std::iter::once(first) - .chain( - lines - .take_while(|l| l.starts_with(char::is_whitespace) && !l.trim().is_empty()) - .map(str::trim), - ) - .collect::>() - .join(" ") -} - -/// The flag a clap complaint is about, when it names one of `fields`. -#[must_use] -pub fn blamed<'a>(complaint: &str, fields: &'a [Field]) -> Option<&'a str> { - fields - .iter() - .map(|f| f.long.as_str()) - .filter(|l| { - complaint.contains(&format!("'--{l}")) || complaint.contains(&format!("--{l} ")) - }) - .max_by_key(|l| l.len()) +/// The Flags screen's form for `run`, which must be built. +pub fn flags(run: &Command) -> Form { + Form::new(run, shown) } #[cfg(test)] diff --git a/src/pipeline/tui/tests/form.rs b/src/pipeline/tui/tests/form.rs index 749b143..34f5bda 100644 --- a/src/pipeline/tui/tests/form.rs +++ b/src/pipeline/tui/tests/form.rs @@ -6,7 +6,7 @@ use clap::FromArgMatches; #[test] fn every_flag_but_the_owned_ones_is_a_row() { let cmd = run(); - let form = Form::new(&cmd, OWN); + let form = flags(&cmd); let longs: Vec<&str> = form.fields.iter().map(|f| f.long.as_str()).collect(); for a in cmd.get_arguments() { let Some(l) = a.get_long() else { continue }; @@ -25,7 +25,7 @@ fn every_flag_but_the_owned_ones_is_a_row() { #[test] fn changed_values_round_trip_through_clap() { let cmd = run(); - let mut form = Form::new(&cmd, OWN); + let mut form = flags(&cmd); // Every switch flipped and every whole-number text bumped by one: the // parsed arguments differ from the defaults in exactly those fields. for f in &mut form.fields { @@ -61,7 +61,7 @@ fn changed_values_round_trip_through_clap() { #[test] fn a_rejected_value_is_blamed_on_its_row() { let cmd = run(); - let mut form = Form::new(&cmd, OWN); + let mut form = flags(&cmd); let i = form .fields .iter() @@ -80,7 +80,7 @@ fn a_rejected_value_is_blamed_on_its_row() { #[test] fn prefill_takes_what_the_command_line_gave() { let cmd = run(); - let mut form = Form::new(&cmd, OWN); + let mut form = flags(&cmd); let m = cmd .clone() .try_get_matches_from(["run", "--max-threads", "3", "--no-zip"]) @@ -94,7 +94,7 @@ fn prefill_takes_what_the_command_line_gave() { #[test] fn headings_follow_the_pipeline_order() { let cmd = run(); - let form = Form::new(&cmd, OWN); + let form = flags(&cmd); let headings = form.headings(); let steps = ["Common", "SNP", "count", "ATOI", "m6A", "APA"]; assert_eq!(headings[..steps.len()], steps, "{headings:?}"); diff --git a/src/tui.rs b/src/tui.rs index db0efa6..a7b88e7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -5,6 +5,7 @@ pub mod browser; pub mod busy; +pub mod form; pub use busy::{busy, busy_counting}; diff --git a/src/tui/form.rs b/src/tui/form.rs new file mode 100644 index 0000000..ef981a8 --- /dev/null +++ b/src/tui/form.rs @@ -0,0 +1,348 @@ +//! A command's flags as rows of a form, read from its clap definition, and +//! the command line the filled form stands for. +//! +//! Nothing here names a flag: every row comes from the command's `Arg`s, so +//! a flag added later shows up on its own. + +use crate::script::{emit, many_of, Many}; +use crate::tui::marker; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use data_beans::interactive::ui::{ACCENTED, DIM}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; + +/// What a row holds and how it is changed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Kind { + /// A switch, given or not. `on` is the value it sets when given. + Flag { on: bool }, + /// One of a fixed set of values; `""` stands for unset. + Choice(Vec), + /// Free text. + Text, +} + +/// One flag of the command. +#[derive(Clone, Debug)] +pub struct Field { + /// clap's id for the argument. + pub id: String, + /// The long name, without `--`. + pub long: String, + pub long_help: String, + /// The help heading the flag is listed under; `""` for none. + pub heading: String, + pub kind: Kind, + /// The value clap would use when the flag is not given; `""` for none. + pub default: String, + pub value: String, + /// Hidden from `--help`: shown only with the advanced rows. + pub advanced: bool, + many: Many, +} + +impl Field { + fn from_arg(a: &Arg) -> Option { + let long = a.get_long()?.to_string(); + let heading = a.get_help_heading().unwrap_or(""); + let kind = match a.get_action() { + ArgAction::SetTrue => Kind::Flag { on: true }, + ArgAction::SetFalse => Kind::Flag { on: false }, + ArgAction::Set | ArgAction::Append => { + let values: Vec = a + .get_possible_values() + .iter() + .filter(|v| !v.is_hide_set()) + .map(|v| v.get_name().to_string()) + .collect(); + if values.is_empty() { + Kind::Text + } else { + Kind::Choice(values) + } + } + // Counters, help and version: not something to fill in. + _ => return None, + }; + let many = many_of(a); + let sep = match many { + Many::Joined(c) => c.to_string(), + _ => ",".to_string(), + }; + let default = a + .get_default_values() + .iter() + .map(|v| v.to_string_lossy().into_owned()) + .collect::>() + .join(&sep); + let mut kind = kind; + if let Kind::Choice(values) = &mut kind { + // A choice with no default can stay unset. + if default.is_empty() { + values.insert(0, String::new()); + } + } + Some(Field { + id: a.get_id().to_string(), + long_help: a + .get_long_help() + .or_else(|| a.get_help()) + .map(ToString::to_string) + .unwrap_or_default(), + heading: heading.to_string(), + value: default.clone(), + default, + advanced: a.is_hide_set(), + kind, + many, + long, + }) + } + + /// Whether the value is the one clap would use anyway. + #[must_use] + pub fn is_default(&self) -> bool { + self.value.trim() == self.default + } + + /// Back to what clap would use. + pub fn reset(&mut self) { + self.value.clone_from(&self.default); + } + + /// Typed text; empty means back to the default. + pub fn set_text(&mut self, text: &str) { + let t = text.trim(); + if t.is_empty() { + self.reset(); + } else { + self.value = t.to_string(); + } + } + + /// Flip a switch. + pub fn toggle(&mut self) { + if let Kind::Flag { .. } = self.kind { + self.value = if self.value == "true" { + "false" + } else { + "true" + } + .into(); + } + } + + /// Step a choice by `d`, wrapping around. + pub fn cycle(&mut self, d: isize) { + if let Kind::Choice(values) = &self.kind { + let n = values.len() as isize; + let at = values.iter().position(|v| *v == self.value).unwrap_or(0) as isize; + self.value + .clone_from(&values[(at + d).rem_euclid(n) as usize]); + } + } + + /// The value as shown: `on` / `off` for a switch, `(unset)` for nothing. + #[must_use] + pub fn shown(&self) -> String { + match self.kind { + Kind::Flag { .. } => if self.value == "true" { "on" } else { "off" }.into(), + _ if self.value.trim().is_empty() => "(unset)".into(), + _ => self.value.clone(), + } + } + + /// What this row adds to the command line: nothing when it is the + /// default. + #[must_use] + pub fn argv(&self) -> Vec { + if self.is_default() { + return Vec::new(); + } + self.line() + } + + /// The flag with the value the row holds, default or not: a switch + /// only when on its given side. + #[must_use] + pub fn line(&self) -> Vec { + let flag = format!("--{}", self.long); + match self.kind { + Kind::Flag { on } => { + if (self.value == "true") == on { + vec![flag] + } else { + Vec::new() + } + } + Kind::Choice(_) | Kind::Text => emit(&flag, self.many, values(self.many, &self.value)), + } + } +} + +/// Row `f` as a line of the form: marked when `at`, starred and its +/// default beside it when changed, its name `width` wide in `style` with +/// `shown` (the value as it reads), and `complaint` after it when one blames +/// it. +#[must_use] +pub fn row_line( + f: &Field, + at: bool, + width: usize, + style: Style, + shown: String, + complaint: Option<&str>, +) -> Line<'static> { + let changed = !f.is_default(); + let mut spans = vec![ + marker(at), + Span::styled(if changed { "* " } else { " " }, ACCENTED), + Span::styled(format!("{: Vec { + let text = text.trim(); + if text.is_empty() { + return Vec::new(); + } + match many { + Many::One => vec![text.to_string()], + _ => text + .split(|c: char| c == ',' || c.is_whitespace()) + .filter(|v| !v.is_empty()) + .map(str::to_string) + .collect(), + } +} + +/// The flags of a command that a form shows, as rows. +#[derive(Clone, Debug)] +pub struct Form { + pub fields: Vec, +} + +impl Form { + /// The form of `cmd`'s flags that `keep` keeps; `cmd` must be built. + pub fn new(cmd: &Command, keep: impl Fn(&Arg) -> bool) -> Self { + let mut form = Form { + fields: cmd + .get_arguments() + .filter(|a| keep(a)) + .filter_map(Field::from_arg) + .collect(), + }; + // Stable: clap's order inside a heading, each heading contiguous. + let order = form.headings(); + form.fields + .sort_by_key(|f| order.iter().position(|h| *h == f.heading)); + form + } + + /// The form of `cmd`'s flags named in `longs`, in that order. + pub fn only(cmd: &Command, longs: &[&str]) -> Self { + let mut form = Self::new(cmd, |a| a.get_long().is_some_and(|l| longs.contains(&l))); + form.fields + .sort_by_key(|f| longs.iter().position(|l| *l == f.long)); + form + } + + pub fn get(&self, long: &str) -> Option<&Field> { + self.fields.iter().find(|f| f.long == long) + } + + /// Flags changed from their defaults. + #[must_use] + pub fn changed(&self) -> usize { + self.fields.iter().filter(|f| !f.is_default()).count() + } + + /// Every changed flag, in form order. + #[must_use] + pub fn argv(&self) -> Vec { + self.fields.iter().flat_map(Field::argv).collect() + } + + /// The headings in form order, `Common` first. + #[must_use] + pub fn headings(&self) -> Vec { + let mut out: Vec = Vec::new(); + for f in &self.fields { + if !out.contains(&f.heading) { + out.push(f.heading.clone()); + } + } + out.sort_by_key(|h| h != "Common"); + out + } + + /// Take the values the command line (or the environment) gave. + pub fn prefill(&mut self, m: &clap::ArgMatches) { + for f in &mut self.fields { + let Some(raw) = crate::script::given_words(m, &f.id) else { + continue; + }; + f.value = match f.kind { + // A switch given on the line is the non-default side. + Kind::Flag { .. } => if f.default == "true" { "false" } else { "true" }.into(), + _ => raw.join(","), + }; + } + } +} + +/// Whether clap takes `argv` (program name first); its complaint if not. +pub fn check(cmd: &Command, argv: &[String]) -> Result<(), String> { + parse(cmd, argv).map(drop) +} + +/// What clap makes of `argv` (program name first); its complaint if it +/// refuses it. +pub fn parse(cmd: &Command, argv: &[String]) -> Result { + cmd.clone() + .try_get_matches_from(argv) + .map_err(|e| complaint(&e.render().to_string())) +} + +/// The first line of a clap error, without its `error: ` lead, with the +/// indented lines under it (such as the missing arguments). +fn complaint(rendered: &str) -> String { + let mut lines = rendered.lines(); + let first = lines.next().unwrap_or_default().trim(); + let first = first.strip_prefix("error: ").unwrap_or(first); + std::iter::once(first) + .chain( + lines + .take_while(|l| l.starts_with(char::is_whitespace) && !l.trim().is_empty()) + .map(str::trim), + ) + .collect::>() + .join(" ") +} + +/// The flag a clap complaint is about, when it names one of `fields`. +#[must_use] +pub fn blamed<'a>(complaint: &str, fields: &'a [Field]) -> Option<&'a str> { + fields + .iter() + .map(|f| f.long.as_str()) + .filter(|l| { + complaint.contains(&format!("'--{l}")) || complaint.contains(&format!("--{l} ")) + }) + .max_by_key(|l| l.len()) +}