use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use super::app::{
FileAction, MouseHitTarget, MouseLayer, MouseScrollTarget, Overlay, PromptKind, TuiApp,
};
use super::draw::panel;
use super::editor::Editor;
use super::new_request::draw_scrollbar;
use super::theme::Theme;
use crate::i18n::{Status, Strings};
use crate::report::edit;
use crate::report::flow::{
Element, EnvClause, FlowNode, ImageSpec, ParallelSpec, Pattern, Producer, ReportStmt,
ResponseFmt, RoleBinding, RoleRef, ShowField, UsingItem, WithItem,
};
use crate::report::model::StatKind;
use crate::shared_utils::truncate_to_width;
pub(crate) use crate::report::edit::{
ClauseForm, DetachWhich, HEADER_PLACEHOLDER, HeaderKind, InsertPos, NodeKind, NodeRow, RowKind,
detach_modifier, flatten_expanded, header_specs, header_unset, insert_node, insert_pos_after,
loop_producer_dir, loop_producer_dir_mut, move_node, node_at, node_at_mut, node_with_items,
parse_one_node, remove_node, replace_node, request_node,
};
pub(crate) struct NodeMenu {
pub(crate) step: NodeMenuStep,
pub(crate) options: Vec<String>,
pub(crate) selected: usize,
pub(crate) pos: InsertPos,
pub(crate) report_id: u64,
pub(crate) report_kind: bool,
pub(crate) edit_path: Option<Vec<usize>>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum NodeMenuStep {
PickKind,
PickRequest,
}
impl NodeMenu {
pub(crate) fn title<'a>(&self, s: &'a Strings) -> &'a str {
match self.step {
NodeMenuStep::PickKind => s.node_menu_title,
NodeMenuStep::PickRequest => s.node_pick_request_title,
}
}
}
pub(crate) struct ShowRow {
pub(crate) name: String,
pub(crate) included: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FormRow {
Name,
Report,
Response,
Alias,
Param(usize),
Field(usize),
Hidden(usize),
With(usize),
AddWith,
OverrideTarget(usize),
OverrideValue(usize),
AddOverride,
}
pub(crate) struct RequestForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) request: String,
pub(crate) titles: Vec<String>,
pub(crate) report: bool,
pub(crate) response: Option<ResponseFmt>,
pub(crate) alias: String,
pub(crate) fields: Vec<ShowRow>,
pub(crate) show_stats: std::collections::HashMap<String, Vec<StatKind>>,
pub(crate) with: Vec<WithItem>,
pub(crate) using: Vec<UsingItem>,
pub(crate) overrides: Vec<edit::OverrideRow>,
pub(crate) params: Vec<edit::ParamRow>,
pub(crate) hide_fields: Vec<ShowRow>,
pub(crate) selected: usize,
}
impl RequestForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
request: String,
titles: Vec<String>,
report: bool,
alias: Option<String>,
response: Option<ResponseFmt>,
current_show: &[ShowField],
report_fields: &[String],
declared_params: &[(String, String)],
with: Vec<WithItem>,
using: Vec<UsingItem>,
hide: Vec<String>,
) -> Self {
let with_fields: Vec<String> = with
.iter()
.filter_map(|w| match w {
WithItem::Field { name, .. } => Some(name.clone()),
_ => None,
})
.collect();
let mut names: Vec<String> = Vec::new();
let push = |name: &str, names: &mut Vec<String>| {
if !names.iter().any(|n| n == name) {
names.push(name.to_string());
}
};
for f in crate::report::run::INTRINSIC_FIELDS {
push(f, &mut names);
}
for f in report_fields {
push(f, &mut names);
}
for f in &with_fields {
push(f, &mut names);
}
for f in current_show {
push(f.name(), &mut names);
}
for f in &hide {
push(f, &mut names);
}
let all = current_show.is_empty();
let fields: Vec<ShowRow> = names
.iter()
.map(|name| {
let included = (all
&& !crate::report::run::OPT_IN_INTRINSIC_FIELDS.contains(&name.as_str()))
|| current_show.iter().any(|s| s.name() == name);
ShowRow {
name: name.clone(),
included,
}
})
.collect();
let hide_fields = names
.iter()
.map(|name| ShowRow {
name: name.clone(),
included: hide.iter().any(|h| h == name),
})
.collect();
RequestForm {
report_id,
path,
request,
titles,
report,
params: edit::param_rows(declared_params, &using),
overrides: edit::override_rows(&using),
using,
show_stats: current_show
.iter()
.filter(|f| !f.stats.is_empty())
.map(|f| (f.field.clone(), f.stats.clone()))
.collect(),
response,
alias: alias.unwrap_or_default(),
fields,
with,
hide_fields,
selected: 0,
}
}
pub(crate) fn visible_rows(&self) -> Vec<FormRow> {
let mut rows = vec![FormRow::Name, FormRow::Report];
rows.extend((0..self.params.len()).map(FormRow::Param));
for i in 0..self.overrides.len() {
rows.push(FormRow::OverrideTarget(i));
rows.push(FormRow::OverrideValue(i));
}
rows.push(FormRow::AddOverride);
if self.report {
rows.push(FormRow::Response);
rows.push(FormRow::Alias);
rows.extend((0..self.fields.len()).map(FormRow::Field));
rows.extend((0..self.hide_fields.len()).map(FormRow::Hidden));
rows.extend((0..self.with.len()).map(FormRow::With));
rows.push(FormRow::AddWith);
}
rows
}
fn hide(&self) -> Vec<String> {
self.hide_fields
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect()
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn show(&self) -> Vec<ShowField> {
if self.fields.iter().all(|r| {
r.included != crate::report::run::OPT_IN_INTRINSIC_FIELDS.contains(&r.name.as_str())
}) {
return Vec::new();
}
self.fields
.iter()
.filter(|r| r.included)
.map(|r| ShowField {
field: r.name.clone(),
stats: self.show_stats.get(&r.name).cloned().unwrap_or_default(),
})
.collect()
}
fn alias_opt(&self) -> Option<String> {
let a = self.alias.trim();
if a.is_empty() {
None
} else {
Some(a.to_string())
}
}
fn cycle_name(&mut self, forward: bool) {
let n = self.titles.len();
if n == 0 {
return;
}
let next = match self.titles.iter().position(|t| t == &self.request) {
Some(i) if forward => (i + 1) % n,
Some(i) => (i + n - 1) % n,
None => 0,
};
self.request = self.titles[next].clone();
}
fn cycle_response(&mut self, forward: bool) {
self.response = if forward {
match self.response {
None => Some(ResponseFmt::Raw),
Some(ResponseFmt::Raw) => Some(ResponseFmt::Pretty),
Some(ResponseFmt::Pretty) => None,
}
} else {
match self.response {
None => Some(ResponseFmt::Pretty),
Some(ResponseFmt::Pretty) => Some(ResponseFmt::Raw),
Some(ResponseFmt::Raw) => None,
}
};
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum VarsRow {
Var(usize),
Other,
Alias,
Stat(usize),
Clause(ClauseRow),
}
pub(crate) struct VarsForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) vars: Vec<ShowRow>,
pub(crate) other: String,
pub(crate) alias: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) clauses: ClauseForm,
pub(crate) selected: usize,
}
impl VarsForm {
fn build(
report_id: u64,
path: Vec<usize>,
chosen: &[String],
alias: Option<String>,
stats: &[StatKind],
image: Option<ImageSpec>,
truth: Option<String>,
detail: bool,
in_scope: Vec<String>,
) -> Self {
let mut names = in_scope;
for c in chosen {
if !names.iter().any(|n| n == c) {
names.push(c.clone());
}
}
VarsForm {
clauses: ClauseForm::of(image, truth.as_deref(), detail),
report_id,
path,
vars: names
.into_iter()
.map(|name| {
let included = chosen.iter().any(|c| c == &name);
ShowRow { name, included }
})
.collect(),
other: String::new(),
alias: alias.unwrap_or_default(),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
selected: 0,
}
}
fn chosen(&self) -> Vec<String> {
let mut out: Vec<String> = self
.vars
.iter()
.filter(|r| r.included)
.map(|r| r.name.clone())
.collect();
let other = self.other.trim();
if !other.is_empty() && !out.iter().any(|n| n == other) {
out.push(other.to_string());
}
out
}
pub(crate) fn visible_rows(&self) -> Vec<VarsRow> {
let mut rows: Vec<VarsRow> = (0..self.vars.len()).map(VarsRow::Var).collect();
rows.push(VarsRow::Other);
if self.chosen().len() == 1 {
rows.push(VarsRow::Alias);
rows.extend((0..self.stats.len()).map(VarsRow::Stat));
rows.extend(clause_rows(&self.clauses).into_iter().map(VarsRow::Clause));
}
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let chosen = self.chosen();
let (first, rest) = chosen.split_first()?;
let alias = self.alias.trim();
let stats: Vec<StatKind> = self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect();
let image = self.clauses.image();
let truth = self.clauses.truth();
if rest.is_empty()
&& (!alias.is_empty()
|| !stats.is_empty()
|| image.is_some()
|| truth.is_some()
|| self.clauses.detail)
{
return Some(FlowNode::Report(ReportStmt::VarAs {
truth,
detail: self.clauses.detail,
var: first.clone(),
name: if alias.is_empty() {
first.clone()
} else {
alias.to_string()
},
stats,
image,
}));
}
Some(FlowNode::Report(ReportStmt::Vars(chosen)))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ComputedRow {
Template,
Alias,
Stat(usize),
Clause(ClauseRow),
}
pub(crate) struct ComputedForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) template: String,
pub(crate) alias: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) clauses: ClauseForm,
pub(crate) selected: usize,
}
impl ComputedForm {
pub(crate) fn visible_rows(&self) -> Vec<ComputedRow> {
let mut rows = vec![ComputedRow::Template, ComputedRow::Alias];
rows.extend((0..self.stats.len()).map(ComputedRow::Stat));
rows.extend(
clause_rows(&self.clauses)
.into_iter()
.map(ComputedRow::Clause),
);
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let template = self.template.trim();
let alias = self.alias.trim();
if template.is_empty() || alias.is_empty() {
return None;
}
Some(FlowNode::Report(ReportStmt::Computed {
truth: self.clauses.truth(),
detail: self.clauses.detail,
template: template.to_string(),
name: alias.to_string(),
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
image: self.clauses.image(),
}))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum AssignRow {
Key,
Value,
}
pub(crate) struct AssignForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) key: String,
pub(crate) value: String,
pub(crate) selected: usize,
}
impl AssignForm {
pub(crate) fn visible_rows(&self) -> Vec<AssignRow> {
vec![AssignRow::Key, AssignRow::Value]
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let key = self.key.trim();
(!key.is_empty()).then(|| FlowNode::Assign {
key: key.to_string(),
value: self.value.trim().to_string(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListRow {
Name,
Value(usize),
Add,
}
pub(crate) struct ListForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) name: String,
pub(crate) values: Vec<String>,
pub(crate) selected: usize,
}
impl ListForm {
pub(crate) fn visible_rows(&self) -> Vec<ListRow> {
let mut rows = vec![ListRow::Name];
rows.extend((0..self.values.len()).map(ListRow::Value));
rows.push(ListRow::Add);
rows
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn node(&self) -> Option<FlowNode> {
let name = self.name.trim();
(!name.is_empty()).then(|| FlowNode::ListDecl {
name: name.to_string(),
producer: Producer::List(
self.values
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.map(|v| Element::Scalar(v.to_string()))
.collect(),
),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum ClauseRow {
Truth,
Detail,
Image,
Fit,
Height,
Width,
}
pub(crate) fn clause_rows(c: &ClauseForm) -> Vec<ClauseRow> {
let mut rows = vec![ClauseRow::Truth, ClauseRow::Detail, ClauseRow::Image];
if c.image_on {
rows.push(ClauseRow::Fit);
if !c.fit {
rows.push(ClauseRow::Height);
rows.push(ClauseRow::Width);
}
}
rows
}
pub(crate) fn clause_key(c: &mut ClauseForm, row: ClauseRow, key: KeyEvent) -> bool {
let digits = |field: &mut String, key: KeyEvent| match key.code {
KeyCode::Char(ch) if ch.is_ascii_digit() => {
field.push(ch);
true
}
KeyCode::Backspace => {
field.pop();
true
}
_ => false,
};
match row {
ClauseRow::Truth => match key.code {
KeyCode::Char(ch) => {
c.truth.push(ch);
true
}
KeyCode::Backspace => {
c.truth.pop();
true
}
_ => false,
},
ClauseRow::Detail => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x')) {
c.detail = !c.detail;
return true;
}
false
}
ClauseRow::Image => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x')) {
c.toggle_image();
return true;
}
false
}
ClauseRow::Fit => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x')) {
c.toggle_fit();
return true;
}
false
}
ClauseRow::Height => digits(&mut c.height, key),
ClauseRow::Width => digits(&mut c.width, key),
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum WithFieldRow {
Name,
Query,
Clause(ClauseRow),
Stats,
Stat(usize),
}
pub(crate) struct WithFieldForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) index: Option<usize>,
pub(crate) name: String,
pub(crate) query: String,
pub(crate) stats: Vec<(StatKind, bool)>,
pub(crate) stats_on: bool,
pub(crate) return_to_request: bool,
pub(crate) clauses: ClauseForm,
pub(crate) selected: usize,
}
impl WithFieldForm {
fn build(
report_id: u64,
path: Vec<usize>,
index: Option<usize>,
existing: Option<&WithItem>,
return_to_request: bool,
) -> Self {
let (name, query, stats, clauses) = match existing {
Some(WithItem::Field {
name,
query,
stats,
image,
truth,
detail,
}) => (
name.clone(),
query.clone(),
stats.clone(),
ClauseForm::of(*image, truth.as_deref(), *detail),
),
_ => (
String::new(),
String::new(),
Vec::new(),
ClauseForm::default(),
),
};
WithFieldForm {
clauses,
report_id,
path,
index,
name,
query,
stats_on: !stats.is_empty(),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
return_to_request,
selected: 0,
}
}
pub(crate) fn visible_rows(&self) -> Vec<WithFieldRow> {
let mut rows = vec![WithFieldRow::Name, WithFieldRow::Query];
rows.extend(
clause_rows(&self.clauses)
.into_iter()
.map(WithFieldRow::Clause),
);
rows.push(WithFieldRow::Stats);
if self.stats_on {
rows.extend((0..self.stats.len()).map(WithFieldRow::Stat));
}
rows
}
fn toggle_stats(&mut self) {
self.stats_on = !self.stats_on;
if self.stats_on {
if !self.stats.iter().any(|(_, on)| *on) {
let seed = self
.stats
.iter()
.position(|(k, _)| *k == StatKind::Count)
.unwrap_or(0);
if let Some((_, on)) = self.stats.get_mut(seed) {
*on = true;
}
}
} else {
for (_, on) in &mut self.stats {
*on = false;
}
}
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn item(&self) -> Option<WithItem> {
let name = self.name.trim();
if name.is_empty() {
return None;
}
Some(WithItem::Field {
name: name.to_string(),
query: self.query.trim().to_string(),
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
image: self.clauses.image(),
detail: self.clauses.detail,
truth: self.clauses.truth(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum EnvsRow {
Var,
Mode,
Parallel,
Degree,
Env(usize),
BaselineShow(usize),
}
pub(crate) struct EnvEntry {
pub(crate) name: String,
pub(crate) baseline: bool,
pub(crate) file: bool,
}
pub(crate) struct EnvsForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) var: String,
pub(crate) compare: bool,
pub(crate) parallel: bool,
pub(crate) entries: Vec<EnvEntry>,
pub(crate) choices: Vec<String>,
pub(crate) snapshots: Vec<String>,
pub(crate) show_stats: std::collections::HashMap<String, Vec<StatKind>>,
pub(crate) selected: usize,
pub(crate) degree: String,
pub(crate) baseline_show: Vec<ShowRow>,
}
impl EnvsForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
var: String,
clause: &EnvClause,
parallel: Option<ParallelSpec>,
choices: Vec<String>,
mut snapshots: Vec<String>,
show_choices: Vec<(String, bool)>,
) -> Self {
let (compare, mut entries, baseline_show_names) = match clause {
EnvClause::Plain(names) => (
false,
names
.iter()
.map(|n| EnvEntry {
name: n.clone(),
baseline: false,
file: false,
})
.collect::<Vec<_>>(),
Vec::new(),
),
EnvClause::Roles {
baseline,
comparisons,
baseline_show,
} => {
let entry = |r: &RoleRef, is_baseline: bool| EnvEntry {
name: r.target().to_string(),
baseline: is_baseline,
file: matches!(r, RoleRef::File(_)),
};
let mut es: Vec<EnvEntry> = baseline.iter().map(|r| entry(r, true)).collect();
es.extend(comparisons.iter().map(|r| entry(r, false)));
(true, es, baseline_show.clone())
}
};
for e in &entries {
if e.file && !e.name.trim().is_empty() && !snapshots.iter().any(|s| s == &e.name) {
snapshots.push(e.name.clone());
}
}
if entries.is_empty() {
entries.push(EnvEntry {
name: choices.first().cloned().unwrap_or_default(),
baseline: compare,
file: false,
});
}
let mut baseline_show: Vec<ShowRow> = show_choices
.into_iter()
.map(|(name, included)| ShowRow { name, included })
.collect();
let show_stats: std::collections::HashMap<String, Vec<StatKind>> = baseline_show_names
.iter()
.filter(|f| !f.stats.is_empty())
.map(|f| (f.field.clone(), f.stats.clone()))
.collect();
for f in &baseline_show_names {
if !baseline_show.iter().any(|r| r.name == f.field) {
baseline_show.push(ShowRow {
name: f.field.clone(),
included: true,
});
}
}
EnvsForm {
report_id,
path,
var,
show_stats,
compare,
parallel: parallel.is_some(),
degree: parallel
.and_then(|p| p.degree)
.map(|d| d.to_string())
.unwrap_or_default(),
entries,
choices,
snapshots,
selected: 0,
baseline_show,
}
}
pub(crate) fn visible_rows(&self) -> Vec<EnvsRow> {
let mut rows = vec![EnvsRow::Var, EnvsRow::Mode, EnvsRow::Parallel];
if self.parallel {
rows.push(EnvsRow::Degree);
}
rows.extend((0..self.entries.len()).map(EnvsRow::Env));
if self.compare && self.entries.iter().any(|e| e.baseline) {
rows.extend((0..self.baseline_show.len()).map(EnvsRow::BaselineShow));
}
rows
}
fn parallel_spec(&self) -> Option<ParallelSpec> {
self.parallel.then(|| ParallelSpec {
degree: self.degree.trim().parse::<u32>().ok().filter(|d| *d > 0),
})
}
fn selected_baseline_show(&self) -> Vec<ShowField> {
self.baseline_show
.iter()
.filter(|r| r.included)
.map(|r| ShowField {
field: r.name.clone(),
stats: self.show_stats.get(&r.name).cloned().unwrap_or_default(),
})
.collect()
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn cycle_entry(&mut self, i: usize, forward: bool) {
let list = if self.entries[i].file {
&self.snapshots
} else {
&self.choices
};
let n = list.len();
if n == 0 {
return;
}
let cur = &self.entries[i].name;
let next = match list.iter().position(|c| c == cur) {
Some(p) if forward => (p + 1) % n,
Some(p) => (p + n - 1) % n,
None => 0,
};
self.entries[i].name = list[next].clone();
}
fn toggle_file(&mut self, i: usize) {
if !self.compare {
return;
}
let becoming_file = !self.entries[i].file;
self.entries[i].file = becoming_file;
let list = if becoming_file {
&self.snapshots
} else {
&self.choices
};
if !list.iter().any(|c| c == &self.entries[i].name)
&& let Some(first) = list.first()
{
self.entries[i].name = first.clone();
}
}
fn toggle_baseline(&mut self, i: usize) {
if !self.compare {
return;
}
let becoming = !self.entries[i].baseline;
for (j, e) in self.entries.iter_mut().enumerate() {
e.baseline = becoming && j == i;
}
}
fn toggle_mode(&mut self) {
self.compare = !self.compare;
if self.compare
&& !self.entries.iter().any(|e| e.baseline)
&& let Some(first) = self.entries.first_mut()
{
first.baseline = true;
}
}
fn toggle_parallel(&mut self) {
self.parallel = !self.parallel;
}
fn add_entry(&mut self) {
self.entries.push(EnvEntry {
name: self.choices.first().cloned().unwrap_or_default(),
baseline: false,
file: false,
});
}
fn remove_entry(&mut self, i: usize) {
if self.entries.len() > 1 && i < self.entries.len() {
self.entries.remove(i);
}
}
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"TARGET".to_string()
} else {
v.to_string()
}
}
fn clause(&self) -> Option<EnvClause> {
if self.compare {
let refs = |want_baseline: bool| -> Vec<RoleRef> {
self.entries
.iter()
.filter(|e| e.baseline == want_baseline)
.filter(|e| !e.name.trim().is_empty())
.map(|e| {
let name = e.name.trim().to_string();
if e.file {
RoleRef::File(name)
} else {
RoleRef::Env(name)
}
})
.collect()
};
let baseline = refs(true);
let comparisons = refs(false);
if baseline.is_empty() && comparisons.is_empty() {
return None;
}
Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show: self.selected_baseline_show(),
})
} else {
let names: Vec<String> = self
.entries
.iter()
.map(|e| e.name.trim().to_string())
.filter(|n| !n.is_empty())
.collect();
if names.is_empty() {
return None;
}
Some(EnvClause::Plain(names))
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum FilesRow {
Var,
Folder,
Match,
Parallel,
Degree,
}
pub(crate) struct FilesForm {
pub(crate) report_id: u64,
pub(crate) path: Vec<usize>,
pub(crate) var: String,
pub(crate) dir: String,
pub(crate) glob: String,
pub(crate) parallel: bool,
pub(crate) degree: String,
pub(crate) folders: bool,
pub(crate) roles: Vec<RoleBinding>,
pub(crate) selected: usize,
}
impl FilesForm {
#[allow(clippy::too_many_arguments)]
fn build(
report_id: u64,
path: Vec<usize>,
var: String,
dir: String,
glob: Option<String>,
parallel: Option<ParallelSpec>,
folders: bool,
roles: Vec<RoleBinding>,
) -> Self {
let selected = if dir.trim().is_empty() { 1 } else { 0 };
FilesForm {
report_id,
path,
var,
dir,
glob: glob.unwrap_or_default(),
parallel: parallel.is_some(),
degree: parallel
.and_then(|p| p.degree)
.map(|d| d.to_string())
.unwrap_or_default(),
folders,
roles,
selected,
}
}
pub(crate) fn visible_rows(&self) -> Vec<FilesRow> {
let mut rows = vec![FilesRow::Var, FilesRow::Folder, FilesRow::Match];
rows.push(FilesRow::Parallel);
if self.parallel {
rows.push(FilesRow::Degree);
}
rows
}
fn parallel_spec(&self) -> Option<ParallelSpec> {
self.parallel.then(|| ParallelSpec {
degree: self.degree.trim().parse::<u32>().ok().filter(|d| *d > 0),
})
}
fn producer(&self) -> Producer {
if self.folders {
Producer::Folders {
dir: self.dir.clone(),
glob: self.glob_opt(),
roles: self.roles.clone(),
}
} else {
Producer::Files {
dir: self.dir.clone(),
glob: self.glob_opt(),
}
}
}
fn last_row(&self) -> usize {
self.visible_rows().len().saturating_sub(1)
}
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"FILE".to_string()
} else {
v.to_string()
}
}
fn glob_opt(&self) -> Option<String> {
let g = self.glob.trim();
if g.is_empty() {
None
} else {
Some(g.to_string())
}
}
fn toggle_parallel(&mut self) {
self.parallel = !self.parallel;
}
}
impl TuiApp {
pub(crate) fn report_index_by_id(&self, id: u64) -> Option<usize> {
self.reports.iter().position(|rt| rt.report.id == id)
}
pub(crate) fn report_node_rows(&self, idx: usize) -> Result<Vec<NodeRow>, String> {
let rt = self.reports.get(idx).ok_or("no report")?;
let flow = rt.report.flow().map_err(|e| e.to_string())?;
let entries = self
.resolve_bound_collection(&rt.report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let helpers = rt.helpers.as_slice();
let resolves =
|name: &str| crate::report::run::resolve_qualified(entries, helpers, name).is_some();
Ok(flatten_expanded(&flow, &resolves, true))
}
fn bound_request_titles(&self, report_id: u64) -> Vec<String> {
let Some(idx) = self.report_index_by_id(report_id) else {
return Vec::new();
};
let entries = self
.resolve_bound_collection(&self.reports[idx].report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
crate::report::context::request_choices(entries, &self.reports[idx].helpers)
.into_iter()
.map(|c| c.qualified)
.collect()
}
pub(crate) fn on_key_report_nodes(&mut self, key: KeyEvent, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
if let Some(sel) = self.reports[idx].node_setting {
return self.on_key_report_settings(key, idx, sel);
}
let last = rows.len().saturating_sub(1);
let sel = self.reports[idx].node_selected.min(last);
self.reports[idx].node_selected = sel;
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
match key.code {
KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.undo_report_node(idx)
}
KeyCode::Up if shift => self.move_selected_node(idx, true),
KeyCode::Down if shift => self.move_selected_node(idx, false),
KeyCode::Char('K') => self.move_selected_node(idx, true),
KeyCode::Char('J') => self.move_selected_node(idx, false),
KeyCode::Up | KeyCode::Char('k') => {
if sel == 0 {
let n = self.setting_row_count(idx);
if n > 0 {
self.reports[idx].node_setting = Some(n - 1);
}
} else {
self.reports[idx].node_selected = sel - 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
self.reports[idx].node_selected = (sel + 1).min(last);
}
KeyCode::Home => {
self.reports[idx].node_selected = 0;
if self.setting_row_count(idx) > 0 {
self.reports[idx].node_setting = Some(0);
}
}
KeyCode::End => self.reports[idx].node_selected = last,
KeyCode::Char('a') | KeyCode::Insert => {
match rows.get(sel).map(|r| (r.kind, r.path.clone())) {
Some((k, path)) if k.is_with() => {
self.open_with_field_editor(idx, &path, usize::MAX)
}
_ => self.open_report_node_menu(idx),
}
}
KeyCode::Enter => self.configure_selected_node(idx),
KeyCode::Char('e') => self.edit_selected_node(idx),
KeyCode::Delete | KeyCode::Backspace => self.delete_selected_node(idx),
_ => return false,
}
true
}
fn open_report_node_menu(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let pos = insert_pos_after(&rows, sel);
let s = Strings::for_language(&self.language);
let options = NodeKind::ALL
.iter()
.map(|k| k.label(&s).to_string())
.collect();
self.overlay = Some(Overlay::ReportNodeMenu(Box::new(NodeMenu {
step: NodeMenuStep::PickKind,
options,
selected: 0,
pos,
report_id: self.reports[idx].report.id,
report_kind: false,
edit_path: None,
})));
}
fn open_report_node_folder(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let current_dir = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path).and_then(loop_producer_dir) {
Some(dir) => dir.to_string(),
None => return false, }
};
let start = {
let p = std::path::Path::new(¤t_dir);
if !current_dir.is_empty() && p.is_dir() {
Some(p.to_path_buf())
} else if let Some(base) = self.active_report_base_dir() {
let joined = base.join(¤t_dir);
Some(if joined.is_dir() { joined } else { base })
} else {
None
}
};
if let Some(dir) = start {
self.last_browse_dir = Some(dir);
}
self.pending_node_folder = Some((self.reports[idx].report.id, path));
self.open_browser(crate::tui::app::FileAction::PickReportNodeFolder);
true
}
pub(crate) fn commit_report_node_folder(&mut self, dir: &str) {
let Some((report_id, path)) = self.pending_node_folder.take() else {
return;
};
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
let Some(node) = node_at_mut(&mut flow, &path) else {
return;
};
match loop_producer_dir_mut(node) {
Some(slot) => *slot = dir.to_string(),
None => return,
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, &path);
self.save_state();
}
fn configure_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
self.open_report_node_menu(idx);
return;
}
if let Some(wi) = self.with_row_index(row) {
let path = row.path.clone();
self.open_with_field_editor(idx, &path, wi);
return;
}
if row.kind == RowKind::WithEnd {
return;
}
let path = row.path.clone();
if self.open_report_node_request(idx) {
return;
}
if self.open_report_node_envs(idx) {
return;
}
if self.open_report_node_files(idx) {
return;
}
if self.open_report_node_vars(idx) {
return;
}
if self.open_report_node_computed(idx) {
return;
}
if self.open_report_node_assign(idx) {
return;
}
if self.open_report_node_list(idx) {
return;
}
if self.open_report_node_folder(idx) {
return;
}
self.open_report_node_line_prompt(idx, &path);
}
fn build_report_node_request_form(
&self,
idx: usize,
path: Vec<usize>,
node: &FlowNode,
) -> Option<RequestForm> {
let report_id = self.reports[idx].report.id;
let (name, report, alias, response, current_show, current_hide, with, using) = match node {
FlowNode::Request { name, using } => (
name.clone(),
false,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
using.clone(),
),
FlowNode::Report(ReportStmt::Request {
name,
alias,
using,
response_fmt,
show,
hide,
with,
}) => (
name.clone(),
true,
alias.clone(),
*response_fmt,
show.clone(),
hide.clone(),
with.clone(),
using.clone(),
),
_ => return None,
};
let report_fields = self.request_report_fields(report_id, &name);
let declared_params = self.request_parameters(report_id, &name);
let titles = self.bound_request_titles(report_id);
Some(RequestForm::build(
report_id,
path,
name,
titles,
report,
alias,
response,
¤t_show,
&report_fields,
&declared_params,
with,
using,
current_hide,
))
}
fn open_report_node_request(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
let Some(node) = node_at(&flow, &path).cloned() else {
return false;
};
let Some(form) = self.build_report_node_request_form(idx, path, &node) else {
return false; };
self.overlay = Some(Overlay::ReportNodeRequest(Box::new(form)));
true
}
fn request_report_fields(&self, report_id: u64, name: &str) -> Vec<String> {
let Some(idx) = self.report_index_by_id(report_id) else {
return Vec::new();
};
let rt = &self.reports[idx];
let entries = self
.resolve_bound_collection(&rt.report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
crate::report::run::resolve_qualified(entries, &rt.helpers, name)
.map(|e| e.reports.iter().map(|(f, _)| f.clone()).collect())
.unwrap_or_default()
}
fn request_parameters(&self, report_id: u64, name: &str) -> Vec<(String, String)> {
let Some(idx) = self.report_index_by_id(report_id) else {
return Vec::new();
};
let rt = &self.reports[idx];
let entries = self
.resolve_bound_collection(&rt.report)
.map(|ci| self.collections[ci].entries.as_slice())
.unwrap_or(&[]);
crate::report::run::resolve_qualified(entries, &rt.helpers, name)
.map(|e| e.variable_defaults())
.unwrap_or_default()
}
fn refresh_form_params(&self, form: &mut RequestForm) {
let declared = self.request_parameters(form.report_id, &form.request);
let using = edit::using_items(&form.params, &form.using);
form.params = edit::param_rows(&declared, &using);
}
pub(crate) fn apply_report_node_request(&mut self, form: RequestForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let node = if form.report {
FlowNode::Report(ReportStmt::Request {
name: form.request.clone(),
alias: form.alias_opt(),
using: edit::using_items(&form.params, &edit::override_items(&form.overrides)),
response_fmt: form.response,
show: form.show(),
hide: form.hide(),
with: form.with.clone(),
})
} else {
FlowNode::Request {
name: form.request.clone(),
using: edit::using_items(&form.params, &edit::override_items(&form.overrides)),
}
};
self.apply_node_replace(idx, &form.path, node);
}
fn open_report_node_vars(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let (chosen, alias, stats, image, truth, detail) = match &node {
FlowNode::Report(ReportStmt::Vars(vars)) => {
(vars.clone(), None, Vec::new(), None, None, false)
}
FlowNode::Report(ReportStmt::VarAs {
var,
name,
stats,
image,
truth,
detail,
}) => (
vec![var.clone()],
Some(name.clone()),
stats.clone(),
*image,
truth.clone(),
*detail,
),
_ => return false,
};
let entries = self
.resolve_bound_collection(&self.reports[idx].report)
.map(|ci| self.collections[ci].entries.clone())
.unwrap_or_default();
let in_scope = match self.reports[idx].report.flow() {
Ok(flow) => crate::report::edit::vars_in_scope(&flow, &path, &entries),
Err(_) => Vec::new(),
};
self.overlay = Some(Overlay::ReportNodeVars(Box::new(VarsForm::build(
report_id, path, &chosen, alias, &stats, image, truth, detail, in_scope,
))));
true
}
pub(crate) fn apply_report_node_vars(&mut self, form: VarsForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_vars_key_handler(&mut self, key: KeyEvent, mut form: Box<VarsForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeVars(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_vars(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(VarsRow::Clause(cr)) => {
clause_key(&mut form.clauses, cr, key);
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
Some(VarsRow::Var(vi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.vars.get_mut(vi)
{
row.included = !row.included;
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(VarsRow::Other) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.other.push(c)
}
KeyCode::Backspace => {
form.other.pop();
}
_ => {}
}
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
Some(VarsRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(VarsRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_report_node_computed(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
image,
truth,
detail,
}) = node
else {
return false;
};
self.overlay = Some(Overlay::ReportNodeComputed(Box::new(ComputedForm {
report_id,
path,
template,
alias: name,
clauses: ClauseForm::of(image, truth.as_deref(), detail),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_computed(&mut self, form: ComputedForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_computed_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<ComputedForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeComputed(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_computed(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(ComputedRow::Clause(cr)) => {
clause_key(&mut form.clauses, cr, key);
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
Some(ComputedRow::Template) => {
match key.code {
KeyCode::Char(c) => form.template.push(c),
KeyCode::Backspace => {
form.template.pop();
}
_ => {}
}
keep(self, form);
}
Some(ComputedRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(ComputedRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_report_node_assign(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::Assign { key, value } = node else {
return false;
};
self.overlay = Some(Overlay::ReportNodeAssign(Box::new(AssignForm {
report_id,
path,
key,
value,
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_assign(&mut self, form: AssignForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_assign_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<AssignForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeAssign(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_assign(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
let target = match rows.get(sel).copied() {
Some(AssignRow::Key) => &mut form.key,
Some(AssignRow::Value) => &mut form.value,
None => {
keep(self, form);
return;
}
};
match key.code {
KeyCode::Char(c) => target.push(c),
KeyCode::Backspace => {
target.pop();
}
_ => {}
}
keep(self, form);
}
}
}
fn open_report_node_list(&mut self, idx: usize) -> bool {
let Some((report_id, path, node)) = self.selected_node(idx) else {
return false;
};
let FlowNode::ListDecl {
name,
producer: Producer::List(elems),
} = node
else {
return false;
};
let mut values = Vec::with_capacity(elems.len());
for e in &elems {
match e {
Element::Scalar(v) => values.push(v.clone()),
Element::Tuple(_) => return false, }
}
self.overlay = Some(Overlay::ReportNodeList(Box::new(ListForm {
report_id,
path,
name,
values,
selected: 0,
})));
true
}
pub(crate) fn apply_report_node_list(&mut self, form: ListForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(node) = form.node() else { return };
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_list_key_handler(&mut self, key: KeyEvent, mut form: Box<ListForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeList(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_list(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(ListRow::Name) => {
match key.code {
KeyCode::Char(c) => form.name.push(c),
KeyCode::Backspace => {
form.name.pop();
}
_ => {}
}
keep(self, form);
}
Some(ListRow::Value(vi)) => {
match key.code {
KeyCode::Delete => {
if vi < form.values.len() {
form.values.remove(vi);
}
form.selected = form.selected.min(form.last_row());
}
KeyCode::Char(c) => form.values[vi].push(c),
KeyCode::Backspace => {
form.values[vi].pop();
}
_ => {}
}
keep(self, form);
}
Some(ListRow::Add) => {
if matches!(key.code, KeyCode::Char(' ')) {
form.values.push(String::new());
form.selected = form.values.len();
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn selected_node(&self, idx: usize) -> Option<(u64, Vec<usize>, FlowNode)> {
let rows = self.report_node_rows(idx).ok()?;
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let path = rows.get(sel)?.path.clone();
let flow = self.reports[idx].report.flow().ok()?;
let node = node_at(&flow, &path)?.clone();
Some((self.reports[idx].report.id, path, node))
}
pub(crate) fn report_node_with_field_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<WithFieldForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeWithField(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => {
let (report_id, path, back) =
(form.report_id, form.path.clone(), form.return_to_request);
let written = self.apply_report_node_with_field(*form);
self.close_report_node_with_field(report_id, &path, back);
if !back && let Some(wi) = written {
self.select_with_row(report_id, &path, wi);
}
}
KeyCode::Esc => {
let (report_id, path, back) =
(form.report_id, form.path.clone(), form.return_to_request);
self.close_report_node_with_field(report_id, &path, back);
}
_ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(WithFieldRow::Name) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.name.push(c)
}
KeyCode::Backspace => {
form.name.pop();
}
_ => {}
}
keep(self, form);
}
Some(WithFieldRow::Query) => {
match key.code {
KeyCode::Char(c) => form.query.push(c),
KeyCode::Backspace => {
form.query.pop();
}
_ => {}
}
keep(self, form);
}
Some(WithFieldRow::Clause(cr)) => {
clause_key(&mut form.clauses, cr, key);
keep(self, form);
}
Some(WithFieldRow::Stats) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x')) {
form.toggle_stats();
}
keep(self, form);
}
Some(WithFieldRow::Stat(si)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some((_, on)) = form.stats.get_mut(si)
{
*on = !*on;
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
pub(crate) fn apply_report_node_with_field(&mut self, form: WithFieldForm) -> Option<usize> {
let idx = self.report_index_by_id(form.report_id)?;
let item = form.item()?;
let flow = self.reports[idx].report.flow().ok()?;
let Some(FlowNode::Report(ReportStmt::Request {
name,
alias,
using,
response_fmt,
show,
hide,
with,
})) = node_at(&flow, &form.path)
else {
return None;
};
let mut with = with.clone();
let written = match form.index {
Some(i) if i < with.len() => {
with[i] = item;
i
}
_ => {
with.push(item);
with.len() - 1
}
};
let node = FlowNode::Report(ReportStmt::Request {
name: name.clone(),
alias: alias.clone(),
using: using.clone(),
response_fmt: *response_fmt,
show: show.clone(),
hide: hide.clone(),
with,
});
self.apply_node_replace(idx, &form.path, node);
Some(written)
}
fn close_report_node_with_field(&mut self, report_id: u64, path: &[usize], to_request: bool) {
if to_request {
self.reopen_report_node_request(report_id, path);
} else {
self.overlay = None;
}
}
fn reopen_report_node_request(&mut self, report_id: u64, path: &[usize]) {
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let Some(node) = node_at(&flow, path).cloned() else {
return;
};
if let Some(form) = self.build_report_node_request_form(idx, path.to_vec(), &node) {
self.overlay = Some(Overlay::ReportNodeRequest(Box::new(form)));
}
}
fn open_report_node_envs(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let report_id = self.reports[idx].report.id;
let (var, clause, parallel, body) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::ForEnvs {
var,
clause,
parallel,
body,
}) => (var.clone(), clause.clone(), *parallel, body.clone()),
_ => return false, }
};
let choices: Vec<String> = self.global_envs.iter().map(|e| e.name.clone()).collect();
let snapshots = self.discover_report_snapshots(idx);
let selected_show = match &clause {
crate::report::flow::EnvClause::Roles { baseline_show, .. } => baseline_show.clone(),
crate::report::flow::EnvClause::Plain(_) => Vec::new(),
};
let show_choices = match self.resolve_bound_collection(&self.reports[idx].report) {
Some(ci) => crate::report::edit::baseline_show_choices(
&self.collections[ci].entries,
&body,
&selected_show,
),
None => crate::report::edit::baseline_show_choices(&[], &body, &selected_show),
};
let form = EnvsForm::build(
report_id,
path,
var,
&clause,
parallel,
choices,
snapshots,
show_choices,
);
self.overlay = Some(Overlay::ReportNodeEnvs(Box::new(form)));
true
}
fn discover_report_snapshots(&self, idx: usize) -> Vec<String> {
let (root, _) = super::reports::report_base_dir(&self.reports[idx].report);
let mut out: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "baseline")
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
out.push(name.to_string());
}
}
}
out.sort();
out
}
pub(crate) fn apply_report_node_envs(&mut self, form: EnvsForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let Some(clause) = form.clause() else {
return;
};
let body = {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
match node_at(&flow, &form.path) {
Some(FlowNode::ForEnvs { body, .. }) => body.clone(),
_ => return,
}
};
let node = FlowNode::ForEnvs {
var: form.var_or_default(),
clause,
body,
parallel: form.parallel_spec(),
};
self.apply_node_replace(idx, &form.path, node);
}
fn open_report_node_files(&mut self, idx: usize) -> bool {
let Ok(rows) = self.report_node_rows(idx) else {
return false;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else {
return false;
};
let path = row.path.clone();
let report_id = self.reports[idx].report.id;
let (var, dir, glob, parallel, folders, roles) = {
let Ok(flow) = self.reports[idx].report.flow() else {
return false;
};
match node_at(&flow, &path) {
Some(FlowNode::ForEach {
pattern,
producer: Producer::Files { dir, glob },
parallel,
..
}) if pattern.is_single() => (
pattern.named().next().unwrap_or("FILE").to_string(),
dir.clone(),
glob.clone(),
*parallel,
false,
Vec::new(),
),
Some(FlowNode::ForEach {
pattern,
producer: Producer::Folders { dir, glob, roles },
parallel,
..
}) if pattern.is_single() => (
pattern.named().next().unwrap_or("FOLDER").to_string(),
dir.clone(),
glob.clone(),
*parallel,
true,
roles.clone(),
),
_ => return false, }
};
let form = FilesForm::build(report_id, path, var, dir, glob, parallel, folders, roles);
self.overlay = Some(Overlay::ReportNodeFiles(Box::new(form)));
true
}
pub(crate) fn apply_report_node_files(&mut self, form: &FilesForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let body = {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
match node_at(&flow, &form.path) {
Some(FlowNode::ForEach { body, .. }) => body.clone(),
_ => return,
}
};
let node = FlowNode::ForEach {
pattern: Pattern::single(form.var_or_default()),
producer: form.producer(),
body,
parallel: form.parallel_spec(),
};
self.apply_node_replace(idx, &form.path, node);
}
pub(crate) fn report_node_files_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<FilesForm>,
) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeFiles(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
if rows.get(sel).copied() == Some(FilesRow::Folder) {
self.apply_report_node_files(&form);
self.open_files_form_folder(&form);
} else {
self.apply_report_node_files(&form);
}
}
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(FilesRow::Var) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => form.var.push(c),
KeyCode::Backspace => {
form.var.pop();
}
_ => {}
}
keep(self, form);
}
Some(FilesRow::Match) => {
match key.code {
KeyCode::Char(c) => form.glob.push(c),
KeyCode::Backspace => {
form.glob.pop();
}
_ => {}
}
keep(self, form);
}
Some(FilesRow::Folder) => {
if matches!(key.code, KeyCode::Char(' ')) {
self.apply_report_node_files(&form);
self.open_files_form_folder(&form);
} else {
keep(self, form);
}
}
Some(FilesRow::Parallel) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_parallel();
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(FilesRow::Degree) => {
match key.code {
KeyCode::Char(c) if c.is_ascii_digit() => form.degree.push(c),
KeyCode::Backspace => {
form.degree.pop();
}
_ => {}
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
fn open_files_form_folder(&mut self, form: &FilesForm) {
let Some(idx) = self.report_index_by_id(form.report_id) else {
return;
};
let start = {
let p = std::path::Path::new(&form.dir);
if !form.dir.trim().is_empty() && p.is_dir() {
Some(p.to_path_buf())
} else if let Some(base) = self.active_report_base_dir() {
let joined = base.join(&form.dir);
Some(if joined.is_dir() { joined } else { base })
} else {
None
}
};
if let Some(dir) = start {
self.last_browse_dir = Some(dir);
}
self.pending_node_folder = Some((form.report_id, form.path.clone()));
let _ = idx;
self.open_browser(crate::tui::app::FileAction::PickReportNodeFolder);
}
pub(crate) fn report_node_envs_key_handler(&mut self, key: KeyEvent, mut form: Box<EnvsForm>) {
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeEnvs(form));
};
let last = form.last_row();
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_envs(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(EnvsRow::Var) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => form.var.push(c),
KeyCode::Backspace => {
form.var.pop();
}
_ => {}
}
keep(self, form);
}
Some(EnvsRow::Mode) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_mode();
}
keep(self, form);
}
Some(EnvsRow::Parallel) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.toggle_parallel();
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(EnvsRow::Degree) => {
match key.code {
KeyCode::Char(c) if c.is_ascii_digit() => form.degree.push(c),
KeyCode::Backspace => {
form.degree.pop();
}
_ => {}
}
keep(self, form);
}
Some(EnvsRow::BaselineShow(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.baseline_show.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(EnvsRow::Env(ei)) => {
match key.code {
KeyCode::Char(' ') | KeyCode::Right => form.cycle_entry(ei, true),
KeyCode::Left => form.cycle_entry(ei, false),
KeyCode::Char('b') => form.toggle_baseline(ei),
KeyCode::Char('f') => form.toggle_file(ei),
KeyCode::Char('n') => {
form.add_entry();
form.selected = form.last_row();
}
KeyCode::Char('x') | KeyCode::Delete => {
form.remove_entry(ei);
form.selected = form.selected.min(form.last_row());
}
_ => {}
}
keep(self, form);
}
None => keep(self, form),
}
}
}
}
pub(crate) fn report_node_request_key_handler(
&mut self,
key: KeyEvent,
mut form: Box<RequestForm>,
) {
let last = form.last_row();
let keep = |app: &mut TuiApp, form| {
app.overlay = Some(Overlay::ReportNodeRequest(form));
};
match key.code {
KeyCode::Up => {
form.selected = form.selected.saturating_sub(1);
keep(self, form);
}
KeyCode::Down | KeyCode::Tab => {
form.selected = (form.selected + 1).min(last);
keep(self, form);
}
KeyCode::Enter => self.apply_report_node_request(*form),
KeyCode::Esc => {} _ => {
let rows = form.visible_rows();
let sel = form.selected.min(rows.len().saturating_sub(1));
match rows.get(sel).copied() {
Some(FormRow::Name) => match key.code {
KeyCode::Char(' ') | KeyCode::Right => {
form.cycle_name(true);
self.refresh_form_params(&mut form);
keep(self, form);
}
KeyCode::Left => {
form.cycle_name(false);
self.refresh_form_params(&mut form);
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::Report) => {
if matches!(
key.code,
KeyCode::Char(' ') | KeyCode::Left | KeyCode::Right
) {
form.report = !form.report;
form.selected = form.selected.min(form.last_row());
}
keep(self, form);
}
Some(FormRow::Response) => match key.code {
KeyCode::Char(' ') | KeyCode::Right => {
form.cycle_response(true);
keep(self, form);
}
KeyCode::Left => {
form.cycle_response(false);
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::Alias) => {
match key.code {
KeyCode::Char(c) if c.is_alphanumeric() || c == '_' => {
form.alias.push(c)
}
KeyCode::Backspace => {
form.alias.pop();
}
_ => {}
}
keep(self, form);
}
Some(FormRow::OverrideTarget(oi)) => {
match key.code {
KeyCode::Char(c) if !c.is_control() => {
if let Some(row) = form.overrides.get_mut(oi) {
row.target.push(c);
}
}
KeyCode::Backspace => {
if let Some(row) = form.overrides.get_mut(oi) {
row.target.pop();
}
}
KeyCode::Delete => {
if oi < form.overrides.len() {
form.overrides.remove(oi);
form.selected = form.selected.min(form.last_row());
}
}
_ => {}
}
keep(self, form);
}
Some(FormRow::OverrideValue(oi)) => {
match key.code {
KeyCode::Char(c) if !c.is_control() => {
if let Some(row) = form.overrides.get_mut(oi) {
row.value.push(c);
}
}
KeyCode::Backspace => {
if let Some(row) = form.overrides.get_mut(oi) {
row.value.pop();
}
}
KeyCode::Delete => {
if oi < form.overrides.len() {
form.overrides.remove(oi);
form.selected = form.selected.min(form.last_row());
}
}
_ => {}
}
keep(self, form);
}
Some(FormRow::AddOverride) => {
if key.code == KeyCode::Char(' ') {
form.overrides.push(edit::OverrideRow::default());
form.selected = form
.visible_rows()
.iter()
.position(|r| {
*r == FormRow::OverrideTarget(form.overrides.len() - 1)
})
.unwrap_or(form.selected);
}
keep(self, form);
}
Some(FormRow::Param(pi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.params.get_mut(pi)
{
row.required = !row.required;
}
keep(self, form);
}
Some(FormRow::Field(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.fields.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(FormRow::Hidden(fi)) => {
if matches!(key.code, KeyCode::Char(' ') | KeyCode::Char('x'))
&& let Some(row) = form.hide_fields.get_mut(fi)
{
row.included = !row.included;
}
keep(self, form);
}
Some(FormRow::With(wi)) => match key.code {
KeyCode::Char(' ') => {
let existing = form.with.get(wi).cloned();
let sub = WithFieldForm::build(
form.report_id,
form.path.clone(),
Some(wi),
existing.as_ref(),
true,
);
self.apply_report_node_request(*form);
self.overlay = Some(Overlay::ReportNodeWithField(Box::new(sub)));
}
KeyCode::Char('x') | KeyCode::Delete => {
if wi < form.with.len() {
form.with.remove(wi);
}
form.selected = form.selected.min(form.last_row());
keep(self, form);
}
_ => keep(self, form),
},
Some(FormRow::AddWith) => {
if matches!(key.code, KeyCode::Char(' ')) {
let sub = WithFieldForm::build(
form.report_id,
form.path.clone(),
None,
None,
true,
);
self.apply_report_node_request(*form);
self.overlay = Some(Overlay::ReportNodeWithField(Box::new(sub)));
} else {
keep(self, form);
}
}
None => keep(self, form),
}
}
}
}
fn edit_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
self.open_report_node_menu(idx);
return;
}
if let Some(wi) = self.with_row_index(row) {
self.open_with_field_editor(idx, &row.path, wi);
return;
}
if row.kind == RowKind::WithEnd {
return;
}
let path = row.path.clone();
self.open_report_node_line_prompt(idx, &path);
}
fn with_row_index(&self, row: &NodeRow) -> Option<usize> {
match row.kind {
RowKind::WithField(i) => Some(i),
RowKind::WithAdd => Some(usize::MAX),
_ => None,
}
}
fn open_with_field_editor(&mut self, idx: usize, path: &[usize], wi: usize) {
let report_id = self.reports[idx].report.id;
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let existing = node_at(&flow, path)
.and_then(node_with_items)
.and_then(|w| w.get(wi))
.cloned();
let form = WithFieldForm::build(
report_id,
path.to_vec(),
existing.is_some().then_some(wi),
existing.as_ref(),
false,
);
self.overlay = Some(Overlay::ReportNodeWithField(Box::new(form)));
}
fn move_with_field(&mut self, idx: usize, path: &[usize], wi: usize, up: bool) {
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
let Some(FlowNode::Report(ReportStmt::Request { with, .. })) =
node_at_mut(&mut flow, path)
else {
return;
};
let other = if up {
match wi.checked_sub(1) {
Some(o) => o,
None => return, }
} else if wi + 1 < with.len() {
wi + 1
} else {
return; };
with.swap(wi, other);
let text = flow.to_text();
rt.set_text_undoable(text);
let sel = rt.node_selected;
rt.node_selected = if up { sel.saturating_sub(1) } else { sel + 1 };
}
self.revalidate_report(idx);
self.save_state();
}
fn open_report_node_line_prompt(&mut self, idx: usize, path: &[usize]) {
let report_id = self.reports[idx].report.id;
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let Some(node) = node_at(&flow, path) else {
return;
};
let line = node.header_line();
let s = Strings::for_language(&self.language);
self.overlay = Some(Overlay::Prompt {
kind: PromptKind::ReportNodeLine {
report_id,
path: path.to_vec(),
},
editor: Editor::new(&line, false),
title: format!(
"{} ({})",
s.report_node_edit_title, s.report_node_edit_hint
),
mask: false,
reset_to: None,
secret_intact: false,
secret_checkbox: None,
});
}
pub(crate) fn report_node_menu_key_handler(&mut self, key: KeyEvent, mut menu: Box<NodeMenu>) {
let last = menu.options.len().saturating_sub(1);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
menu.selected = menu.selected.saturating_sub(1);
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Down | KeyCode::Char('j') => {
menu.selected = (menu.selected + 1).min(last);
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Home => {
menu.selected = 0;
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::End => {
menu.selected = last;
self.overlay = Some(Overlay::ReportNodeMenu(menu));
}
KeyCode::Enter => match menu.step {
NodeMenuStep::PickKind => self.node_menu_pick_kind(*menu),
NodeMenuStep::PickRequest => self.node_menu_pick_request(*menu),
},
_ => {}
}
}
fn node_menu_pick_kind(&mut self, mut menu: NodeMenu) {
let Some(&kind) = NodeKind::ALL.get(menu.selected) else {
return;
};
let Some(idx) = self.report_index_by_id(menu.report_id) else {
return;
};
if kind.needs_request() {
let report_kind = matches!(kind, NodeKind::ReportRequest);
let titles = self.bound_request_titles(menu.report_id);
if titles.is_empty() {
let path = self.apply_node_insert(idx, &menu.pos, request_node("", report_kind));
self.open_report_node_line_prompt(idx, &path);
return;
}
menu.step = NodeMenuStep::PickRequest;
menu.options = titles;
menu.selected = 0;
menu.report_kind = report_kind;
self.overlay = Some(Overlay::ReportNodeMenu(Box::new(menu)));
} else if let Some(node) = kind.template() {
self.apply_node_insert(idx, &menu.pos, node);
self.configure_selected_node(idx);
}
}
fn node_menu_pick_request(&mut self, menu: NodeMenu) {
let Some(idx) = self.report_index_by_id(menu.report_id) else {
return;
};
let Some(name) = menu.options.get(menu.selected) else {
return;
};
let node = request_node(name, menu.report_kind);
match &menu.edit_path {
Some(path) => self.apply_node_replace(idx, path, node),
None => {
self.apply_node_insert(idx, &menu.pos, node);
}
}
}
fn apply_node_insert(&mut self, idx: usize, pos: &InsertPos, node: FlowNode) -> Vec<usize> {
let mut path = pos.parent.clone();
path.push(pos.index);
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return path;
};
insert_node(&mut flow, pos, node);
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, &path);
self.save_state();
path
}
fn apply_node_replace(&mut self, idx: usize, path: &[usize], node: FlowNode) {
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if !replace_node(&mut flow, path, node) {
return;
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.select_node_path(idx, path);
self.save_state();
}
fn delete_selected_node(&mut self, idx: usize) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
return; }
if let Some(wi) = row.kind.with_item() {
let path = row.path.clone();
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if node_at(&flow, &path)
.and_then(node_with_items)
.is_none_or(|w| wi >= w.len())
{
return;
}
detach_modifier(&mut flow, &path, DetachWhich::With(wi));
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.save_state();
return;
}
if row.kind.is_with() {
return; }
let path = row.path.clone();
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if !remove_node(&mut flow, &path) {
return;
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.save_state();
}
fn move_selected_node(&mut self, idx: usize, up: bool) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let sel = self.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
let Some(row) = rows.get(sel) else { return };
if row.kind == RowKind::Begin {
return;
}
if let Some(wi) = row.kind.with_item() {
self.move_with_field(idx, &row.path.clone(), wi, up);
return;
}
if row.kind.is_with() {
return;
}
let path = row.path.clone();
let new_path = {
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
let Some(np) = move_node(&mut flow, &path, up) else {
return; };
let text = flow.to_text();
rt.set_text_undoable(text);
np
};
self.revalidate_report(idx);
self.select_node_path(idx, &new_path);
self.save_state();
}
fn undo_report_node(&mut self, idx: usize) {
let Some(snap) = self.reports[idx].node_undo.pop() else {
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportNodeNothingToUndo(
s.report_node_undo_empty.to_string(),
));
return;
};
{
let rt = &mut self.reports[idx];
rt.report.set_text(snap.text);
rt.node_selected = snap.node_selected;
}
self.revalidate_report(idx);
self.save_state();
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportNodeUndone(s.report_node_undone.to_string()));
}
pub(crate) fn commit_report_node_line(&mut self, report_id: u64, path: &[usize], text: String) {
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let was_loop = self.reports[idx]
.report
.flow()
.ok()
.and_then(|flow| node_at(&flow, path).map(FlowNode::is_loop))
.unwrap_or(false);
match parse_one_node(&text, was_loop) {
Some(node) => self.apply_node_replace(idx, path, node),
None => {
let s = Strings::for_language(&self.language);
self.status = Some(Status::ReportRunBlocked(
s.report_node_line_invalid.to_string(),
));
}
}
}
fn select_with_row(&mut self, report_id: u64, path: &[usize], wi: usize) {
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
if let Some(at) = rows
.iter()
.position(|r| r.path == path && r.kind.with_item() == Some(wi))
{
self.reports[idx].node_selected = at;
}
}
fn select_node_path(&mut self, idx: usize, path: &[usize]) {
let Ok(rows) = self.report_node_rows(idx) else {
return;
};
let target = rows
.iter()
.position(|r| r.path == path && r.kind != RowKind::LoopEnd && !r.kind.is_with())
.unwrap_or_else(|| rows.len().saturating_sub(1));
self.reports[idx].node_selected = target;
}
}
pub(crate) fn draw_report_nodes(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
idx: usize,
s: &Strings,
th: &Theme,
) {
let focused = app.report_body_focused();
let title = format!("{} — {}", s.report_nodes_heading, s.report_nodes_hint);
let block = panel(title, focused, th);
let inner = block.inner(area);
f.render_widget(block, area);
app.report_pane_areas[crate::tui::reports::ReportPane::Source.idx()] = Rect::default();
app.report_pane_bars[crate::tui::reports::ReportPane::Source.idx()] = Rect::default();
if inner.width == 0 || inner.height == 0 {
return;
}
let rows = match app.report_node_rows(idx) {
Ok(rows) => rows,
Err(e) => {
let lines = vec![
Line::from(Span::styled(
s.report_nodes_parse_error,
Style::default().fg(th.err),
)),
Line::from(Span::styled(e, Style::default().fg(th.dim))),
];
f.render_widget(Paragraph::new(lines), inner);
return;
}
};
let sel = app.reports[idx]
.node_selected
.min(rows.len().saturating_sub(1));
app.reports[idx].node_selected = sel;
let settings = app.report_setting_rows(idx);
let add_row = !app.missing_report_settings(idx).is_empty();
let set_sel = app.reports[idx].node_setting;
let w = inner.width as usize;
let key_w = settings.iter().map(|r| r.key.len()).max().unwrap_or(0);
let head_lines = if settings.is_empty() {
0
} else {
1 + settings.len() + usize::from(add_row) + 1
};
let mut lines: Vec<Line> = Vec::with_capacity(head_lines + rows.len());
let mut targets: Vec<Option<MouseHitTarget>> = Vec::with_capacity(head_lines + rows.len());
if !settings.is_empty() {
lines.push(Line::from(Span::styled(
s.report_settings_heading.to_string(),
Style::default().fg(th.dim).add_modifier(Modifier::BOLD),
)));
targets.push(None);
for (i, row) in settings.iter().enumerate() {
lines.push(render_setting_row(row, key_w, set_sel == Some(i), w, s, th));
targets.push(Some(MouseHitTarget::ReportSettingRow(i)));
}
if add_row {
let i = settings.len();
let style = if set_sel == Some(i) {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
};
lines.push(Line::from(Span::styled(
format!(" + {}", s.report_setting_add_row),
style,
)));
targets.push(Some(MouseHitTarget::ReportSettingRow(i)));
}
lines.push(Line::from(Span::styled(
"─".repeat(w),
Style::default().fg(th.dim),
)));
targets.push(None);
}
for (i, row) in rows.iter().enumerate() {
lines.push(render_node_row(
row,
i == sel && set_sel.is_none(),
w,
s,
th,
));
targets.push(Some(MouseHitTarget::ReportNodeRow(i)));
}
let cursor = match set_sel {
Some(i) => 1 + i,
None => head_lines + sel,
};
let h = inner.height as usize;
let total = lines.len();
let first = if cursor >= h { cursor + 1 - h } else { 0 };
f.render_widget(
Paragraph::new(lines.into_iter().skip(first).take(h).collect::<Vec<_>>()),
inner,
);
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::ReportPane(
crate::tui::reports::ReportPane::Source,
)),
);
for (line, target) in targets.iter().enumerate().skip(first).take(h) {
if let Some(target) = target {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(inner.x, inner.y + (line - first) as u16, inner.width, 1),
*target,
);
}
}
if total > h {
let bar = Rect {
x: area.x + area.width - 1,
y: inner.y,
width: 1,
height: inner.height,
};
draw_scrollbar(f, bar, total, h, first, th);
}
}
fn render_setting_row(
row: &SettingRow,
key_w: usize,
selected: bool,
width: usize,
s: &Strings,
th: &Theme,
) -> Line<'static> {
let unset = row.unset();
let (value, value_colour) = if unset {
(
s.report_setting_unset.to_string(),
if row.required { th.err } else { th.dim },
)
} else {
(row.value.clone(), th.text)
};
let key_colour = if unset && row.required {
th.err
} else {
th.dim
};
let text = format!(
" {:<key_w$} {value}",
row.key.to_uppercase(),
key_w = key_w
);
let text = truncate_to_width(&text, width);
if selected {
return Line::from(Span::styled(
text,
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD),
));
}
let head_len = 2 + key_w + 2;
let (head, tail) = text.split_at(head_len.min(text.len()));
Line::from(vec![
Span::styled(head.to_string(), Style::default().fg(key_colour)),
Span::styled(tail.to_string(), Style::default().fg(value_colour)),
])
}
fn render_node_row(
row: &NodeRow,
selected: bool,
width: usize,
s: &Strings,
th: &Theme,
) -> Line<'static> {
let indent = " ".repeat(row.depth);
let (text, base, bold) = match row.kind {
RowKind::Begin => (s.report_node_begin.to_string(), th.accent, true),
RowKind::LoopHead => (row.label.clone(), th.accent, false),
RowKind::LoopEnd => ("END".to_string(), th.accent, false),
RowKind::Leaf => (row.label.clone(), th.text, false),
RowKind::Comment => (row.label.clone(), th.dim, false),
RowKind::WithField(_) => (row.label.clone(), th.text, false),
RowKind::WithComment(_) => (row.label.clone(), th.dim, false),
RowKind::WithAdd => (format!("+ {}", s.report_with_add_row), th.dim, false),
RowKind::WithEnd => ("END".to_string(), th.accent, false),
};
let colour = match row.req_ok {
Some(true) => th.ok,
Some(false) => th.pending,
None => base,
};
let mut content = format!("{indent}{text}");
if selected {
let len = content.chars().count();
if len < width {
content.extend(std::iter::repeat_n(' ', width - len));
}
}
let mut style = if selected {
Style::default().fg(th.select_fg).bg(th.select_bg)
} else {
Style::default().fg(colour)
};
if bold {
style = style.add_modifier(Modifier::BOLD);
}
Line::from(Span::styled(content, style))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envs_form_round_trip_preserves_baseline_show() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec!["Time".into()],
};
let form = EnvsForm::build(1, vec![], "T".into(), &clause, None, vec![], vec![], vec![]);
assert_eq!(
form.selected_baseline_show(),
vec!["Time".to_string()],
"a SHOW field the clause names must come back ticked"
);
let rebuilt = form.clause().expect("clause must be Some");
assert_eq!(
rebuilt,
EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec!["Time".into()],
}
);
}
#[test]
fn envs_form_preserves_and_rebuilds_a_file_role() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::File("prod.baseline".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec![],
};
let form = EnvsForm::build(1, vec![], "T".into(), &clause, None, vec![], vec![], vec![]);
assert!(
form.snapshots.iter().any(|s| s == "prod.baseline"),
"existing FILE path must be seeded into the cycle"
);
let rebuilt = form.clause().expect("clause must be Some");
assert_eq!(rebuilt, clause);
}
#[test]
fn envs_form_toggle_file_switches_a_role_to_a_snapshot() {
let clause = EnvClause::Roles {
baseline: vec![RoleRef::Env("prod".into())],
comparisons: vec![RoleRef::Env("staging".into())],
baseline_show: vec![],
};
let mut form = EnvsForm::build(
1,
vec![],
"T".into(),
&clause,
None,
vec!["prod".into(), "staging".into()],
vec!["snap.baseline".into()],
vec![],
);
form.toggle_file(0);
assert!(form.entries[0].file);
assert_eq!(form.entries[0].name, "snap.baseline");
match form.clause().expect("clause") {
EnvClause::Roles { baseline, .. } => {
assert_eq!(baseline, vec![RoleRef::File("snap.baseline".into())]);
}
other => panic!("expected roles, got {other:?}"),
}
form.toggle_file(0);
assert!(!form.entries[0].file);
assert_eq!(form.entries[0].name, "prod");
}
}
pub(crate) struct SettingRow {
pub(crate) key: &'static str,
pub(crate) kind: HeaderKind,
pub(crate) required: bool,
pub(crate) occurrence: usize,
pub(crate) value: String,
}
impl SettingRow {
pub(crate) fn unset(&self) -> bool {
header_unset(&self.value)
}
}
impl TuiApp {
pub(crate) fn report_setting_rows(&self, idx: usize) -> Vec<SettingRow> {
let Some(rt) = self.reports.get(idx) else {
return Vec::new();
};
let Ok(flow) = rt.report.flow() else {
return Vec::new();
};
header_specs()
.into_iter()
.flat_map(|spec| {
let values = if spec.repeatable {
let all = flow.header.get_all(spec.key);
if all.is_empty() {
vec![String::new()]
} else {
all.into_iter().map(str::to_string).collect()
}
} else {
vec![flow.header.get(spec.key).unwrap_or_default().to_string()]
};
values
.into_iter()
.enumerate()
.filter(|(_, value)| spec.always_shown || !value.is_empty())
.map(|(occurrence, value)| SettingRow {
key: spec.key,
kind: spec.kind,
required: spec.required && occurrence == 0,
occurrence,
value,
})
.collect::<Vec<_>>()
})
.collect()
}
pub(crate) fn missing_report_settings(&self, idx: usize) -> Vec<&'static str> {
let Some(rt) = self.reports.get(idx) else {
return Vec::new();
};
let Ok(flow) = rt.report.flow() else {
return Vec::new();
};
header_specs()
.into_iter()
.filter(|spec| {
!spec.always_shown && flow.header.get(spec.key).unwrap_or_default().is_empty()
})
.map(|spec| spec.key)
.collect()
}
pub(crate) fn setting_row_count(&self, idx: usize) -> usize {
let rows = self.report_setting_rows(idx).len();
rows + usize::from(!self.missing_report_settings(idx).is_empty())
}
fn on_add_setting_row(&self, idx: usize, sel: usize) -> bool {
sel >= self.report_setting_rows(idx).len()
}
fn on_key_report_settings(&mut self, key: KeyEvent, idx: usize, sel: usize) -> bool {
let last = self.setting_row_count(idx).saturating_sub(1);
let sel = sel.min(last);
self.reports[idx].node_setting = Some(sel);
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.reports[idx].node_setting = Some(sel.saturating_sub(1));
}
KeyCode::Down | KeyCode::Char('j') => {
if sel >= last {
self.reports[idx].node_setting = None;
self.reports[idx].node_selected = 0;
} else {
self.reports[idx].node_setting = Some(sel + 1);
}
}
KeyCode::Home => self.reports[idx].node_setting = Some(0),
KeyCode::End => {
self.reports[idx].node_setting = None;
let rows = self.report_node_rows(idx).map(|r| r.len()).unwrap_or(1);
self.reports[idx].node_selected = rows.saturating_sub(1);
}
KeyCode::Enter => self.configure_selected_setting(idx, sel),
KeyCode::Char('e') => self.edit_selected_setting_raw(idx, sel),
KeyCode::Delete | KeyCode::Backspace => self.clear_selected_setting(idx, sel),
KeyCode::Char('a') | KeyCode::Insert => self.open_add_setting_menu(idx),
_ => return false,
}
true
}
fn configure_selected_setting(&mut self, idx: usize, sel: usize) {
if self.on_add_setting_row(idx, sel) {
self.open_add_setting_menu(idx);
return;
}
let rows = self.report_setting_rows(idx);
let Some(row) = rows.get(sel) else { return };
let (key, kind, occurrence) = (row.key, row.kind, row.occurrence);
match kind {
HeaderKind::Collection if occurrence == 0 => self.open_report_bind(),
HeaderKind::Collection => self.open_setting_text_prompt(idx, sel),
HeaderKind::Environment | HeaderKind::Format => {
self.open_setting_value_menu(idx, key, occurrence, kind)
}
HeaderKind::Folder | HeaderKind::File => self.open_setting_path_browser(idx, key, kind),
HeaderKind::Text => self.open_setting_text_prompt(idx, sel),
}
}
fn edit_selected_setting_raw(&mut self, idx: usize, sel: usize) {
if self.on_add_setting_row(idx, sel) {
self.open_add_setting_menu(idx);
return;
}
self.open_setting_text_prompt(idx, sel);
}
fn open_setting_text_prompt(&mut self, idx: usize, sel: usize) {
let rows = self.report_setting_rows(idx);
let Some(row) = rows.get(sel) else { return };
let seed = if row.unset() {
String::new()
} else {
row.value.clone()
};
let key = row.key;
let occurrence = row.occurrence;
let report_id = self.reports[idx].report.id;
self.overlay = Some(Overlay::Prompt {
kind: PromptKind::ReportHeaderValue {
report_id,
key,
occurrence,
},
editor: Editor::new(&seed, false),
title: key.to_uppercase(),
mask: false,
reset_to: None,
secret_intact: false,
secret_checkbox: None,
});
}
fn clear_selected_setting(&mut self, idx: usize, sel: usize) {
if self.on_add_setting_row(idx, sel) {
return;
}
let rows = self.report_setting_rows(idx);
let Some(row) = rows.get(sel) else { return };
let (key, occurrence) = (row.key, row.occurrence);
self.apply_report_setting(idx, key, occurrence, None);
let last = self.setting_row_count(idx).saturating_sub(1);
self.reports[idx].node_setting = Some(sel.min(last));
}
pub(crate) fn apply_report_setting(
&mut self,
idx: usize,
key: &str,
occurrence: usize,
value: Option<&str>,
) {
{
let rt = &mut self.reports[idx];
let Ok(mut flow) = rt.report.flow() else {
return;
};
if !crate::report::edit::set_header_nth(&mut flow, key, occurrence, value) {
return;
}
let text = flow.to_text();
rt.set_text_undoable(text);
}
self.revalidate_report(idx);
self.save_state();
}
}
pub(crate) struct SettingMenu {
pub(crate) step: SettingMenuStep,
pub(crate) options: Vec<String>,
pub(crate) filter: String,
pub(crate) selected: usize,
pub(crate) report_id: u64,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum SettingMenuStep {
AddSetting,
PickValue {
key: &'static str,
occurrence: usize,
},
PickParam { name: String },
}
impl SettingMenu {
pub(crate) fn visible(&self) -> Vec<&String> {
if self.filter.is_empty() {
return self.options.iter().collect();
}
let needle = self.filter.to_lowercase();
self.options
.iter()
.filter(|o| o.to_lowercase().contains(&needle))
.collect()
}
pub(crate) fn choice(&self) -> Option<String> {
self.visible().get(self.selected).map(|o| (*o).clone())
}
fn clamp_selection(&mut self) {
self.selected = 0;
}
pub(crate) fn title(&self, s: &Strings) -> String {
match &self.step {
SettingMenuStep::AddSetting => s.report_add_setting.to_string(),
SettingMenuStep::PickValue { key, .. } => key.to_uppercase(),
SettingMenuStep::PickParam { name } => name.clone(),
}
}
}
impl TuiApp {
fn open_add_setting_menu(&mut self, idx: usize) {
let s = Strings::for_language(&self.language);
let missing = self.missing_report_settings(idx);
let mut options: Vec<String> = missing.iter().map(|k| k.to_uppercase()).collect();
options.push(s.report_add_helper_collection.to_string());
if self
.reports
.get(idx)
.and_then(|rt| rt.report.flow().ok())
.is_some_and(|f| !f.header.labels().is_empty())
{
options.push(s.report_add_label_class.to_string());
}
self.overlay = Some(Overlay::ReportSettingMenu(Box::new(SettingMenu {
step: SettingMenuStep::AddSetting,
options,
filter: String::new(),
selected: 0,
report_id: self.reports[idx].report.id,
})));
}
fn add_helper_collection(&mut self, idx: usize) {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let occurrence = flow.header.get_all("collection").len().max(1);
self.apply_report_setting(idx, "collection", occurrence, Some(HEADER_PLACEHOLDER));
let rows = self.report_setting_rows(idx);
if let Some(pos) = rows
.iter()
.position(|r| r.key == "collection" && r.occurrence == occurrence)
{
self.reports[idx].node_setting = Some(pos);
self.open_setting_text_prompt(idx, pos);
}
}
fn add_label_class(&mut self, idx: usize) {
let Ok(flow) = self.reports[idx].report.flow() else {
return;
};
let occurrence = flow.header.labels().len();
self.apply_report_setting(idx, "labels", occurrence, Some(HEADER_PLACEHOLDER));
let rows = self.report_setting_rows(idx);
if let Some(pos) = rows
.iter()
.position(|r| r.key == "labels" && r.occurrence == occurrence)
{
self.reports[idx].node_setting = Some(pos);
self.open_setting_text_prompt(idx, pos);
}
}
fn open_setting_value_menu(
&mut self,
idx: usize,
key: &'static str,
occurrence: usize,
kind: HeaderKind,
) {
let options: Vec<String> = match kind {
HeaderKind::Format => crate::report::writer::OUTPUT_EXTENSIONS
.iter()
.map(|e| e.to_string())
.collect(),
_ => self.global_envs.iter().map(|e| e.name.clone()).collect(),
};
if options.is_empty() {
self.status = Some(Status::ReportSettingNoChoices);
return;
}
let current = self
.report_setting_rows(idx)
.into_iter()
.find(|r| r.key == key)
.map(|r| r.value)
.unwrap_or_default();
let selected = options.iter().position(|o| *o == current).unwrap_or(0);
self.overlay = Some(Overlay::ReportSettingMenu(Box::new(SettingMenu {
step: SettingMenuStep::PickValue { key, occurrence },
options,
filter: String::new(),
selected,
report_id: self.reports[idx].report.id,
})));
}
fn open_setting_path_browser(&mut self, idx: usize, key: &'static str, kind: HeaderKind) {
self.pending_header_path = Some((self.reports[idx].report.id, key));
if let Some(dir) = self.active_report_base_dir() {
self.last_browse_dir = Some(dir);
}
self.open_browser(match kind {
HeaderKind::Folder => FileAction::PickReportHeaderFolder,
_ => FileAction::PickReportHeaderFile,
});
}
pub(crate) fn commit_report_param_path(&mut self, path: &str) {
let Some((report_id, name)) = self.pending_param_path.take() else {
return;
};
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let value = self
.reports
.get(idx)
.and_then(|rt| rt.report.path.as_deref())
.and_then(|p| p.parent())
.and_then(|base| std::path::Path::new(path).strip_prefix(base).ok())
.map(|rel| rel.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
self.set_report_param(report_id, &name, value);
}
pub(crate) fn commit_report_header_path(&mut self, path: &str) {
let Some((report_id, key)) = self.pending_header_path.take() else {
return;
};
let Some(idx) = self.report_index_by_id(report_id) else {
return;
};
let value = self
.reports
.get(idx)
.and_then(|rt| rt.report.path.as_deref())
.and_then(|p| p.parent())
.and_then(|base| std::path::Path::new(path).strip_prefix(base).ok())
.map(|rel| rel.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
self.apply_report_setting(idx, key, 0, Some(&value));
}
pub(crate) fn report_setting_menu_key_handler(
&mut self,
key: KeyEvent,
mut menu: Box<SettingMenu>,
) {
let last = menu.visible().len().saturating_sub(1);
match key.code {
KeyCode::Up => {
menu.selected = menu.selected.saturating_sub(1);
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::Down => {
menu.selected = (menu.selected + 1).min(last);
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::Home => {
menu.selected = 0;
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::End => {
menu.selected = last;
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
menu.filter.push(c);
menu.clamp_selection();
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::Backspace => {
if menu.filter.pop().is_some() {
menu.clamp_selection();
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
}
KeyCode::Enter if menu.choice().is_none() => {
self.overlay = Some(Overlay::ReportSettingMenu(menu));
}
KeyCode::Enter => self.apply_setting_menu(*menu),
_ => {}
}
}
fn apply_setting_menu(&mut self, menu: SettingMenu) {
let Some(idx) = self.report_index_by_id(menu.report_id) else {
return;
};
let Some(choice) = menu.choice() else {
return;
};
match &menu.step {
SettingMenuStep::AddSetting => {
let s = Strings::for_language(&self.language);
if choice == s.report_add_helper_collection {
self.add_helper_collection(idx);
return;
}
if choice == s.report_add_label_class {
self.add_label_class(idx);
return;
}
let key = choice.to_ascii_lowercase();
let Some(spec) = header_specs().into_iter().find(|s| s.key == key) else {
return;
};
self.apply_report_setting(idx, spec.key, 0, Some(HEADER_PLACEHOLDER));
let rows = self.report_setting_rows(idx);
if let Some(pos) = rows.iter().position(|r| r.key == spec.key) {
self.reports[idx].node_setting = Some(pos);
self.configure_selected_setting(idx, pos);
}
}
SettingMenuStep::PickValue { key, occurrence } => {
self.apply_report_setting(idx, key, *occurrence, Some(&choice))
}
SettingMenuStep::PickParam { name } => {
let (id, name) = (menu.report_id, name.clone());
self.set_report_param(id, &name, choice);
}
}
}
}