use eframe::egui::{self, RichText};
use crate::report::context;
use crate::report::edit::node_at;
use crate::report::flow::{
EnvClause, FlowNode, ParallelSpec, Pattern, Producer, ReportStmt, ResponseFmt, RoleRef,
WithItem,
};
use crate::report::model::StatKind;
use super::app::GuiApp;
use super::report_editor::ReportEditor;
pub enum Wizard {
Request(RequestForm),
Envs(EnvsForm),
Files(FilesForm),
Assign(AssignForm),
List(ListForm),
Folders(FoldersForm),
Vars(VarsForm),
Computed(ComputedForm),
Raw(RawForm),
WithField(WithFieldForm),
}
pub struct VarsForm {
path: Vec<usize>,
vars: Vec<(String, bool)>,
other: String,
alias: String,
stats: Vec<(StatKind, bool)>,
}
impl VarsForm {
fn chosen(&self) -> Vec<String> {
let mut v: Vec<String> = self
.vars
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect();
let other = self.other.trim();
if !other.is_empty() && !v.iter().any(|n| n == other) {
v.push(other.to_string());
}
v
}
fn node(&self) -> Option<FlowNode> {
let chosen = self.chosen();
let (first, rest) = chosen.split_first()?;
let alias = self.alias.trim();
if rest.is_empty() && !alias.is_empty() {
return Some(FlowNode::Report(ReportStmt::VarAs {
var: first.clone(),
name: alias.to_string(),
stats: self
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
}));
}
Some(FlowNode::Report(ReportStmt::Vars(chosen)))
}
}
pub struct ComputedForm {
path: Vec<usize>,
template: String,
alias: String,
stats: Vec<(StatKind, bool)>,
}
pub struct AssignForm {
path: Vec<usize>,
key: String,
value: String,
}
pub struct ListForm {
path: Vec<usize>,
name: String,
values: String,
}
pub struct FoldersForm {
path: Vec<usize>,
var: String,
dir: String,
parallel: bool,
degree: String,
}
pub struct RawForm {
path: Vec<usize>,
text: String,
is_loop: bool,
}
pub struct WithFieldForm {
path: Vec<usize>,
index: Option<usize>,
name: String,
query: String,
stats: Vec<(StatKind, bool)>,
}
impl WithFieldForm {
fn stats(&self) -> Vec<StatKind> {
self.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect()
}
}
pub struct RequestForm {
path: Vec<usize>,
name: String,
titles: Vec<String>,
report: bool,
response: Option<ResponseFmt>,
alias: String,
fields: Vec<(String, bool)>,
hide_fields: Vec<(String, bool)>,
with: Vec<WithItem>,
}
impl RequestForm {
fn show(&self) -> Vec<String> {
if self.fields.iter().all(|(_, on)| *on) {
return Vec::new();
}
self.fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect()
}
fn hide(&self) -> Vec<String> {
self.hide_fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect()
}
fn alias_opt(&self) -> Option<String> {
let a = self.alias.trim();
(!a.is_empty()).then(|| a.to_string())
}
fn to_node(&self) -> FlowNode {
if self.report {
FlowNode::Report(ReportStmt::Request {
name: self.name.trim().to_string(),
alias: self.alias_opt(),
response_fmt: self.response,
show: self.show(),
hide: self.hide(),
with: self.with.clone(),
})
} else {
FlowNode::Request {
name: self.name.trim().to_string(),
}
}
}
}
pub struct EnvEntry {
name: String,
baseline: bool,
file: bool,
}
pub struct EnvsForm {
path: Vec<usize>,
var: String,
compare: bool,
parallel: bool,
degree: String,
entries: Vec<EnvEntry>,
choices: Vec<String>,
snapshots: Vec<String>,
baseline_show_fields: Vec<(String, bool)>,
}
impl EnvsForm {
fn var_or_default(&self) -> String {
let v = self.var.trim();
if v.is_empty() {
"TARGET".to_string()
} else {
v.to_string()
}
}
fn selected_baseline_show(&self) -> Vec<String> {
self.baseline_show_fields
.iter()
.filter(|(_, on)| *on)
.map(|(n, _)| n.clone())
.collect()
}
fn clause(&self) -> Option<EnvClause> {
if self.compare {
let refs = |want_baseline: bool| -> Vec<RoleRef> {
self.entries
.iter()
.filter(|e| e.baseline == want_baseline && !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();
(!names.is_empty()).then_some(EnvClause::Plain(names))
}
}
}
pub struct FilesForm {
path: Vec<usize>,
var: String,
dir: String,
glob: String,
parallel: bool,
degree: String,
}
impl FilesForm {
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();
(!g.is_empty()).then(|| g.to_string())
}
}
fn build_vars(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: &[usize],
node: &FlowNode,
) -> VarsForm {
let (chosen, alias, stats) = match node {
FlowNode::Report(ReportStmt::Vars(vars)) => (vars.clone(), String::new(), Vec::new()),
FlowNode::Report(ReportStmt::VarAs { var, name, stats }) => {
(vec![var.clone()], name.clone(), stats.clone())
}
_ => (Vec::new(), String::new(), Vec::new()),
};
let entries = context::resolve_bound_collection(&app.session.collections, flow, report_path)
.map(|ci| app.session.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let in_scope = crate::report::edit::vars_in_scope(flow, path, entries);
let mut vars: Vec<(String, bool)> = in_scope
.iter()
.map(|n| (n.clone(), chosen.contains(n)))
.collect();
for c in &chosen {
if !vars.iter().any(|(n, _)| n == c) {
vars.push((c.clone(), true));
}
}
VarsForm {
path: path.to_vec(),
vars,
other: String::new(),
alias,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
}
}
pub fn open(ed: &mut ReportEditor, app: &GuiApp, path: &[usize]) {
let Some(flow) = ed.flow.clone() else {
return;
};
let Some(node) = node_at(&flow, path).cloned() else {
return;
};
let report_path = ed.report.path.clone();
let wiz = match &node {
FlowNode::Request { .. } | FlowNode::Report(ReportStmt::Request { .. }) => Wizard::Request(
build_request(app, &flow, report_path.as_deref(), path.to_vec(), &node),
),
FlowNode::ForEnvs { .. } => Wizard::Envs(build_envs(
app,
&flow,
report_path.as_deref(),
path.to_vec(),
&node,
)),
FlowNode::ForEach {
pattern,
producer: Producer::Files { .. },
..
} if single_named_binder(pattern).is_some() => {
Wizard::Files(build_files(path.to_vec(), &node))
}
FlowNode::ForEach {
pattern,
producer: Producer::Folders { .. },
..
} if single_named_binder(pattern).is_some() => {
Wizard::Folders(build_folders(path.to_vec(), &node))
}
FlowNode::Assign { key, value } => Wizard::Assign(AssignForm {
path: path.to_vec(),
key: key.clone(),
value: value.clone(),
}),
FlowNode::ListDecl {
name,
producer: Producer::List(elems),
} if elems
.iter()
.all(|e| matches!(e, crate::report::flow::Element::Scalar(_))) =>
{
Wizard::List(ListForm {
path: path.to_vec(),
name: name.clone(),
values: list_values_text(elems),
})
}
FlowNode::Report(ReportStmt::Vars(_)) | FlowNode::Report(ReportStmt::VarAs { .. }) => {
Wizard::Vars(build_vars(app, &flow, report_path.as_deref(), path, &node))
}
FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
}) => Wizard::Computed(ComputedForm {
path: path.to_vec(),
template: template.clone(),
alias: name.clone(),
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
}),
_ => Wizard::Raw(RawForm {
path: path.to_vec(),
text: node.header_line(),
is_loop: node.is_loop(),
}),
};
ed.wizard = Some(wiz);
}
pub fn open_with_field(ed: &mut ReportEditor, path: &[usize], index: Option<usize>) {
let existing = index.and_then(|i| {
ed.flow
.as_ref()
.and_then(|f| node_at(f, path))
.and_then(|n| match n {
FlowNode::Report(ReportStmt::Request { with, .. }) => with.get(i).cloned(),
_ => None,
})
});
let (index, name, query, stats) = match existing {
Some(WithItem::Field { name, query, stats }) => (index, name, query, stats),
_ => (None, String::new(), String::new(), Vec::new()),
};
ed.wizard = Some(Wizard::WithField(WithFieldForm {
path: path.to_vec(),
index,
name,
query,
stats: StatKind::CHOOSABLE
.iter()
.map(|k| (*k, stats.contains(k)))
.collect(),
}));
}
fn single_named_binder(pattern: &Pattern) -> Option<&str> {
if pattern.is_single() {
pattern.named().next()
} else {
None
}
}
fn list_values_text(elems: &[crate::report::flow::Element]) -> String {
use crate::report::flow::Element;
elems
.iter()
.map(|e| match e {
Element::Scalar(s) => s.clone(),
Element::Tuple(parts) => parts.join(", "),
})
.collect::<Vec<_>>()
.join("\n")
}
fn parse_list_values(text: &str) -> Vec<crate::report::flow::Element> {
use crate::report::flow::Element;
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Element::Scalar(l.to_string()))
.collect()
}
fn parallel_row(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
parallel: &mut bool,
degree: &mut String,
) {
ui.horizontal(|ui| {
ui.checkbox(
&mut *parallel,
RichText::new(s.report_node_parallel_label).color(th.text),
);
ui.add_enabled_ui(*parallel, |ui| {
ui.add(
egui::TextEdit::singleline(degree)
.hint_text(s.node_form_parallel_degree)
.desired_width(48.0),
);
ui.label(RichText::new(s.node_form_parallel_degree_label).color(th.dim));
});
});
}
fn degree_text(parallel: &Option<ParallelSpec>) -> String {
parallel
.as_ref()
.and_then(|p| p.degree)
.map(|n| n.to_string())
.unwrap_or_default()
}
fn parallel_spec(on: bool, degree: &str) -> Option<ParallelSpec> {
on.then(|| ParallelSpec {
degree: degree.trim().parse::<u32>().ok().filter(|n| *n > 0),
})
}
fn build_folders(path: Vec<usize>, node: &FlowNode) -> FoldersForm {
let (var, dir, parallel) = match node {
FlowNode::ForEach {
pattern,
producer: Producer::Folders { dir, .. },
parallel,
..
} => (
pattern.named().next().unwrap_or("FOLDER").to_string(),
dir.clone(),
*parallel,
),
_ => unreachable!("build_folders called on a non-FOLDERS node"),
};
FoldersForm {
path,
var,
dir,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
}
}
fn build_request(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: Vec<usize>,
node: &FlowNode,
) -> RequestForm {
let (name, report, alias, response, show, hide, with) = match node {
FlowNode::Request { name } => (
name.clone(),
false,
None,
None,
Vec::new(),
Vec::new(),
Vec::new(),
),
FlowNode::Report(ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
}) => (
name.clone(),
true,
alias.clone(),
*response_fmt,
show.clone(),
hide.clone(),
with.clone(),
),
_ => unreachable!("build_request called on a non-request node"),
};
let titles: Vec<String> =
context::resolve_bound_collection(&app.session.collections, flow, report_path)
.map(|ci| {
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.clone())
.collect()
})
.unwrap_or_default();
let report_fields =
context::resolve_bound_collection(&app.session.collections, flow, report_path)
.and_then(|ci| {
crate::report::run::resolve_title(&app.session.collections[ci].entries, &name)
.map(|e| e.reports.iter().map(|(f, _)| f.clone()).collect::<Vec<_>>())
})
.unwrap_or_default();
let mut names: Vec<String> = Vec::new();
let push = |n: &str, names: &mut Vec<String>| {
if !names.iter().any(|x| x == n) {
names.push(n.to_string());
}
};
for f in crate::report::run::INTRINSIC_FIELDS {
push(f, &mut names);
}
for f in &report_fields {
push(f, &mut names);
}
for w in &with {
if let WithItem::Field { name, .. } = w {
push(name, &mut names);
}
}
for f in &show {
push(f, &mut names);
}
for f in &hide {
push(f, &mut names);
}
let all = show.is_empty();
let fields = names
.iter()
.map(|n| {
let on = all || show.iter().any(|s| s == n);
(n.clone(), on)
})
.collect();
let hide_fields = names
.iter()
.map(|n| (n.clone(), hide.iter().any(|h| h == n)))
.collect();
RequestForm {
path,
name,
titles,
report,
response,
alias: alias.unwrap_or_default(),
fields,
hide_fields,
with,
}
}
fn build_envs(
app: &GuiApp,
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
path: Vec<usize>,
node: &FlowNode,
) -> EnvsForm {
let (var, clause, parallel, body) = match node {
FlowNode::ForEnvs {
var,
clause,
parallel,
body,
} => (var.clone(), clause.clone(), *parallel, body.as_slice()),
_ => unreachable!("build_envs called on a non-ENVS node"),
};
let (compare, mut entries, baseline_show) = 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())
}
};
let choices: Vec<String> = app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect();
let mut snapshots = discover_snapshots(flow, report_path);
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 bound_entries =
context::resolve_bound_collection(&app.session.collections, flow, report_path)
.map(|ci| app.session.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let baseline_show_fields =
crate::report::edit::baseline_show_choices(bound_entries, body, &baseline_show);
EnvsForm {
path,
var,
compare,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
entries,
choices,
snapshots,
baseline_show_fields,
}
}
fn build_files(path: Vec<usize>, node: &FlowNode) -> FilesForm {
let (var, dir, glob, parallel) = match node {
FlowNode::ForEach {
pattern,
producer: Producer::Files { dir, glob },
parallel,
..
} => (
pattern.named().next().unwrap_or("FILE").to_string(),
dir.clone(),
glob.clone().unwrap_or_default(),
*parallel,
),
_ => unreachable!("build_files called on a non-FILES node"),
};
FilesForm {
path,
var,
dir,
glob,
parallel: parallel.is_some(),
degree: degree_text(¶llel),
}
}
fn discover_snapshots(
flow: &crate::report::flow::ReportFlow,
report_path: Option<&std::path::Path>,
) -> Vec<String> {
let (root, _) = context::report_base_dir(flow, report_path);
let mut out: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&root) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|e| e == "baseline")
&& let Some(name) = p.file_name().and_then(|n| n.to_str())
{
out.push(name.to_string());
}
}
}
out.sort();
out
}
enum Outcome {
None,
Apply,
Cancel,
}
pub fn show(ed: &mut ReportEditor, app: &mut GuiApp, ctx: &egui::Context) {
if ed.wizard.is_none() {
return;
}
let th = app.theme;
let fallback = app
.session
.picker_dir(crate::session::PickerKind::Other)
.map(|p| p.to_path_buf());
let s = &app.strings;
let mut outcome = Outcome::None;
let modal = egui::Modal::new(egui::Id::new("pt_node_wizard")).show(ctx, |ui| {
ui.set_min_width(360.0);
let wiz = ed.wizard.as_mut().expect("wizard is Some");
match wiz {
Wizard::Request(f) => request_ui(ui, &th, s, f),
Wizard::Envs(f) => envs_ui(ui, &th, s, f),
Wizard::Files(f) => files_ui(ui, &th, s, f, fallback.as_deref()),
Wizard::Assign(f) => assign_ui(ui, &th, s, f),
Wizard::List(f) => list_ui(ui, &th, s, f),
Wizard::Folders(f) => folders_ui(ui, &th, s, f, fallback.as_deref()),
Wizard::Vars(f) => vars_ui(ui, &th, s, f),
Wizard::Computed(f) => computed_ui(ui, &th, s, f),
Wizard::Raw(f) => raw_ui(ui, &th, s, f),
Wizard::WithField(f) => with_field_ui(ui, &th, s, f),
}
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
if ui.button(RichText::new(s.gui_ok).color(th.text)).clicked() {
outcome = Outcome::Apply;
}
if ui
.button(RichText::new(s.gui_cancel).color(th.text))
.clicked()
{
outcome = Outcome::Cancel;
}
});
});
if modal.should_close() {
outcome = Outcome::Cancel;
}
match outcome {
Outcome::None => {}
Outcome::Cancel => ed.wizard = None,
Outcome::Apply => {
apply(ed, app);
ed.wizard = None;
}
}
}
fn request_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut RequestForm,
) {
ui.heading(RichText::new(s.node_request_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_req_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_form_name).color(th.dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(180.0));
if !f.titles.is_empty() {
egui::ComboBox::from_id_salt("pt_req_name_pick")
.selected_text(RichText::new("…").color(th.dim))
.show_ui(ui, |ui| {
for t in f.titles.clone() {
if ui.selectable_label(f.name == t, &t).clicked() {
f.name = t;
}
}
});
}
});
ui.end_row();
ui.label(RichText::new(s.node_form_report).color(th.dim));
ui.checkbox(
&mut f.report,
RichText::new(s.node_form_report_hint).color(th.text),
);
ui.end_row();
if f.report {
ui.label(RichText::new(s.node_form_response).color(th.dim));
ui.horizontal(|ui| {
ui.selectable_value(&mut f.response, None, s.node_form_response_default);
ui.selectable_value(&mut f.response, Some(ResponseFmt::Raw), "RAW");
ui.selectable_value(&mut f.response, Some(ResponseFmt::Pretty), "PRETTY");
});
ui.end_row();
ui.label(RichText::new(s.node_form_alias).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(180.0));
ui.end_row();
}
});
if f.report && !f.fields.is_empty() {
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_show).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_req_fields")
.max_height(160.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in &mut f.fields {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_hide).color(th.dim));
ui.label(RichText::new(s.node_form_hide_hint).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_req_hide_fields")
.max_height(120.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in &mut f.hide_fields {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
}
}
fn envs_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut EnvsForm,
) {
ui.heading(RichText::new(s.report_node_envs_title).color(th.text));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_envs_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(160.0));
});
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_envs_mode_label).color(th.dim));
let mut compare = f.compare;
ui.selectable_value(&mut compare, false, s.report_node_envs_mode_plain);
ui.selectable_value(&mut compare, true, s.report_node_envs_mode_roles);
if compare != f.compare {
f.compare = compare;
if f.compare
&& !f.entries.iter().any(|e| e.baseline)
&& let Some(first) = f.entries.first_mut()
{
first.baseline = true;
}
}
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
ui.add_space(6.0);
ui.label(RichText::new(s.node_envs_environments).color(th.dim));
let mut remove: Option<usize> = None;
let mut make_baseline: Option<usize> = None;
let mut toggled_file: Option<usize> = None;
let compare = f.compare;
let count = f.entries.len();
let choices = f.choices.clone();
let snapshots = f.snapshots.clone();
for i in 0..count {
ui.horizontal(|ui| {
let list = if f.entries[i].file {
&snapshots
} else {
&choices
};
let selected = f.entries[i].name.clone();
egui::ComboBox::from_id_salt(("pt_env_pick", i))
.selected_text(if selected.is_empty() {
RichText::new("—").color(th.dim)
} else {
RichText::new(selected.as_str()).color(th.text)
})
.show_ui(ui, |ui| {
for c in list.clone() {
if ui.selectable_label(f.entries[i].name == c, &c).clicked() {
f.entries[i].name = c;
}
}
});
if compare {
if ui
.radio(f.entries[i].baseline, s.report_node_envs_baseline)
.clicked()
{
make_baseline = Some(i);
}
if ui
.checkbox(&mut f.entries[i].file, s.report_node_envs_file)
.changed()
{
toggled_file = Some(i);
}
}
if count > 1 && ui.button(RichText::new("×").color(th.err)).clicked() {
remove = Some(i);
}
});
if compare && f.entries[i].baseline {
baseline_show_ui(ui, th, s, &mut f.baseline_show_fields);
}
}
if let Some(i) = make_baseline {
for (j, e) in f.entries.iter_mut().enumerate() {
e.baseline = j == i;
}
}
if let Some(i) = toggled_file {
let list = if f.entries[i].file {
&f.snapshots
} else {
&f.choices
};
if !list.iter().any(|c| c == &f.entries[i].name)
&& let Some(first) = list.first().cloned()
{
f.entries[i].name = first;
}
}
if let Some(i) = remove {
f.entries.remove(i);
}
if ui
.button(RichText::new(format!("{} {}", super::icons::PLUS, s.node_envs_add)).color(th.text))
.clicked()
{
f.entries.push(EnvEntry {
name: f.choices.first().cloned().unwrap_or_default(),
baseline: false,
file: false,
});
}
}
fn files_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut FilesForm,
fallback: Option<&std::path::Path>,
) {
ui.heading(RichText::new(s.report_node_files_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_files_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_files_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.report_node_files_folder_label).color(th.dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.dir).desired_width(220.0));
if ui.button(s.gui_browse).clicked() {
if let Some(p) = super::filepick::pick_folder(
s.report_node_files_folder_label,
super::filepick::seed_dir(&f.dir).as_deref().or(fallback),
) {
f.dir = p.to_string_lossy().into_owned();
}
}
});
ui.end_row();
ui.label(RichText::new(s.report_node_files_match_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.glob).desired_width(220.0));
ui.end_row();
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
}
fn assign_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut AssignForm,
) {
ui.heading(RichText::new(s.node_assign_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_assign_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_form_var).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.key).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.node_form_value).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(220.0));
ui.end_row();
});
}
fn list_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut ListForm,
) {
ui.heading(RichText::new(s.node_list_title).color(th.text));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.node_form_list_name).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(200.0));
});
ui.add_space(6.0);
ui.label(RichText::new(s.node_form_list_values).color(th.dim));
ui.add(
egui::TextEdit::multiline(&mut f.values)
.desired_width(f32::INFINITY)
.desired_rows(5)
.font(egui::TextStyle::Monospace),
);
}
fn folders_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut FoldersForm,
fallback: Option<&std::path::Path>,
) {
ui.heading(RichText::new(s.node_folders_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_folders_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_files_var_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.var).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.report_node_files_folder_label).color(th.dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut f.dir).desired_width(220.0));
if ui.button(s.gui_browse).clicked() {
if let Some(p) = super::filepick::pick_folder(
s.report_node_files_folder_label,
super::filepick::seed_dir(&f.dir).as_deref().or(fallback),
) {
f.dir = p.to_string_lossy().into_owned();
}
}
});
ui.end_row();
});
parallel_row(ui, th, s, &mut f.parallel, &mut f.degree);
}
fn raw_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut RawForm,
) {
ui.heading(RichText::new(s.node_raw_title).color(th.text));
ui.add_space(4.0);
ui.label(RichText::new(s.node_form_raw).color(th.dim));
ui.add(
egui::TextEdit::multiline(&mut f.text)
.desired_width(f32::INFINITY)
.desired_rows(2)
.font(egui::TextStyle::Monospace),
);
}
fn stats_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
stats: &mut [(StatKind, bool)],
) {
ui.label(RichText::new(s.node_form_statistics).color(th.dim));
ui.label(RichText::new(s.node_form_statistics_hint).color(th.dim));
ui.horizontal_top(|ui| {
for chunk in stats.chunks_mut(5) {
ui.vertical(|ui| {
for (kind, on) in chunk {
ui.checkbox(on, RichText::new(kind.label()).color(th.text));
}
});
}
});
}
fn vars_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut VarsForm,
) {
ui.heading(RichText::new(s.report_node_vars_title).color(th.text));
ui.add_space(4.0);
if f.vars.is_empty() {
ui.label(RichText::new(s.report_node_vars_none).color(th.dim));
}
for (name, on) in &mut f.vars {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_vars_other_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.other).desired_width(200.0));
});
if f.chosen().len() == 1 {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(RichText::new(s.report_node_alias_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(200.0));
});
if !f.alias.trim().is_empty() {
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
}
}
}
fn computed_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut ComputedForm,
) {
ui.heading(RichText::new(s.report_node_computed_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_computed_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.report_node_computed_template_label).color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut f.template)
.hint_text(s.report_node_computed_template_hint)
.desired_width(240.0)
.font(egui::TextStyle::Monospace),
);
ui.end_row();
ui.label(RichText::new(s.report_node_computed_name_label).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.alias).desired_width(240.0));
ui.end_row();
});
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
}
fn with_field_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
f: &mut WithFieldForm,
) {
ui.heading(RichText::new(s.node_with_title).color(th.text));
ui.add_space(4.0);
egui::Grid::new("pt_with_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label(RichText::new(s.node_with_name).color(th.dim));
ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(220.0));
ui.end_row();
ui.label(RichText::new(s.node_with_query).color(th.dim));
ui.add(
egui::TextEdit::singleline(&mut f.query)
.hint_text(s.node_with_query_hint)
.desired_width(220.0)
.font(egui::TextStyle::Monospace),
);
ui.end_row();
});
ui.add_space(6.0);
stats_ui(ui, th, s, &mut f.stats);
}
fn apply(ed: &mut ReportEditor, app: &mut GuiApp) {
let Some(wiz) = ed.wizard.as_ref() else {
return;
};
match wiz {
Wizard::Request(f) => {
let path = f.path.clone();
let node = f.to_node();
ed.wizard_apply(app, &path, node);
}
Wizard::Envs(f) => {
let Some(clause) = f.clause() else {
return;
};
let path = f.path.clone();
let body = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEnvs { body, .. }) => body.clone(),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEnvs {
var: f.var_or_default(),
clause,
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Files(f) => {
let path = f.path.clone();
let body = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEach { body, .. }) => body.clone(),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEach {
pattern: Pattern::single(f.var_or_default()),
producer: Producer::Files {
dir: f.dir.clone(),
glob: f.glob_opt(),
},
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Assign(f) => {
let path = f.path.clone();
let key = f.key.trim();
if key.is_empty() {
return;
}
let node = FlowNode::Assign {
key: key.to_string(),
value: f.value.clone(),
};
ed.wizard_apply(app, &path, node);
}
Wizard::List(f) => {
let path = f.path.clone();
let name = f.name.trim();
if name.is_empty() {
return;
}
let node = FlowNode::ListDecl {
name: name.to_string(),
producer: Producer::List(parse_list_values(&f.values)),
};
ed.wizard_apply(app, &path, node);
}
Wizard::Folders(f) => {
let path = f.path.clone();
let (body, roles) = match ed.flow.as_ref().and_then(|fl| node_at(fl, &path)) {
Some(FlowNode::ForEach {
body,
producer: Producer::Folders { roles, .. },
..
}) => (body.clone(), roles.clone()),
_ => return,
};
let parallel = parallel_spec(f.parallel, &f.degree);
let node = FlowNode::ForEach {
pattern: Pattern::single(if f.var.trim().is_empty() {
"FOLDER".to_string()
} else {
f.var.trim().to_string()
}),
producer: Producer::Folders {
dir: f.dir.clone(),
roles,
},
body,
parallel,
};
ed.wizard_apply(app, &path, node);
}
Wizard::Vars(f) => {
let path = f.path.clone();
let Some(node) = f.node() else { return };
ed.wizard_apply(app, &path, node);
}
Wizard::Computed(f) => {
let path = f.path.clone();
let (template, alias) = (f.template.trim(), f.alias.trim());
if template.is_empty() || alias.is_empty() {
return;
}
let node = FlowNode::Report(ReportStmt::Computed {
template: template.to_string(),
name: alias.to_string(),
stats: f
.stats
.iter()
.filter(|(_, on)| *on)
.map(|(k, _)| *k)
.collect(),
});
ed.wizard_apply(app, &path, node);
}
Wizard::Raw(f) => {
let path = f.path.clone();
if let Some(node) = crate::report::edit::parse_one_node(&f.text, f.is_loop) {
ed.wizard_apply(app, &path, node);
}
}
Wizard::WithField(f) => {
let path = f.path.clone();
let name = f.name.trim().to_string();
let query = f.query.trim().to_string();
if name.is_empty() || query.is_empty() {
return;
}
let index = f.index;
let stats = f.stats();
ed.commit_edit(app, |flow| match index {
Some(i) => {
crate::report::edit::set_with_field(flow, &path, i, &name, &query, stats);
}
None => {
crate::report::edit::add_with_field(flow, &path, &name, &query, stats);
}
});
ed.selection = path;
}
}
}
fn baseline_show_ui(
ui: &mut egui::Ui,
th: &super::theme::GuiTheme,
s: &crate::i18n::Strings,
fields: &mut [(String, bool)],
) {
if fields.is_empty() {
return;
}
ui.horizontal(|ui| {
ui.add_space(24.0);
egui::Frame::new()
.fill(th.panel)
.stroke(egui::Stroke::new(1.0, th.dim))
.corner_radius(4.0)
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label(RichText::new(s.node_envs_baseline_show).color(th.accent));
ui.label(
RichText::new(format!("({})", s.node_envs_baseline_show_applies))
.italics()
.color(th.dim),
);
});
ui.label(RichText::new(s.node_envs_baseline_show_hint).color(th.dim));
egui::ScrollArea::vertical()
.id_salt("pt_envs_baseline_show")
.max_height(140.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for (name, on) in fields.iter_mut() {
ui.checkbox(on, RichText::new(name.as_str()).color(th.text));
}
});
});
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::flow::{EnvClause, RoleRef};
fn compare_form(fields: Vec<(String, bool)>) -> EnvsForm {
EnvsForm {
path: vec![0],
var: "TARGET".into(),
compare: true,
parallel: false,
degree: String::new(),
entries: vec![
EnvEntry {
name: "prod".into(),
baseline: true,
file: false,
},
EnvEntry {
name: "staging".into(),
baseline: false,
file: false,
},
],
choices: vec!["prod".into(), "staging".into()],
snapshots: Vec::new(),
baseline_show_fields: fields,
}
}
#[test]
fn a_named_single_variable_becomes_an_alias_and_a_pair_stays_plain() {
let mut form = VarsForm {
path: vec![0],
vars: vec![("TIER".into(), true), ("REGION".into(), false)],
other: String::new(),
alias: "Plan".into(),
stats: StatKind::CHOOSABLE.iter().map(|k| (*k, false)).collect(),
};
assert!(
matches!(
form.node(),
Some(FlowNode::Report(ReportStmt::VarAs { ref var, ref name, .. }))
if var == "TIER" && name == "Plan"
),
"a single ticked variable with a name is reported AS that name"
);
form.vars[1].1 = true;
assert!(
matches!(form.node(), Some(FlowNode::Report(ReportStmt::Vars(ref v))) if v == &["TIER".to_string(), "REGION".to_string()]),
"two ticked variables report both columns and ignore the name"
);
}
#[test]
fn a_typed_variable_is_reported_even_when_nothing_is_ticked() {
let form = VarsForm {
path: vec![0],
vars: vec![("TIER".into(), false)],
other: " RUNTIME_ONLY ".into(),
alias: String::new(),
stats: Vec::new(),
};
assert!(
matches!(form.node(), Some(FlowNode::Report(ReportStmt::Vars(ref v))) if v == &["RUNTIME_ONLY".to_string()]),
"the typed name is trimmed and reported"
);
}
#[test]
fn ticking_a_baseline_field_writes_a_show_clause_on_the_baseline_role() {
let form = compare_form(vec![
("Time".into(), true),
("Status".into(), false),
("Response".into(), true),
]);
let Some(EnvClause::Roles {
baseline,
comparisons,
baseline_show,
}) = form.clause()
else {
panic!("a compare form describes a Roles clause")
};
assert_eq!(
baseline,
vec![RoleRef::Env("prod".into())],
"the baseline role is unaffected by the SHOW"
);
assert_eq!(
comparisons,
vec![RoleRef::Env("staging".into())],
"and so are the comparisons"
);
assert_eq!(
baseline_show,
vec!["Time".to_string(), "Response".to_string()],
"the ticked fields become the SHOW list, in the checklist's own order"
);
}
#[test]
fn ticking_nothing_leaves_the_baseline_with_no_show_clause_at_all() {
let form = compare_form(vec![("Time".into(), false), ("Status".into(), false)]);
let Some(EnvClause::Roles { baseline_show, .. }) = form.clause() else {
panic!("a compare form describes a Roles clause")
};
assert!(
baseline_show.is_empty(),
"nothing ticked writes no SHOW: {baseline_show:?}"
);
}
#[test]
fn a_show_field_no_request_offers_is_still_listed_so_editing_cannot_drop_it() {
let flow = crate::report::parser::parse_flow(
"# collection: api.hurl\nFOR TARGET IN ENVS BASELINE(\"p\") SHOW(Handwritten), COMPARISON(\"s\")\n REPORT REQUEST login\nEND\n",
)
.expect("the fixture flow parses");
let FlowNode::ForEnvs { clause, body, .. } = &flow.nodes[0] else {
panic!("the fixture's first node is the ENVS loop")
};
let EnvClause::Roles { baseline_show, .. } = clause else {
panic!("the fixture's clause has roles")
};
let fields = crate::report::edit::baseline_show_choices(&[], body, baseline_show);
assert!(
fields.iter().any(|(n, on)| n == "Handwritten" && *on),
"the unrecognised field is offered, and offered already ticked: {fields:?}"
);
assert!(
fields.iter().any(|(n, _)| n == "Time"),
"alongside the intrinsics every request has: {fields:?}"
);
}
#[test]
fn a_baseline_show_checklist_is_only_offered_for_reported_requests() {
let nodes = vec![
FlowNode::Request {
name: "warmup".into(),
},
FlowNode::Report(crate::report::flow::ReportStmt::Request {
name: "login".into(),
alias: None,
response_fmt: None,
show: Vec::new(),
hide: Vec::new(),
with: Vec::new(),
}),
];
assert_eq!(
crate::report::edit::reported_requests(&nodes),
vec!["login".to_string()],
"only the reported request is a source of fields"
);
}
}