use eframe::egui::{self, Color32, RichText};
use crate::i18n::{Status, Strings};
use crate::report::Report;
use crate::report::context;
use crate::report::edit::{
self, CarriedMod, DetachWhich, HEADER_PLACEHOLDER, HeaderKind, HeaderSpec, InsertPos, Modifier,
NodeKind, RowKind, attach_modifier, attach_to_node, carry_modifier, detach_modifier, flatten,
header_specs, insert_node, insert_pos_after, move_node, node_at, remove_node, replace_node,
report_assignment, request_node, set_request_name, transfer_modifier,
};
use crate::report::filter::RowFilter;
use crate::report::flow::{FlowNode, ReportFlow, ReportStmt, WithItem};
use crate::report::indent::{
INDENT_UNIT, ReformatError, indent_for_new_line, is_end_line, matching_opener_indent,
};
use crate::report::model::ReportResult;
use crate::report::validate::{Diagnostic, Severity};
use crate::tui::report_highlight::{self, HlCtx};
use super::app::GuiApp;
use super::report_run::{self, ParkedRun, RowState, RunHandle, RunKey, RunProgress};
use super::theme::GuiTheme;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum EditorView {
Blocks,
Source,
Results,
}
pub enum ReportOrigin {
Session(usize),
Workspace,
}
pub struct ReportEditor {
pub origin: ReportOrigin,
pub report: Report,
pub view: EditorView,
pub flow: Option<ReportFlow>,
pub parse_error: Option<String>,
pub parse_error_line: Option<usize>,
pub diagnostics: Vec<Diagnostic>,
pub helpers: Vec<crate::report::run::HelperCollection>,
diag_key: Option<u64>,
hl_cache: Option<(u64, std::rc::Rc<HlCtx>)>,
pub selection: Vec<usize>,
pub palette: Option<PaletteState>,
pub undo: Vec<String>,
pub result: Option<ReportResult>,
pub progress: Option<RunProgress>,
pub run: Option<RunHandle>,
pub results_exported: bool,
pub last_export: Option<String>,
pub results_filter: usize,
pub results_find: String,
pub results_detail: Option<usize>,
results_textures: std::collections::HashMap<(usize, String), egui::TextureHandle>,
pub wizard: Option<super::report_wizard::Wizard>,
pub diag_h: f32,
pub summary_h: f32,
pub detail_h: f32,
pub palette_w: f32,
pub inspector: Option<CellInspector>,
pub dry_run: Option<Box<crate::report::dry_run::DryRunReport>>,
pub param_values: crate::report::params::ParamValues,
params_seeded: bool,
params_sig: String,
params_confirmed: bool,
params_modal: Option<RunIntent>,
result_params: Vec<(String, String)>,
pending_toolbar: Option<ToolbarAct>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum RunIntent {
Run,
DryRun,
Review,
}
#[derive(Clone, PartialEq, Eq, Debug)]
enum ToolbarAct {
Run,
DryRun,
RunSettings,
Save,
Close,
OpenExport(String),
}
pub struct CellInspector {
pub title: String,
pub content: String,
}
pub struct PaletteState {
pub pos: InsertPos,
pub pick_request: Option<bool>,
pub request_name: String,
}
impl ReportEditor {
pub fn new(origin: ReportOrigin, report: Report) -> Self {
let mut ed = Self {
origin,
report,
view: EditorView::Blocks,
flow: None,
parse_error: None,
parse_error_line: None,
diagnostics: Vec::new(),
helpers: Vec::new(),
diag_key: None,
hl_cache: None,
selection: Vec::new(),
palette: None,
undo: Vec::new(),
result: None,
progress: None,
run: None,
results_exported: false,
last_export: None,
results_filter: 0,
results_find: String::new(),
results_detail: None,
results_textures: std::collections::HashMap::new(),
wizard: None,
diag_h: 132.0,
summary_h: 240.0,
detail_h: 320.0,
palette_w: 168.0,
inspector: None,
dry_run: None,
param_values: Default::default(),
result_params: Vec::new(),
params_seeded: false,
params_sig: String::new(),
params_confirmed: false,
params_modal: None,
pending_toolbar: None,
};
ed.reparse();
ed
}
pub fn run_key(&self) -> RunKey {
RunKey::of(&self.report)
}
pub fn park_run(&mut self) -> ParkedRun {
ParkedRun {
result: self.result.take(),
progress: self.progress.take(),
run: self.run.take(),
results_exported: self.results_exported,
last_export: self.last_export.clone(),
params: std::mem::take(&mut self.param_values),
}
}
pub fn adopt_run(&mut self, parked: ParkedRun) {
self.result = parked.result;
self.progress = parked.progress;
self.run = parked.run;
self.results_exported = parked.results_exported;
self.last_export = parked.last_export;
if !parked.params.is_empty() {
self.param_values = parked.params;
self.params_seeded = true;
}
if self.result.is_some() {
self.view = EditorView::Results;
}
}
pub fn path(&self) -> Option<&std::path::Path> {
self.report.path.as_deref()
}
pub fn is_workspace(&self) -> bool {
matches!(self.origin, ReportOrigin::Workspace)
}
fn reparse(&mut self) {
match self.report.flow() {
Ok(flow) => {
self.flow = Some(flow);
self.parse_error = None;
self.parse_error_line = None;
}
Err(e) => {
self.flow = None;
self.parse_error = Some(e.to_string());
self.parse_error_line = Some(e.line);
}
}
let Some(sig) = self.param_signature() else {
return;
};
if sig != self.params_sig {
self.params_sig = sig;
self.params_confirmed = false;
self.params_seeded = false;
}
}
fn param_signature(&self) -> Option<String> {
let flow = self.flow.as_ref()?;
Some(
flow.params()
.iter()
.map(|p| {
format!(
"{}:{:?}:{}",
p.name,
p.kind,
p.default.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("\n"),
)
}
fn set_text(&mut self, text: String) {
self.report.set_text(text);
self.reparse();
}
fn edit_flow(&mut self, f: impl FnOnce(&mut ReportFlow)) {
let Some(mut flow) = self.flow.clone() else {
return;
};
f(&mut flow);
let new_text = flow.to_text();
if new_text != self.report.text {
self.undo.push(self.report.text.clone());
self.set_text(new_text);
}
}
fn reformat(&mut self) -> Result<bool, ReformatError> {
match crate::report::indent::reformat(&self.report.text)? {
Some(text) => {
self.undo.push(self.report.text.clone());
self.set_text(text);
Ok(true)
}
None => Ok(false),
}
}
fn undo(&mut self) {
if let Some(prev) = self.undo.pop() {
self.report.set_text(prev);
self.reparse();
}
}
pub(super) fn wizard_apply(&mut self, app: &mut GuiApp, path: &[usize], node: FlowNode) {
self.edit_flow(|flow| {
replace_node(flow, path, node);
});
self.selection = path.to_vec();
sync_back(self, app);
}
pub(super) fn commit_edit(&mut self, app: &mut GuiApp, f: impl FnOnce(&mut ReportFlow)) {
self.edit_flow(f);
sync_back(self, app);
}
pub(super) fn is_running(&self) -> bool {
self.run.as_ref().is_some_and(|h| !h.finished())
}
fn can_run(&self) -> bool {
self.flow.is_some()
&& self
.diagnostics
.iter()
.all(|d| d.severity != Severity::Error)
}
pub fn has_params(&self) -> bool {
self.flow.as_ref().is_some_and(|f| !f.params().is_empty())
}
pub fn param_rows(&self, s: &Strings) -> Vec<crate::report::params::ParamRow> {
let Some(flow) = self.flow.as_ref() else {
return Vec::new();
};
crate::report::params::rows(&flow.params(), &self.param_values, s)
}
pub fn seed_params(&mut self, session: &crate::session::Session) {
if self.params_seeded {
return;
}
self.params_seeded = true;
let Some(flow) = self.flow.as_ref() else {
return;
};
let remembered = session.remembered_params(&self.report.param_key());
let declared: Vec<(String, Option<String>)> = flow
.params()
.into_iter()
.map(|p| (p.name.clone(), p.default.clone()))
.collect();
for (name, default) in declared {
if self.param_values.contains_key(&name) {
continue;
}
let value = remembered
.get(&name)
.cloned()
.or(default)
.unwrap_or_default();
self.param_values.insert(name, value);
}
}
fn run_needs_settings(&self) -> bool {
!self.is_running() && !self.params_confirmed && self.has_params()
}
pub(super) fn params_changed_since_result(&self, s: &Strings) -> bool {
if self.result_params.is_empty() || (self.result.is_none() && self.dry_run.is_none()) {
return false;
}
let now: Vec<(String, String)> = self
.param_rows(s)
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
now != self.result_params
}
pub(super) fn open_param_modal(&mut self, app: &GuiApp, intent: RunIntent) {
self.seed_params(&app.session);
self.params_modal = Some(intent);
}
pub(super) fn param_summary(&self, s: &Strings) -> String {
self.param_rows(s)
.iter()
.map(|r| {
let v = if r.value.is_empty() {
s.param_value_unset
} else {
r.value.as_str()
};
format!("{}={}", r.name, v)
})
.collect::<Vec<_>>()
.join(" · ")
}
fn start_run(&mut self, app: &mut GuiApp) {
let Some(flow) = self.flow.clone() else {
return;
};
if self.run_needs_settings() {
self.open_param_modal(app, RunIntent::Run);
return;
}
let rows = self.param_rows(&app.strings);
if let Some(problem) = rows
.iter()
.find_map(|r| r.problem.as_ref().map(|p| format!("{}: {p}", r.prompt)))
{
app.session.status = Some(Status::ReportRunBlocked(problem));
self.open_param_modal(app, RunIntent::Run);
return;
}
let chosen: crate::report::params::ParamValues = rows
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
if app
.session
.remember_params(&self.report.param_key(), &chosen)
{
app.session.save();
}
self.result_params = rows
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
match context::report_run_inputs(
&app.session.collections,
&app.session.global_envs,
app.session.active_env_id,
&flow,
self.report.path.as_deref(),
) {
Ok(mut inputs) => {
inputs.language = app.session.language.clone();
inputs.params = chosen;
self.result = None;
self.progress = None;
self.results_exported = false;
self.last_export = None;
self.results_filter = 0;
self.results_find.clear();
self.results_detail = None;
self.results_textures.clear();
self.dry_run = None;
self.view = EditorView::Results;
self.run = Some(report_run::spawn(inputs));
app.session.status = Some(Status::ReportRunning);
}
Err(context::RunInputError::Unbound) => {
app.session.status = Some(Status::ReportRunBlocked(
app.strings.report_run_unbound.to_string(),
));
}
}
}
fn show_preview(&mut self, preview: Box<crate::report::dry_run::DryRunReport>) {
self.dry_run = Some(preview);
self.view = EditorView::Results;
}
fn start_dry_run(&mut self, app: &mut GuiApp) {
use crate::report::run::{DryRunner, RunContext, run_flow_raw};
let Some(flow) = self.flow.clone() else {
return;
};
if self.run_needs_settings() {
self.open_param_modal(app, RunIntent::DryRun);
return;
}
let rows = self.param_rows(&app.strings);
let chosen: crate::report::params::ParamValues = rows
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
self.result_params = rows
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
match context::report_run_inputs(
&app.session.collections,
&app.session.global_envs,
app.session.active_env_id,
&flow,
self.report.path.as_deref(),
) {
Ok(inputs) => {
let strings = Strings::for_language(&app.session.language);
let ctx = RunContext {
entries: &inputs.entries,
helpers: &inputs.helpers,
base_vars: inputs.base_vars.clone(),
named_envs: inputs.named_envs.clone(),
root: inputs.root.clone(),
runner: &DryRunner,
strings: &strings,
params: chosen,
sink: None,
};
let result = run_flow_raw(&inputs.flow, &ctx);
let var_warnings: Vec<String> = self
.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.map(|d| d.message.clone())
.collect();
self.show_preview(Box::new(crate::report::dry_run::DryRunReport::from_result(
result,
flow.header.clone(),
var_warnings,
)));
}
Err(context::RunInputError::Unbound) => {
app.session.status = Some(Status::ReportRunBlocked(
app.strings.report_run_unbound.to_string(),
));
}
}
}
fn stop_run(&mut self, app: &mut GuiApp) {
if let Some(h) = self.run.take() {
h.cancel();
}
self.progress = None;
app.session.status = Some(Status::ReportRunStopped);
}
fn poll_run(&mut self, app: &mut GuiApp) -> bool {
let Some(handle) = self.run.as_mut() else {
return false;
};
match report_run::drain(handle, &mut self.result, &mut self.progress) {
report_run::Drained::Progress { done, total } => {
app.session.status = Some(Status::ReportRunProgress { done, total });
}
report_run::Drained::Done { rows, errors } => {
app.session.status = Some(Status::ReportRunDone { rows, errors });
}
report_run::Drained::Disconnected => {
self.run = None;
return false;
}
report_run::Drained::Idle => {}
}
if handle.finished() {
self.run = None;
false
} else {
true
}
}
}
fn mix(a: Color32, b: Color32, t: f32) -> Color32 {
let l = |x: u8, y: u8| (x as f32 * (1.0 - t) + y as f32 * t).round() as u8;
Color32::from_rgb(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()))
}
fn kind_color(kind: NodeKind, th: &GuiTheme) -> Color32 {
match kind {
NodeKind::Request | NodeKind::ReportRequest => th.ok,
NodeKind::ReportVar | NodeKind::ReportComputed => th.subst,
NodeKind::Assign => th.accent,
NodeKind::ForFiles | NodeKind::ForFolders | NodeKind::ForEnvs => th.accent,
NodeKind::List => th.pending,
}
}
fn node_for_kind(kind: NodeKind, titles: &[String]) -> FlowNode {
if kind.needs_request() {
let name = titles
.first()
.cloned()
.unwrap_or_else(|| "request".to_string());
request_node(&name, matches!(kind, NodeKind::ReportRequest))
} else {
kind.template()
.unwrap_or_else(|| request_node("request", false))
}
}
#[derive(Clone, PartialEq, Eq)]
enum DragItem {
Row(Vec<usize>),
Chip {
path: Vec<usize>,
which: DetachWhich,
},
}
struct Chip {
text: String,
color: Color32,
is_base: bool,
detach: Option<DetachWhich>,
edit: ChipEdit,
help: &'static str,
tethered: bool,
join_prev: bool,
join_next: bool,
hovered: bool,
}
impl Chip {
fn tether(mut self) -> Chip {
self.tethered = true;
self
}
}
impl Chip {
fn with_help(mut self, help: &'static str) -> Chip {
self.help = help;
self
}
}
#[derive(Clone)]
enum ChipEdit {
None,
Request {
name: String,
},
EnvRole {
baseline: bool,
index: usize,
name: String,
},
Alias {
text: String,
},
Parallel {
degree: Option<u32>,
},
Loop(LoopEdit),
}
#[derive(Clone)]
struct LoopEdit {
var: Option<String>,
keyword: String,
dir: Option<(String, bool)>,
glob: Option<String>,
tail: String,
}
impl Chip {
fn ghost_shape(&self) -> (String, f32) {
match &self.edit {
ChipEdit::None => (self.text.clone(), 0.0),
ChipEdit::Request { name } => (format!("{} {name}", self.text), COMBO_CHIP_WIDTH),
ChipEdit::EnvRole { name, .. } => (format!("{} {name}", self.text), COMBO_CHIP_WIDTH),
ChipEdit::Alias { text } => (format!("AS {text}"), ALIAS_FIELD_WIDTH),
ChipEdit::Parallel { degree } => (
degree
.map(|n| format!("PARALLEL({n})"))
.unwrap_or_else(|| "PARALLEL".to_string()),
PARALLEL_FIELD_WIDTH,
),
ChipEdit::Loop(l) => {
let mut label = "FOR".to_string();
let mut extra = 0.0;
if let Some(v) = &l.var {
label.push(' ');
label.push_str(v);
extra += LOOP_VAR_FIELD_WIDTH;
}
label.push(' ');
label.push_str(&l.keyword);
if let Some((dir, _)) = &l.dir {
label.push(' ');
label.push_str(dir);
extra += LOOP_PATH_FIELD_WIDTH + PICKER_BUTTON_WIDTH;
}
if let Some(g) = &l.glob {
label.push_str(" MATCH ");
label.push_str(g);
extra += LOOP_GLOB_FIELD_WIDTH;
}
if !l.tail.is_empty() {
label.push(' ');
label.push_str(&l.tail);
}
(label, extra)
}
}
}
fn base(text: String, color: Color32) -> Chip {
Chip {
text,
color,
is_base: true,
detach: None,
edit: ChipEdit::None,
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn modifier(text: String, color: Color32, which: DetachWhich) -> Chip {
Chip {
text,
color,
is_base: false,
detach: Some(which),
edit: ChipEdit::None,
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn request(name: &str, color: Color32) -> Chip {
Chip {
text: format!("REQUEST {name}"),
color,
is_base: true,
detach: None,
edit: ChipEdit::Request {
name: name.to_string(),
},
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn env_role(baseline: bool, index: usize, name: &str, color: Color32) -> Chip {
let kw = if baseline { "BASELINE" } else { "COMPARISON" };
Chip {
text: format!("{kw}({name})"),
color,
is_base: false,
detach: Some(DetachWhich::Role { baseline, index }),
edit: ChipEdit::EnvRole {
baseline,
index,
name: name.to_string(),
},
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn alias(text: &str, color: Color32, detach: Option<DetachWhich>) -> Chip {
Chip {
text: String::new(),
color,
is_base: false,
detach,
edit: ChipEdit::Alias {
text: text.to_string(),
},
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn loop_head(edit: LoopEdit, color: Color32) -> Chip {
Chip {
text: String::new(),
color,
is_base: true,
detach: None,
edit: ChipEdit::Loop(edit),
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
fn parallel(degree: Option<u32>, color: Color32) -> Chip {
Chip {
text: String::new(),
color,
is_base: false,
detach: Some(DetachWhich::Parallel),
edit: ChipEdit::Parallel { degree },
help: "",
tethered: false,
join_prev: false,
join_next: false,
hovered: false,
}
}
}
fn loop_edit_parts(node: &FlowNode, envs: bool) -> LoopEdit {
use crate::report::flow::{Binder, Producer};
let var = match node {
FlowNode::ForEnvs { var, .. } => Some(var.clone()),
FlowNode::ForEach { pattern, .. } => match (pattern.rest, pattern.binders.as_slice()) {
(false, [Binder::Named(n)]) => Some(n.clone()),
_ => None,
},
_ => None,
};
if envs {
let tail = match node {
FlowNode::ForEnvs {
clause: crate::report::flow::EnvClause::Plain(names),
..
} => names
.iter()
.map(|n| format!("\"{n}\""))
.collect::<Vec<_>>()
.join(", "),
_ => String::new(),
};
return LoopEdit {
var,
keyword: "IN ENVS".to_string(),
dir: None,
glob: None,
tail,
};
}
let FlowNode::ForEach { producer, .. } = node else {
return LoopEdit {
var,
keyword: "IN".to_string(),
dir: None,
glob: None,
tail: String::new(),
};
};
match producer {
Producer::Files { dir, glob } => LoopEdit {
var,
keyword: "IN FILES".to_string(),
dir: Some((dir.clone(), false)),
glob: Some(glob.clone().unwrap_or_default()),
tail: String::new(),
},
Producer::Folders { dir, glob, roles } => LoopEdit {
var,
keyword: "IN FOLDERS".to_string(),
dir: Some((dir.clone(), false)),
glob: Some(glob.clone().unwrap_or_default()),
tail: if roles.is_empty() {
String::new()
} else {
let rs: Vec<String> = roles.iter().map(role_label).collect();
format!("WITH {}", rs.join(", "))
},
},
Producer::Tuples { path } => LoopEdit {
var,
keyword: "IN TUPLES FROM".to_string(),
dir: Some((path.clone(), true)),
glob: None,
tail: String::new(),
},
other => LoopEdit {
var,
keyword: "IN".to_string(),
dir: None,
glob: None,
tail: producer_label(other),
},
}
}
fn role_label(r: &crate::report::flow::RoleBinding) -> String {
let opt = if r.optional { "?" } else { "" };
format!("{}=\"{}\"{opt}", r.name, r.glob)
}
fn producer_label(p: &crate::report::flow::Producer) -> String {
use crate::report::flow::{Element, Producer};
fn element(e: &Element) -> String {
match e {
Element::Scalar(s) => format!("\"{s}\""),
Element::Tuple(parts) => {
let items: Vec<String> = parts.iter().map(|s| format!("\"{s}\"")).collect();
format!("({})", items.join(", "))
}
}
}
match p {
Producer::List(elems) => {
let items: Vec<String> = elems.iter().map(element).collect();
format!("[{}]", items.join(", "))
}
Producer::Zip(ps) => {
let items: Vec<String> = ps.iter().map(producer_label).collect();
format!("ZIP({})", items.join(", "))
}
Producer::Concat(ps) => {
let items: Vec<String> = ps.iter().map(producer_label).collect();
format!("CONCAT({})", items.join(", "))
}
Producer::Named(n) => n.clone(),
Producer::Files { dir, glob } => match glob {
Some(g) => format!("FILES \"{dir}\" MATCH \"{g}\""),
None => format!("FILES \"{dir}\""),
},
Producer::Folders { dir, glob, .. } => match glob {
Some(g) => format!("FOLDERS \"{dir}\" MATCH \"{g}\""),
None => format!("FOLDERS \"{dir}\""),
},
Producer::Tuples { path } => format!("TUPLES FROM \"{path}\""),
}
}
fn node_chips(
node: &FlowNode,
req_ok: Option<bool>,
th: &GuiTheme,
s: &crate::i18n::Strings,
) -> Vec<Chip> {
let req_col = |ok: Option<bool>| match ok {
Some(true) => th.ok,
Some(false) => th.pending,
None => th.ok,
};
let mut chips = build_node_chips(node, req_col(req_ok), th, s);
for chip in &mut chips {
if let Some(which) = chip.detach
&& !crate::report::edit::detach_leaves_statement(node, which)
{
chip.detach = None;
}
}
chips
}
fn build_node_chips(
node: &FlowNode,
req_col: Color32,
th: &GuiTheme,
s: &crate::i18n::Strings,
) -> Vec<Chip> {
match node {
FlowNode::Comment(text) => {
vec![Chip::base(format!("#{text}"), th.dim).with_help(s.chip_help_comment)]
}
FlowNode::Request { name } => {
vec![Chip::request(name, req_col).with_help(s.chip_help_request)]
}
FlowNode::Report(ReportStmt::Request {
name,
alias,
response_fmt,
show,
hide,
with,
}) => {
let mut chips = vec![
Chip::modifier("REPORT".into(), th.subst, DetachWhich::Report)
.with_help(s.chip_help_report),
];
chips.push(Chip::request(name, req_col).with_help(s.chip_help_request));
if let Some(fmt) = response_fmt {
let text = match fmt {
crate::report::flow::ResponseFmt::Raw => "RESPONSE RAW",
crate::report::flow::ResponseFmt::Pretty => "RESPONSE PRETTY",
};
chips.push(
Chip::modifier(text.into(), th.accent, DetachWhich::Response)
.with_help(s.chip_help_response),
);
}
if !show.is_empty() {
chips.push(
Chip::modifier(
format!("SHOW({})", crate::report::flow::show_text(show)),
th.ok,
DetachWhich::Show,
)
.with_help(s.chip_help_show),
);
}
if !hide.is_empty() {
chips.push(
Chip::modifier(
format!("HIDE({})", hide.join(", ")),
th.dim,
DetachWhich::Hide,
)
.with_help(s.chip_help_hide),
);
}
if let Some(a) = alias {
chips.push(
Chip::alias(a, th.pending, Some(DetachWhich::As)).with_help(s.chip_help_alias),
);
}
if !with.is_empty() {
chips.push(
Chip::modifier("WITH".into(), th.accent, DetachWhich::WithBlock)
.with_help(s.chip_help_with),
);
}
chips
}
FlowNode::Report(ReportStmt::Vars(vars)) => {
let text = if vars.len() == 1 {
vars[0].clone()
} else {
format!("({})", vars.join(", "))
};
vec![
Chip::modifier("REPORT".into(), th.subst, DetachWhich::Report)
.with_help(s.chip_help_report),
Chip::base(text, th.text).with_help(s.chip_help_var),
]
}
FlowNode::Report(ReportStmt::VarAs {
var,
name,
stats,
image,
truth,
detail,
}) => {
let mut chips = vec![
Chip::modifier("REPORT".into(), th.subst, DetachWhich::Report)
.with_help(s.chip_help_report),
Chip::base(var.clone(), th.text).with_help(s.chip_help_var),
Chip::alias(name, th.pending, Some(DetachWhich::As)).with_help(s.chip_help_alias),
];
chips.extend(stats_chip(stats, th, s));
chips.extend(clause_chips(
image.as_ref(),
truth.as_deref(),
*detail,
th,
s,
));
chips
}
FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
image,
truth,
detail,
}) => {
let mut chips = vec![
Chip::modifier("REPORT".into(), th.subst, DetachWhich::Report)
.with_help(s.chip_help_report),
Chip::base(format!("\"{template}\""), th.text).with_help(s.chip_help_computed),
Chip::alias(name, th.pending, None).with_help(s.chip_help_alias_required),
];
chips.extend(stats_chip(stats, th, s));
chips.extend(clause_chips(
image.as_ref(),
truth.as_deref(),
*detail,
th,
s,
));
chips
}
FlowNode::Param(_) => {
vec![Chip::base(node.label(), th.pending).with_help(s.chip_help_param)]
}
FlowNode::Assign { .. } | FlowNode::ListDecl { .. } => {
let (col, help) = if matches!(node, FlowNode::Assign { .. }) {
(th.accent, s.chip_help_assign)
} else {
(th.pending, s.chip_help_list)
};
vec![Chip::base(node.label(), col).with_help(help)]
}
FlowNode::ForEach { parallel, .. } | FlowNode::ForEnvs { parallel, .. } => {
let mut chips = Vec::new();
if let Some(spec) = parallel {
chips.push(Chip::parallel(spec.degree, th.err).with_help(s.chip_help_parallel));
}
if let FlowNode::ForEnvs {
clause:
crate::report::flow::EnvClause::Roles {
baseline,
comparisons,
baseline_show,
},
..
} = node
{
chips.push(
Chip::loop_head(loop_edit_parts(node, true), th.accent)
.with_help(s.chip_help_for_envs),
);
use crate::report::flow::RoleRef;
if let [RoleRef::Env(name)] = baseline.as_slice() {
chips.push(
Chip::env_role(true, 0, name, th.pending).with_help(s.chip_help_baseline),
);
} else if !baseline.is_empty() {
chips.push(
Chip::modifier(
format!("BASELINE({})", role_refs_text(baseline)),
th.pending,
DetachWhich::Role {
baseline: true,
index: 0,
},
)
.with_help(s.chip_help_roles_fixed),
);
}
if !baseline_show.is_empty() {
chips.push(
Chip::modifier(
format!("SHOW({})", crate::report::flow::show_text(baseline_show)),
th.ok,
DetachWhich::BaselineShow,
)
.with_help(s.chip_help_baseline_show)
.tether(),
);
}
if let [RoleRef::Env(name)] = comparisons.as_slice() {
chips.push(
Chip::env_role(false, 0, name, th.pending)
.with_help(s.chip_help_comparison),
);
} else if !comparisons.is_empty() {
chips.push(
Chip::modifier(
format!("COMPARISON({})", role_refs_text(comparisons)),
th.pending,
DetachWhich::Role {
baseline: false,
index: 0,
},
)
.with_help(s.chip_help_roles_fixed),
);
}
} else {
let envs = matches!(node, FlowNode::ForEnvs { .. });
let help = if envs {
s.chip_help_for_envs
} else {
s.chip_help_for
};
chips.push(Chip::loop_head(loop_edit_parts(node, envs), th.accent).with_help(help));
}
chips
}
}
}
fn stats_chip(
stats: &[crate::report::model::StatKind],
th: &GuiTheme,
s: &crate::i18n::Strings,
) -> Option<Chip> {
if stats.is_empty() {
return None;
}
let list = stats
.iter()
.map(|k| k.keyword())
.collect::<Vec<_>>()
.join(", ");
Some(
Chip::modifier(
format!("STATISTICS({list})"),
th.subst,
DetachWhich::Statistics,
)
.with_help(s.chip_help_statistics)
.tether(),
)
}
fn clause_chips(
image: Option<&crate::report::flow::ImageSpec>,
truth: Option<&str>,
detail: bool,
th: &GuiTheme,
s: &crate::i18n::Strings,
) -> Vec<Chip> {
let mut out = Vec::new();
if image.is_some() {
out.push(
Chip::modifier(
crate::report::flow::image_text(image).trim().to_string(),
th.subst,
DetachWhich::Image,
)
.with_help(s.chip_help_image)
.tether(),
);
}
if truth.is_some() {
out.push(
Chip::modifier(
crate::report::flow::truth_text(truth).trim().to_string(),
th.subst,
DetachWhich::Truth,
)
.with_help(s.chip_help_truth)
.tether(),
);
}
if detail {
out.push(
Chip::modifier("DETAIL".to_string(), th.subst, DetachWhich::Detail)
.with_help(s.chip_help_detail)
.tether(),
);
}
out
}
fn role_refs_text(refs: &[crate::report::flow::RoleRef]) -> String {
use crate::report::flow::RoleRef;
refs.iter()
.map(|r| match r {
RoleRef::Env(n) => n.clone(),
RoleRef::File(p) => format!("FILE(\"{p}\")"),
})
.collect::<Vec<_>>()
.join(", ")
}
enum Act {
Select(Vec<usize>),
OpenPalette(InsertPos),
ClosePalette,
PickKind(NodeKind),
InsertRequest {
report: bool,
name: String,
},
Move {
path: Vec<usize>,
up: bool,
},
DropNode {
pos: InsertPos,
node: FlowNode,
},
AttachMod {
path: Vec<usize>,
modifier: Modifier,
},
MoveMod {
from: Vec<usize>,
which: DetachWhich,
to: Vec<usize>,
copy: bool,
},
DetachMod {
path: Vec<usize>,
which: DetachWhich,
},
RenameRequest {
path: Vec<usize>,
name: String,
},
SetEnvRole {
path: Vec<usize>,
baseline: bool,
index: usize,
name: String,
},
MoveNode {
from: Vec<usize>,
pos: InsertPos,
},
DeletePath(Vec<usize>),
OpenWizard(Vec<usize>),
SetAlias {
path: Vec<usize>,
text: String,
},
SetLoopVar {
path: Vec<usize>,
text: String,
},
SetLoopDir {
path: Vec<usize>,
text: String,
},
SetLoopGlob {
path: Vec<usize>,
text: String,
},
PickLoopDir {
path: Vec<usize>,
file: bool,
},
SetParallelDegree {
path: Vec<usize>,
degree: Option<u32>,
},
AddWith {
path: Vec<usize>,
},
EditWith {
path: Vec<usize>,
index: usize,
},
RemoveWith {
path: Vec<usize>,
index: usize,
},
AttachWithStats {
path: Vec<usize>,
index: usize,
},
SetHeader {
key: &'static str,
occurrence: usize,
value: Option<String>,
},
PickHeaderFile {
key: &'static str,
occurrence: usize,
},
}
impl Act {
fn is_value_commit(&self) -> bool {
matches!(
self,
Act::SetAlias { .. }
| Act::SetLoopVar { .. }
| Act::SetLoopDir { .. }
| Act::SetLoopGlob { .. }
| Act::SetParallelDegree { .. }
| Act::SetHeader { .. }
)
}
}
pub fn ui(app: &mut GuiApp, ui: &mut egui::Ui) {
let Some(mut ed) = app.report_editor.take() else {
return;
};
let th = app.theme;
let mut close = false;
ed.seed_params(&app.session);
let running = ed.poll_run(app);
if running {
ui.ctx().request_repaint();
}
match &ed.flow {
Some(flow) => {
let key = context::diagnostics_fingerprint(
&app.session.collections,
&app.session.global_envs,
app.session.active_env_id,
flow,
ed.report.path.as_deref(),
&app.strings,
);
if ed.diag_key != Some(key) {
ed.diagnostics = context::report_diagnostics(
&app.session.collections,
&app.session.global_envs,
app.session.active_env_id,
flow,
ed.report.path.as_deref(),
&app.strings,
);
let (helpers, _) = context::load_helpers(
&app.session.collections,
flow,
ed.report.path.as_deref(),
&app.strings,
);
ed.helpers = helpers;
ed.diag_key = Some(key);
}
}
None => {
ed.diagnostics.clear();
ed.helpers.clear();
ed.diag_key = None;
}
}
let mut reindent = false;
ui.horizontal(|ui| {
let mut title = RichText::new(&ed.report.name).strong().color(th.text);
if ed.report.dirty {
title = RichText::new(format!("{} •", ed.report.name))
.strong()
.color(th.accent);
}
ui.label(title);
ui.separator();
for (view, label) in [
(EditorView::Blocks, app.strings.gui_report_view_blocks),
(EditorView::Source, app.strings.gui_report_view_source),
(EditorView::Results, app.strings.gui_report_view_results),
] {
if super::widgets::selectable(ui, ed.view == view, RichText::new(label)).clicked() {
ed.view = view;
}
}
ui.separator();
if ui
.button(app.strings.gui_report_reindent)
.on_hover_text(app.strings.gui_report_reindent_help)
.clicked()
{
reindent = true;
}
if ed.is_running() {
ui.separator();
ui.colored_label(
th.pending,
format!(
"{} {}",
super::icons::RUNNING,
app.strings.gui_report_running
),
);
}
if let Some(prog) = &ed.progress {
ui.colored_label(th.dim, format!("{}/{}", prog.done, prog.total));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(format!("{} {}", super::icons::CLOSE, app.strings.gui_close))
.clicked()
{
ed.pending_toolbar = Some(ToolbarAct::Close);
}
let save = ui.add_enabled(
ed.report.dirty,
egui::Button::new(format!("{} {}", super::icons::SAVE, app.strings.gui_save)),
);
if save.clicked() {
ed.pending_toolbar = Some(ToolbarAct::Save);
}
if ed.is_running() {
if ui
.button(format!(
"{} {}",
super::icons::STOP,
app.strings.gui_report_stop
))
.clicked()
{
ed.stop_run(app);
}
} else {
let run = ui.add_enabled(
ed.can_run(),
egui::Button::new(format!(
"{} {}",
super::icons::PLAY,
app.strings.gui_report_run
)),
);
if run.clicked() {
ed.pending_toolbar = Some(ToolbarAct::Run);
}
let dry = ui
.add_enabled(
ed.can_run(),
egui::Button::new(format!(
"{} {}",
super::icons::PREVIEW,
app.strings.gui_report_dry_run
)),
)
.on_hover_text(app.strings.gui_report_dry_run_tooltip);
if dry.clicked() {
ed.pending_toolbar = Some(ToolbarAct::DryRun);
}
if ed.has_params() {
if param_chip(ui, &ed, app, &th).clicked() {
ed.pending_toolbar = Some(ToolbarAct::RunSettings);
}
}
}
if ed.view == EditorView::Results && ed.dry_run.is_none() {
ui.separator();
let has_rows = ed.result.as_ref().is_some_and(|r| !r.rows.is_empty());
let export = ui.add_enabled(
has_rows,
egui::Button::new(format!(
"{} {}",
super::icons::EXPORT,
app.strings.gui_report_export
)),
);
if export.clicked() {
open_export_dialog(app);
}
if let Some(exported) = ed.last_export.clone() {
let open = ui
.add(egui::Button::new(format!(
"{} {}",
super::icons::OPEN_EXTERNAL,
app.strings.gui_report_open_export
)))
.on_hover_text(exported.clone());
if open.clicked() {
ed.pending_toolbar = Some(ToolbarAct::OpenExport(exported));
}
}
let baseline = ui
.add_enabled(
has_rows,
egui::Button::new(format!(
"{} {}",
super::icons::SAVE,
app.strings.gui_report_save_baseline
)),
)
.on_hover_text(app.strings.help_report_baseline)
.on_disabled_hover_text(app.strings.report_baseline_no_result);
if baseline.clicked() {
super::menu::save_via_picker(app, super::app::SaveKind::ReportBaseline);
}
}
});
});
ui.separator();
if reindent {
app.session.status = Some(match ed.reformat() {
Ok(true) => Status::ReportReformatted,
Ok(false) => Status::ReportAlreadyTidy,
Err(ReformatError::Unparseable(msg)) => Status::ReportReformatFailed(msg),
Err(ReformatError::WouldChangeMeaning) => {
Status::ReportReformatFailed(app.strings.report_reformat_unsafe.to_string())
}
});
}
match ed.view {
EditorView::Source => source_view(&mut ed, app, ui),
EditorView::Blocks => blocks_view(&mut ed, app, ui),
EditorView::Results => results_view(&mut ed, app, ui),
}
let typing = ui.memory(|m| m.focused().is_some());
if !typing
&& ed.has_params()
&& ed.params_modal.is_none()
&& ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::P))
{
ed.pending_toolbar = Some(ToolbarAct::RunSettings);
}
show_param_modal(&mut ed, app, ui.ctx());
super::report_wizard::show(&mut ed, app, ui.ctx());
if ed.inspector.is_some() {
let mut open = true;
let esc = ui.ctx().input(|i| i.key_pressed(egui::Key::Escape));
let ins = ed.inspector.as_ref().unwrap();
egui::Window::new(RichText::new(&ins.title).heading().color(th.text))
.id(egui::Id::new("pt_cell_inspector"))
.collapsible(false)
.resizable(true)
.default_size([520.0, 340.0])
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.open(&mut open)
.show(ui.ctx(), |ui| {
ui.horizontal(|ui| {
if ui.button(app.strings.gui_report_cell_copy_full).clicked() {
ui.ctx().copy_text(ins.content.clone());
app.session.status = Some(crate::i18n::Status::Copied);
}
});
ui.separator();
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Frame::new()
.fill(th.sunken())
.inner_margin(6.0)
.show(ui, |ui| {
ui.add(
egui::Label::new(
RichText::new(&ins.content).monospace().color(th.text),
)
.selectable(true)
.wrap_mode(egui::TextWrapMode::Extend),
);
});
});
});
if !open || esc {
ed.inspector = None;
}
}
if ed.view != EditorView::Source
&& ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z))
{
ed.undo();
}
match ed.pending_toolbar.take() {
Some(ToolbarAct::Run) => ed.start_run(app),
Some(ToolbarAct::DryRun) => ed.start_dry_run(app),
Some(ToolbarAct::RunSettings) => ed.open_param_modal(app, RunIntent::Review),
Some(ToolbarAct::Save) => save_report(&mut ed, app),
Some(ToolbarAct::Close) => close = true,
Some(ToolbarAct::OpenExport(path)) => {
app.session.status = Some(match crate::shared_utils::open_in_desktop(&path) {
Ok(()) => Status::ReportOpened(path),
Err(e) => Status::Error(format!("{path}: {e}")),
});
}
None => {}
}
if close {
let key = ed.run_key();
let parked = ed.park_run();
if parked.is_worth_keeping() {
app.report_runs.insert(key, parked);
}
} else {
app.report_editor = Some(ed);
}
}
fn dry_run_body(
app: &GuiApp,
ui: &mut egui::Ui,
preview: &crate::report::dry_run::DryRunReport,
) -> Option<CellInspector> {
let th = app.theme;
let s = &app.strings;
let mut opened = None;
ui.colored_label(th.dim, s.report_dry_run_preview_notice);
ui.add_space(4.0);
ui.label(
RichText::new(format!("{} {}", s.report_dry_run_rows, preview.rows))
.strong()
.color(th.accent),
);
ui.add_space(4.0);
if preview.var_warnings.is_empty() && preview.errors.is_empty() {
ui.colored_label(th.accent, s.report_dry_run_no_problems);
} else {
if !preview.var_warnings.is_empty() {
ui.label(
RichText::new(s.report_dry_run_warnings_heading)
.strong()
.color(th.pending),
);
for w in &preview.var_warnings {
ui.colored_label(th.pending, format!("! {w}"));
}
}
if !preview.errors.is_empty() {
ui.label(
RichText::new(s.report_dry_run_problems_heading)
.strong()
.color(th.err),
);
for e in &preview.errors {
ui.colored_label(th.err, format!("• {e}"));
}
}
}
ui.separator();
if preview.rows == 0 {
ui.colored_label(th.dim, s.report_dry_run_no_rows);
return None;
}
let columns = preview.result.resolved_columns(&preview.header);
if columns.is_empty() {
ui.colored_label(th.dim, app.strings.gui_report_no_results);
return None;
}
let (preview_grid_cols, preview_detail_cols) = crate::report::detail::split_columns(&columns);
let mut none = None;
if let Some(ins) = results_grid(
&th,
ui,
&preview.result,
&preview_grid_cols,
None,
None,
&preview_detail_cols,
&mut none,
) {
opened = Some(ins);
}
opened
}
fn highlight_ctx(ed: &mut ReportEditor, app: &GuiApp) -> std::rc::Rc<HlCtx> {
let bound = ed.flow.as_ref().and_then(|flow| {
context::resolve_bound_collection(&app.session.collections, flow, ed.report.path.as_deref())
});
let key = highlight_ctx_key(ed, app, bound);
if let Some((cached_key, ctx)) = &ed.hl_cache
&& *cached_key == key
{
return ctx.clone();
}
let ctx = std::rc::Rc::new(HlCtx {
error_line: ed.parse_error_line,
collection_resolves: bound.is_some(),
loaded_envs: app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect(),
request_names: bound
.map(|ci| {
context::request_choices(&app.session.collections[ci].entries, &ed.helpers)
.into_iter()
.map(|c| c.qualified)
.collect()
})
.unwrap_or_default(),
});
ed.hl_cache = Some((key, ctx.clone()));
ctx
}
fn highlight_ctx_key(ed: &ReportEditor, app: &GuiApp, bound: Option<usize>) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
ed.parse_error_line.hash(&mut h);
bound.hash(&mut h);
for e in &app.session.global_envs {
e.name.hash(&mut h);
}
if let Some(ci) = bound {
for e in &app.session.collections[ci].entries {
e.title.hash(&mut h);
}
}
for helper in &ed.helpers {
helper.alias.hash(&mut h);
for e in &helper.entries {
e.title.hash(&mut h);
}
}
h.finish()
}
fn highlight_job(
text: &str,
ctx: &HlCtx,
spec: &crate::theme::ThemeSpec,
th: &GuiTheme,
font: egui::FontId,
wrap_width: f32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
let theme = spec.to_theme();
let mut job = LayoutJob {
wrap: egui::text::TextWrapping {
max_width: wrap_width,
..Default::default()
},
..Default::default()
};
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
job.append("\n", 0.0, TextFormat::simple(font.clone(), th.text));
}
for span in report_highlight::highlight_row(i, line, ctx, &theme) {
let style = span.style;
let mut fmt = TextFormat::simple(
font.clone(),
style
.fg
.map_or(th.text, |c| super::theme::from_ratatui(c, th.text)),
);
fmt.underline = if style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
{
egui::Stroke::new(1.0, fmt.color)
} else {
egui::Stroke::NONE
};
job.append(&span.content, 0.0, fmt);
}
}
job
}
#[allow(clippy::too_many_arguments)]
fn cached_highlight_job(
ui: &egui::Ui,
id: egui::Id,
text: &str,
ctx: &HlCtx,
spec: &crate::theme::ThemeSpec,
th: &GuiTheme,
font: egui::FontId,
wrap_width: f32,
) -> egui::text::LayoutJob {
let key = highlight_key(text, ctx, spec, &font, wrap_width);
if let Some((cached_key, job)) = ui.data(|d| d.get_temp::<(u64, egui::text::LayoutJob)>(id))
&& cached_key == key
{
return job;
}
let job = highlight_job(text, ctx, spec, th, font, wrap_width);
ui.data_mut(|d| d.insert_temp(id, (key, job.clone())));
job
}
fn highlight_key(
text: &str,
ctx: &HlCtx,
spec: &crate::theme::ThemeSpec,
font: &egui::FontId,
wrap_width: f32,
) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
fnv1a(text.as_bytes(), FNV_OFFSET).hash(&mut h);
ctx.error_line.hash(&mut h);
ctx.collection_resolves.hash(&mut h);
let mut names = 0u64;
for e in &ctx.loaded_envs {
names ^= fnv1a(e.as_bytes(), FNV_OFFSET);
}
for r in &ctx.request_names {
names ^= fnv1a(r.as_bytes(), 0x9e37_79b9_7f4a_7c15);
}
names.hash(&mut h);
spec.hash(&mut h);
font.size.to_bits().hash(&mut h);
wrap_width.to_bits().hash(&mut h);
h.finish()
}
fn byte_at(text: &str, at: usize) -> usize {
text.char_indices()
.nth(at)
.map(|(b, _)| b)
.unwrap_or(text.len())
}
fn row_col_at(text: &str, at: usize) -> (usize, usize) {
let (mut row, mut col) = (0usize, 0usize);
for ch in text.chars().take(at) {
if ch == '\n' {
row += 1;
col = 0;
} else {
col += 1;
}
}
(row, col)
}
fn row_start(text: &str, row: usize) -> usize {
let mut idx = 0usize;
for (n, line) in text.split('\n').enumerate() {
if n == row {
return idx;
}
idx += line.chars().count() + 1; }
text.chars().count()
}
fn newline_with_indent(text: &str, sel: std::ops::Range<usize>) -> (String, usize) {
let lines: Vec<&str> = text.split('\n').collect();
let (row, _) = row_col_at(text, sel.start);
let (end_row, end_col) = row_col_at(text, sel.end);
let at_line_end = end_col == lines.get(end_row).map_or(0, |l| l.chars().count());
let indent = if at_line_end {
indent_for_new_line(lines.get(row).copied().unwrap_or(""))
} else {
String::new()
};
let (from, to) = (byte_at(text, sel.start), byte_at(text, sel.end));
let mut out = String::with_capacity(text.len() + 1 + indent.len());
out.push_str(&text[..from]);
out.push('\n');
out.push_str(&indent);
out.push_str(&text[to..]);
(out, sel.start + 1 + indent.chars().count())
}
fn indent_at(text: &str, sel: std::ops::Range<usize>) -> (String, usize) {
let (from, to) = (byte_at(text, sel.start), byte_at(text, sel.end));
let mut out = String::with_capacity(text.len() + INDENT_UNIT.len());
out.push_str(&text[..from]);
out.push_str(INDENT_UNIT);
out.push_str(&text[to..]);
(out, sel.start + INDENT_UNIT.chars().count())
}
fn dedent_span(text: &str, at: usize) -> Option<usize> {
let (row, col) = row_col_at(text, at);
let chars: Vec<char> = text.split('\n').nth(row)?.chars().collect();
if col == 0 || chars.get(col - 1) != Some(&' ') {
return None;
}
let mut run_start = col;
while run_start > 0 && chars[run_start - 1] == ' ' {
run_start -= 1;
}
Some((col - run_start - 1) % INDENT_UNIT.len() + 1)
}
fn delete_before(text: &str, at: usize, n: usize) -> (String, usize) {
let start = at.saturating_sub(n);
let (from, to) = (byte_at(text, start), byte_at(text, at));
let mut out = String::with_capacity(text.len());
out.push_str(&text[..from]);
out.push_str(&text[to..]);
(out, start)
}
fn source_edit_key(
ui: &egui::Ui,
text: &str,
sel: std::ops::Range<usize>,
) -> Option<(String, usize)> {
use egui::{Key, Modifiers};
if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
return Some(newline_with_indent(text, sel));
}
if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
return Some(indent_at(text, sel));
}
if ui.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::Tab)) {
return dedent_span(text, sel.start).map(|n| delete_before(text, sel.start, n));
}
if sel.is_empty()
&& let Some(n) = dedent_span(text, sel.start)
&& ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace))
{
return Some(delete_before(text, sel.start, n));
}
None
}
fn snap_end_line(text: &str, at: usize) -> Option<(String, usize)> {
let lines: Vec<&str> = text.split('\n').collect();
let (row, _) = row_col_at(text, at);
let line = *lines.get(row)?;
if !is_end_line(line) {
return None;
}
let indent = matching_opener_indent(&lines[..row])?;
let fixed = format!("{indent}{}", line.trim_start());
if fixed == line {
return None;
}
let start = row_start(text, row);
let cursor = start + fixed.chars().count();
let mut out: Vec<&str> = lines;
out[row] = &fixed;
Some((out.join("\n"), cursor))
}
fn param_chip(
ui: &mut egui::Ui,
ed: &ReportEditor,
app: &GuiApp,
th: &super::theme::GuiTheme,
) -> egui::Response {
use egui::text::{LayoutJob, TextFormat};
let s = &app.strings;
let font = egui::TextStyle::Button.resolve(ui.style());
let fmt = |color: egui::Color32| TextFormat {
font_id: font.clone(),
color,
..Default::default()
};
let mut job = LayoutJob::default();
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
job.wrap.overflow_character = Some('…');
job.wrap.max_width = (ui.available_width() - 24.0).max(120.0);
job.append(
&format!("{} {}: ", super::icons::ENV, s.gui_report_run_settings),
0.0,
fmt(th.text),
);
for (i, row) in ed.param_rows(s).iter().enumerate() {
if i > 0 {
job.append(" · ", 0.0, fmt(th.dim));
}
job.append(&format!("{}=", row.name), 0.0, fmt(th.dim));
let (text, color) = if row.problem.is_some() {
(s.param_value_unset, th.err)
} else if row.value.is_empty() {
(s.param_value_unset, th.pending)
} else {
(row.value.as_str(), th.text)
};
job.append(text, 0.0, fmt(color));
}
let stale = ed.params_changed_since_result(s);
if stale {
job.append(
&format!(" · {}", s.param_changed_since_run),
0.0,
fmt(th.accent),
);
}
let mut hover = format!(
"{}\n{}",
ed.param_summary(s),
s.gui_report_run_settings_shortcut
);
if stale {
hover = format!(
"{}\n{}",
s.param_result_ran_with
.replace("{}", &summarize(&ed.result_params, s)),
hover
);
}
ui.add(egui::Button::new(job)).on_hover_text(hover)
}
fn summarize(values: &[(String, String)], s: &Strings) -> String {
values
.iter()
.map(|(name, value)| {
let shown = if value.is_empty() {
s.param_value_unset
} else {
value.as_str()
};
format!("{name}={shown}")
})
.collect::<Vec<_>>()
.join(" · ")
}
#[cfg(test)]
pub(crate) fn arm_params_for_audit(ed: &mut ReportEditor) {
ed.params_modal = Some(RunIntent::Run);
}
fn show_param_modal(ed: &mut ReportEditor, app: &mut GuiApp, ctx: &egui::Context) {
let Some(intent) = ed.params_modal else {
return;
};
let th = app.theme;
let s = Strings::for_language(&app.session.language);
let rows = ed.param_rows(&s);
let envs: Vec<String> = app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect();
let ready = rows.iter().all(|r| r.problem.is_none());
let mut set: Option<(String, String)> = None;
let mut browse: Option<(String, bool)> = None;
let mut leave: Option<Option<RunIntent>> = None;
let modal = egui::Modal::new(egui::Id::new("pt_run_settings")).show(ctx, |ui| {
ui.set_min_width(FORM_MAX_WIDTH);
ui.set_max_width(FORM_MAX_WIDTH);
ui.horizontal(|ui| {
ui.heading(RichText::new(s.param_view_title).color(th.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(th.dim))
.clicked()
{
leave = Some(None);
}
});
});
ui.colored_label(th.dim, s.param_view_lead);
ui.add_space(8.0);
egui::ScrollArea::vertical()
.max_height(420.0)
.auto_shrink([false, true])
.show(ui, |ui| {
for row in &rows {
if let Some((name, edit)) = param_card(ui, &th, row, &envs, &s) {
match edit {
CardEdit::Set(v) => set = Some((name, v)),
CardEdit::Browse(folder) => browse = Some((name, folder)),
}
}
ui.add_space(6.0);
}
});
ui.add_space(8.0);
ui.separator();
ui.horizontal(|ui| {
let run = |ui: &mut egui::Ui, leave: &mut Option<Option<RunIntent>>| {
if ui
.add_enabled(
ready,
egui::Button::new(format!("{} {}", super::icons::PLAY, s.gui_report_run)),
)
.clicked()
{
*leave = Some(Some(RunIntent::Run));
}
};
let dry = |ui: &mut egui::Ui, leave: &mut Option<Option<RunIntent>>| {
if ui
.add_enabled(
ready,
egui::Button::new(format!(
"{} {}",
super::icons::PREVIEW,
s.gui_report_dry_run
)),
)
.on_hover_text(s.gui_report_dry_run_tooltip)
.clicked()
{
*leave = Some(Some(RunIntent::DryRun));
}
};
if intent == RunIntent::DryRun {
dry(ui, &mut leave);
run(ui, &mut leave);
} else {
run(ui, &mut leave);
dry(ui, &mut leave);
}
if ui.button(s.gui_cancel).clicked() {
leave = Some(None);
}
if !ready {
ui.colored_label(th.err, s.param_blocked_hint);
}
});
});
if modal.should_close() {
leave = Some(None);
}
if let Some((name, value)) = set {
ed.param_values.insert(name, value);
}
if let Some((name, folder)) = browse {
let seed = ed
.report
.path
.as_deref()
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf);
let kind = if folder {
super::filepick::PickKind::Folder
} else {
super::filepick::PickKind::File {
filters: super::filepick::owned_filters(&[("*", &["*"])]),
}
};
app.request_pick(
kind,
s.param_pick_path,
seed.as_deref(),
super::menu::PickAction::ReportParamPath { name },
);
}
if let Some(started) = leave {
ed.params_modal = None;
if let Some(intent) = started {
ed.params_confirmed = true;
match intent {
RunIntent::Run => ed.start_run(app),
RunIntent::DryRun => ed.start_dry_run(app),
RunIntent::Review => {}
}
}
}
}
const FORM_MAX_WIDTH: f32 = 620.0;
const PARAM_FIELD_WIDTH: f32 = 320.0;
enum CardEdit {
Set(String),
Browse(bool),
}
fn param_card(
ui: &mut egui::Ui,
th: &GuiTheme,
row: &crate::report::params::ParamRow,
envs: &[String],
s: &Strings,
) -> Option<(String, CardEdit)> {
use crate::report::flow::ParamKind;
let hue = if row.problem.is_some() {
th.err
} else {
th.subst
};
let mut edit = None;
egui::Frame::NONE
.fill(mix(th.panel, hue, 0.10))
.stroke(egui::Stroke::new(1.0, mix(th.panel, hue, 0.45)))
.inner_margin(egui::Margin::symmetric(10, 8))
.corner_radius(BLOCK_RADIUS as u8)
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(&row.prompt).strong().color(th.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.colored_label(th.dim, row.kind.keyword());
ui.label(
RichText::new(&row.name)
.monospace()
.color(mix(th.dim, th.subst, 0.5)),
);
});
});
ui.add_space(4.0);
let mut value = row.value.clone();
let name = row.name.clone();
ui.horizontal(|ui| {
match &row.kind {
ParamKind::Choice(options) if !options.is_empty() => {
if param_combo(ui, th, &name, &mut value, options) {
edit = Some((name.clone(), CardEdit::Set(value.clone())));
}
}
ParamKind::Env if !envs.is_empty() => {
if param_combo(ui, th, &name, &mut value, envs) {
edit = Some((name.clone(), CardEdit::Set(value.clone())));
}
}
ParamKind::Folder | ParamKind::File => {
if param_field(ui, &mut value, s.param_value_unset) {
edit = Some((name.clone(), CardEdit::Set(value.clone())));
}
if ui
.button(format!("{} {}", super::icons::FOLDER, s.gui_browse))
.on_hover_text(s.param_pick_path)
.clicked()
{
edit = Some((
name.clone(),
CardEdit::Browse(matches!(row.kind, ParamKind::Folder)),
));
}
}
_ => {
if param_field(ui, &mut value, s.param_value_unset) {
edit = Some((name.clone(), CardEdit::Set(value.clone())));
}
}
}
});
if let Some(problem) = &row.problem {
ui.add_space(4.0);
ui.colored_label(th.err, format!("{} {}", super::icons::WARNING, problem));
}
});
edit
}
fn param_field(ui: &mut egui::Ui, value: &mut String, hint: &str) -> bool {
ui.add(
egui::TextEdit::singleline(value)
.hint_text(hint)
.font(egui::TextStyle::Monospace)
.desired_width(PARAM_FIELD_WIDTH),
)
.changed()
}
fn param_combo(
ui: &mut egui::Ui,
th: &GuiTheme,
name: &str,
value: &mut String,
options: &[String],
) -> bool {
let mut changed = false;
let known = options.iter().any(|o| o == value);
let shown = if value.is_empty() {
RichText::new("—").color(th.dim)
} else {
RichText::new(value.clone()).color(if known { th.text } else { th.pending })
};
egui::ComboBox::from_id_salt(("pt_param", name))
.width(PARAM_FIELD_WIDTH)
.selected_text(shown)
.show_ui(ui, |ui| {
if !known && !value.is_empty() {
let cur = value.clone();
let _ = ui.selectable_label(true, RichText::new(cur).color(th.pending));
}
for o in options {
if ui.selectable_label(o == value, o).clicked() {
*value = o.clone();
changed = true;
}
}
});
changed
}
fn source_view(ed: &mut ReportEditor, app: &GuiApp, ui: &mut egui::Ui) {
let avail = ui.available_height();
let diag_h = ed.diag_h.clamp(48.0, (avail - 100.0).max(48.0));
let edit_h = (avail - diag_h - 8.0).max(80.0);
let hl_ctx = highlight_ctx(ed, app);
let hl = &*hl_ctx;
let spec = app.session.active_theme_spec();
let th = app.theme;
let job_id = egui::Id::new("report_source_highlight");
let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let job = cached_highlight_job(ui, job_id, buf.as_str(), &hl, &spec, &th, font, wrap_width);
ui.ctx().fonts_mut(|f| f.layout_job(job))
};
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.max_height(edit_h)
.show(ui, |ui| {
let te_id = ui.id().with("trail_source");
let mut text = ed.report.text.clone();
let cursor = ui
.memory(|m| m.has_focus(te_id))
.then(|| egui::TextEdit::load_state(ui.ctx(), te_id))
.flatten()
.and_then(|s| s.cursor.char_range())
.map(|r| {
let r = r.as_sorted_char_range();
r.start.0..r.end.0
});
let mut new_cursor: Option<usize> = None;
let mut edited = false;
if let Some(range) = cursor
&& let Some((next, caret)) = source_edit_key(ui, &text, range)
{
text = next;
new_cursor = Some(caret);
edited = true;
}
let resp = ui.add(
egui::TextEdit::multiline(&mut text)
.id(te_id)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20)
.layouter(&mut layouter),
);
if (edited || resp.changed())
&& let Some(state) = egui::TextEdit::load_state(ui.ctx(), te_id)
{
let at = new_cursor.unwrap_or_else(|| {
state
.cursor
.char_range()
.map(|r| r.primary.index.0)
.unwrap_or(0)
});
if let Some((next, caret)) = snap_end_line(&text, at) {
text = next;
new_cursor = Some(caret);
edited = true;
}
}
if let Some(caret) = new_cursor
&& let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), te_id)
{
state
.cursor
.set_char_range(Some(egui::text_selection::CCursorRange::one(
egui::text::CCursor::new(egui::text::CharIndex(caret)),
)));
egui::TextEdit::store_state(ui.ctx(), te_id, state);
}
if edited || resp.changed() {
if ed.undo.last().map(String::as_str) != Some(ed.report.text.as_str()) {
ed.undo.push(ed.report.text.clone());
}
ed.set_text(text);
}
});
diag_splitter(ed, ui);
diagnostics_panel(ed, app, ui);
}
fn results_view(ed: &mut ReportEditor, app: &mut GuiApp, ui: &mut egui::Ui) {
let th = app.theme;
if let Some(preview) = ed.dry_run.take() {
let mut keep = true;
ui.horizontal(|ui| {
ui.colored_label(th.pending, app.strings.report_dry_run_preview_notice);
if !ed.result_params.is_empty() {
ui.colored_label(
th.dim,
format!(
"{} {}",
app.strings.param_previewed_with,
summarize(&ed.result_params, &app.strings)
),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(format!(
"{} {}",
super::icons::CLOSE,
app.strings.gui_report_dry_run_close
))
.on_hover_text(app.strings.gui_report_dry_run_close_tooltip)
.clicked()
{
keep = false;
}
});
});
ui.separator();
if let Some(ins) = dry_run_body(app, ui, &preview) {
ed.inspector = Some(ins);
}
if keep {
ed.dry_run = Some(preview);
}
return;
}
let Some(result) = ed.result.as_ref() else {
ui.add_space(8.0);
ui.colored_label(th.dim, app.strings.gui_report_no_results);
return;
};
let header = ed
.flow
.as_ref()
.map(|f| f.header.clone())
.unwrap_or_default();
let columns = result.resolved_columns(&header);
let (grid_columns, detail_columns) = crate::report::detail::split_columns(&columns);
if columns.is_empty() {
ui.add_space(8.0);
ui.colored_label(th.dim, app.strings.gui_report_no_results);
return;
}
if !result.errors.is_empty() {
for e in &result.errors {
ui.colored_label(th.err, format!("{} {e}", super::icons::FAIL));
}
ui.add_space(2.0);
}
let states = ed.progress.as_ref().map(|p| p.states.as_slice());
let live = states.is_some();
if let Some(visible) = visible_for_view(
result,
&columns,
&header,
live,
&mut ed.results_filter,
&ed.results_find,
) {
let metrics = result.metrics(&columns, &header);
let (filters, buttons) = crate::report::filter::all_filters(result, metrics.as_ref());
let summary_h = ed
.summary_h
.clamp(60.0, (ui.available_height() - 120.0).max(60.0));
let mut filter = ed.results_filter;
let mut find = std::mem::take(&mut ed.results_find);
egui::ScrollArea::vertical()
.id_salt("pb_report_summary")
.max_height(summary_h)
.auto_shrink([false, true])
.show(ui, |ui| {
if let Some(metrics) = &metrics {
metric_cards(&th, ui, app, metrics);
}
filter_bar(
&th,
ui,
app,
&filters[..buttons],
&mut filter,
&mut find,
visible.len(),
result.rows.len(),
);
if let Some(metrics) = &metrics {
for m in &metrics.columns {
if let Some(matrix) = &m.matrix {
confusion_matrix(
&th,
ui,
app,
&m.header,
matrix,
&filters,
&mut filter,
);
}
}
}
});
ed.results_filter = filter;
ed.results_find = find;
summary_splitter(&mut ed.summary_h, ui);
ui.colored_label(
th.dim,
app.strings
.gui_report_cell_hint
.replace("{c}", super::icons::CARET_RIGHT),
);
ui.add_space(2.0);
let avail = ui.available_height();
let grid_h = if ed.results_detail.is_some() {
let panel = ed.detail_h.clamp(80.0, (avail - 120.0).max(80.0));
(avail - panel - 10.0).max(80.0)
} else {
avail
};
let mut open_detail = ed.results_detail;
let mut inspector = None;
ui.allocate_ui(egui::vec2(ui.available_width(), grid_h), |ui| {
inspector = results_grid(
&th,
ui,
result,
&grid_columns,
states,
Some(&visible),
&detail_columns,
&mut open_detail,
);
});
if let Some(ins) = inspector {
ed.inspector = Some(ins);
}
if open_detail.is_some_and(|r| !visible.contains(&r)) {
open_detail = None;
}
ed.results_detail = open_detail;
if let Some(r) = ed.results_detail {
detail_splitter(&mut ed.detail_h, ui);
detail_panel(
&th,
ui,
app,
result,
r,
&columns,
&detail_columns,
&mut ed.results_textures,
&mut ed.results_detail,
);
}
return;
}
ui.colored_label(
th.dim,
app.strings
.gui_report_cell_hint
.replace("{c}", super::icons::CARET_RIGHT),
);
ui.add_space(2.0);
let mut none = None;
if let Some(ins) = results_grid(
&th,
ui,
result,
&grid_columns,
states,
None,
&detail_columns,
&mut none,
) {
ed.inspector = Some(ins);
}
}
fn detail_splitter(height: &mut f32, ui: &mut egui::Ui) {
ui.add_space(2.0);
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), egui::Sense::drag());
if resp.dragged() {
*height = (*height - resp.drag_delta().y).clamp(80.0, 2000.0);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let active = resp.hovered() || resp.dragged();
let visuals = ui.visuals();
let colour = if active {
visuals.widgets.active.fg_stroke.color
} else {
visuals.widgets.noninteractive.bg_stroke.color
};
let w = (rect.width() * 0.25).clamp(40.0, 160.0);
let x0 = rect.center().x - w / 2.0;
ui.painter()
.hline(x0..=x0 + w, rect.center().y, egui::Stroke::new(2.0, colour));
}
fn summary_splitter(height: &mut f32, ui: &mut egui::Ui) {
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), egui::Sense::drag());
if resp.dragged() {
*height = (*height + resp.drag_delta().y).clamp(60.0, 2000.0);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let active = resp.hovered() || resp.dragged();
let visuals = ui.visuals();
let colour = if active {
visuals.widgets.active.fg_stroke.color
} else {
visuals.widgets.noninteractive.bg_stroke.color
};
let w = (rect.width() * 0.25).clamp(40.0, 160.0);
let x0 = rect.center().x - w / 2.0;
ui.painter()
.hline(x0..=x0 + w, rect.center().y, egui::Stroke::new(2.0, colour));
}
fn detail_layout_columns(avail: f32, min_w: f32, sections: usize) -> usize {
if sections == 0 {
return 1;
}
let fits = (avail / min_w).floor().max(1.0) as usize;
fits.min(sections).max(1)
}
const DETAIL_MIN_SECTION_W: f32 = 340.0;
#[allow(clippy::too_many_arguments)]
fn detail_panel(
th: &GuiTheme,
ui: &mut egui::Ui,
app: &GuiApp,
result: &ReportResult,
r: usize,
all_columns: &[crate::report::model::OutputColumn],
detail_columns: &[&crate::report::model::OutputColumn],
textures: &mut std::collections::HashMap<(usize, String), egui::TextureHandle>,
open: &mut Option<usize>,
) {
ui.horizontal(|ui| {
ui.label(
RichText::new(
app.strings
.report_detail_title
.replace("{n}", &(r + 1).to_string()),
)
.strong()
.color(th.accent),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(super::icons::CLOSE).frame(false))
.on_hover_text(app.strings.report_detail_close)
.clicked()
{
*open = None;
}
});
});
egui::ScrollArea::vertical()
.id_salt("pb_report_detail")
.auto_shrink([false, false])
.show(ui, |ui| {
let sections = crate::report::detail::sections(result, r, all_columns, detail_columns);
let n =
detail_layout_columns(ui.available_width(), DETAIL_MIN_SECTION_W, sections.len());
ui.columns(n, |cols| {
for (i, section) in sections.into_iter().enumerate() {
let ui = &mut cols[i % n];
detail_section(th, ui, app, r, section, textures);
ui.add_space(6.0);
}
});
});
}
fn detail_section(
th: &GuiTheme,
ui: &mut egui::Ui,
app: &GuiApp,
r: usize,
section: crate::report::detail::DetailSection<'_>,
textures: &mut std::collections::HashMap<(usize, String), egui::TextureHandle>,
) {
use crate::report::detail::DetailSection;
match section {
DetailSection::Image {
header,
image,
value,
} => {
ui.label(RichText::new(header).strong().color(th.accent));
detail_image(ui, textures, r, header, image, &value, app);
}
DetailSection::Text {
header,
value,
verdict,
} => {
ui.horizontal(|ui| {
ui.label(RichText::new(header).strong().color(th.accent));
if let Some((v, truth)) = &verdict {
let col = if *v == crate::report::model::Verdict::Correct {
th.ok
} else {
th.err
};
ui.label(
RichText::new(crate::report::detail::verdict_label(*v, truth))
.strong()
.color(col),
);
}
});
ui.add(
egui::TextEdit::multiline(&mut value.as_str())
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);
}
DetailSection::Diff { header, fields } => {
ui.label(
RichText::new(app.strings.report_detail_changed.replace("{c}", header))
.strong()
.color(th.accent),
);
egui::Grid::new(("pb_detail_diff", header))
.striped(true)
.num_columns(3)
.show(ui, |ui| {
for f in &fields {
let col = if f.differs() { th.err } else { th.dim };
ui.colored_label(col, &f.path);
ui.colored_label(col, f.baseline.as_deref().unwrap_or("\u{2014}"));
ui.colored_label(col, f.candidate.as_deref().unwrap_or("\u{2014}"));
ui.end_row();
}
});
}
}
}
fn detail_image(
ui: &mut egui::Ui,
textures: &mut std::collections::HashMap<(usize, String), egui::TextureHandle>,
r: usize,
header: &str,
image: &crate::report::model::ImageData,
value: &str,
app: &GuiApp,
) {
let key = (r, header.to_string());
let tex = textures.entry(key).or_insert_with(|| {
let colour = ::image::load_from_memory(&image.bytes)
.map(|i| {
let rgba = i.to_rgba8();
let (w, h) = rgba.dimensions();
egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], rgba.as_raw())
})
.unwrap_or_else(|_| egui::ColorImage::new([1, 1], vec![egui::Color32::TRANSPARENT]));
ui.ctx().load_texture(
format!("pb_detail_{r}_{header}"),
colour,
egui::TextureOptions::LINEAR,
)
});
let natural = egui::vec2(tex.size()[0] as f32, tex.size()[1] as f32);
let scale = (ui.available_width() / natural.x).min(1.0);
ui.add(egui::Image::new((tex.id(), natural * scale)))
.on_hover_text(value);
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
ui.add(egui::Label::new(RichText::new(value).color(ui.visuals().weak_text_color())).wrap());
});
let _ = app;
}
fn visible_for_view(
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
header: &crate::report::flow::Header,
live: bool,
selected: &mut usize,
find: &str,
) -> Option<Vec<usize>> {
if live {
return None;
}
let (filters, _) =
crate::report::filter::all_filters(result, result.metrics(columns, header).as_ref());
*selected = (*selected).min(filters.len().saturating_sub(1));
let labels = crate::report::labels::LabelMap::parse(&header.labels());
Some(crate::report::filter::visible_rows(
result,
columns,
&labels,
filters.get(*selected).unwrap_or(&RowFilter::All),
find,
))
}
fn metric_cards(
th: &GuiTheme,
ui: &mut egui::Ui,
app: &GuiApp,
metrics: &crate::report::metrics::Metrics,
) {
let tinted = |ui: &mut egui::Ui, k: &str, v: &str, colour: egui::Color32| {
egui::Frame::group(ui.style())
.inner_margin(egui::Margin::symmetric(8, 4))
.show(ui, |ui| {
ui.vertical(|ui| {
ui.label(RichText::new(k).size(11.0).color(th.dim));
ui.label(RichText::new(v).strong().color(colour));
});
});
};
let card = |ui: &mut egui::Ui, k: &str, v: &str| tinted(ui, k, v, th.text);
if let Some(mv) = &metrics.movement {
ui.horizontal_wrapped(|ui| {
if mv.is_still() {
card(
ui,
app.strings.report_metric_movement,
app.strings.report_metric_nothing_moved,
);
} else {
tinted(
ui,
app.strings.report_metric_fixed,
&mv.fixed.to_string(),
th.ok,
);
tinted(
ui,
app.strings.report_metric_regressed,
&mv.regressed.to_string(),
if mv.regressed > 0 { th.err } else { th.text },
);
}
if mv.still_wrong > 0 {
card(
ui,
app.strings.report_metric_still_wrong,
&mv.still_wrong.to_string(),
);
}
});
ui.add_space(2.0);
}
for m in metrics.overall.iter().chain(metrics.columns.iter()) {
ui.horizontal_wrapped(|ui| {
card(
ui,
&format!("{} — {}", m.header, app.strings.report_metric_compared),
&format!("{} / {}", m.compared, m.total),
);
card(
ui,
app.strings.report_metric_incorrect,
&m.incorrect.to_string(),
);
card(
ui,
app.strings.report_metric_accuracy,
m.accuracy_text().as_deref().unwrap_or("\u{2014}"),
);
});
ui.add_space(2.0);
}
}
#[allow(clippy::too_many_arguments)]
fn confusion_matrix(
th: &GuiTheme,
ui: &mut egui::Ui,
app: &GuiApp,
column: &str,
matrix: &crate::report::metrics::ConfusionMatrix,
filters: &[RowFilter],
selected: &mut usize,
) {
let max = matrix.max();
let base = ui.text_style_height(&egui::TextStyle::Body);
let cell_font = base * 1.15;
let axis_font = base * 1.0;
let cell_w = (base * 3.2).max(
matrix
.axis
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(1) as f32
* base
* 0.45,
);
egui::CollapsingHeader::new(RichText::new(column).strong().size(base * 1.1))
.id_salt(("pb_matrix", column))
.default_open(true)
.show(ui, |ui| {
egui::Grid::new(("pb_matrix_grid", column))
.spacing(egui::vec2(2.0, 2.0))
.show(ui, |ui| {
ui.label("");
for label in &matrix.axis {
cell_slot(ui, cell_w, base, |ui| {
ui.label(RichText::new(label).color(th.dim).size(axis_font));
});
}
ui.end_row();
for (t, truth) in matrix.axis.iter().enumerate() {
ui.label(RichText::new(truth).color(th.dim).size(axis_font));
for (p, answer) in matrix.axis.iter().enumerate() {
let n = matrix.counts[t][p];
let ([r, g, b], hot) =
crate::report::metrics::heat_rgb(n, max);
let fg = if hot {
egui::Color32::WHITE
} else {
egui::Color32::from_rgb(0x12, 0x30, 0x5a)
};
let pick = (n > 0)
.then(|| {
filters.iter().position(|f| {
matches!(
f,
RowFilter::MatrixCell { column: c, truth: tr, answer: a }
if c == column && tr == truth && a == answer
)
})
})
.flatten();
let framed = egui::Frame::NONE
.fill(egui::Color32::from_rgb(r, g, b))
.inner_margin(egui::Margin::symmetric(6, 6))
.show(ui, |ui| {
ui.set_min_width(cell_w);
ui.vertical_centered(|ui| {
ui.add(
egui::Label::new(
RichText::new(n.to_string())
.color(fg)
.size(cell_font),
)
.selectable(false),
);
});
});
let cell = framed.response.rect;
if let Some(i) = pick {
let resp = ui.interact(
cell,
ui.id().with(("pb_matrix_cell", column, t, p)),
egui::Sense::click(),
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(app.strings.help_report_matrix_cell)
.clicked()
{
*selected = i;
}
}
}
ui.end_row();
}
});
let clean = if matrix.is_diagonal() {
app.strings.report_matrix_all_matched
} else {
""
};
ui.label(
RichText::new(format!(
"{} {clean}",
app.strings
.report_matrix_caption
.replace("{n}", &matrix.total().to_string())
))
.color(th.dim)
.size(axis_font * 0.95),
);
});
ui.add_space(2.0);
}
#[allow(clippy::too_many_arguments)]
fn filter_bar(
th: &GuiTheme,
ui: &mut egui::Ui,
app: &GuiApp,
filters: &[RowFilter],
selected: &mut usize,
find: &mut String,
shown: usize,
total: usize,
) {
ui.horizontal_wrapped(|ui| {
if filters.len() > 1 {
for (i, f) in filters.iter().enumerate() {
if ui
.selectable_label(i == *selected, filter_label(&app.strings, f))
.on_hover_text(app.strings.help_report_filter)
.clicked()
{
*selected = i;
}
}
ui.separator();
}
ui.add(
egui::TextEdit::singleline(find)
.desired_width(180.0)
.hint_text(app.strings.report_find_placeholder),
);
ui.colored_label(
th.dim,
app.strings
.report_rows_shown
.replace("{shown}", &shown.to_string())
.replace("{total}", &total.to_string()),
);
});
ui.add_space(2.0);
}
fn filter_label(s: &crate::i18n::Strings, f: &RowFilter) -> String {
match f {
RowFilter::All => s.report_filter_all.to_string(),
RowFilter::Differ => s.report_filter_differences.to_string(),
RowFilter::Incorrect => s.report_filter_incorrect.to_string(),
RowFilter::Regressed => s.report_filter_regressions.to_string(),
RowFilter::MatrixCell { .. } => f.label(),
}
}
const MIN_COL_W: f32 = 46.0;
fn fit_column_widths(natural: &[f32], avail: f32, spacing: f32) -> Vec<f32> {
if natural.is_empty() {
return Vec::new();
}
let gaps = spacing * (natural.len() as f32 - 1.0);
let budget = (avail - gaps).max(0.0);
let total: f32 = natural.iter().sum();
if total <= 0.0 {
return vec![(budget / natural.len() as f32).max(MIN_COL_W); natural.len()];
}
if total <= budget {
let scale = budget / total;
return natural.iter().map(|w| w * scale).collect();
}
let mut order: Vec<usize> = (0..natural.len()).collect();
order.sort_by(|&a, &b| natural[a].total_cmp(&natural[b]));
let mut out = vec![0.0f32; natural.len()];
let mut left = budget;
for (i, &c) in order.iter().enumerate() {
let share = left / (order.len() - i) as f32;
if natural[c] <= share {
out[c] = natural[c];
left -= natural[c];
} else {
out[c] = share;
left -= share;
}
}
out.iter().map(|w| w.max(MIN_COL_W)).collect()
}
fn results_grid(
th: &GuiTheme,
ui: &mut egui::Ui,
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
states: Option<&[RowState]>,
visible: Option<&[usize]>,
detail_columns: &[&crate::report::model::OutputColumn],
open_detail: &mut Option<usize>,
) -> Option<CellInspector> {
let show_icons = states.is_some();
let expandable: std::collections::HashSet<usize> = (0..result.rows.len())
.filter(|&r| {
!crate::report::detail::sections(result, r, columns, detail_columns).is_empty()
})
.collect();
let show_expanders = !expandable.is_empty();
let mut opened: Option<CellInspector> = None;
let widths = fitted_column_widths(ui, result, columns, show_icons);
let row_h = ui.text_style_height(&egui::TextStyle::Body);
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("report_results_grid")
.striped(true)
.spacing(egui::vec2(SPACING_X, 3.0))
.show(ui, |ui| {
if show_expanders {
ui.label(" ");
}
if show_icons {
ui.label(" ");
}
for (c, col) in columns.iter().enumerate() {
let w = widths.get(c).copied().unwrap_or(MIN_COL_W);
cell_slot(ui, w, row_h, |ui| {
ui.add(
egui::Label::new(
RichText::new(&col.header).strong().color(th.accent),
)
.truncate(),
)
.on_hover_text(&col.header);
});
}
ui.end_row();
let order: Vec<usize> = match visible {
Some(v) => v.to_vec(),
None => (0..result.rows.len()).collect(),
};
for i in order {
let Some(row) = result.rows.get(i) else {
continue;
};
let state = states.and_then(|s| s.get(i)).copied();
let row_target = expandable.contains(&i).then(|| {
let w: f32 = widths.iter().sum::<f32>()
+ SPACING_X * widths.len() as f32
+ if show_expanders { 24.0 } else { 0.0 }
+ if show_icons { 18.0 + SPACING_X } else { 0.0 };
let rect =
egui::Rect::from_min_size(ui.cursor().min, egui::vec2(w, row_h));
let r = ui.interact(
rect,
ui.id().with(("pb_result_row", i)),
egui::Sense::click(),
);
if r.contains_pointer() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
r
});
if show_expanders {
let has = expandable.contains(&i);
let open = *open_detail == Some(i);
if has {
let glyph = if open {
super::icons::CARET_DOWN
} else {
super::icons::CARET_RIGHT
};
if ui
.add(
egui::Button::new(RichText::new(glyph).color(if open {
th.accent
} else {
th.dim
}))
.frame(false),
)
.clicked()
{
*open_detail = (!open).then_some(i);
}
} else {
ui.label(" ");
}
}
if show_icons {
let (glyph, colour) = match state {
Some(RowState::Running) => (super::icons::RUNNING, th.pending),
Some(RowState::Finished) => (super::icons::PASS, th.ok),
_ => (super::icons::ROW_SCHEDULED, th.dim),
};
ui.colored_label(colour, glyph);
}
let text_col = match state {
Some(RowState::Running) => th.pending,
Some(RowState::Scheduled) => th.dim,
_ => th.text,
};
for (c, col) in columns.iter().enumerate() {
let full = col.value(row, &result.no_match_marker);
let w = widths.get(c).copied().unwrap_or(MIN_COL_W);
cell_slot(ui, w, row_h, |ui| {
if let Some(ins) = result_cell(ui, text_col, &col.header, &full) {
if expandable.contains(&i) {
*open_detail = Some(i);
} else {
opened = Some(ins);
}
}
});
}
if row_target.is_some_and(|r| r.clicked()) {
*open_detail = Some(i);
}
ui.end_row();
}
for srow in result.summary_rows(columns) {
if show_expanders {
ui.label(" ");
}
if show_icons {
ui.label(" ");
}
for (c, col) in columns.iter().enumerate() {
let full = srow.text_cell(c);
let cell = flatten_cell(&full);
let w = widths.get(c).copied().unwrap_or(MIN_COL_W);
cell_slot(ui, w, row_h, |ui| {
let resp = ui
.add(
egui::Label::new(
RichText::new(truncate_cell(&cell))
.italics()
.color(th.accent),
)
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_text(&cell);
if resp.clicked() {
opened = Some(CellInspector {
title: col.header.clone(),
content: pretty_json_cell(&full),
});
}
});
}
ui.end_row();
}
});
});
opened
}
const TETHER_GAP: f32 = 0.0;
const CHIP_RADIUS: u8 = 3;
const ROUND_CHIP: egui::CornerRadius = egui::CornerRadius::same(CHIP_RADIUS);
fn chip_corners(chip: &Chip) -> egui::CornerRadius {
let joined = |yes: bool| if yes { 0 } else { CHIP_RADIUS };
egui::CornerRadius {
nw: joined(chip.join_prev),
sw: joined(chip.join_prev),
ne: joined(chip.join_next),
se: joined(chip.join_next),
}
}
fn link_tethers(chips: &mut [Chip]) {
for i in 1..chips.len() {
if !chips[i].tethered {
continue;
}
chips[i - 1].join_next = true;
chips[i].join_prev = true;
}
}
fn split_tether(chips: &mut [Chip], i: usize) {
chips[i - 1].join_next = false;
chips[i].join_prev = false;
chips[i].tethered = false;
}
fn paint_tether_hover(ui: &egui::Ui, th: &GuiTheme, anchor: egui::Rect, hanger: egui::Rect) {
let pair = anchor.union(hanger);
if ui.ctx().dragged_id().is_some() || !ui.rect_contains_pointer(pair) {
return;
}
if ui
.ctx()
.data(|d| d.get_temp::<Option<egui::Rect>>(chip_hover_id()))
.flatten()
.is_some()
{
return;
}
ui.painter().rect_stroke(
pair.expand(2.0),
egui::CornerRadius::same(CHIP_RADIUS + 2),
egui::Stroke::new(1.0, mix(th.panel, th.text, 0.75)),
egui::StrokeKind::Outside,
);
}
const SPACING_X: f32 = 14.0;
fn fitted_column_widths(
ui: &egui::Ui,
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
show_icons: bool,
) -> Vec<f32> {
let icon_w = if show_icons { 18.0 + SPACING_X } else { 0.0 };
let natural = cached_natural_widths(ui, result, columns);
let avail = (ui.available_width() - icon_w).max(0.0);
fit_column_widths(&natural, avail, SPACING_X)
}
fn cached_natural_widths(
ui: &egui::Ui,
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
) -> Vec<f32> {
let key = widths_fingerprint(ui, result, columns);
let id = egui::Id::new("report_grid_widths");
if let Some((cached_key, widths)) = ui.data(|d| d.get_temp::<(u64, Vec<f32>)>(id))
&& cached_key == key
{
return widths;
}
let widths = natural_column_widths(ui, result, columns);
ui.data_mut(|d| d.insert_temp(id, (key, widths.clone())));
widths
}
pub(super) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
pub(super) fn fnv1a(bytes: &[u8], seed: u64) -> u64 {
let mut h = seed;
for b in bytes {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn widths_fingerprint(
ui: &egui::Ui,
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let font = egui::TextStyle::Body.resolve(ui.style());
font.size.to_bits().hash(&mut h);
format!("{:?}", font.family).hash(&mut h);
result.no_match_marker.hash(&mut h);
for col in columns {
col.header.hash(&mut h);
format!("{:?}", col.stats).hash(&mut h);
format!("{:?}", col.sources).hash(&mut h);
}
result.rows.len().hash(&mut h);
for row in &result.rows {
let mut cells = 0u64;
for (k, v) in row.cells.iter().chain(&row.vars) {
cells ^= fnv1a(k.as_bytes(), fnv1a(v.as_bytes(), FNV_OFFSET));
}
cells.hash(&mut h);
row.target.hash(&mut h);
}
h.finish()
}
fn cell_slot(ui: &mut egui::Ui, w: f32, h: f32, add: impl FnOnce(&mut egui::Ui)) {
ui.allocate_ui_with_layout(
egui::vec2(w, h),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.set_min_size(egui::vec2(w, h));
add(ui);
},
);
}
fn natural_column_widths(
ui: &egui::Ui,
result: &ReportResult,
columns: &[crate::report::model::OutputColumn],
) -> Vec<f32> {
let font = egui::TextStyle::Body.resolve(ui.style());
let summary = result.summary_rows(columns);
let pad = 6.0;
let measure = |text: &str| {
ui.painter()
.layout_no_wrap(text.to_string(), font.clone(), egui::Color32::WHITE)
.size()
.x
+ pad
};
columns
.iter()
.enumerate()
.map(|(c, col)| {
let mut longest = String::new();
let mut longest_len = 0usize;
let mut consider = |text: String| {
let n = text.chars().count();
if n > longest_len {
longest_len = n;
longest = text;
}
};
for row in &result.rows {
consider(truncate_cell(&flatten_cell(
&col.value(row, &result.no_match_marker),
)));
}
for srow in &summary {
consider(truncate_cell(&flatten_cell(&srow.text_cell(c))));
}
measure(&col.header).max(measure(&longest))
})
.collect()
}
fn result_cell(
ui: &mut egui::Ui,
text_col: Color32,
header: &str,
full: &str,
) -> Option<CellInspector> {
let cell = flatten_cell(full);
let resp = ui
.add(
egui::Label::new(RichText::new(truncate_cell(&cell)).color(text_col))
.truncate()
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(cell);
resp.clicked().then(|| CellInspector {
title: header.to_string(),
content: pretty_json_cell(full),
})
}
fn pretty_json_cell(raw: &str) -> String {
let trimmed = raw.trim();
if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
return raw.to_string();
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_string()),
Err(_) => raw.to_string(),
}
}
fn flatten_cell(value: &str) -> String {
if value.contains(['\n', '\r']) {
value.replace("\r\n", "⏎").replace(['\n', '\r'], "⏎")
} else {
value.to_string()
}
}
fn truncate_cell(value: &str) -> String {
const MAX: usize = 48;
if value.chars().count() > MAX {
let mut s: String = value.chars().take(MAX - 1).collect();
s.push('…');
s
} else {
value.to_string()
}
}
fn open_export_dialog(app: &mut GuiApp) {
let path = app
.report_editor
.as_ref()
.map(|e| {
let p = crate::report::writer::export_path(
&e.report,
&crate::report::writer::report_output_extension(&e.report),
);
if p.is_absolute() {
p
} else {
crate::report::context::report_base_dir(
&e.flow.clone().unwrap_or_default(),
e.report.path.as_deref(),
)
.0
.join(p)
}
})
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "results.csv".to_string());
app.dialog = Some(super::app::Dialog::ExportResults { path });
}
fn blocks_view(ed: &mut ReportEditor, app: &mut GuiApp, ui: &mut egui::Ui) {
let th = app.theme;
if let Some(err) = &ed.parse_error {
ui.add_space(6.0);
ui.colored_label(th.err, app.strings.report_nodes_parse_error);
ui.colored_label(th.dim, err);
ui.separator();
diagnostics_panel(ed, app, ui);
return;
}
let Some(flow) = ed.flow.clone() else {
return;
};
let bound = context::resolve_bound_collection(
&app.session.collections,
&flow,
ed.report.path.as_deref(),
);
let entries = bound
.map(|ci| app.session.collections[ci].entries.as_slice())
.unwrap_or(&[]);
let choices = context::request_choices(entries, &ed.helpers);
let titles: Vec<String> = choices.iter().map(|c| c.qualified.clone()).collect();
let helpers = ed.helpers.clone();
let resolves =
move |name: &str| crate::report::run::resolve_qualified(entries, &helpers, name).is_some();
let rows = flatten(&flow, &resolves);
let mut acts: Vec<Act> = Vec::new();
ui.horizontal(|ui| {
let sel_pos = rows
.iter()
.position(|r| r.path == ed.selection && r.kind != RowKind::LoopEnd)
.or_else(|| rows.iter().position(|r| r.kind == RowKind::Begin))
.unwrap_or(0);
if ui
.button(format!(
"{} {}",
super::icons::PLUS,
app.strings.gui_report_add_block
))
.clicked()
{
acts.push(Act::OpenPalette(insert_pos_after(&rows, sel_pos)));
}
let on_node = !ed.selection.is_empty();
if ui
.add_enabled(
on_node,
egui::Button::new(super::icons::CARET_UP.to_string()),
)
.on_hover_text(app.strings.gui_report_move_up)
.clicked()
{
acts.push(Act::Move {
path: ed.selection.clone(),
up: true,
});
}
if ui
.add_enabled(
on_node,
egui::Button::new(super::icons::CARET_DOWN.to_string()),
)
.on_hover_text(app.strings.gui_report_move_down)
.clicked()
{
acts.push(Act::Move {
path: ed.selection.clone(),
up: false,
});
}
if ui
.add_enabled(
on_node,
egui::Button::new(format!(
"{} {}",
super::icons::TRASH,
app.strings.gui_report_delete_block
)),
)
.clicked()
{
acts.push(Act::DeletePath(ed.selection.clone()));
}
});
ui.separator();
if ed.palette.is_some() {
palette_panel(ed, app, ui, &titles, &mut acts);
}
let avail = ui.available_height();
let diag_h = ed.diag_h.clamp(48.0, (avail - 120.0).max(48.0));
let body_h = (avail - diag_h - 12.0).max(120.0);
ui.allocate_ui(egui::vec2(ui.available_width(), body_h), |ui| {
ui.horizontal_top(|ui| {
let palette_w = ed
.palette_w
.clamp(96.0, (ui.available_width() - 160.0).max(96.0));
ui.allocate_ui_with_layout(
egui::vec2(palette_w, body_h),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_min_width(palette_w);
ui.set_max_width(palette_w);
egui::ScrollArea::vertical()
.id_salt("pt_palette")
.auto_shrink([false, false])
.show(ui, |ui| palette_list(app, ui));
},
);
palette_splitter(ed, ui, body_h);
ui.vertical(|ui| {
egui::ScrollArea::vertical()
.id_salt("pt_blocks")
.auto_shrink([false, false])
.show(ui, |ui| {
let bg = ui.interact(
ui.max_rect(),
ui.id().with("pt_bg_deselect"),
egui::Sense::click(),
);
header_strip(ed, app, ui, &mut acts);
ui.add_space(4.0);
let mut lift = DragLift::default();
let mut hovers: Vec<RowHover> = Vec::new();
for (i, row) in rows.iter().enumerate() {
let selected = row.path == ed.selection
&& (row.kind != RowKind::LoopEnd || ed.selection.is_empty());
let drop_pos = insert_pos_after(&rows, i);
if let Some(h) = block_row(
ed, app, ui, row, i, selected, &drop_pos, &titles, &mut lift,
&mut acts,
) {
hovers.push(h);
}
}
paint_hover_group(ui, &th, &hovers);
if rows.is_empty() {
empty_flow_hint(ui, &th, &app.strings);
}
lift.follow_pointer(ui.ctx());
flow_end_row(ui, &th, &app.strings);
tail_drop_zone(ui, &th, flow.nodes.len(), &titles, &mut acts);
if bg.clicked() && !ed.selection.is_empty() {
acts.push(Act::Select(Vec::new()));
}
});
});
});
});
let typing = ui.memory(|m| m.focused().is_some());
if !typing && !ed.selection.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete)) {
acts.push(Act::DeletePath(ed.selection.clone()));
}
if egui::DragAndDrop::has_payload_of_type::<DragItem>(ui.ctx()) {
ui.add_space(4.0);
trash_bar(app, ui, &mut acts);
}
diag_splitter(ed, ui);
diagnostics_panel(ed, app, ui);
apply_block_actions(ed, app, acts);
let _ = th;
}
const BASE_KINDS: [NodeKind; 8] = [
NodeKind::Request,
NodeKind::ReportVar,
NodeKind::ReportComputed,
NodeKind::Assign,
NodeKind::List,
NodeKind::ForFiles,
NodeKind::ForFolders,
NodeKind::ForEnvs,
];
fn palette_list(app: &GuiApp, ui: &mut egui::Ui) {
let th = app.theme;
ui.label(
RichText::new(app.strings.gui_report_palette_blocks)
.strong()
.color(th.text),
);
ui.colored_label(th.dim, app.strings.gui_report_palette_hint);
ui.add_space(2.0);
for (i, kind) in BASE_KINDS.into_iter().enumerate() {
let base = kind_color(kind, &th);
let id = ui.id().with(("pt_base_chip", i));
let src = ui.dnd_drag_source(id, kind, |ui| {
palette_chip(ui, &th, kind.label(&app.strings), base);
});
if ui.ctx().is_being_dragged(id) {
let size = src.response.rect.size();
ui.ctx()
.data_mut(|d| d.insert_temp(palette_drag_size_id(), size));
}
ui.add_space(4.0);
}
ui.add_space(6.0);
ui.label(
RichText::new(app.strings.gui_report_palette_mods)
.strong()
.color(th.text),
);
ui.colored_label(th.dim, app.strings.gui_report_palette_mods_hint);
ui.add_space(2.0);
for (i, m) in Modifier::ALL.into_iter().enumerate() {
let base = modifier_color(m, &th);
let id = ui.id().with(("pt_mod_chip", i));
ui.dnd_drag_source(id, m, |ui| {
palette_chip(ui, &th, m.label(&app.strings), base);
});
ui.add_space(4.0);
}
}
fn trash_bar(app: &GuiApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
let th = app.theme;
let frame = egui::Frame::NONE
.fill(mix(th.panel, th.err, 0.14))
.stroke(egui::Stroke::new(1.0, mix(th.panel, th.err, 0.5)))
.inner_margin(egui::Margin::symmetric(8, 8))
.corner_radius(BLOCK_RADIUS as u8);
let resp = frame
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.vertical_centered(|ui| {
ui.add(
egui::Label::new(
RichText::new(format!(
"{} {}",
super::icons::TRASH,
app.strings.gui_report_trash
))
.color(th.err),
)
.selectable(false),
);
});
})
.response;
let zone = ui.interact(resp.rect, ui.id().with("pt_trash"), egui::Sense::hover());
if zone.dnd_hover_payload::<DragItem>().is_some() {
ui.painter().rect_stroke(
resp.rect.expand(2.0),
egui::CornerRadius::same(6),
egui::Stroke::new(2.0, th.err),
egui::StrokeKind::Outside,
);
}
if let Some(item) = release_payload::<DragItem>(&zone) {
match &*item {
DragItem::Row(path) => acts.push(Act::DeletePath(path.clone())),
DragItem::Chip { path, which } => acts.push(Act::DetachMod {
path: path.clone(),
which: *which,
}),
}
}
}
fn palette_chip(ui: &mut egui::Ui, th: &GuiTheme, text: &str, base: Color32) {
let tint = chip_tint(th, base);
chip_shell(ui, &tint, true, ROUND_CHIP, |ui| {
ui.add(egui::Label::new(RichText::new(text).color(tint.text)).selectable(false));
});
}
fn modifier_color(m: Modifier, th: &GuiTheme) -> Color32 {
match m {
Modifier::Report => th.subst,
Modifier::With => th.accent,
Modifier::As => th.pending,
Modifier::Parallel => th.err,
Modifier::Response => th.accent,
Modifier::Show => th.ok,
Modifier::Hide => th.dim,
Modifier::Statistics => th.subst,
}
}
#[allow(clippy::too_many_arguments)]
fn release_payload<T: std::any::Any + Send + Sync>(
resp: &egui::Response,
) -> Option<std::sync::Arc<T>> {
resp.dnd_hover_payload::<T>()?;
resp.dnd_release_payload::<T>()
}
const ALIAS_FIELD_WIDTH: f32 = 96.0;
const PARALLEL_FIELD_WIDTH: f32 = 44.0;
const LOOP_VAR_FIELD_WIDTH: f32 = 72.0;
const LOOP_PATH_FIELD_WIDTH: f32 = 150.0;
const LOOP_GLOB_FIELD_WIDTH: f32 = 84.0;
const FIELD_MAX_WIDTH: f32 = 320.0;
const PICKER_BUTTON_WIDTH: f32 = 28.0;
const COMBO_CHIP_WIDTH: f32 = 24.0;
const INDENT_STEP: f32 = 24.0;
fn dragged_row_path(ctx: &egui::Context) -> Option<Vec<usize>> {
egui::DragAndDrop::payload::<DragItem>(ctx).and_then(|d| match &*d {
DragItem::Row(p) => Some(p.clone()),
_ => None,
})
}
fn dragged_chip(ctx: &egui::Context) -> Option<(Vec<usize>, DetachWhich)> {
egui::DragAndDrop::payload::<DragItem>(ctx).and_then(|d| match &*d {
DragItem::Chip { path, which } => Some((path.clone(), *which)),
_ => None,
})
}
fn row_is_lifted(dragged: &[usize], row_path: &[usize]) -> bool {
row_path.starts_with(dragged)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum HoverTier {
Block,
CarriedAlong,
}
fn hover_tier(hovered: &[usize], row_path: &[usize]) -> Option<HoverTier> {
if !row_is_lifted(hovered, row_path) {
None
} else if row_path.len() == hovered.len() {
Some(HoverTier::Block)
} else {
Some(HoverTier::CarriedAlong)
}
}
fn indented_content(rect: egui::Rect, depth: usize) -> egui::Rect {
let indent = (depth as f32 * INDENT_STEP).min((rect.width() - 1.0).max(0.0));
egui::Rect::from_min_max(
egui::pos2(rect.left() + indent, rect.top()),
rect.right_bottom(),
)
}
fn lifted_shape_id() -> egui::Id {
egui::Id::new("pt_lifted_block_shape")
}
fn palette_drag_size_id() -> egui::Id {
egui::Id::new("pt_palette_drag_size")
}
#[derive(Default)]
struct DragLift {
layer: Option<egui::LayerId>,
head: Option<egui::Rect>,
bounds: Option<egui::Rect>,
rows: Vec<egui::Rect>,
}
impl DragLift {
fn add(&mut self, layer: egui::LayerId, rect: egui::Rect, is_head: bool) {
self.layer = Some(layer);
if is_head {
self.head = Some(rect);
}
self.bounds = Some(match self.bounds {
Some(bounds) => bounds.union(rect),
None => rect,
});
self.rows.push(rect);
}
fn follow_pointer(self, ctx: &egui::Context) {
let (Some(layer), Some(head), Some(bounds)) = (self.layer, self.head, self.bounds) else {
ctx.data_mut(|d| d.remove::<Vec<egui::Rect>>(lifted_shape_id()));
return;
};
let shape: Vec<egui::Rect> = self
.rows
.iter()
.map(|r| egui::Rect::from_min_size(r.min - bounds.min.to_vec2(), r.size()))
.collect();
ctx.data_mut(|d| d.insert_temp(lifted_shape_id(), shape));
if let Some(pointer) = ctx.pointer_interact_pos() {
ctx.transform_layer_shapes(
layer,
egui::emath::TSTransform::from_translation(pointer - head.center()),
);
}
}
}
fn dragged_block_shape(ui: &egui::Ui) -> Vec<egui::Rect> {
let ctx = ui.ctx();
let one = chip_h(ui) + 10.0;
let default_w = 160.0;
let stack = |w: f32, rows: usize| -> Vec<egui::Rect> {
(0..rows)
.map(|i| egui::Rect::from_min_size(egui::pos2(0.0, i as f32 * one), egui::vec2(w, one)))
.collect()
};
if let Some(kind) = egui::DragAndDrop::payload::<NodeKind>(ctx) {
let rows = match *kind {
NodeKind::ForFiles | NodeKind::ForFolders | NodeKind::ForEnvs => 2,
_ => 1,
};
let w = ctx
.data(|d| d.get_temp::<egui::Vec2>(palette_drag_size_id()))
.map(|s| s.x)
.filter(|w| *w >= 1.0)
.unwrap_or(default_w);
return stack(w, rows);
}
let dragging_row = egui::DragAndDrop::payload::<DragItem>(ctx)
.is_some_and(|d| matches!(&*d, DragItem::Row(_)));
if dragging_row
&& let Some(shape) = ctx.data(|d| d.get_temp::<Vec<egui::Rect>>(lifted_shape_id()))
&& !shape.is_empty()
&& shape.iter().all(|r| r.width() >= 1.0 && r.height() >= 1.0)
{
let h: f32 = shape
.iter()
.fold(f32::NEG_INFINITY, |acc, r| acc.max(r.bottom()));
if h >= one {
return shape;
}
}
stack(default_w, 1)
}
fn dragged_block_size(ui: &egui::Ui) -> egui::Vec2 {
dragged_block_shape(ui)
.into_iter()
.reduce(egui::Rect::union)
.map_or(egui::Vec2::ZERO, |r| r.max.to_vec2())
}
fn dragged_block_h(ui: &egui::Ui) -> f32 {
dragged_block_size(ui).y
}
fn paint_drop_silhouette(
ui: &egui::Ui,
origin: egui::Pos2,
shape: &[egui::Rect],
clip: egui::Rect,
th: &GuiTheme,
) {
if clip.width() < 1.0 || clip.height() < 1.0 {
return;
}
let painter = ui.painter().with_clip_rect(clip);
for r in shape {
let rect = egui::Rect::from_min_size(origin + r.min.to_vec2(), r.size());
if rect.width() < 1.0 || rect.height() < 1.0 {
continue;
}
painter.rect(
rect.shrink2(egui::vec2(0.0, 2.0)),
egui::CornerRadius::same(BLOCK_RADIUS as u8),
mix(th.panel, th.accent, 0.18),
egui::Stroke::new(1.5, th.accent),
egui::StrokeKind::Inside,
);
}
}
const BLOCK_RADIUS: f32 = 3.0;
fn rounded_rect_path(rect: egui::Rect, radius: f32) -> Vec<egui::Pos2> {
let r = radius
.min(rect.width() * 0.5)
.min(rect.height() * 0.5)
.max(0.0);
const SEGMENTS: usize = 8;
let mut path = Vec::with_capacity(SEGMENTS * 4 + 5);
let corners = [
(
egui::pos2(rect.left() + r, rect.top() + r),
std::f32::consts::PI,
),
(
egui::pos2(rect.right() - r, rect.top() + r),
1.5 * std::f32::consts::PI,
),
(egui::pos2(rect.right() - r, rect.bottom() - r), 0.0),
(
egui::pos2(rect.left() + r, rect.bottom() - r),
0.5 * std::f32::consts::PI,
),
];
for (centre, start) in corners {
for i in 0..=SEGMENTS {
let a = start + (i as f32 / SEGMENTS as f32) * 0.5 * std::f32::consts::PI;
path.push(egui::pos2(centre.x + r * a.cos(), centre.y + r * a.sin()));
}
}
if let Some(&first) = path.first() {
path.push(first);
}
path
}
fn paint_origin_ghost(painter: &egui::Painter, rect: egui::Rect, th: &GuiTheme) {
if rect.width() < 1.0 || rect.height() < 1.0 {
return;
}
let r = rect.expand(1.0);
let radius = BLOCK_RADIUS;
painter.rect_filled(
r,
egui::CornerRadius::same(radius as u8),
mix(th.panel, th.dim, 0.12),
);
for shape in egui::Shape::dashed_line(
&rounded_rect_path(r, radius),
egui::Stroke::new(1.0, th.dim),
4.0,
3.0,
) {
painter.add(shape);
}
}
#[derive(Clone)]
enum PendingMod {
New(Modifier),
Moved(CarriedMod),
}
impl PendingMod {
fn reject_reason(&self, node: &FlowNode, s: &crate::i18n::Strings) -> Option<&'static str> {
match self {
PendingMod::New(m) => m.reject_reason(node, s),
PendingMod::Moved(carried) => carried.reject_reason(node, s),
}
}
fn apply(&self, node: &mut FlowNode) -> bool {
match self {
PendingMod::New(m) => attach_to_node(node, *m),
PendingMod::Moved(carried) => carried.attach_to(node),
}
}
}
fn preview_chip(
node: &FlowNode,
pending: &PendingMod,
req_ok: Option<bool>,
th: &GuiTheme,
s: &crate::i18n::Strings,
) -> Option<(usize, String, f32)> {
let mut probe = node.clone();
if !pending.apply(&mut probe) {
return None;
}
let before = node_chips(node, req_ok, th, s);
let after = node_chips(&probe, req_ok, th, s);
if after.len() <= before.len() {
return None;
}
let idx = before
.iter()
.zip(after.iter())
.position(|(b, a)| b.ghost_shape() != a.ghost_shape())
.unwrap_or(before.len());
let (text, extra) = after.get(idx)?.ghost_shape();
Some((idx, text, extra))
}
fn mod_ghost_id(row_index: usize) -> egui::Id {
egui::Id::new(("pt_modghost", row_index))
}
fn ghost_chip(ui: &mut egui::Ui, th: &GuiTheme, text: &str, extra_width: f32) {
let h = chip_h(ui);
let rect = egui::Frame::NONE
.inner_margin(egui::Margin::symmetric(8, 3))
.corner_radius(ROUND_CHIP)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.set_min_height(h);
ui.add(
egui::Label::new(RichText::new(text).color(th.dim))
.selectable(false)
.truncate(),
);
ui.add_space(extra_width);
});
})
.response
.rect;
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(6),
mix(th.panel, th.accent, 0.10),
);
for shape in egui::Shape::dashed_line(
&rounded_rect_path(rect, 6.0),
egui::Stroke::new(1.0, th.accent),
4.0,
3.0,
) {
ui.painter().add(shape);
}
}
struct RowHover {
path: Vec<usize>,
kind: RowKind,
rect: egui::Rect,
bg: egui::layers::ShapeIdx,
pointer_inside: bool,
}
fn block_row(
ed: &mut ReportEditor,
app: &GuiApp,
ui: &mut egui::Ui,
row: &edit::NodeRow,
row_index: usize,
selected: bool,
drop_pos: &InsertPos,
titles: &[String],
lift: &mut DragLift,
acts: &mut Vec<Act>,
) -> Option<RowHover> {
let th = app.theme;
let s = &app.strings;
let bg_slot = ui.painter().add(egui::Shape::Noop);
let env_choices: Vec<String> = app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect();
let node = ed
.flow
.as_ref()
.and_then(|f| node_at(f, &row.path))
.cloned();
let with_items: Vec<WithItem> = match &node {
Some(FlowNode::Report(ReportStmt::Request { with, .. })) => with.clone(),
_ => Vec::new(),
};
let drag_path = dragged_row_path(ui.ctx());
let lifted = drag_path
.as_deref()
.is_some_and(|d| row_is_lifted(d, &row.path));
let is_drag_head = drag_path.as_deref() == Some(row.path.as_slice());
let lifted_chip = dragged_chip(ui.ctx());
let chip_in_this_row = |which: DetachWhich| {
lifted_chip
.as_ref()
.is_some_and(|(p, w)| p.as_slice() == row.path.as_slice() && *w == which)
};
let carried_chip: Option<(Vec<usize>, DetachWhich, CarriedMod)> =
lifted_chip.as_ref().and_then(|(p, w)| {
let node = ed.flow.as_ref().and_then(|f| node_at(f, p))?;
Some((p.clone(), *w, carry_modifier(node, *w)?))
});
let hover_lit = row.kind != RowKind::LoopEnd
&& hovered_last_frame(ui).is_some_and(|h| hover_tier(&h, &row.path).is_some());
let mod_ghost: Option<(usize, String, f32)> = ui
.ctx()
.data(|d| d.get_temp(mod_ghost_id(row_index)))
.unwrap_or(None);
let block_body = |ui: &mut egui::Ui| -> egui::Rect {
let mut chip_lift: Option<(egui::LayerId, egui::Rect)> = None;
let inner = ui.horizontal_top(|ui| {
ui.add_space(row.depth as f32 * INDENT_STEP);
match row.kind {
RowKind::Begin => static_chip(
ui,
&th,
app.strings.report_node_begin,
th.dim,
s.chip_help_begin,
),
RowKind::LoopEnd => static_chip(ui, &th, "END", th.accent, s.chip_help_end),
RowKind::WithField(_)
| RowKind::WithComment(_)
| RowKind::WithAdd
| RowKind::WithEnd => {}
RowKind::Leaf | RowKind::LoopHead | RowKind::Comment => {
let mut chips = node
.as_ref()
.map(|n| node_chips(n, row.req_ok, &th, s))
.unwrap_or_default();
if hover_lit {
for chip in &mut chips {
chip.hovered = true;
}
}
link_tethers(&mut chips);
for i in (1..chips.len()).rev() {
let parted = [i - 1, i]
.iter()
.any(|&j| chips[j].detach.is_some_and(&chip_in_this_row))
|| mod_ghost.as_ref().is_some_and(|(gi, _, _)| *gi == i);
if chips[i].join_prev && parted {
split_tether(&mut chips, i);
}
}
let gap = ui.spacing().item_spacing.x;
let mut prev: Option<egui::Rect> = None;
let mut tethers: Vec<(egui::Rect, egui::Rect)> = Vec::new();
for (ci, chip) in chips.iter().enumerate() {
if let Some((gi, text, extra)) = &mod_ghost
&& *gi == ci
{
ghost_chip(ui, &th, text, *extra);
}
if chip.join_next {
ui.spacing_mut().item_spacing.x = TETHER_GAP;
}
let in_hand = chip.detach.is_some_and(&chip_in_this_row);
let rect = if in_hand {
let (layer, slot) = lift_chip(
ui,
&th,
s,
chip,
selected,
&row.path,
row_index,
titles,
&env_choices,
acts,
);
chip_lift = Some((layer, slot));
slot
} else {
render_chip(
ui,
&th,
s,
chip,
selected,
&row.path,
titles,
&env_choices,
acts,
)
};
ui.spacing_mut().item_spacing.x = gap;
if chip.join_prev
&& let Some(anchor) = prev
{
tethers.push((anchor, rect));
}
prev = Some(rect);
}
if let Some((gi, text, extra)) = &mod_ghost
&& *gi >= chips.len()
{
ghost_chip(ui, &th, text, *extra);
}
for (anchor, hanger) in tethers {
paint_tether_hover(ui, &th, anchor, hanger);
}
}
}
});
if !with_items.is_empty() {
let cluster = inner.response.rect;
let lifting_with = chip_in_this_row(DetachWhich::WithBlock);
let with_rect = match chip_lift {
Some((layer, _)) if lifting_with => {
let rect = ui
.scope_builder(
egui::UiBuilder::new()
.layer_id(layer)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| with_block(ui, &th, s, &row.path, row.depth, &with_items, acts),
)
.inner;
paint_origin_ghost(ui.painter(), rect, &th);
rect
}
_ => with_block(ui, &th, s, &row.path, row.depth, &with_items, acts),
};
if !lifting_with {
let indent = row.depth as f32 * INDENT_STEP;
let unit = egui::Rect::from_min_max(
egui::pos2(cluster.left() + indent, cluster.top()),
egui::pos2(cluster.right().max(with_rect.right()), with_rect.bottom()),
)
.expand(3.0);
ui.painter().rect_stroke(
unit,
egui::CornerRadius::same(6),
egui::Stroke::new(1.0, mix(th.panel, th.subst, 0.55)),
egui::StrokeKind::Outside,
);
}
}
if let Some((layer, slot)) = chip_lift {
follow_pointer(ui.ctx(), layer, slot);
}
inner.response.rect
};
if lifted {
let layer_id = egui::LayerId::new(
egui::Order::Tooltip,
ui.id()
.with(("pt_drag_subtree", drag_path.as_ref().unwrap())),
);
let ir = ui.scope_builder(
egui::UiBuilder::new()
.layer_id(layer_id)
.layout(egui::Layout::top_down(egui::Align::Min)),
block_body,
);
let content = indented_content(ir.response.rect, row.depth);
paint_origin_ghost(ui.painter(), content, &th);
lift.add(layer_id, content, is_drag_head);
return None;
}
let block = ui.vertical(block_body);
let cluster = block.inner;
let block_rect = block.response.rect;
let hover_rect = indented_content(block_rect, row.depth);
let hover = RowHover {
path: row.path.clone(),
kind: row.kind,
rect: hover_rect,
bg: bg_slot,
pointer_inside: ui.rect_contains_pointer(hover_rect),
};
if let Some(n) = &node
&& matches!(row.kind, RowKind::Leaf | RowKind::LoopHead)
{
let zone_rect =
egui::Rect::from_x_y_ranges(cluster.left()..=ui.max_rect().right(), cluster.y_range());
let zresp = ui.interact(
zone_rect,
ui.id().with(("pt_modzone", row_index)),
egui::Sense::hover(),
);
let pending: Option<PendingMod> = if let Some(m) = zresp.dnd_hover_payload::<Modifier>() {
Some(PendingMod::New(*m))
} else if zresp.dnd_hover_payload::<DragItem>().is_some() {
carried_chip
.as_ref()
.filter(|(from, _, _)| from.as_slice() != row.path.as_slice())
.map(|(_, _, carried)| PendingMod::Moved(carried.clone()))
} else {
None
};
let ghost = match &pending {
Some(p) if p.reject_reason(n, s).is_none() => preview_chip(n, p, row.req_ok, &th, s),
_ => None,
};
ui.ctx()
.data_mut(|d| d.insert_temp(mod_ghost_id(row_index), ghost.clone()));
if let Some(p) = &pending {
match p.reject_reason(n, s) {
None => {
if ghost.is_none() {
ui.painter().rect_stroke(
zone_rect.expand(2.0),
egui::CornerRadius::same(6),
egui::Stroke::new(2.0, th.accent),
egui::StrokeKind::Outside,
);
}
}
Some(why) => {
ui.painter().rect_stroke(
zone_rect.expand(2.0),
egui::CornerRadius::same(6),
egui::Stroke::new(2.0, th.err),
egui::StrokeKind::Outside,
);
egui::Tooltip::always_open(
ui.ctx().clone(),
ui.layer_id(),
ui.id().with(("pt_modwhy", row_index)),
egui::PopupAnchor::Pointer,
)
.show(|ui| {
ui.colored_label(th.err, why);
});
}
}
}
if let Some(m) = release_payload::<Modifier>(&zresp)
&& m.applies_to(n)
{
acts.push(Act::AttachMod {
path: row.path.clone(),
modifier: *m,
});
}
let copy = ui.input(|i| i.modifiers.shift);
if let Some((from, which, carried)) = &carried_chip
&& from.as_slice() != row.path.as_slice()
&& carried.applies_to(n)
&& release_payload::<DragItem>(&zresp).is_some()
{
acts.push(Act::MoveMod {
from: from.clone(),
which: *which,
to: row.path.clone(),
copy,
});
}
}
let gap_h = dragged_block_h(ui);
let gap_id = ui.id().with(("pt_gap", row_index));
let prev_gap: f32 = ui.ctx().data(|d| d.get_temp(gap_id)).unwrap_or(0.0);
let strip = egui::Rect::from_x_y_ranges(
ui.max_rect().x_range(),
block_rect.top()..=block_rect.bottom() + prev_gap,
);
let strip_resp = ui.interact(
strip,
ui.id().with(("pt_drop", row_index)),
egui::Sense::hover(),
);
let hovering_new = strip_resp.dnd_hover_payload::<NodeKind>().is_some();
let hovering_move = strip_resp
.dnd_hover_payload::<DragItem>()
.map(|d| matches!(&*d, DragItem::Row(from) if *from != row.path))
.unwrap_or(false);
let hovering_base = hovering_new || hovering_move;
let gap =
ui.ctx()
.animate_value_with_time(gap_id, if hovering_base { gap_h } else { 0.0 }, 0.12);
ui.ctx().data_mut(|d| d.insert_temp(gap_id, gap));
if let Some(kind) = release_payload::<NodeKind>(&strip_resp) {
acts.push(Act::DropNode {
pos: drop_pos.clone(),
node: node_for_kind(*kind, titles),
});
} else if let Some(item) = release_payload::<DragItem>(&strip_resp) {
if let DragItem::Row(from) = &*item
&& *from != row.path
{
acts.push(Act::MoveNode {
from: from.clone(),
pos: drop_pos.clone(),
});
}
}
if gap > 0.5 {
let indent = (drop_pos.parent.len() + 1) as f32 * INDENT_STEP;
let top = block_rect.bottom() + 2.0;
let origin = egui::pos2(strip.left() + indent, top);
let clip =
egui::Rect::from_min_max(origin, egui::pos2(strip.right() - 8.0, top + gap - 4.0));
paint_drop_silhouette(ui, origin, &dragged_block_shape(ui), clip, &th);
ui.add_space(gap);
}
Some(hover)
}
fn hovered_block(rows: &[RowHover]) -> Option<&[usize]> {
rows.iter()
.rev()
.find(|r| r.pointer_inside && r.kind != RowKind::Begin)
.map(|r| r.path.as_slice())
}
fn paint_hover_group(ui: &egui::Ui, th: &GuiTheme, rows: &[RowHover]) {
let chip_rect: Option<egui::Rect> = ui.ctx().data_mut(|d| {
let r = d.get_temp::<Option<egui::Rect>>(chip_hover_id()).flatten();
d.remove_temp::<Option<egui::Rect>>(chip_hover_id());
r
});
if let Some(rect) = chip_rect {
ui.ctx().data_mut(|d| {
d.insert_temp::<Option<Vec<usize>>>(hover_group_id(), None);
});
ui.painter().rect_stroke(
rect.expand(2.0),
egui::CornerRadius::same(CHIP_RADIUS + 2),
egui::Stroke::new(1.5, th.accent),
egui::StrokeKind::Outside,
);
return;
}
let hovered = (!drag_in_flight(ui.ctx()))
.then(|| hovered_block(rows))
.flatten();
ui.ctx()
.data_mut(|d| d.insert_temp(hover_group_id(), hovered.map(<[usize]>::to_vec)));
let Some(hovered) = hovered else {
return;
};
let block = mix(th.panel, th.accent, 0.28);
let carried = mix(th.panel, th.accent, 0.12);
for row in rows {
let Some(tier) = hover_tier(hovered, &row.path) else {
continue;
};
let fill = match tier {
HoverTier::Block => block,
HoverTier::CarriedAlong => carried,
};
ui.painter().set(
row.bg,
egui::Shape::rect_filled(
row.rect.expand2(egui::vec2(4.0, 1.0)),
egui::CornerRadius::same(4),
fill,
),
);
}
}
fn chip_hover_id() -> egui::Id {
egui::Id::new("pt_hoverchip")
}
fn record_chip_hover(ui: &egui::Ui, chip: &Chip, rect: egui::Rect) {
if chip.is_base
|| chip.detach.is_none()
|| drag_in_flight(ui.ctx())
|| ui.input(|i| i.modifiers.command)
|| !ui.rect_contains_pointer(rect)
{
return;
}
ui.ctx()
.data_mut(|d| d.insert_temp(chip_hover_id(), Some(rect)));
}
fn hover_group_id() -> egui::Id {
egui::Id::new("pt_hovergroup")
}
fn hovered_last_frame(ui: &egui::Ui) -> Option<Vec<usize>> {
ui.ctx().data(|d| d.get_temp(hover_group_id())).flatten()
}
fn drag_in_flight(ctx: &egui::Context) -> bool {
egui::DragAndDrop::has_payload_of_type::<DragItem>(ctx)
|| egui::DragAndDrop::has_payload_of_type::<NodeKind>(ctx)
|| egui::DragAndDrop::has_payload_of_type::<Modifier>(ctx)
}
fn with_block(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
path: &[usize],
depth: usize,
items: &[WithItem],
acts: &mut Vec<Act>,
) -> egui::Rect {
let field_indent = (depth as f32 + 1.0) * INDENT_STEP;
ui.vertical(|ui| {
for (i, item) in items.iter().enumerate() {
ui.horizontal(|ui| {
ui.add_space(field_indent);
let text = match item {
WithItem::Field {
name,
query,
stats,
image,
truth,
detail,
} => {
let mut t = format!("{name}: {query}");
if !stats.is_empty() {
t.push_str(&format!(
" STATISTICS({})",
stats
.iter()
.map(|k| k.keyword())
.collect::<Vec<_>>()
.join(", ")
));
}
t.push_str(&crate::report::flow::image_text(image.as_ref()));
t.push_str(&crate::report::flow::truth_text(truth.as_deref()));
t.push_str(&crate::report::flow::detail_text(*detail));
t
}
WithItem::ResponseFmt(fmt) => format!(
"RESPONSE {}",
match fmt {
crate::report::flow::ResponseFmt::Raw => "RAW",
crate::report::flow::ResponseFmt::Pretty => "PRETTY",
}
),
WithItem::Comment(text) => format!("#{text}"),
};
let tint = if matches!(item, WithItem::Comment(_)) {
chip_tint(th, th.dim)
} else {
chip_tint(th, th.subst)
};
let lbl = chip_shell(ui, &tint, true, ROUND_CHIP, |ui| {
let lbl = ui.add(
egui::Label::new(RichText::new(&text).color(tint.text))
.selectable(false)
.sense(egui::Sense::click()),
);
if detach_x(ui, tint.text) {
acts.push(Act::RemoveWith {
path: path.to_vec(),
index: i,
});
}
lbl
});
if lbl.clicked() && matches!(item, WithItem::Field { .. }) {
acts.push(Act::EditWith {
path: path.to_vec(),
index: i,
});
}
let zone = egui::Rect::from_x_y_ranges(
lbl.rect.left()..=ui.max_rect().right(),
lbl.rect.y_range(),
);
let zresp = ui.interact(
zone,
ui.id().with(("pt_withstats", path, i)),
egui::Sense::hover(),
);
if let Some(m) = zresp.dnd_hover_payload::<Modifier>() {
let ok = *m == Modifier::Statistics && edit::with_stats_applies(items, i);
ui.painter().rect_stroke(
zone.expand(2.0),
egui::CornerRadius::same(6),
egui::Stroke::new(2.0, if ok { th.accent } else { th.err }),
egui::StrokeKind::Outside,
);
if !ok {
let why = if *m != Modifier::Statistics {
s.mod_reject_with_field
} else {
s.mod_reject_present
};
egui::Tooltip::always_open(
ui.ctx().clone(),
ui.layer_id(),
ui.id().with(("pt_withwhy", path, i)),
egui::PopupAnchor::Pointer,
)
.show(|ui| {
ui.colored_label(th.err, why);
});
}
}
if let Some(m) = release_payload::<Modifier>(&zresp)
&& *m == Modifier::Statistics
&& edit::with_stats_applies(items, i)
{
acts.push(Act::AttachWithStats {
path: path.to_vec(),
index: i,
});
}
});
}
ui.horizontal(|ui| {
ui.add_space(field_indent);
if ui
.add(
egui::Button::new(
RichText::new(format!("{} {}", super::icons::PLUS, s.gui_report_with_add))
.color(th.subst),
)
.small(),
)
.clicked()
{
acts.push(Act::AddWith {
path: path.to_vec(),
});
}
});
ui.horizontal(|ui| {
ui.add_space(depth as f32 * INDENT_STEP);
static_chip(ui, th, "END", th.accent, "");
});
})
.response
.rect
}
#[derive(Clone, Debug, PartialEq)]
struct CollectionChoice {
value: String,
label: String,
detail: String,
in_workspace: bool,
}
fn collection_choices(
root: Option<&std::path::Path>,
report_path: Option<&std::path::Path>,
open: &[crate::collection::Collection],
unsaved_label: &str,
) -> Vec<CollectionChoice> {
let mut out: Vec<CollectionChoice> = Vec::new();
let root_scope = root;
if let Some(root) = root {
for e in crate::workspace::scan_workspace(root, true) {
if e.is_dir || !is_collection_file(&e.path) {
continue;
}
out.push(CollectionChoice {
value: portable_ref(&e.path, report_path, root_scope),
label: collection_label(&e.path),
detail: e
.path
.strip_prefix(root)
.unwrap_or(&e.path)
.to_string_lossy()
.into_owned(),
in_workspace: true,
});
}
}
for c in open {
match c.path.as_deref() {
Some(p) => {
let value = portable_ref(p, report_path, root_scope);
if out.iter().any(|ch| ch.value == value) {
continue;
}
out.push(CollectionChoice {
value,
label: c.name.clone(),
detail: p.to_string_lossy().into_owned(),
in_workspace: root.is_some_and(|r| p.starts_with(r)),
});
}
None => out.push(CollectionChoice {
value: c.name.clone(),
label: c.name.clone(),
detail: unsaved_label.to_string(),
in_workspace: false,
}),
}
}
out.sort_by(|a, b| {
b.in_workspace
.cmp(&a.in_workspace)
.then_with(|| a.label.to_lowercase().cmp(&b.label.to_lowercase()))
.then_with(|| a.value.cmp(&b.value))
});
out
}
fn is_collection_file(path: &std::path::Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("hurl") | Some("json")
)
}
fn collection_label(path: &std::path::Path) -> String {
path.file_stem()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn collection_menu(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
choices: &[CollectionChoice],
current: &str,
picked: &mut Option<String>,
browse: &mut bool,
) {
let (mine, others): (Vec<&CollectionChoice>, Vec<&CollectionChoice>) =
choices.iter().partition(|c| c.in_workspace);
let show_all_id = ui.make_persistent_id("pt_hdr_collection_show_all");
let mut show_all = mine.is_empty()
|| ui
.ctx()
.data(|d| d.get_temp::<bool>(show_all_id))
.unwrap_or(false);
if !mine.is_empty() {
ui.label(
RichText::new(s.gui_report_ws_collections)
.color(th.dim)
.small(),
);
for c in &mine {
collection_item(ui, th, c, current, picked);
}
}
if !others.is_empty() {
if mine.is_empty() {
show_all = true;
} else {
ui.separator();
if ui
.checkbox(&mut show_all, s.gui_report_show_all_collections)
.changed()
{
ui.ctx().data_mut(|d| d.insert_temp(show_all_id, show_all));
}
}
if show_all {
if !mine.is_empty() {
ui.label(
RichText::new(s.gui_report_other_collections)
.color(th.dim)
.small(),
);
}
for c in &others {
collection_item(ui, th, c, current, picked);
}
}
}
if choices.is_empty() {
ui.colored_label(th.dim, s.gui_report_no_collections);
}
if !choices.is_empty() {
ui.separator();
}
if ui.button(s.gui_report_browse).clicked() {
*browse = true;
ui.close();
}
}
fn collection_item(
ui: &mut egui::Ui,
th: &GuiTheme,
c: &CollectionChoice,
current: &str,
picked: &mut Option<String>,
) {
if ui
.selectable_label(c.value == current, &c.label)
.on_hover_text(&c.detail)
.clicked()
{
*picked = Some(c.value.clone());
}
ui.label(RichText::new(&c.detail).color(th.dim).small());
}
fn report_workspace_root(app: &GuiApp, ed: &ReportEditor) -> Option<std::path::PathBuf> {
let path = ed.report.path.as_deref()?;
app.session
.collections
.iter()
.filter_map(|c| c.workspace_root.as_ref())
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
.cloned()
}
fn settings_frame(th: &GuiTheme) -> egui::Frame {
egui::Frame::NONE
.fill(mix(th.panel, th.dim, 0.10))
.stroke(egui::Stroke::new(1.0, mix(th.panel, th.dim, 0.35)))
.inner_margin(egui::Margin::symmetric(8, 6))
.corner_radius(BLOCK_RADIUS as u8)
}
fn header_strip(ed: &ReportEditor, app: &GuiApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
let th = app.theme;
let s = &app.strings;
let Some(flow) = ed.flow.as_ref() else {
return;
};
let ws_root = report_workspace_root(app, ed);
let collections = || {
collection_choices(
ws_root.as_deref(),
ed.report.path.as_deref(),
&app.session.collections,
s.gui_report_collection_unsaved,
)
};
let envs: Vec<String> = app
.session
.global_envs
.iter()
.map(|e| e.name.clone())
.collect();
let formats: Vec<String> = crate::report::writer::OUTPUT_EXTENSIONS
.iter()
.map(|e| e.to_string())
.collect();
settings_panel(
ui,
&th,
s,
&|key| {
flow.header
.get_all(key)
.into_iter()
.map(str::to_string)
.collect()
},
&collections,
&envs,
&formats,
acts,
);
}
#[allow(clippy::too_many_arguments)]
fn settings_panel(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
value_of: &dyn Fn(&str) -> Vec<String>,
collections: &dyn Fn() -> Vec<CollectionChoice>,
envs: &Vec<String>,
formats: &Vec<String>,
acts: &mut Vec<Act>,
) {
let specs = header_specs();
settings_frame(th).show(ui, |ui| {
ui.label(
RichText::new(s.report_settings_heading)
.strong()
.color(th.text),
)
.on_hover_text(s.report_settings_help);
ui.set_width(settings_width(ui));
for spec in &specs {
let mut values = value_of(spec.key);
if values.is_empty() {
values.push(String::new());
}
for (occurrence, value) in values.into_iter().enumerate() {
if value.is_empty() && !spec.always_shown {
continue;
}
let choices = match spec.kind {
HeaderKind::Environment => Some(envs),
HeaderKind::Format => Some(formats),
_ => None,
};
ui.horizontal(|ui| {
header_chip(
ui,
th,
s,
spec,
occurrence,
&value,
choices,
collections,
acts,
)
});
}
}
let missing: Vec<&HeaderSpec> = specs
.iter()
.filter(|sp| !sp.always_shown && value_of(sp.key).is_empty())
.collect();
header_add_menu(
ui,
s,
&missing,
value_of("collection").len().max(1),
value_of("labels").len(),
acts,
);
});
}
fn settings_width(ui: &egui::Ui) -> f32 {
ui.available_width()
}
fn header_add_menu(
ui: &mut egui::Ui,
s: &crate::i18n::Strings,
missing: &[&HeaderSpec],
next_collection: usize,
next_label: usize,
acts: &mut Vec<Act>,
) {
let label = format!("{} {}", super::icons::PLUS, s.report_add_setting);
ui.menu_button(label, |ui| {
for spec in missing {
if ui
.button(spec.key.to_uppercase())
.on_hover_text(edit::header_help(spec.key, s))
.clicked()
{
acts.push(Act::SetHeader {
key: spec.key,
occurrence: 0,
value: Some(HEADER_PLACEHOLDER.to_string()),
});
ui.close();
}
}
if ui
.button(s.report_add_helper_collection)
.on_hover_text(s.report_helper_collection_help)
.clicked()
{
acts.push(Act::SetHeader {
key: "collection",
occurrence: next_collection,
value: Some(HEADER_PLACEHOLDER.to_string()),
});
ui.close();
}
if next_label > 0
&& ui
.button(s.report_add_label_class)
.on_hover_text(edit::header_help("labels", s))
.clicked()
{
acts.push(Act::SetHeader {
key: "labels",
occurrence: next_label,
value: Some(HEADER_PLACEHOLDER.to_string()),
});
ui.close();
}
})
.response
.on_hover_text(s.report_settings_help);
}
fn header_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
spec: &HeaderSpec,
occurrence: usize,
value: &str,
choices: Option<&Vec<String>>,
collections: &dyn Fn() -> Vec<CollectionChoice>,
acts: &mut Vec<Act>,
) {
let helper = spec.key == "collection" && occurrence > 0;
let (value, alias) = if spec.key == "collection" {
let (r, a) = crate::report::flow::split_collection_ref(value);
(r, a.unwrap_or_default())
} else {
(value, "")
};
let rejoin = move |reference: &str| {
if helper && !alias.is_empty() {
format!("{reference} AS {alias}")
} else {
reference.to_string()
}
};
let unset = value.is_empty() || value == "?";
let color = if unset && spec.required {
th.err
} else {
th.dim
};
let mut tint = chip_tint(th, color);
if !(unset && spec.required) {
tint.rule = None;
}
tint.text = if unset && spec.required {
th.err
} else {
th.text
};
let key = spec.key;
let text_col = tint.text;
let combo = matches!(
spec.kind,
HeaderKind::Collection | HeaderKind::Environment | HeaderKind::Format
);
let scope = ui.scope(|ui| {
chip_shell(ui, &tint, !combo, ROUND_CHIP, |ui| {
let font = egui::TextStyle::Button.resolve(ui.style());
let galley = ui.painter().layout_no_wrap(key.to_uppercase(), font, color);
let gsize = galley.size();
let (label_rect, _) = ui.allocate_exact_size(gsize, egui::Sense::hover());
match spec.kind {
HeaderKind::Collection | HeaderKind::Environment | HeaderKind::Format => {
let shown = if unset {
s.report_setting_unset.to_string()
} else if matches!(spec.kind, HeaderKind::Collection) {
collection_label(std::path::Path::new(value))
} else {
value.to_string()
};
let mut picked = None;
let mut browse = false;
egui::ComboBox::from_id_salt(("pt_hdr", key))
.selected_text(RichText::new(shown).color(text_col))
.show_ui(ui, |ui| {
if matches!(spec.kind, HeaderKind::Collection) {
collection_menu(
ui,
th,
s,
&collections(),
value,
&mut picked,
&mut browse,
);
} else {
for c in choices.map(Vec::as_slice).unwrap_or_default() {
if ui.selectable_label(c == value, c).clicked() {
picked = Some(c.clone());
}
}
}
});
if browse {
acts.push(Act::PickHeaderFile { key, occurrence });
}
if let Some(v) = picked
&& v != value
{
acts.push(Act::SetHeader {
key,
occurrence,
value: Some(rejoin(&v)),
});
}
if helper {
let id = ui.make_persistent_id(("pt_hdr_alias", occurrence));
if let Some(text) = inline_text_edit(
ui,
id,
alias,
s.report_alias_unset,
"AS ",
80.0,
FIELD_MAX_WIDTH,
) && text != alias
{
let text = text.trim().to_string();
let joined = if text.is_empty() {
value.to_string()
} else {
format!("{value} AS {text}")
};
acts.push(Act::SetHeader {
key,
occurrence,
value: Some(joined),
});
}
}
}
HeaderKind::Text if key == "labels" => {
let raw = if value == "?" { "" } else { value };
let (name, synonyms) = crate::report::flow::split_label_class(raw);
let rejoin = |name: &str, synonyms: &str| {
let name = name.trim();
let synonyms = synonyms.trim();
let name = if name.is_empty() {
HEADER_PLACEHOLDER
} else {
name
};
if synonyms.is_empty() {
name.to_string()
} else {
format!("{name} = {synonyms}")
}
};
let id = ui.make_persistent_id(("pt_hdr_label", occurrence));
if let Some(text) = inline_text_edit(
ui,
id,
name,
s.report_label_class_unset,
"",
90.0,
FIELD_MAX_WIDTH,
) && text != name
{
acts.push(Act::SetHeader {
key,
occurrence,
value: Some(rejoin(&text, synonyms)),
});
}
ui.label(RichText::new("=").color(color).monospace());
let id = ui.make_persistent_id(("pt_hdr_label_syn", occurrence));
if let Some(text) = inline_text_edit(
ui,
id,
synonyms,
s.report_label_synonyms_unset,
s.report_label_synonyms_help,
150.0,
FIELD_MAX_WIDTH,
) && text != synonyms
{
acts.push(Act::SetHeader {
key,
occurrence,
value: Some(rejoin(name, &text)),
});
}
}
HeaderKind::Folder | HeaderKind::File | HeaderKind::Text => {
let current = if value == "?" { "" } else { value };
let id = ui.make_persistent_id(("pt_hdr_text", key));
if let Some(text) = inline_text_edit(
ui,
id,
current,
s.report_setting_unset,
"",
150.0,
FIELD_MAX_WIDTH,
) && text != current
{
acts.push(Act::SetHeader {
key,
occurrence,
value: Some(text),
});
}
if spec.kind.is_path()
&& ui
.small_button(super::icons::FOLDER)
.on_hover_text(s.gui_report_browse)
.clicked()
{
acts.push(Act::PickHeaderFile { key, occurrence });
}
}
}
if (!unset || !spec.always_shown || helper) && detach_x(ui, color) {
acts.push(Act::SetHeader {
key,
occurrence,
value: None,
});
}
let cy = ui.min_rect().center().y;
ui.painter().galley(
egui::pos2(label_rect.left(), cy - gsize.y / 2.0),
galley,
color,
);
});
});
if ui.ctx().dragged_id().is_none() {
scope.response.on_hover_text(edit::header_help(key, s));
}
}
fn pick_header_file(ed: &mut ReportEditor, app: &mut GuiApp, key: &'static str, occurrence: usize) {
let current = ed
.flow
.as_ref()
.and_then(|f| f.header.get_all(key).get(occurrence).copied())
.unwrap_or_default()
.to_string();
let (current_ref, _) = crate::report::flow::split_collection_ref(¤t);
let seed = Some(current_ref)
.filter(|r| !r.is_empty())
.and_then(super::filepick::seed_dir)
.or_else(|| {
ed.report
.path
.as_deref()
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf)
});
let title = edit::header_help(key, &app.strings);
let kind = match key {
"root" => super::filepick::PickKind::Folder,
"collection" => super::filepick::PickKind::File {
filters: super::filepick::owned_filters(&[("hurl", &["hurl"]), ("*", &["*"])]),
},
_ => super::filepick::PickKind::File {
filters: super::filepick::owned_filters(&[
("baseline", &["baseline", "json"]),
("*", &["*"]),
]),
},
};
app.request_pick(
kind,
title,
seed.as_deref(),
super::menu::PickAction::ReportHeaderFile { key, occurrence },
);
}
pub(super) fn apply_picked_header_file(
app: &mut GuiApp,
key: &'static str,
occurrence: usize,
picked: Option<std::path::PathBuf>,
) {
let Some(path) = picked else {
return; };
let Some(mut ed) = app.report_editor.take() else {
return; };
apply_header_file(&mut ed, app, key, occurrence, &path);
app.report_editor = Some(ed);
}
fn apply_header_file(
ed: &mut ReportEditor,
app: &mut GuiApp,
key: &'static str,
occurrence: usize,
path: &std::path::Path,
) {
let current = ed
.flow
.as_ref()
.and_then(|f| f.header.get_all(key).get(occurrence).copied())
.unwrap_or_default()
.to_string();
let (_, alias) = crate::report::flow::split_collection_ref(¤t);
let text = if key == "collection" {
portable_ref(
path,
ed.report.path.as_deref(),
report_workspace_root(app, ed).as_deref(),
)
} else {
relative_to_report(path, ed.report.path.as_deref())
};
let text = match alias {
Some(a) if key == "collection" && occurrence > 0 => format!("{text} AS {a}"),
_ => text,
};
ed.edit_flow(|flow| {
edit::set_header_nth(flow, key, occurrence, Some(&text));
});
}
fn pick_loop_dir(ed: &mut ReportEditor, app: &mut GuiApp, path: &[usize], file: bool) {
let report_dir = ed
.report
.path
.as_deref()
.and_then(|p| p.parent())
.map(std::path::Path::to_path_buf);
let current = ed
.flow
.as_ref()
.and_then(|f| edit::loop_dir(f, path))
.unwrap_or_default();
let seed = if current.is_empty() {
report_dir.clone()
} else {
let joined = match &report_dir {
Some(dir) => dir.join(¤t),
None => std::path::PathBuf::from(¤t),
};
super::filepick::seed_dir(joined.to_string_lossy().as_ref()).or(report_dir.clone())
};
let title = if file {
app.strings.gui_pick_loop_file
} else {
app.strings.gui_pick_loop_folder
};
let kind = if file {
super::filepick::PickKind::File {
filters: super::filepick::owned_filters(&[("*", &["*"])]),
}
} else {
super::filepick::PickKind::Folder
};
app.request_pick(
kind,
title,
seed.as_deref(),
super::menu::PickAction::ReportLoopDir {
path: path.to_vec(),
file,
},
);
}
pub(super) fn apply_picked_loop_dir(
app: &mut GuiApp,
path: &[usize],
_file: bool,
picked: Option<std::path::PathBuf>,
) {
let Some(picked) = picked else {
return; };
let Some(mut ed) = app.report_editor.take() else {
return; };
let text = relative_to_report(&picked, ed.report.path.as_deref());
ed.edit_flow(|flow| {
edit::set_loop_dir(flow, path, &text);
});
ed.selection = path.to_vec();
app.report_editor = Some(ed);
}
fn relative_to_report(path: &std::path::Path, report: Option<&std::path::Path>) -> String {
report
.and_then(|r| r.parent())
.and_then(|dir| path.strip_prefix(dir).ok())
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
fn portable_ref(
path: &std::path::Path,
report: Option<&std::path::Path>,
scope: Option<&std::path::Path>,
) -> String {
let plain = relative_to_report(path, report);
if !std::path::Path::new(&plain).is_absolute() {
return plain;
}
let (Some(dir), Some(scope)) = (report.and_then(std::path::Path::parent), scope) else {
return plain;
};
if !path.starts_with(scope) || !dir.starts_with(scope) {
return plain;
}
let mut from = dir.components().peekable();
let mut to = path.components().peekable();
while let (Some(a), Some(b)) = (from.peek(), to.peek()) {
if a != b {
break;
}
from.next();
to.next();
}
let mut rel = std::path::PathBuf::new();
for _ in from {
rel.push("..");
}
rel.extend(to);
if rel.as_os_str().is_empty() {
return plain;
}
rel.to_string_lossy().replace('\\', "/")
}
fn flow_end_row(ui: &mut egui::Ui, th: &GuiTheme, s: &crate::i18n::Strings) {
ui.vertical(|ui| {
ui.horizontal_top(|ui| {
static_chip(ui, th, s.report_node_end, th.dim, s.chip_help_flow_end);
});
});
}
fn empty_flow_hint(ui: &mut egui::Ui, th: &GuiTheme, s: &crate::i18n::Strings) {
ui.horizontal(|ui| {
ui.add_space(INDENT_STEP);
ui.colored_label(th.dim, s.gui_report_empty_flow);
});
ui.add_space(2.0);
}
fn tail_drop_zone(
ui: &mut egui::Ui,
th: &GuiTheme,
top_len: usize,
titles: &[String],
acts: &mut Vec<Act>,
) {
let remaining = ui.available_size_before_wrap();
if remaining.y < 6.0 {
return;
}
let (rect, _) = ui.allocate_exact_size(remaining, egui::Sense::hover());
let resp = ui.interact(rect, ui.id().with("pt_tail_drop"), egui::Sense::hover());
let end = InsertPos {
parent: Vec::new(),
index: top_len,
};
let hovering = resp.dnd_hover_payload::<NodeKind>().is_some()
|| resp
.dnd_hover_payload::<DragItem>()
.is_some_and(|d| matches!(&*d, DragItem::Row(_)));
if hovering {
let size = dragged_block_size(ui);
let clip = egui::Rect::from_min_size(
rect.left_top(),
egui::vec2(
(rect.width() - 8.0).max(1.0),
(size.y - 4.0).min(rect.height()).max(6.0),
),
);
paint_drop_silhouette(ui, rect.left_top(), &dragged_block_shape(ui), clip, th);
}
if let Some(kind) = release_payload::<NodeKind>(&resp) {
acts.push(Act::DropNode {
pos: end,
node: node_for_kind(*kind, titles),
});
} else if let Some(item) = release_payload::<DragItem>(&resp) {
if let DragItem::Row(from) = &*item
&& from.as_slice() != [top_len.saturating_sub(1)]
{
acts.push(Act::MoveNode {
from: from.clone(),
pos: end,
});
}
}
}
fn chip_h(ui: &egui::Ui) -> f32 {
let sp = ui.spacing();
let row = ui.text_style_height(&egui::TextStyle::Body);
(sp.interact_size.y.max(row + 2.0 * sp.button_padding.y)).ceil()
}
fn chip_label_job(ui: &egui::Ui, text: &str, color: Color32) -> egui::text::LayoutJob {
let base = egui::TextStyle::Button.resolve(ui.style());
let mono = egui::FontId::new(base.size, egui::FontFamily::Monospace);
let split = text.find(['(', ' ']).unwrap_or(text.len());
let (keyword, rest) = text.split_at(split);
let mut job = egui::text::LayoutJob::default();
let mut push = |s: &str, font: egui::FontId| {
if s.is_empty() {
return;
}
job.append(
s,
0.0,
egui::TextFormat {
font_id: font,
color,
..Default::default()
},
);
};
push(keyword, base.clone());
push(rest, mono);
job
}
fn chip_label(ui: &mut egui::Ui, text: &str, color: Color32) -> egui::Response {
let job = chip_label_job(ui, text, color);
ui.add(
egui::Label::new(job)
.selectable(false)
.sense(egui::Sense::click_and_drag()),
)
}
fn chip_shell<R>(
ui: &mut egui::Ui,
tint: &ChipTint,
grow: bool,
corners: egui::CornerRadius,
content: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let h = chip_h(ui);
let framed = egui::Frame::NONE
.fill(tint.fill)
.stroke(tint.stroke)
.inner_margin(egui::Margin::symmetric(8, 3))
.corner_radius(corners)
.show(ui, |ui| {
ui.spacing_mut().interact_size.y = h;
ui.horizontal(|ui| {
if grow {
ui.set_min_height(h);
}
content(ui)
})
.inner
});
if let Some(rule) = tint.rule {
let rect = framed.response.rect;
ui.painter().rect_filled(
egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.left() + CHIP_RULE_W, rect.bottom()),
),
egui::CornerRadius {
nw: corners.nw,
sw: corners.sw,
ne: 0,
se: 0,
},
rule,
);
}
if let Some(seam) = tint.seam {
let rect = framed.response.rect;
ui.painter().vline(
rect.left(),
rect.top() + 1.0..=rect.bottom() - 1.0,
egui::Stroke::new(1.0, seam),
);
}
framed.inner
}
const CHIP_FILL_MIX: f32 = 0.05;
const CHIP_STROKE_MIX: f32 = 0.16;
const CHIP_FILL_MIX_HOVER: f32 = 0.32;
const CHIP_STROKE_MIX_HOVER: f32 = 0.55;
const CHIP_TEXT_MIX: f32 = 0.45;
const CHIP_RULE_W: f32 = 3.0;
struct ChipTint {
fill: Color32,
stroke: egui::Stroke,
text: Color32,
rule: Option<Color32>,
seam: Option<Color32>,
}
fn chip_tint(th: &GuiTheme, color: Color32) -> ChipTint {
ChipTint {
fill: mix(th.panel, color, CHIP_FILL_MIX),
stroke: egui::Stroke::new(1.0, mix(th.panel, color, CHIP_STROKE_MIX)),
text: mix(th.text, color, CHIP_TEXT_MIX),
rule: Some(color),
seam: None,
}
}
fn chip_colors(th: &GuiTheme, chip: &Chip, selected: bool) -> ChipTint {
let mut tint = if chip.is_base && selected {
ChipTint {
fill: th.select_bg,
stroke: egui::Stroke::new(1.0, th.select_fg),
text: th.select_fg,
rule: None,
seam: None,
}
} else if chip.hovered {
ChipTint {
fill: mix(th.panel, chip.color, CHIP_FILL_MIX_HOVER),
stroke: egui::Stroke::new(1.0, mix(th.panel, chip.color, CHIP_STROKE_MIX_HOVER)),
text: mix(th.text, chip.color, CHIP_TEXT_MIX),
rule: Some(chip.color),
seam: None,
}
} else {
chip_tint(th, chip.color)
};
if chip.join_prev {
tint.rule = None;
tint.seam = Some(mix(tint.fill, tint.text, CHIP_SEAM_MIX));
}
tint
}
const CHIP_SEAM_MIX: f32 = 0.45;
fn detach_x(ui: &mut egui::Ui, col: Color32) -> bool {
ui.add(
egui::Button::new(RichText::new("×").color(col))
.small()
.frame(false),
)
.clicked()
}
fn fitted_field_width(ui: &egui::Ui, text: &str, hint: &str, min: f32, max: f32) -> f32 {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let measure = |t: &str| {
ui.painter()
.layout_no_wrap(t.to_string(), font.clone(), egui::Color32::PLACEHOLDER)
.size()
.x
};
let padding = 12.0;
(measure(text).max(measure(hint)) + padding).clamp(min, max)
}
fn inline_text_edit(
ui: &mut egui::Ui,
id: egui::Id,
current: &str,
hint: &str,
help: &str,
min_width: f32,
max_width: f32,
) -> Option<String> {
let mut buf = ui
.data(|d| d.get_temp::<String>(id))
.unwrap_or_else(|| current.to_string());
let width = fitted_field_width(ui, &buf, hint, min_width, max_width);
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.hint_text(hint)
.background_color(ui.visuals().widgets.inactive.weak_bg_fill)
.font(egui::TextStyle::Monospace)
.desired_width(width),
);
if !help.is_empty() && !resp.has_focus() {
resp.clone().on_hover_text(help);
}
if resp.lost_focus() {
ui.data_mut(|d| d.remove::<String>(id));
Some(buf.trim().to_string())
} else if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(id, buf));
None
} else {
ui.data_mut(|d| d.remove::<String>(id));
None
}
}
fn static_chip(ui: &mut egui::Ui, th: &GuiTheme, text: &str, color: Color32, help: &str) {
let tint = chip_tint(th, color);
let text_col = tint.text;
let scope = ui.scope(|ui| {
chip_shell(ui, &tint, true, ROUND_CHIP, |ui| {
let job = chip_label_job(ui, text, text_col);
ui.add(egui::Label::new(job).selectable(false));
});
});
if !help.is_empty() && ui.ctx().dragged_id().is_none() {
scope.response.on_hover_text(help);
}
}
#[allow(clippy::too_many_arguments)]
fn lift_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
selected: bool,
path: &[usize],
row_index: usize,
titles: &[String],
env_choices: &[String],
acts: &mut Vec<Act>,
) -> (egui::LayerId, egui::Rect) {
let layer_id = egui::LayerId::new(
egui::Order::Tooltip,
ui.id().with(("pt_drag_chip", row_index)),
);
let slot = ui
.scope_builder(
egui::UiBuilder::new()
.layer_id(layer_id)
.layout(egui::Layout::left_to_right(egui::Align::Min)),
|ui| render_chip(ui, th, s, chip, selected, path, titles, env_choices, acts),
)
.inner;
paint_origin_ghost(ui.painter(), slot, th);
(layer_id, slot)
}
fn follow_pointer(ctx: &egui::Context, layer_id: egui::LayerId, anchor: egui::Rect) {
if let Some(pointer) = ctx.pointer_interact_pos() {
ctx.transform_layer_shapes(
layer_id,
egui::emath::TSTransform::from_translation(pointer - anchor.center()),
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
selected: bool,
path: &[usize],
titles: &[String],
env_choices: &[String],
acts: &mut Vec<Act>,
) -> egui::Rect {
let scope = ui.scope(|ui| {
render_chip_body(ui, th, s, chip, selected, path, titles, env_choices, acts);
});
let rect = scope.response.rect;
record_chip_hover(ui, chip, rect);
if ui.ctx().dragged_id().is_none() {
let help = match (chip.help.is_empty(), chip.detach.is_some()) {
(true, false) => String::new(),
(false, false) => chip.help.to_string(),
(true, true) => s.chip_help_drag_gesture.to_string(),
(false, true) => format!("{}\n\n{}", chip.help, s.chip_help_drag_gesture),
};
if !help.is_empty() {
scope.response.on_hover_text(help);
}
}
rect
}
#[allow(clippy::too_many_arguments)]
fn render_chip_body(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
selected: bool,
path: &[usize],
titles: &[String],
env_choices: &[String],
acts: &mut Vec<Act>,
) {
match &chip.edit {
ChipEdit::Request { name } if !titles.is_empty() => {
combo_chip(
ui,
th,
s,
chip,
selected,
path,
"REQUEST",
name,
titles,
true,
acts,
|picked| Act::RenameRequest {
path: path.to_vec(),
name: picked,
},
);
return;
}
ChipEdit::EnvRole {
baseline,
index,
name,
} if !env_choices.is_empty() => {
let kw = if *baseline { "BASELINE" } else { "COMPARISON" };
let (baseline, index) = (*baseline, *index);
combo_chip(
ui,
th,
s,
chip,
selected,
path,
kw,
name,
env_choices,
false,
acts,
move |picked| Act::SetEnvRole {
path: path.to_vec(),
baseline,
index,
name: picked,
},
);
return;
}
ChipEdit::Alias { text } => {
alias_chip(ui, th, s, chip, path, text, acts);
return;
}
ChipEdit::Parallel { degree } => {
parallel_chip(ui, th, s, chip, path, *degree, acts);
return;
}
ChipEdit::Loop(l) => {
let l = l.clone();
loop_chip(ui, th, s, chip, path, &l, acts);
return;
}
_ => {}
}
let tint = chip_colors(th, chip, selected);
let text_col = tint.text;
let handle = chip_shell(ui, &tint, true, chip_corners(chip), |ui| {
let handle = chip_label(ui, &chip.text, text_col);
if let Some(which) = chip.detach
&& detach_x(ui, text_col)
{
acts.push(Act::DetachMod {
path: path.to_vec(),
which,
});
}
handle
});
if handle.dragged() {
handle.dnd_set_drag_payload(chip_drag_payload(ui, chip, path));
}
if chip.is_base {
if handle.double_clicked() {
acts.push(Act::OpenWizard(path.to_vec()));
} else if handle.clicked() {
acts.push(Act::Select(path.to_vec()));
}
} else if chip_opens_wizard_on_click(chip) && handle.clicked() {
acts.push(Act::OpenWizard(path.to_vec()));
}
}
fn chip_drag_payload(ui: &egui::Ui, chip: &Chip, path: &[usize]) -> DragItem {
let force_row = ui.input(|i| i.modifiers.command);
match (force_row, chip.is_base, chip.detach) {
(false, false, Some(which)) => DragItem::Chip {
path: path.to_vec(),
which,
},
_ => DragItem::Row(path.to_vec()),
}
}
fn chip_opens_wizard_on_click(chip: &Chip) -> bool {
matches!(
chip.detach,
Some(
DetachWhich::Show
| DetachWhich::Hide
| DetachWhich::Response
| DetachWhich::BaselineShow
)
)
}
fn alias_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
path: &[usize],
current: &str,
acts: &mut Vec<Act>,
) {
let tint = chip_colors(th, chip, false);
let text_col = tint.text;
let handle = chip_shell(ui, &tint, true, chip_corners(chip), |ui| {
let handle = ui.add(
egui::Label::new(RichText::new("AS").color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
let id = ui.make_persistent_id(("pt_alias", path));
if let Some(text) = inline_text_edit(
ui,
id,
current,
s.gui_report_alias_hint,
"",
ALIAS_FIELD_WIDTH,
FIELD_MAX_WIDTH,
) && text != current
{
acts.push(Act::SetAlias {
path: path.to_vec(),
text,
});
}
if let Some(which) = chip.detach
&& detach_x(ui, text_col)
{
acts.push(Act::DetachMod {
path: path.to_vec(),
which,
});
}
handle
});
if handle.dragged() {
handle.dnd_set_drag_payload(chip_drag_payload(ui, chip, path));
}
}
fn loop_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
path: &[usize],
l: &LoopEdit,
acts: &mut Vec<Act>,
) {
let tint = chip_colors(th, chip, false);
let text_col = tint.text;
let handle = chip_shell(ui, &tint, true, chip_corners(chip), |ui| {
let mut handle = ui.add(
egui::Label::new(RichText::new("FOR").color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
if let Some(var) = &l.var {
let id = ui.make_persistent_id(("pt_loop_var", path));
let resp = inline_text_edit(
ui,
id,
var,
s.gui_report_loop_var_hint,
s.chip_help_loop_var,
LOOP_VAR_FIELD_WIDTH,
FIELD_MAX_WIDTH,
);
if let Some(text) = resp
&& &text != var
{
acts.push(Act::SetLoopVar {
path: path.to_vec(),
text,
});
}
}
handle |= ui.add(
egui::Label::new(RichText::new(&l.keyword).color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
if let Some((dir, is_file)) = &l.dir {
let id = ui.make_persistent_id(("pt_loop_dir", path));
let resp = inline_text_edit(
ui,
id,
dir,
s.gui_report_loop_dir_hint,
s.chip_help_loop_dir,
LOOP_PATH_FIELD_WIDTH,
FIELD_MAX_WIDTH,
);
if let Some(text) = resp
&& &text != dir
{
acts.push(Act::SetLoopDir {
path: path.to_vec(),
text,
});
}
let pick = ui
.add(
egui::Button::new(RichText::new(super::icons::FOLDER).color(text_col))
.min_size(egui::vec2(PICKER_BUTTON_WIDTH, 0.0)),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(if *is_file {
s.chip_help_loop_pick_file
} else {
s.chip_help_loop_pick_folder
});
if pick.clicked() {
acts.push(Act::PickLoopDir {
path: path.to_vec(),
file: *is_file,
});
}
}
if let Some(glob) = &l.glob {
handle |= ui
.add(
egui::Label::new(RichText::new("MATCH").color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
)
.on_hover_text(s.chip_help_loop_glob);
let id = ui.make_persistent_id(("pt_loop_glob", path));
let resp = inline_text_edit(
ui,
id,
glob,
s.gui_report_loop_glob_hint,
s.chip_help_loop_glob,
LOOP_GLOB_FIELD_WIDTH,
FIELD_MAX_WIDTH,
);
if let Some(text) = resp
&& &text != glob
{
acts.push(Act::SetLoopGlob {
path: path.to_vec(),
text,
});
}
}
if !l.tail.is_empty() {
handle |= ui.add(
egui::Label::new(RichText::new(&l.tail).color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
}
handle
});
if handle.dragged() {
handle.dnd_set_drag_payload(chip_drag_payload(ui, chip, path));
}
if handle.double_clicked() {
acts.push(Act::OpenWizard(path.to_vec()));
} else if handle.clicked() {
acts.push(Act::Select(path.to_vec()));
}
}
fn parallel_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
path: &[usize],
current: Option<u32>,
acts: &mut Vec<Act>,
) {
let tint = chip_colors(th, chip, false);
let text_col = tint.text;
let shown = current.map(|n| n.to_string()).unwrap_or_default();
let handle = chip_shell(ui, &tint, true, chip_corners(chip), |ui| {
let handle = ui.add(
egui::Label::new(RichText::new("PARALLEL").color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
let id = ui.make_persistent_id(("pt_parallel", path));
if let Some(text) = inline_text_edit(
ui,
id,
&shown,
s.node_form_parallel_degree,
"",
PARALLEL_FIELD_WIDTH,
FIELD_MAX_WIDTH,
) && text != shown
{
let degree = match text.trim() {
"" => Some(None),
t => t.parse::<u32>().ok().filter(|n| *n > 0).map(Some),
};
if let Some(degree) = degree {
acts.push(Act::SetParallelDegree {
path: path.to_vec(),
degree,
});
}
}
if let Some(which) = chip.detach
&& detach_x(ui, text_col)
{
acts.push(Act::DetachMod {
path: path.to_vec(),
which,
});
}
handle
});
if handle.dragged() {
handle.dnd_set_drag_payload(chip_drag_payload(ui, chip, path));
}
}
#[allow(clippy::too_many_arguments)]
fn combo_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
selected: bool,
path: &[usize],
prefix: &str,
current: &str,
choices: &[String],
filter: bool,
acts: &mut Vec<Act>,
make_act: impl FnOnce(String) -> Act,
) {
let tint = chip_colors(th, chip, selected);
let text_col = tint.text;
let mut picked: Option<String> = None;
let mut detached: Option<DetachWhich> = None;
let handle = chip_shell(ui, &tint, false, chip_corners(chip), |ui| {
let font = egui::TextStyle::Button.resolve(ui.style());
let galley = ui
.painter()
.layout_no_wrap(prefix.to_string(), font, text_col);
let gsize = galley.size();
let (label_rect, handle) = ui.allocate_exact_size(gsize, egui::Sense::click_and_drag());
egui::ComboBox::from_id_salt((path, prefix))
.selected_text(RichText::new(current).color(text_col))
.show_ui(ui, |ui| {
if filter {
filtered_choices(ui, s, path, prefix, current, choices, &mut picked);
} else {
for c in choices {
if ui.selectable_label(c == current, c).clicked() {
picked = Some(c.clone());
}
}
}
});
if let Some(which) = chip.detach
&& detach_x(ui, text_col)
{
detached = Some(which);
}
let cy = ui.min_rect().center().y;
ui.painter().galley(
egui::pos2(label_rect.left(), cy - gsize.y / 2.0),
galley,
text_col,
);
handle
});
if let Some(which) = detached {
acts.push(Act::DetachMod {
path: path.to_vec(),
which,
});
}
if handle.dragged() {
handle.dnd_set_drag_payload(chip_drag_payload(ui, chip, path));
}
if handle.double_clicked() {
acts.push(Act::OpenWizard(path.to_vec()));
} else if handle.clicked() {
acts.push(Act::Select(path.to_vec()));
}
if let Some(name) = picked
&& name != current
{
acts.push(make_act(name));
}
}
fn filtered_choices(
ui: &mut egui::Ui,
s: &crate::i18n::Strings,
path: &[usize],
prefix: &str,
current: &str,
choices: &[String],
picked: &mut Option<String>,
) {
let filt_id = ui.make_persistent_id(("pt_chip_filter", path, prefix));
let mut q = ui
.data(|d| d.get_temp::<String>(filt_id))
.unwrap_or_default();
let te = ui.add(
egui::TextEdit::singleline(&mut q)
.hint_text(s.gui_report_filter_hint)
.desired_width(200.0),
);
if q.is_empty() && ui.memory(|m| m.focused().is_none()) {
te.request_focus();
}
ui.data_mut(|d| d.insert_temp(filt_id, q.clone()));
ui.separator();
let needle = q.to_lowercase();
egui::ScrollArea::vertical()
.max_height(220.0)
.show(ui, |ui| {
for c in choices
.iter()
.filter(|c| needle.is_empty() || c.to_lowercase().contains(&needle))
{
if ui.selectable_label(c == current, c).clicked() {
*picked = Some(c.clone());
ui.data_mut(|d| d.remove::<String>(filt_id));
}
}
});
}
fn palette_panel(
ed: &mut ReportEditor,
app: &GuiApp,
ui: &mut egui::Ui,
titles: &[String],
acts: &mut Vec<Act>,
) {
let th = app.theme;
let s = &app.strings;
egui::Frame::NONE
.fill(th.raised())
.stroke(egui::Stroke::new(1.0, th.accent))
.inner_margin(8)
.corner_radius(BLOCK_RADIUS as u8)
.show(ui, |ui| {
let pick_request = ed.palette.as_ref().and_then(|p| p.pick_request);
ui.horizontal(|ui| {
let title = if pick_request.is_some() {
s.node_pick_request_title
} else {
s.gui_report_add_block
};
ui.label(RichText::new(title).strong().color(th.text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(super::icons::CLOSE.to_string()).clicked() {
acts.push(Act::ClosePalette);
}
});
});
ui.separator();
match pick_request {
None => {
for kind in BASE_KINDS {
if ui.selectable_label(false, kind.label(s)).clicked() {
acts.push(Act::PickKind(kind));
}
}
}
Some(report) => {
if titles.is_empty() {
ui.colored_label(th.dim, s.node_pick_request_none);
}
if let Some(p) = ed.palette.as_mut() {
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut p.request_name)
.hint_text(s.node_pick_request_title)
.desired_width(200.0),
);
if ui.button(s.gui_ok).clicked() && !p.request_name.trim().is_empty() {
acts.push(Act::InsertRequest {
report,
name: p.request_name.trim().to_string(),
});
}
});
}
for name in titles {
if ui.selectable_label(false, name).clicked() {
acts.push(Act::InsertRequest {
report,
name: name.clone(),
});
}
}
}
}
});
}
fn diag_splitter(ed: &mut ReportEditor, ui: &mut egui::Ui) {
ui.add_space(2.0);
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), egui::Sense::drag());
if resp.dragged() {
ed.diag_h = (ed.diag_h - resp.drag_delta().y).clamp(48.0, 600.0);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let active = resp.hovered() || resp.dragged();
let visuals = ui.visuals();
let colour = if active {
visuals.widgets.active.fg_stroke.color
} else {
visuals.widgets.noninteractive.bg_stroke.color
};
let w = (rect.width() * 0.25).clamp(40.0, 160.0);
let x0 = rect.center().x - w / 2.0;
ui.painter()
.hline(x0..=x0 + w, rect.center().y, egui::Stroke::new(2.0, colour));
}
fn palette_splitter(ed: &mut ReportEditor, ui: &mut egui::Ui, body_h: f32) {
let (rect, resp) = ui.allocate_exact_size(egui::vec2(8.0, body_h), egui::Sense::drag());
if resp.dragged() {
ed.palette_w = (ed.palette_w + resp.drag_delta().x).clamp(96.0, 480.0);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
let active = resp.hovered() || resp.dragged();
let visuals = ui.visuals();
let colour = if active {
visuals.widgets.active.fg_stroke.color
} else {
visuals.widgets.noninteractive.bg_stroke.color
};
let h = (rect.height() * 0.25).clamp(24.0, 120.0);
let y0 = rect.center().y - h / 2.0;
ui.painter()
.vline(rect.center().x, y0..=y0 + h, egui::Stroke::new(2.0, colour));
}
fn diagnostics_panel(ed: &ReportEditor, app: &GuiApp, ui: &mut egui::Ui) {
let th = app.theme;
ui.add_space(4.0);
ui.label(
RichText::new(app.strings.report_validation_heading)
.strong()
.color(th.text),
);
egui::ScrollArea::vertical()
.id_salt("report_diags")
.auto_shrink([false, false])
.show(ui, |ui| {
if ed.diagnostics.is_empty() && ed.parse_error.is_none() {
ui.colored_label(th.ok, app.strings.report_no_diagnostics);
return;
}
if let Some(err) = &ed.parse_error {
ui.colored_label(th.err, format!("{} {err}", super::icons::FAIL));
}
for d in &ed.diagnostics {
let (icon, colour) = match d.severity {
Severity::Error => (super::icons::FAIL, th.err),
Severity::Warning => (super::icons::WARNING, th.pending),
};
ui.colored_label(colour, format!("{icon} {}", d.message));
}
});
}
fn apply_block_actions(ed: &mut ReportEditor, app: &mut GuiApp, acts: Vec<Act>) {
let (commits, rest): (Vec<Act>, Vec<Act>) = acts.into_iter().partition(Act::is_value_commit);
for act in commits.into_iter().chain(rest) {
match act {
Act::Select(path) => {
ed.selection = path;
}
Act::OpenPalette(pos) => {
ed.palette = Some(PaletteState {
pos,
pick_request: None,
request_name: String::new(),
});
}
Act::ClosePalette => ed.palette = None,
Act::PickKind(kind) => {
if kind.needs_request() {
if let Some(p) = ed.palette.as_mut() {
p.pick_request = Some(matches!(kind, NodeKind::ReportRequest));
}
} else if let Some(node) = kind.template() {
if let Some(sel) = insert_at_palette(ed, node) {
super::report_wizard::open(ed, app, &sel);
}
}
}
Act::InsertRequest { report, name } => {
if let Some(sel) = insert_at_palette(ed, request_node(&name, report)) {
super::report_wizard::open(ed, app, &sel);
}
}
Act::Move { path, up } => {
let mut new_sel = None;
ed.edit_flow(|flow| {
new_sel = move_node(flow, &path, up);
});
if let Some(ns) = new_sel {
ed.selection = ns;
}
}
Act::DropNode { pos, node } => {
ed.edit_flow(|flow| insert_node(flow, &pos, node));
let mut sel = pos.parent.clone();
sel.push(pos.index);
ed.selection = sel.clone();
super::report_wizard::open(ed, app, &sel);
}
Act::AttachMod { path, modifier } => {
if modifier == Modifier::With {
ed.selection = path.clone();
super::report_wizard::open_with_field(ed, &path, None);
continue;
}
let assign_report = modifier == Modifier::Report
&& matches!(
ed.flow.as_ref().and_then(|f| node_at(f, &path)),
Some(FlowNode::Assign { .. })
);
if assign_report {
let mut new_sel = None;
ed.edit_flow(|flow| {
new_sel = report_assignment(flow, &path);
});
ed.selection = new_sel.unwrap_or(path);
} else {
ed.edit_flow(|flow| {
attach_modifier(flow, &path, modifier);
});
ed.selection = path;
}
let sel = ed.selection.clone();
super::report_wizard::open(ed, app, &sel);
}
Act::MoveMod {
from,
which,
to,
copy,
} => {
let mut moved = false;
ed.edit_flow(|flow| {
moved = transfer_modifier(flow, &from, which, &to, copy);
});
if moved {
ed.selection = to;
}
}
Act::DetachMod { path, which } => {
ed.edit_flow(|flow| {
if detach_modifier(flow, &path, which) {
remove_node(flow, &path);
}
});
ed.selection = Vec::new();
}
Act::RenameRequest { path, name } => {
ed.edit_flow(|flow| {
set_request_name(flow, &path, &name);
});
ed.selection = path;
}
Act::SetEnvRole {
path,
baseline,
index,
name,
} => {
ed.edit_flow(|flow| {
edit::set_env_role(flow, &path, baseline, index, &name);
});
ed.selection = path;
}
Act::MoveNode { from, pos } => {
let mut new_sel = None;
ed.edit_flow(|flow| {
new_sel = edit::move_node_to(flow, &from, &pos);
});
if let Some(ns) = new_sel {
ed.selection = ns;
}
}
Act::DeletePath(path) => {
ed.edit_flow(|flow| {
remove_node(flow, &path);
});
ed.selection = Vec::new();
}
Act::OpenWizard(path) => super::report_wizard::open(ed, app, &path),
Act::SetAlias { path, text } => {
ed.edit_flow(|flow| {
edit::set_report_alias(flow, &path, &text);
});
ed.selection = path;
}
Act::SetLoopVar { path, text } => {
ed.edit_flow(|flow| {
edit::set_loop_var(flow, &path, &text);
});
ed.selection = path;
}
Act::SetLoopDir { path, text } => {
ed.edit_flow(|flow| {
edit::set_loop_dir(flow, &path, &text);
});
ed.selection = path;
}
Act::SetLoopGlob { path, text } => {
ed.edit_flow(|flow| {
edit::set_loop_glob(flow, &path, &text);
});
ed.selection = path;
}
Act::PickLoopDir { path, file } => {
pick_loop_dir(ed, app, &path, file);
}
Act::SetParallelDegree { path, degree } => {
ed.edit_flow(|flow| {
edit::set_parallel_degree(flow, &path, degree);
});
ed.selection = path;
}
Act::SetHeader {
key,
occurrence,
value,
} => {
ed.edit_flow(|flow| {
edit::set_header_nth(flow, key, occurrence, value.as_deref());
});
}
Act::PickHeaderFile { key, occurrence } => {
pick_header_file(ed, app, key, occurrence);
}
Act::AddWith { path } => {
ed.selection = path.clone();
super::report_wizard::open_with_field(ed, &path, None);
}
Act::EditWith { path, index } => {
ed.selection = path.clone();
super::report_wizard::open_with_field(ed, &path, Some(index));
}
Act::RemoveWith { path, index } => {
ed.edit_flow(|flow| {
detach_modifier(flow, &path, DetachWhich::With(index));
});
ed.selection = path;
}
Act::AttachWithStats { path, index } => {
ed.edit_flow(|flow| {
edit::attach_with_stats(flow, &path, index);
});
ed.selection = path;
}
}
}
sync_back(ed, app);
}
fn insert_at_palette(ed: &mut ReportEditor, node: FlowNode) -> Option<Vec<usize>> {
let p = ed.palette.take()?;
let pos = p.pos.clone();
ed.edit_flow(|flow| insert_node(flow, &pos, node));
let mut sel = pos.parent.clone();
sel.push(pos.index);
ed.selection = sel.clone();
Some(sel)
}
fn sync_back(ed: &ReportEditor, app: &mut GuiApp) {
if let ReportOrigin::Session(i) = ed.origin
&& let Some(r) = app.session.reports.get_mut(i)
{
r.text = ed.report.text.clone();
r.name = ed.report.name.clone();
}
}
pub(super) fn request_save(ed: &mut ReportEditor) {
ed.pending_toolbar = Some(ToolbarAct::Save);
}
pub(super) fn save_report_to(app: &mut GuiApp, path: &std::path::Path) -> Result<(), String> {
let Some(mut ed) = app.report_editor.take() else {
return Err(app.strings.gui_nothing_to_save.to_string());
};
let res = ed.report.save_local(path);
if res.is_ok() {
sync_back(&ed, app);
if let ReportOrigin::Session(i) = ed.origin
&& let Some(r) = app.session.reports.get_mut(i)
{
r.path = Some(path.to_string_lossy().into_owned());
}
}
app.report_editor = Some(ed);
res
}
fn save_report(ed: &mut ReportEditor, app: &mut GuiApp) {
if let Some(path) = ed.report.path.clone() {
if let Err(e) = ed.report.save_local(&path) {
app.session.status = Some(crate::i18n::Status::Error(e));
return;
}
} else {
ed.report.dirty = false;
}
sync_back(ed, app);
app.session.save();
app.session.status = Some(crate::i18n::Status::Saved);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::{Language, Strings};
#[test]
fn enter_inherits_the_current_lines_indent() {
let text = "FOR F IN [\"a\"]\n REQUEST r";
let at = text.chars().count();
let (out, caret) = newline_with_indent(text, at..at);
assert_eq!(out, "FOR F IN [\"a\"]\n REQUEST r\n ");
assert_eq!(caret, out.chars().count());
}
#[test]
fn enter_after_an_opener_adds_a_level() {
let text = "FOR F IN [\"a\"]";
let at = text.chars().count();
let (out, caret) = newline_with_indent(text, at..at);
assert_eq!(out, "FOR F IN [\"a\"]\n ");
assert_eq!(caret, out.chars().count());
let text = " REPORT REQUEST p WITH";
let at = text.chars().count();
let (out, _) = newline_with_indent(text, at..at);
assert_eq!(out, " REPORT REQUEST p WITH\n ");
}
#[test]
fn a_mid_line_split_is_not_indented() {
let text = " REQUEST rs";
let at = text.chars().count() - 1;
let (out, caret) = newline_with_indent(text, at..at);
assert_eq!(out, " REQUEST r\ns");
assert_eq!(caret, at + 1);
}
#[test]
fn enter_replaces_the_selection() {
let text = "FOR F IN [\"a\"]\n REQUEST rXXX";
let start = text.chars().count() - 3;
let (out, caret) = newline_with_indent(text, start..text.chars().count());
assert_eq!(out, "FOR F IN [\"a\"]\n REQUEST r\n ");
assert_eq!(caret, out.chars().count());
}
#[test]
fn end_dedents_to_its_opener() {
let text = "FOR F IN [\"a\"]\n REQUEST r\n END";
let at = text.chars().count();
let (out, caret) = snap_end_line(text, at).expect("END should snap");
assert_eq!(out, "FOR F IN [\"a\"]\n REQUEST r\nEND");
assert_eq!(caret, out.chars().count());
}
#[test]
fn end_snapping_leaves_aligned_or_unresolvable_lines_alone() {
let aligned = "FOR F IN [\"a\"]\n REQUEST r\nEND";
assert!(snap_end_line(aligned, aligned.chars().count()).is_none());
let stray = "REQUEST r\n END";
assert!(snap_end_line(stray, stray.chars().count()).is_none());
let not_an_end = "FOR F IN [\"a\"]\n ENDPOINT = x";
assert!(snap_end_line(not_an_end, not_an_end.chars().count()).is_none());
}
#[test]
fn tab_indents_one_level() {
let (out, caret) = indent_at("REQUEST r", 0..0);
assert_eq!(out, " REQUEST r");
assert_eq!(caret, 4);
let (out, caret) = indent_at("REQUEST rXX", 9..11);
assert_eq!(out, "REQUEST r ");
assert_eq!(caret, 13);
}
#[test]
fn dedent_walks_back_to_the_previous_four_stop() {
assert_eq!(dedent_span(" END", 8), Some(4));
assert_eq!(dedent_span(" END", 4), Some(4));
assert_eq!(dedent_span(" END", 6), Some(2));
assert_eq!(dedent_span(" END", 5), Some(1));
let (out, caret) = delete_before(" END", 8, 4);
assert_eq!(out, " END");
assert_eq!(caret, 4);
}
#[test]
fn dedent_declines_anywhere_but_a_run_of_spaces() {
assert_eq!(dedent_span("END", 3), None); assert_eq!(dedent_span(" END", 0), None); assert_eq!(dedent_span("\tEND", 1), None); }
#[test]
fn dedent_also_clears_trailing_padding() {
assert_eq!(dedent_span("END ", 7), Some(4));
let (out, caret) = delete_before("END ", 7, 4);
assert_eq!(out, "END");
assert_eq!(caret, 3);
}
#[test]
fn indent_helpers_are_char_indexed_not_byte_indexed() {
let text = " REQUEST \u{e9}\u{e9}\u{e9}";
let at = text.chars().count();
let (out, caret) = newline_with_indent(text, at..at);
assert_eq!(out, " REQUEST \u{e9}\u{e9}\u{e9}\n ");
assert_eq!(caret, out.chars().count());
assert_eq!(row_col_at(text, at), (0, 15));
assert_eq!(row_start("\u{e9}\u{e9}\n x", 1), 3);
}
fn luminance(c: Color32) -> f64 {
let ch = |v: u8| {
let v = v as f64 / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b())
}
fn contrast(a: Color32, b: Color32) -> f64 {
let (hi, lo) = {
let (x, y) = (luminance(a), luminance(b));
if x > y { (x, y) } else { (y, x) }
};
(hi + 0.05) / (lo + 0.05)
}
#[test]
fn every_chip_categorys_label_is_readable_on_its_own_tint_in_every_theme() {
for spec in crate::theme::builtin_presets() {
let th = GuiTheme::from_spec(&spec);
for kind in [
NodeKind::Request,
NodeKind::ReportRequest,
NodeKind::ReportVar,
NodeKind::ReportComputed,
NodeKind::Assign,
NodeKind::ForFiles,
NodeKind::ForFolders,
NodeKind::ForEnvs,
NodeKind::List,
] {
let color = kind_color(kind, &th);
let tint = chip_tint(&th, color);
let ratio = contrast(tint.text, tint.fill);
assert!(
ratio >= 4.5,
"{} chip {kind:?}: label contrast {ratio:.2} is below WCAG AA",
spec.name
);
}
let tint = chip_tint(&th, th.err);
assert!(
contrast(tint.text, tint.fill) >= 4.5,
"{} error chip",
spec.name
);
}
}
#[test]
fn the_chip_categories_remain_visually_distinct_from_one_another() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let kinds = [
NodeKind::Request,
NodeKind::ReportVar,
NodeKind::Assign,
NodeKind::List,
];
let rules: Vec<Color32> = kinds
.into_iter()
.map(|k| {
chip_tint(&th, kind_color(k, &th))
.rule
.expect("a category chip shows a rule")
})
.collect();
let labels: Vec<Color32> = kinds
.into_iter()
.map(|k| chip_tint(&th, kind_color(k, &th)).text)
.collect();
for channel in [&rules, &labels] {
for (i, a) in channel.iter().enumerate() {
for b in &channel[i + 1..] {
let dist = (a.r() as i32 - b.r() as i32).abs()
+ (a.g() as i32 - b.g() as i32).abs()
+ (a.b() as i32 - b.b() as i32).abs();
assert!(
dist > 40,
"two categories came out too close to tell apart: {a:?} vs {b:?}"
);
}
}
}
}
#[test]
fn the_category_rule_carries_the_hue_at_full_strength() {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
for kind in [NodeKind::Request, NodeKind::Assign, NodeKind::List] {
let color = kind_color(kind, &th);
let tint = chip_tint(&th, color);
assert_eq!(tint.rule, Some(color), "{kind:?} rule is the category hue");
let from_panel = (tint.fill.r() as i32 - th.panel.r() as i32).abs()
+ (tint.fill.g() as i32 - th.panel.g() as i32).abs()
+ (tint.fill.b() as i32 - th.panel.b() as i32).abs();
assert!(
from_panel < 60,
"{kind:?} chip fill drifted away from the panel colour ({from_panel})"
);
}
}
#[test]
fn chips_that_should_not_show_a_rule_do_not() {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let mut chip = Chip::base("GET".to_string(), th.accent);
assert!(
chip_colors(&th, &chip, false).rule.is_some(),
"an ordinary base chip shows its category"
);
assert!(
chip_colors(&th, &chip, true).rule.is_none(),
"a selected chip drops the rule"
);
chip.join_prev = true;
assert!(
chip_colors(&th, &chip, false).rule.is_none(),
"the hanger of a tethered pair drops the rule"
);
}
fn section_text(job: &egui::text::LayoutJob, i: usize) -> &str {
let r = &job.sections[i].byte_range;
&job.text[usize::from(r.start)..usize::from(r.end)]
}
#[test]
fn a_chip_label_sets_the_keyword_and_the_identifier_in_different_faces() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
{
let col = Color32::WHITE;
let job = chip_label_job(ui, "BASELINE(staging)", col);
assert_eq!(job.sections.len(), 2, "keyword and identifier");
assert_eq!(section_text(&job, 0), "BASELINE");
assert_eq!(section_text(&job, 1), "(staging)");
assert_eq!(
job.sections[1].format.font_id.family,
egui::FontFamily::Monospace,
"the user's own name is set in the monospace face"
);
assert_ne!(
job.sections[0].format.font_id.family,
egui::FontFamily::Monospace,
"the keyword stays in the UI face"
);
let job = chip_label_job(ui, "REQUEST orders", col);
assert_eq!(section_text(&job, 0), "REQUEST");
assert_eq!(
job.sections[1].format.font_id.family,
egui::FontFamily::Monospace
);
let job = chip_label_job(ui, "PARALLEL", col);
assert_eq!(job.sections.len(), 1);
assert_ne!(
job.sections[0].format.font_id.family,
egui::FontFamily::Monospace
);
}
});
}
#[test]
fn the_synthetic_sentinels_are_captions_not_keywords() {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let s = Strings::for_language(&Language::English);
assert_ne!(s.report_node_begin, "BEGIN");
assert_ne!(s.report_node_end, "END");
for lang in [Language::English, Language::French, Language::Danish] {
let s = Strings::for_language(&lang);
assert_ne!(
s.report_node_end, "END",
"the synthetic end must not collide with the real keyword"
);
}
assert_ne!(
th.dim, th.accent,
"the test below is only meaningful if the two colours differ"
);
}
#[test]
fn an_inline_field_grows_to_fit_its_value_but_not_without_limit() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let min = LOOP_PATH_FIELD_WIDTH;
let empty = fitted_field_width(ui, "", "", min, FIELD_MAX_WIDTH);
assert_eq!(empty, min, "an empty field rests at its minimum");
let short = fitted_field_width(ui, "envs", "", min, FIELD_MAX_WIDTH);
assert_eq!(short, min, "a value that already fits doesn't shrink it");
let long = fitted_field_width(
ui,
"reports/2026/quarterly/regional-breakdowns",
"",
min,
FIELD_MAX_WIDTH,
);
assert!(
long > min,
"a value wider than the box widens it ({long} vs {min})"
);
let absurd = fitted_field_width(ui, &"x".repeat(500), "", min, FIELD_MAX_WIDTH);
assert_eq!(
absurd, FIELD_MAX_WIDTH,
"growth stops at the cap rather than running off the pane"
);
let hinted = fitted_field_width(ui, "", "*.json", 10.0, FIELD_MAX_WIDTH);
assert!(hinted > 10.0, "an empty field makes room for its hint");
});
}
#[test]
fn a_fields_placeholder_is_short_and_its_explanation_is_not_the_same_string() {
for lang in [Language::English, Language::French, Language::Danish] {
let s = Strings::for_language(&lang);
for hint in [
s.gui_report_loop_var_hint,
s.gui_report_loop_dir_hint,
s.gui_report_loop_glob_hint,
s.gui_report_alias_hint,
] {
assert!(
hint.chars().count() <= 12,
"{lang:?}: placeholder {hint:?} is too long to fit its box"
);
}
assert_ne!(
s.gui_report_loop_glob_hint, s.chip_help_loop_glob,
"{lang:?}: the placeholder must not be the explanation"
);
assert!(
s.chip_help_loop_glob.chars().count() > 40,
"{lang:?}: the MATCH explanation should say what a pattern is"
);
assert!(
s.chip_help_loop_pick_folder != s.chip_help_loop_dir,
"{lang:?}: the picker button explains itself, not the box"
);
}
}
#[test]
fn the_source_view_colours_match_the_terminal_uis_exactly() {
let spec = crate::theme::preset_for_language(&Language::English);
let th = GuiTheme::from_spec(&spec);
let theme = spec.to_theme();
let ctx = HlCtx::default();
let text = "# collection: api.hurl\nFOR f IN FILES \"*.json\"\n REQUEST Health\nEND\n";
let job = highlight_job(
text,
&ctx,
&spec,
&th,
egui::FontId::monospace(12.0),
f32::INFINITY,
);
let mut expected: Vec<egui::Color32> = Vec::new();
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
expected.push(th.text);
}
for span in report_highlight::highlight_row(i, line, &ctx, &theme) {
let c = span
.style
.fg
.map_or(th.text, |c| super::super::theme::from_ratatui(c, th.text));
expected.extend(std::iter::repeat_n(c, span.content.len()));
}
}
assert_eq!(
expected.len(),
job.text.len(),
"every byte of the source is covered exactly once"
);
for section in &job.sections {
let (start, end) = byte_span(section);
for (b, want) in expected.iter().enumerate().take(end).skip(start) {
assert_eq!(
section.format.color, *want,
"byte {b} of {text:?} is coloured like the terminal UI"
);
}
}
let distinct: std::collections::HashSet<_> =
job.sections.iter().map(|s| s.format.color).collect();
assert!(
distinct.len() > 2,
"the source is multi-coloured, not flat: {distinct:?}"
);
}
#[test]
fn the_line_the_parser_rejected_is_underlined() {
let spec = crate::theme::preset_for_language(&Language::English);
let th = GuiTheme::from_spec(&spec);
let text = "REQUEST Health\nOOPS not papertrail\nREQUEST Other\n";
let ctx = HlCtx {
error_line: Some(2),
..Default::default()
};
let job = highlight_job(
text,
&ctx,
&spec,
&th,
egui::FontId::monospace(12.0),
f32::INFINITY,
);
let bad_start = text.find("OOPS").unwrap();
let bad_end = bad_start + "OOPS not papertrail".len();
for section in &job.sections {
let underlined = section.format.underline != egui::Stroke::NONE;
let (start, end) = byte_span(section);
let overlaps = start < bad_end && bad_start < end;
assert_eq!(
underlined, overlaps,
"only the rejected line is underlined (bytes {start}..{end})"
);
}
}
fn byte_span(section: &egui::text::LayoutSection) -> (usize, usize) {
(
usize::from(section.byte_range.start),
usize::from(section.byte_range.end),
)
}
fn style_like_the_app(ctx: &egui::Context) {
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
ctx.all_styles_mut(|s| {
for (_, font) in s.text_styles.iter_mut() {
font.size *= 1.08;
}
});
}
fn chip_height(build: impl Fn(&mut egui::Ui, &GuiTheme, &Strings, &mut Vec<Act>)) -> f32 {
let ctx = egui::Context::default();
style_like_the_app(&ctx);
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::for_language(&Language::English);
let mut h = 0.0;
for _ in 0..3 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.horizontal(|ui| {
let mut acts = Vec::new();
let r = ui.scope(|ui| build(ui, &th, &s, &mut acts));
h = r.response.rect.height();
});
});
}
h
}
fn pair_rects(chips: &[Chip]) -> Vec<egui::Rect> {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::for_language(&Language::English);
let mut rects = Vec::new();
for _ in 0..3 {
rects.clear();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.horizontal_top(|ui| {
let gap = ui.spacing().item_spacing.x;
let mut acts = Vec::new();
for chip in chips {
if chip.join_next {
ui.spacing_mut().item_spacing.x = TETHER_GAP;
}
rects.push(render_chip(
ui,
&th,
&s,
chip,
false,
&[0],
&[],
&[],
&mut acts,
));
ui.spacing_mut().item_spacing.x = gap;
}
});
});
}
rects
}
#[test]
fn the_halves_of_a_tethered_pill_are_rendered_touching() {
let pair = |tether: bool| {
let show = Chip::modifier(
"SHOW(Time)".into(),
Color32::GREEN,
DetachWhich::BaselineShow,
);
let mut chips = vec![
Chip::modifier(
"BASELINE(prod)".into(),
Color32::RED,
DetachWhich::Statistics,
),
if tether { show.tether() } else { show },
];
link_tethers(&mut chips);
let r = pair_rects(&chips);
r[1].left() - r[0].right()
};
assert_eq!(
pair(true),
0.0,
"the tethered half sits flush against the chip it qualifies, so their \
borders meet as the pill's divider"
);
assert!(
pair(false) > 0.0,
"while an untethered neighbour keeps the normal gap — otherwise every \
chip on the line would look joined"
);
}
#[test]
fn all_chip_kinds_render_at_the_same_height() {
let label = chip_height(|ui, th, s, acts| {
let chip = Chip::base("REPORT".into(), th.subst);
render_chip(ui, th, s, &chip, false, &[0], &[], &[], acts);
});
let combo = chip_height(|ui, th, s, acts| {
let chip = Chip::request("oauth2", th.subst);
let titles = vec!["oauth2".to_string()];
render_chip(ui, th, s, &chip, false, &[0], &titles, &[], acts);
});
let alias = chip_height(|ui, th, s, acts| {
let chip = Chip::alias("Env", th.subst, Some(DetachWhich::As));
render_chip(ui, th, s, &chip, false, &[0], &[], &[], acts);
});
assert!(label > 0.0 && combo > 0.0 && alias > 0.0);
assert_eq!(
label, combo,
"a combo chip must be exactly as tall as a label chip"
);
let parallel = chip_height(|ui, th, s, acts| {
let chip = Chip::parallel(Some(4), th.err);
render_chip(ui, th, s, &chip, false, &[0], &[], &[], acts);
});
assert!(parallel > 0.0);
assert_eq!(
label, alias,
"an inline text field must be exactly as tall as a label chip"
);
assert_eq!(
label, parallel,
"the PARALLEL field must be exactly as tall as a label chip"
);
let flow_end = chip_height(|ui, th, s, _acts| flow_end_row(ui, th, s));
assert_eq!(
label, flow_end,
"the closing END must be exactly as tall as a label chip"
);
}
#[test]
fn the_flow_is_bracketed_by_begin_and_a_matching_end() {
let s = Strings::for_language(&Language::English);
assert_ne!(
s.chip_help_flow_end, s.chip_help_end,
"the flow's END explains itself, not the loop END"
);
assert!(
s.chip_help_flow_end.contains("END"),
"the help names the block it describes"
);
let begin = chip_height(|ui, th, s, _acts| {
static_chip(ui, th, s.report_node_begin, th.accent, s.chip_help_begin)
});
let end = chip_height(|ui, th, s, _acts| flow_end_row(ui, th, s));
assert!(begin > 0.0 && end > 0.0);
assert!(
(begin - end).abs() < 0.5,
"BEGIN {begin} and END {end} are the same size"
);
}
#[test]
fn the_settings_strip_covers_every_header_directive_the_language_has() {
let flow = crate::report::parse_flow(
"# collection: c.hurl\n# output: o.csv\n# environment: dev\n# root: /r\n\
# baseline: b.baseline\n# columns: a,b\n# labels: Pass = ok\nREQUEST login\n",
)
.expect("fixture parses");
let specs = header_specs();
for spec in &specs {
assert!(
flow.header.get(spec.key).is_some(),
"{} is a real directive",
spec.key
);
}
assert_eq!(
specs
.iter()
.filter(|sp| sp.always_shown)
.map(|sp| sp.key)
.collect::<Vec<_>>(),
["collection", "output"],
"only collection and output are always shown; the rest are opt-in"
);
assert_eq!(
specs
.iter()
.filter(|sp| sp.required)
.map(|sp| sp.key)
.collect::<Vec<_>>(),
["collection"],
"only a missing collection actually stops the report running"
);
let s = Strings::for_language(&Language::English);
let helps: Vec<&str> = specs
.iter()
.map(|sp| crate::report::edit::header_help(sp.key, &s))
.collect();
for (i, h) in helps.iter().enumerate() {
assert!(!h.is_empty(), "{} has help", specs[i].key);
assert!(
helps.iter().filter(|o| o == &h).count() == 1,
"{} has help of its own",
specs[i].key
);
}
}
#[test]
fn every_addable_setting_starts_at_a_value_that_survives_being_set() {
assert!(
!crate::report::edit::HEADER_PLACEHOLDER.is_empty(),
"an added setting would be dropped again the moment it was added"
);
assert!(header_specs().iter().any(|sp| !sp.always_shown));
}
#[test]
fn the_output_setting_offers_only_the_formats_the_runner_can_write() {
let spec = header_specs()
.into_iter()
.find(|sp| sp.key == "output")
.expect("output is a setting");
assert!(
matches!(spec.kind, HeaderKind::Format),
"output is chosen from a list, not typed or browsed for"
);
for ext in crate::report::writer::OUTPUT_EXTENSIONS {
assert!(
crate::report::writer::writer_for_extension(ext).is_some(),
"{ext} has a writer"
);
let flow = crate::report::parse_flow(&format!(
"# collection: c.hurl\n# output: {ext}\nREQUEST a\n"
))
.expect("parses");
assert!(
crate::report::validate::validate(
&flow,
&crate::report::validate::Context::default(),
)
.iter()
.all(|d| !d.message.contains("unsupported output")),
"{ext} is accepted by the validator"
);
}
let paths: Vec<&str> = header_specs()
.iter()
.filter(|sp| sp.kind.is_path())
.map(|sp| sp.key)
.collect();
assert_eq!(paths, ["root", "baseline"]);
}
#[test]
fn the_run_settings_chip_shows_the_values_it_will_run_with() {
const TRAIL: &str = "\
# name: Face
# collection: c.hurl
PARAM TEXT TICKET LABEL \"Ticket number\"
PARAM CHOICE(\"au\", \"eu\") REGION = \"au\"
REPORT REQUEST x
";
let mut app = crate::gui::app::GuiApp::for_test(crate::session::Session::default());
app.open_report_editor(
ReportOrigin::Workspace,
crate::report::Report::from_text("Face", TRAIL),
);
let ed = app.report_editor.take().unwrap();
let s = Strings::for_language(&Language::English);
let th = app.theme;
let ctx = egui::Context::default();
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
param_chip(ui, &ed, &app, &th);
});
let painted: String = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect();
assert!(
painted.contains(s.gui_report_run_settings),
"the chip is labelled with the words of the box it opens: {painted:?}"
);
assert!(
painted.contains("REGION=au"),
"an answered value is shown: {painted:?}"
);
assert!(
painted.contains(&format!("TICKET={}", s.param_value_unset)),
"and an unanswered one is called out: {painted:?}"
);
}
#[test]
fn the_chip_says_when_the_answers_moved_on_since_the_result() {
const TRAIL: &str = "\
# name: Face
# collection: c.hurl
PARAM CHOICE(\"au\", \"eu\") REGION = \"au\"
REPORT REQUEST x
";
let mut app = crate::gui::app::GuiApp::for_test(crate::session::Session::default());
app.open_report_editor(
ReportOrigin::Workspace,
crate::report::Report::from_text("Face", TRAIL),
);
let ed = app.report_editor.as_mut().unwrap();
let s = Strings::for_language(&Language::English);
assert!(
!ed.params_changed_since_result(&s),
"nothing has been run, so nothing has moved on"
);
ed.result_params = ed
.param_rows(&s)
.iter()
.map(|r| (r.name.clone(), r.value.clone()))
.collect();
ed.result = Some(crate::report::model::ReportResult::default());
assert!(
!ed.params_changed_since_result(&s),
"the table on screen was made with exactly these answers"
);
ed.param_values
.insert("REGION".to_string(), "eu".to_string());
assert!(
ed.params_changed_since_result(&s),
"changing an answer leaves the table on screen out of date"
);
}
#[test]
fn the_add_setting_button_says_what_it_adds() {
let ctx = egui::Context::default();
let s = Strings::for_language(&Language::English);
let specs = header_specs();
let missing: Vec<&HeaderSpec> = specs.iter().filter(|sp| !sp.always_shown).collect();
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut acts = Vec::new();
header_add_menu(ui, &s, &missing, 1, 0, &mut acts);
});
let painted: String = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
_ => None,
})
.collect();
assert!(
painted.contains(s.report_add_setting),
"the button is labelled, not a bare glyph (painted: {painted:?})"
);
for lang in [Language::English, Language::French, Language::Danish] {
let s = Strings::for_language(&lang);
assert!(
s.report_add_setting.split_whitespace().count() >= 3,
"{lang:?} label {:?} names what it adds",
s.report_add_setting
);
}
}
#[test]
fn the_settings_are_stacked_and_flush_with_the_flow() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
let specs = header_specs();
let choices = vec!["dev".to_string()];
let mut rows: Vec<egui::Rect> = Vec::new();
let mut frame_rect = egui::Rect::NOTHING;
let mut begin_rect = egui::Rect::NOTHING;
for _ in 0..3 {
rows.clear();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.vertical(|ui| {
frame_rect = settings_frame(&th)
.show(ui, |ui| {
for spec in &specs {
let r = ui.horizontal(|ui| {
let mut acts = Vec::new();
header_chip(
ui,
&th,
&s,
spec,
0,
"dev",
Some(&choices),
&collection_choices_fixture,
&mut acts,
);
});
rows.push(r.response.rect);
}
})
.response
.rect;
begin_rect = ui
.horizontal_top(|ui| {
static_chip(ui, &th, s.report_node_begin, th.accent, s.chip_help_begin)
})
.response
.rect;
});
});
}
let left = rows[0].left();
for (i, r) in rows.iter().enumerate() {
assert!(
(r.left() - left).abs() < 0.5,
"row {i} starts at {} but the first starts at {left}",
r.left()
);
}
for pair in rows.windows(2) {
assert!(
pair[1].top() >= pair[0].bottom() - 0.5,
"rows overlap: {:?} then {:?}",
pair[0],
pair[1]
);
}
assert!(
(frame_rect.left() - begin_rect.left()).abs() < 0.5,
"the settings panel starts at {} but BEGIN starts at {}",
frame_rect.left(),
begin_rect.left()
);
assert!(
frame_rect.bottom() <= begin_rect.top() + 0.5,
"the settings panel overlaps the flow"
);
}
#[test]
fn the_collection_dropdown_shows_names_but_stores_relative_paths() {
let root = std::env::temp_dir().join(format!("paperboy_cc_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("apis")).expect("scratch workspace");
std::fs::write(
root.join("apis/billing.hurl"),
"GET https://x
",
)
.expect("collection");
std::fs::write(
root.join("smoke.hurl"),
"GET https://x
",
)
.expect("collection");
std::fs::write(
root.join("nightly.trail"),
"# collection: smoke.hurl
",
)
.expect("report");
std::fs::write(
root.join("dev.vars"),
"K=v
",
)
.expect("env");
let report = root.join("nightly.trail");
let choices = collection_choices(Some(&root), Some(&report), &[], "unsaved");
let labels: Vec<&str> = choices.iter().map(|c| c.label.as_str()).collect();
assert_eq!(
labels,
["billing", "smoke"],
"collections are listed by name, and only collections are listed"
);
let values: Vec<&str> = choices.iter().map(|c| c.value.as_str()).collect();
assert_eq!(
values,
[
std::path::Path::new("apis/billing.hurl")
.to_string_lossy()
.as_ref(),
"smoke.hurl"
],
"the stored value stays a path, relative to the report so the pair stays portable"
);
assert!(
choices.iter().all(|c| c.in_workspace),
"everything found by scanning the workspace is in the workspace"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_collection_in_a_sibling_folder_is_referenced_relatively_so_it_stays_portable() {
let root = std::path::Path::new("/w");
let report = std::path::Path::new("/w/reports/nightly.trail");
let target = std::path::Path::new("/w/apis/billing.hurl");
assert_eq!(
portable_ref(target, Some(report), Some(root)),
"../apis/billing.hurl",
"it walks up out of reports/ and back down into apis/"
);
assert_eq!(
portable_ref(
std::path::Path::new("/w/reports/sub/c.hurl"),
Some(report),
Some(root)
),
"sub/c.hurl",
"a collection under the report is named directly"
);
assert_eq!(
portable_ref(
std::path::Path::new("/elsewhere/legacy.hurl"),
Some(report),
Some(root)
),
"/elsewhere/legacy.hurl",
"nothing outside the workspace is made to look relative to it"
);
assert_eq!(
portable_ref(target, Some(report), None),
"/w/apis/billing.hurl",
"without a workspace there is no scope to stay inside"
);
assert_eq!(
crate::report::context::resolve_ref_path(Some(report), "../apis/billing.hurl"),
std::path::PathBuf::from("/w/reports/../apis/billing.hurl"),
"the relative ref resolves against the report's folder"
);
}
#[test]
fn open_collections_outside_the_workspace_are_offered_but_marked_as_such() {
let root = std::env::temp_dir().join(format!("paperboy_cc2_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("scratch workspace");
std::fs::write(
root.join("smoke.hurl"),
"GET https://x
",
)
.expect("collection");
let report = root.join("nightly.trail");
let mut inside = crate::collection::Collection::new("smoke".to_string(), Vec::new());
inside.path = Some(root.join("smoke.hurl"));
let mut outside = crate::collection::Collection::new("legacy".to_string(), Vec::new());
outside.path = Some(std::path::PathBuf::from("/elsewhere/legacy.hurl"));
let scratch = crate::collection::Collection::new("Untitled".to_string(), Vec::new());
let choices = collection_choices(
Some(&root),
Some(&report),
&[inside, outside, scratch],
"unsaved",
);
assert_eq!(
choices.iter().filter(|c| c.label == "smoke").count(),
1,
"a collection both open and in the workspace is offered once, not twice"
);
let legacy = choices
.iter()
.find(|c| c.label == "legacy")
.expect("an open collection outside the workspace is still offered");
assert!(!legacy.in_workspace, "it is not in the workspace");
assert_eq!(
legacy.value, "/elsewhere/legacy.hurl",
"with nowhere shorter to be relative to, the path stays absolute"
);
let scratch = choices
.iter()
.find(|c| c.label == "Untitled")
.expect("an unsaved collection is offered");
assert_eq!(
(scratch.value.as_str(), scratch.detail.as_str()),
("Untitled", "unsaved"),
"with no path to write it can only be referenced by name, and says so"
);
assert!(
choices[0].in_workspace,
"the workspace's own collections are offered first"
);
let _ = std::fs::remove_dir_all(&root);
}
fn collection_choices_fixture() -> Vec<CollectionChoice> {
vec![CollectionChoice {
value: "c.hurl".to_string(),
label: "c".to_string(),
detail: "c.hurl".to_string(),
in_workspace: true,
}]
}
fn run_settings_panel(
ctx: &egui::Context,
set: &[(&str, &str)],
avail: f32,
) -> (egui::Rect, Vec<(String, egui::Rect)>) {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
let envs = vec!["dev".to_string()];
let formats: Vec<String> = crate::report::writer::OUTPUT_EXTENSIONS
.iter()
.map(|e| e.to_string())
.collect();
let owned: Vec<(String, String)> = set
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let value_of = move |key: &str| -> Vec<String> {
owned
.iter()
.filter(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.collect()
};
let mut rect = egui::Rect::NOTHING;
let mut out = None;
for _ in 0..3 {
let full = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.set_max_width(avail);
let mut acts = Vec::new();
rect = ui
.scope(|ui| {
settings_panel(
ui,
&th,
&s,
&value_of,
&collection_choices_fixture,
&envs,
&formats,
&mut acts,
);
})
.response
.rect;
});
out = Some(full);
}
let mut texts = Vec::new();
fn walk(sh: &egui::Shape, out: &mut Vec<(String, egui::Rect)>) {
match sh {
egui::Shape::Text(t) => {
out.push((
t.galley.text().to_string(),
egui::Rect::from_min_size(t.pos, t.galley.size()),
));
}
egui::Shape::Vec(v) => v.iter().for_each(|sh| walk(sh, out)),
_ => {}
}
}
for c in &out.expect("rendered").shapes {
walk(&c.shape, &mut texts);
}
(rect, texts)
}
#[test]
fn the_add_setting_button_sits_below_the_settings_it_adds_to() {
let ctx = egui::Context::default();
let s = Strings::for_language(&Language::English);
let (_, texts) = run_settings_panel(&ctx, &[("collection", "c.hurl")], 600.0);
let button = texts
.iter()
.find(|(t, _)| t.contains(s.report_add_setting))
.map(|(_, r)| *r)
.expect("the add button is drawn while settings are still missing");
let collection = texts
.iter()
.find(|(t, _)| t == "COLLECTION")
.map(|(_, r)| *r)
.expect("the collection setting is drawn");
assert!(
button.top() >= collection.bottom(),
"the add button at {} should sit below the settings ending at {}",
button.top(),
collection.bottom()
);
let (_, full) = run_settings_panel(
&ctx,
&[
("collection", "c.hurl"),
("output", "csv"),
("environment", "dev"),
("root", "/r"),
("baseline", "b.baseline"),
("columns", "a,b"),
],
600.0,
);
assert!(
full.iter().any(|(t, _)| t.contains(s.report_add_setting)),
"a helper collection can still be added"
);
}
#[test]
fn the_settings_panel_keeps_its_width_whatever_it_holds() {
let ctx = egui::Context::default();
let (bare, _) = run_settings_panel(&ctx, &[("collection", "c.hurl")], 600.0);
let (full, _) = run_settings_panel(
&ctx,
&[
("collection", "c.hurl"),
("output", "xlsx"),
("environment", "dev"),
(
"root",
"/a/very/long/root/path/that/would/otherwise/stretch/things",
),
("baseline", "/another/rather/long/baseline/path.baseline"),
("columns", "name,status,duration,size,assertions"),
],
600.0,
);
assert!(
(bare.width() - full.width()).abs() < 0.5,
"one setting gives width {} but six give {}",
bare.width(),
full.width()
);
assert!(
bare.width() > 500.0,
"the panel fills the 600px pane rather than sitting in a corner of it, got {}",
bare.width()
);
let (narrow, _) = run_settings_panel(&ctx, &[("collection", "c.hurl")], 120.0);
assert!(
narrow.width() < bare.width(),
"a 120px column still gave the panel {} (it takes {} when there is room)",
narrow.width(),
bare.width()
);
}
#[test]
fn a_hovered_chip_deepens_its_own_colour() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let mut chip = Chip::base("REQUEST login".into(), th.accent);
let plain = chip_colors(&th, &chip, false);
chip.hovered = true;
let lit = chip_colors(&th, &chip, false);
let dist = |c: Color32| {
let d = |a: u8, b: u8| (a as f32 - b as f32).abs();
d(c.r(), th.panel.r()) + d(c.g(), th.panel.g()) + d(c.b(), th.panel.b())
};
assert!(
dist(lit.fill) > dist(plain.fill) + 1.0,
"hovered fill {:?} is no deeper than {:?} against panel {:?}",
lit.fill,
plain.fill,
th.panel
);
assert!(
dist(lit.stroke.color) > dist(plain.stroke.color) + 1.0,
"the edge follows the fill so it doesn't vanish into it"
);
assert_eq!(
lit.text, plain.text,
"the label is left alone — a bolder-looking label reads as a different kind of chip"
);
assert_eq!(lit.rule, plain.rule, "the colour rule still identifies it");
let sel = chip_colors(&th, &chip, true);
assert_eq!(sel.fill, th.select_bg);
}
#[test]
fn a_settings_key_label_is_centred_against_its_dropdown() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let strings = Strings::for_language(&Language::English);
let choices = vec!["dev".to_string()];
for spec in &header_specs() {
let mut label = egui::Rect::NOTHING;
let mut chip = egui::Rect::NOTHING;
for _ in 0..3 {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut acts = Vec::new();
chip = ui
.horizontal(|ui| {
header_chip(
ui,
&th,
&strings,
spec,
0,
"dev",
Some(&choices),
&collection_choices_fixture,
&mut acts,
);
})
.response
.rect;
});
let want = spec.key.to_uppercase();
label = out
.shapes
.iter()
.find_map(|c| match &c.shape {
egui::Shape::Text(t) if t.galley.text() == want => {
Some(egui::Rect::from_min_size(t.pos, t.galley.size()))
}
_ => None,
})
.unwrap_or_else(|| panic!("{} label was not painted", spec.key));
}
assert!(
(label.center().y - chip.center().y).abs() < 2.0,
"{}: label centred at {} but the chip at {}",
spec.key,
label.center().y,
chip.center().y
);
}
}
#[test]
fn settings_chips_render_at_the_same_height_as_flow_chips() {
let specs = header_specs();
let baseline = chip_height(|ui, th, s, acts| {
let chip = Chip::base("REPORT".into(), th.subst);
render_chip(ui, th, s, &chip, false, &[0], &[], &[], acts);
});
for spec in &specs {
let choices = vec!["dev".to_string()];
let h = chip_height(|ui, th, s, acts| {
header_chip(
ui,
th,
s,
spec,
0,
"dev",
Some(&choices),
&collection_choices_fixture,
acts,
);
});
assert!(
(h - baseline).abs() < 1.0,
"{} chip is {h}, flow chips are {baseline}",
spec.key
);
}
}
#[test]
fn a_picked_path_is_stored_relative_to_the_report_when_it_can_be() {
let report = std::path::Path::new("/w/reports/daily.paper");
assert_eq!(
relative_to_report(std::path::Path::new("/w/reports/out.csv"), Some(report)),
"out.csv",
"a sibling file travels with the report"
);
assert_eq!(
relative_to_report(std::path::Path::new("/elsewhere/out.csv"), Some(report)),
"/elsewhere/out.csv",
"anything outside the report's folder keeps its absolute path"
);
assert_eq!(
relative_to_report(std::path::Path::new("/w/out.csv"), None),
"/w/out.csv",
"an unsaved report has nothing to be relative to"
);
}
#[test]
fn every_chip_a_block_can_show_carries_hover_help() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
let sources = [
"REQUEST login",
"REPORT REQUEST login AS Login RESPONSE PRETTY SHOW(Time) HIDE(Error)",
"REPORT userId",
"REPORT userId AS Id",
"REPORT \"{{a}}-{{b}}\" AS Combined",
"TOKEN = abc",
"LIST NAMES = [\"a\", \"b\"]",
"PARALLEL(3) FOR F IN FILES \"/d\"",
"FOR T IN ENVS BASELINE(\"dev\"), COMPARISON(\"prod\")",
"FOR T IN ENVS BASELINE(FILE(\"a.baseline\")), COMPARISON(\"prod\", \"uat\")",
];
for src in sources {
let node = crate::report::edit::parse_one_node(src, true)
.unwrap_or_else(|| panic!("could not parse {src:?}"));
for chip in node_chips(&node, None, &th, &s) {
assert!(
!chip.help.is_empty(),
"chip {:?} of {src:?} has no hover help",
chip.text
);
}
}
}
#[test]
fn a_parallel_loop_shows_an_editable_degree_chip() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
let node =
crate::report::edit::parse_one_node("PARALLEL(4) FOR F IN FILES \"/d\"", true).unwrap();
let chips = node_chips(&node, None, &th, &s);
assert!(matches!(
chips[0].edit,
ChipEdit::Parallel { degree: Some(4) }
));
assert!(
!chips[1].text.contains("PARALLEL"),
"base chip repeated the PARALLEL prefix: {:?}",
chips[1].text
);
let node =
crate::report::edit::parse_one_node("PARALLEL FOR F IN FILES \"/d\"", true).unwrap();
let chips = node_chips(&node, None, &th, &s);
assert!(matches!(chips[0].edit, ChipEdit::Parallel { degree: None }));
let node = crate::report::edit::parse_one_node("FOR F IN FILES \"/d\"", true).unwrap();
let chips = node_chips(&node, None, &th, &s);
assert!(
!chips
.iter()
.any(|c| matches!(c.edit, ChipEdit::Parallel { .. }))
);
}
#[test]
fn chips_on_a_line_stay_vertically_aligned() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::for_language(&Language::English);
let mut tops: Vec<f32> = Vec::new();
for _ in 0..3 {
tops.clear();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.horizontal_top(|ui| {
let mut acts = Vec::new();
let staging = vec!["api_staging".to_string()];
let dev = vec!["api_dev".to_string()];
let classify = vec!["classify_result".to_string()];
let chips: Vec<(Chip, &[String])> = vec![
(Chip::base("FOR".into(), th.subst), &[]),
(Chip::env_role(true, 0, "api_staging", th.subst), &staging),
(Chip::env_role(false, 1, "api_dev", th.subst), &dev),
(
Chip::modifier(
"RESPONSE PRETTY".into(),
th.subst,
DetachWhich::Response,
),
&[],
),
(Chip::request("classify_result", th.subst), &classify),
(
Chip::alias("Environment", th.subst, Some(DetachWhich::As)),
&[],
),
];
for (chip, envs) in &chips {
let r = ui.scope(|ui| {
render_chip(ui, &th, &s, chip, false, &[0], envs, envs, &mut acts)
});
tops.push(r.response.rect.top());
}
});
});
}
let first = tops[0];
for (i, t) in tops.iter().enumerate() {
assert!(
(t - first).abs() < 0.5,
"chip {i} top {t} drifted from first chip top {first}: {tops:?}"
);
}
}
#[test]
fn selecting_a_block_keeps_its_size_and_position() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::for_language(&Language::English);
let mut unsel = egui::Rect::ZERO;
let mut sel = egui::Rect::ZERO;
for _ in 0..3 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let mut acts = Vec::new();
let chip = Chip::base("REQUEST".into(), th.subst);
unsel = ui
.horizontal_top(|ui| {
render_chip(ui, &th, &s, &chip, false, &[0], &[], &[], &mut acts)
})
.response
.rect;
sel = ui
.horizontal_top(|ui| {
render_chip(ui, &th, &s, &chip, true, &[0], &[], &[], &mut acts)
})
.response
.rect;
});
}
assert!(
(unsel.width() - sel.width()).abs() < 0.5,
"selected width {} vs unselected {}",
sel.width(),
unsel.width()
);
assert!(
(unsel.height() - sel.height()).abs() < 0.5,
"selected height {} vs unselected {}",
sel.height(),
unsel.height()
);
}
#[test]
fn pretty_json_cell_reflows_json_documents_only() {
let pretty = pretty_json_cell(r#"{"a":1,"b":[2,3]}"#);
assert!(pretty.contains('\n'), "object should be reflowed: {pretty}");
assert!(pretty.contains("\"a\": 1"));
assert_eq!(pretty_json_cell("42"), "42");
assert_eq!(pretty_json_cell("just text"), "just text");
assert_eq!(pretty_json_cell("{not json"), "{not json");
}
#[test]
fn dragged_row_path_tracks_only_row_drags() {
let ctx = egui::Context::default();
assert_eq!(dragged_row_path(&ctx), None, "no drag → no lifted row");
egui::DragAndDrop::set_payload(&ctx, DragItem::Row(vec![1, 2]));
assert_eq!(dragged_row_path(&ctx), Some(vec![1, 2]));
egui::DragAndDrop::set_payload(
&ctx,
DragItem::Chip {
path: vec![0],
which: DetachWhich::Report,
},
);
assert_eq!(
dragged_row_path(&ctx),
None,
"a modifier-chip drag must not lift a row"
);
}
#[test]
fn a_named_column_shows_its_statistics_and_keeps_report_attached() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::english();
let flow = crate::report::parse_flow("REPORT TIER AS Plan STATISTICS(MEAN)\n")
.expect("fixture parses");
let chips = node_chips(&flow.nodes[0], None, &th, s);
assert!(
chips.iter().any(|c| c.text.contains("STATISTICS(MEAN)")),
"the statistics clause is drawn: {:?}",
chips.iter().map(|c| &c.text).collect::<Vec<_>>()
);
let report = chips
.iter()
.find(|c| c.text == "REPORT")
.expect("the REPORT chip is drawn");
assert!(
report.detach.is_none(),
"REPORT is load-bearing here, so grabbing it moves the whole row"
);
}
#[test]
fn a_named_column_shows_all_four_of_its_clauses() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::english();
let flow = crate::report::parse_flow(
"REPORT SHOT AS Frame STATISTICS(COUNT) IMAGE(HEIGHT 110) TRUTH \"pass\" DETAIL\n",
)
.expect("fixture parses");
let chips = node_chips(&flow.nodes[0], None, &th, s);
let texts: Vec<&String> = chips.iter().map(|c| &c.text).collect();
for want in [
"STATISTICS(COUNT)",
"IMAGE(HEIGHT 110)",
"TRUTH \"pass\"",
"DETAIL",
] {
assert!(
texts.iter().any(|t| t.as_str() == want),
"{want} is drawn: {texts:?}"
);
}
}
#[test]
fn detaching_one_clause_leaves_the_column_and_its_other_clauses() {
let mut flow = crate::report::parse_flow(
"REPORT SHOT AS Frame IMAGE(HEIGHT 110) TRUTH \"pass\" DETAIL\n",
)
.expect("fixture parses");
crate::report::edit::detach_modifier(&mut flow, &[0], DetachWhich::Truth);
let text = flow.to_text();
assert!(!text.contains("TRUTH"), "the truth is gone: {text}");
assert!(
text.contains("AS Frame")
&& text.contains("IMAGE(HEIGHT 110)")
&& text.contains("DETAIL"),
"and nothing else is: {text}"
);
}
#[test]
fn a_with_field_shows_its_clauses_in_its_row() {
let flow = crate::report::parse_flow(
"REPORT REQUEST svc WITH\n Frame: jsonpath \"$.f\" IMAGE TRUTH \"ok\" DETAIL\nEND\n",
)
.expect("fixture parses");
let crate::report::flow::FlowNode::Report(crate::report::flow::ReportStmt::Request {
with,
..
}) = &flow.nodes[0]
else {
panic!("fixture is a report request");
};
let crate::report::flow::WithItem::Field {
image,
truth,
detail,
..
} = &with[0]
else {
panic!("fixture's first item is a field");
};
assert!(
image.is_some() && truth.is_some() && *detail,
"parsed: {with:?}"
);
}
#[test]
fn the_drop_preview_is_exactly_the_chip_the_drop_will_add() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::english();
let flow = crate::report::parse_flow("REPORT REQUEST A\n").expect("fixture parses");
let node = &flow.nodes[0];
let pending = PendingMod::New(Modifier::Show);
let (idx, text, _) =
preview_chip(node, &pending, Some(true), &th, s).expect("SHOW adds a chip");
let before = node_chips(node, Some(true), &th, s);
assert_eq!(idx, before.len(), "SHOW appends to the end of the line");
let mut after_node = node.clone();
assert!(pending.apply(&mut after_node));
assert_eq!(
node_chips(&after_node, Some(true), &th, s)[idx]
.ghost_shape()
.0,
text,
"the ghost's label is the landed chip's label, so the gap is its size"
);
}
#[test]
fn the_palette_offers_every_block_that_cannot_be_composed() {
for kind in NodeKind::ALL {
if matches!(kind, NodeKind::ReportRequest) {
assert!(
!BASE_KINDS.contains(&kind),
"{kind:?} is composed from REQUEST + REPORT, not offered directly"
);
continue;
}
assert!(
BASE_KINDS.contains(&kind),
"{kind:?} has no other route into a report, so the palette must offer it"
);
}
}
#[test]
fn a_ghost_is_sized_from_what_the_chip_really_draws() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::english();
let flow = crate::report::parse_flow("FOR X IN FILES \"/a\"\n REQUEST A\nEND\n")
.expect("fixture parses");
let (_, text, extra) = preview_chip(
&flow.nodes[0],
&PendingMod::New(Modifier::Parallel),
None,
&th,
s,
)
.expect("PARALLEL adds a chip");
assert_eq!(
text, "PARALLEL",
"the keyword is in the ghost, not an empty box"
);
assert!(
extra >= PARALLEL_FIELD_WIDTH,
"the concurrency box's width is reserved too, got {extra}"
);
}
#[test]
fn a_refused_drop_previews_nothing() {
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = crate::i18n::Strings::english();
let flow =
crate::report::parse_flow("REPORT REQUEST A SHOW(Time)\n").expect("fixture parses");
let carried = crate::report::edit::carry_modifier(&flow.nodes[0], DetachWhich::Show)
.expect("the SHOW is there to pick up");
assert!(
preview_chip(
&flow.nodes[0],
&PendingMod::Moved(carried),
Some(true),
&th,
s
)
.is_none(),
"a request that already has SHOW opens no gap for another"
);
}
#[test]
fn dragged_chip_tracks_only_chip_drags() {
let ctx = egui::Context::default();
assert_eq!(dragged_chip(&ctx), None, "no drag → no lifted chip");
egui::DragAndDrop::set_payload(
&ctx,
DragItem::Chip {
path: vec![0, 3],
which: DetachWhich::BaselineShow,
},
);
assert_eq!(
dragged_chip(&ctx),
Some((vec![0, 3], DetachWhich::BaselineShow)),
"the picked-up chip is identified by its row and its clause"
);
egui::DragAndDrop::set_payload(&ctx, DragItem::Row(vec![0, 3]));
assert_eq!(
dragged_chip(&ctx),
None,
"a whole-line drag must not also lift a chip out of that line"
);
}
#[test]
fn a_tethered_chip_gets_a_seam_to_be_read_apart_by() {
let th = GuiTheme::from_spec(&crate::theme::default_preset());
let mut chips = vec![
Chip::alias("Result", th.pending, None),
Chip::modifier("TRUTH(\"Expected\")".into(), th.subst, DetachWhich::Truth).tether(),
];
link_tethers(&mut chips);
assert!(
chip_colors(&th, &chips[0], false).seam.is_none(),
"the anchor opens the pill, so nothing is drawn down its leading edge"
);
let hanger = chip_colors(&th, &chips[1], false);
assert!(
hanger.seam.is_some(),
"the hanger's leading edge is the seam between the two segments"
);
assert!(
hanger.rule.is_none(),
"and the category bar goes, since inside a pill it reads as a divider"
);
}
#[test]
fn hovering_a_detachable_chip_scopes_the_highlight_to_that_chip() {
let modi = Chip::modifier(
"PARALLEL(8)".into(),
egui::Color32::WHITE,
DetachWhich::Parallel,
);
let base = Chip::base("FOR f IN FILES".into(), egui::Color32::WHITE);
let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(80.0, 20.0));
let over = egui::RawInput {
events: vec![egui::Event::PointerMoved(rect.center())],
..Default::default()
};
let ctrl_over = egui::RawInput {
modifiers: egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
},
events: vec![egui::Event::PointerMoved(rect.center())],
..Default::default()
};
let recorded = |input: egui::RawInput, chip: &Chip| {
let ctx = egui::Context::default();
let _ = ctx.run_ui(input.clone(), |ui| record_chip_hover(ui, chip, rect));
ctx.data_mut(|d| d.remove_temp::<Option<egui::Rect>>(chip_hover_id()));
let _ = ctx.run_ui(input, |ui| record_chip_hover(ui, chip, rect));
ctx.data(|d| d.get_temp::<Option<egui::Rect>>(chip_hover_id()))
.flatten()
};
assert_eq!(
recorded(over.clone(), &modi),
Some(rect),
"a plain drag would lift this chip alone, so it is what lights up"
);
assert_eq!(
recorded(ctrl_over, &modi),
None,
"with Ctrl the drag takes the whole line, so the block highlight stands"
);
assert_eq!(
recorded(over, &base),
None,
"a base chip moves its line either way, so it never scopes the highlight"
);
}
#[test]
fn chip_drag_payload_detaches_plainly_and_moves_the_line_with_ctrl() {
let ctx = egui::Context::default();
let base = Chip::base("REQUEST x".into(), egui::Color32::WHITE);
let modi = Chip::modifier("SHOW(Time)".into(), egui::Color32::WHITE, DetachWhich::Show);
let path = vec![1usize];
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(matches!(
chip_drag_payload(ui, &base, &path),
DragItem::Row(p) if p == path
));
assert!(matches!(
chip_drag_payload(ui, &modi, &path),
DragItem::Chip {
which: DetachWhich::Show,
..
}
));
});
let ctrl = egui::RawInput {
modifiers: egui::Modifiers {
ctrl: true,
command: true,
..Default::default()
},
..Default::default()
};
let _ = ctx.run_ui(ctrl, |ui| {
assert!(matches!(
chip_drag_payload(ui, &modi, &path),
DragItem::Row(p) if p == path
));
assert!(matches!(
chip_drag_payload(ui, &base, &path),
DragItem::Row(p) if p == path
));
});
}
#[test]
fn clause_chips_open_the_wizard_on_click() {
assert!(chip_opens_wizard_on_click(&Chip::modifier(
"SHOW".into(),
egui::Color32::WHITE,
DetachWhich::Show
)));
assert!(chip_opens_wizard_on_click(&Chip::modifier(
"HIDE".into(),
egui::Color32::WHITE,
DetachWhich::Hide
)));
assert!(chip_opens_wizard_on_click(&Chip::modifier(
"RESPONSE".into(),
egui::Color32::WHITE,
DetachWhich::Response
)));
assert!(chip_opens_wizard_on_click(&Chip::modifier(
"SHOW(Time)".into(),
egui::Color32::WHITE,
DetachWhich::BaselineShow
)));
assert!(!chip_opens_wizard_on_click(&Chip::modifier(
"REPORT".into(),
egui::Color32::WHITE,
DetachWhich::Report
)));
assert!(!chip_opens_wizard_on_click(&Chip::base(
"x".into(),
egui::Color32::WHITE
)));
}
#[test]
fn lifted_subtree_covers_a_loop_body_but_not_siblings() {
assert!(row_is_lifted(&[2], &[2]));
assert!(!row_is_lifted(&[2], &[1]));
assert!(!row_is_lifted(&[2], &[3]));
assert!(row_is_lifted(&[2], &[2, 0]), "loop body row is lifted");
assert!(row_is_lifted(&[2], &[2, 1, 0]), "nested body row is lifted");
assert!(!row_is_lifted(&[2], &[3]), "the next sibling stays put");
assert!(
!row_is_lifted(&[2], &[1, 0]),
"another loop's body stays put"
);
assert!(
!row_is_lifted(&[2], &[20]),
"prefix is index-wise, not textual"
);
}
#[test]
fn hover_lights_the_block_strongly_and_its_body_softly() {
assert_eq!(hover_tier(&[2], &[2]), Some(HoverTier::Block));
assert_eq!(hover_tier(&[2], &[1]), None);
assert_eq!(hover_tier(&[2], &[3]), None);
assert_eq!(hover_tier(&[2], &[2, 0]), Some(HoverTier::CarriedAlong));
assert_eq!(hover_tier(&[2], &[2, 1, 0]), Some(HoverTier::CarriedAlong));
assert_eq!(hover_tier(&[2], &[1, 0]), None);
assert_eq!(hover_tier(&[2], &[20]), None);
assert_eq!(hover_tier(&[2, 1], &[2, 1]), Some(HoverTier::Block));
assert_eq!(hover_tier(&[2, 1], &[2]), None);
assert_eq!(hover_tier(&[2, 1], &[2, 0]), None);
for path in [vec![2], vec![2, 0], vec![2, 1, 0], vec![3], vec![1, 0]] {
assert_eq!(
hover_tier(&[2], &path).is_some(),
row_is_lifted(&[2], &path),
"hover and lift disagree about {path:?}"
);
}
}
#[test]
fn hovered_block_prefers_the_innermost_row_and_ignores_begin() {
fn row(path: &[usize], kind: RowKind, inside: bool) -> RowHover {
RowHover {
path: path.to_vec(),
kind,
rect: egui::Rect::ZERO,
bg: egui::layers::ShapeIdx(0),
pointer_inside: inside,
}
}
let rows = vec![
row(&[], RowKind::Begin, true),
row(&[0], RowKind::LoopHead, true),
row(&[0, 0], RowKind::Leaf, true),
row(&[0], RowKind::LoopEnd, false),
];
assert_eq!(hovered_block(&rows), Some([0, 0].as_slice()));
let only_begin = vec![
row(&[], RowKind::Begin, true),
row(&[0], RowKind::Leaf, false),
];
assert_eq!(hovered_block(&only_begin), None);
let on_end = vec![
row(&[0], RowKind::LoopHead, false),
row(&[0, 0], RowKind::Leaf, false),
row(&[0], RowKind::LoopEnd, true),
];
assert_eq!(hovered_block(&on_end), Some([0].as_slice()));
assert_eq!(hover_tier(&[0], &[0]), Some(HoverTier::Block));
}
#[test]
fn origin_ghost_paints_without_panicking() {
let ctx = egui::Context::default();
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
paint_origin_ghost(
ui.painter(),
egui::Rect::from_min_size(egui::pos2(4.0, 4.0), egui::vec2(80.0, 22.0)),
&th,
);
paint_origin_ghost(ui.painter(), egui::Rect::ZERO, &th);
});
}
#[test]
fn the_origin_outline_starts_at_the_blocks_own_indent() {
let row = egui::Rect::from_min_size(egui::pos2(4.0, 40.0), egui::vec2(300.0, 22.0));
let top = indented_content(row, 0);
assert_eq!(top, row, "an unindented block is its own rect");
let nested = indented_content(row, 2);
assert_eq!(
nested.left(),
row.left() + 2.0 * INDENT_STEP,
"the outline starts where the nested block does"
);
assert_eq!(nested.right(), row.right(), "the right edge is untouched");
assert_eq!(nested.y_range(), row.y_range(), "the height is untouched");
let tiny = indented_content(
egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(10.0, 22.0)),
8,
);
assert!(tiny.width() > 0.0, "the outline never collapses: {tiny:?}");
}
#[test]
fn the_origin_outline_is_as_rounded_as_a_block() {
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 40.0));
let path = rounded_rect_path(rect, BLOCK_RADIUS);
assert!(path.len() > 8, "a rounded path is more than four corners");
assert_eq!(path.first(), path.last(), "the path closes back on itself");
for p in &path {
assert!(
rect.expand(0.01).contains(*p),
"{p:?} escapes the rect it traces"
);
}
for corner in [
rect.left_top(),
rect.right_top(),
rect.right_bottom(),
rect.left_bottom(),
] {
let nearest = path
.iter()
.map(|p| (*p - corner).length())
.fold(f32::MAX, f32::min);
assert!(
nearest > BLOCK_RADIUS * 0.3,
"the path reaches the square corner {corner:?} (nearest {nearest})"
);
}
let thin = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(100.0, 4.0));
for p in rounded_rect_path(thin, BLOCK_RADIUS) {
assert!(thin.expand(0.01).contains(p), "{p:?} escapes a thin rect");
}
}
#[test]
fn the_drop_marker_is_as_wide_as_the_block_being_dropped() {
let measure = |setup: &dyn Fn(&egui::Context)| -> egui::Vec2 {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let mut size = egui::Vec2::ZERO;
for _ in 0..2 {
setup(&ctx);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
size = dragged_block_size(ui)
});
}
size
};
let moved = measure(&|ctx| {
egui::DragAndDrop::set_payload(ctx, DragItem::Row(vec![0]));
ctx.data_mut(|d| {
d.insert_temp(
lifted_shape_id(),
vec![egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(214.0, 60.0),
)],
)
});
});
assert_eq!(moved, egui::vec2(214.0, 60.0));
let from_palette = measure(&|ctx| {
egui::DragAndDrop::set_payload(ctx, NodeKind::Request);
ctx.data_mut(|d| d.insert_temp(palette_drag_size_id(), egui::vec2(96.0, 24.0)));
});
assert_eq!(from_palette.x, 96.0, "the palette chip's own width");
let unmeasured = measure(&|ctx| egui::DragAndDrop::set_payload(ctx, NodeKind::Request));
assert!(
unmeasured.x > 40.0 && unmeasured.x < 400.0,
"unmeasured width {} is block-like",
unmeasured.x
);
}
#[test]
fn the_drop_marker_takes_the_shape_of_the_block_being_dropped() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let shape = vec![
egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(140.0, 24.0)),
egui::Rect::from_min_size(egui::pos2(24.0, 24.0), egui::vec2(180.0, 24.0)),
egui::Rect::from_min_size(egui::pos2(0.0, 48.0), egui::vec2(50.0, 24.0)),
];
let origin = egui::pos2(30.0, 100.0);
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
paint_drop_silhouette(
ui,
origin,
&shape,
egui::Rect::from_min_size(origin, egui::vec2(400.0, 72.0)),
&th,
);
});
let painted: Vec<egui::Rect> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) => Some(r.rect),
_ => None,
})
.collect();
assert_eq!(painted.len(), 3, "one rect per row, not one bounding box");
for (drawn, want) in painted.iter().zip(&shape) {
assert_eq!(
drawn.left(),
origin.x + want.left(),
"the row keeps its own indent"
);
assert_eq!(drawn.width(), want.width(), "the row keeps its own width");
}
assert_ne!(painted[0].left(), painted[1].left());
assert_ne!(painted[0].width(), painted[2].width());
let under_the_end = egui::pos2(origin.x + 150.0, origin.y + 60.0);
assert!(
!painted.iter().any(|r| r.contains(under_the_end)),
"the marker filled in a gap the block does not occupy"
);
}
#[test]
fn a_half_open_drop_marker_is_clipped_not_stretched() {
let ctx = egui::Context::default();
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let shape = vec![
egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(140.0, 24.0)),
egui::Rect::from_min_size(egui::pos2(0.0, 24.0), egui::vec2(140.0, 24.0)),
];
let origin = egui::pos2(0.0, 0.0);
let clipped = |h: f32| -> Vec<(egui::Rect, egui::Rect)> {
ctx.run_ui(egui::RawInput::default(), |ui| {
paint_drop_silhouette(
ui,
origin,
&shape,
egui::Rect::from_min_size(origin, egui::vec2(400.0, h)),
&th,
);
})
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) => Some((r.rect, c.clip_rect)),
_ => None,
})
.collect()
};
let full = clipped(48.0);
let half = clipped(20.0);
assert_eq!(full.len(), half.len(), "the same rows are always emitted");
for (a, b) in full.iter().zip(&half) {
assert_eq!(a.0, b.0, "the row's own geometry never changes");
}
assert!(
half[0].1.height() < full[0].1.height(),
"the half-open marker is held back by a shorter clip"
);
}
fn input_with_pointer(pos: egui::Pos2) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(1000.0, 800.0),
)),
events: vec![egui::Event::PointerMoved(pos)],
..Default::default()
}
}
#[test]
fn a_dragged_subtree_moves_every_row_by_the_same_delta() {
let ctx = egui::Context::default();
let head_rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(120.0, 20.0));
let body_rect = egui::Rect::from_min_size(egui::pos2(10.0, 34.0), egui::vec2(120.0, 20.0));
let end_rect = egui::Rect::from_min_size(egui::pos2(10.0, 58.0), egui::vec2(120.0, 20.0));
let fills = [
Color32::from_rgb(1, 2, 3),
Color32::from_rgb(4, 5, 6),
Color32::from_rgb(7, 8, 9),
];
let rects = [head_rect, body_rect, end_rect];
let pointer = egui::pos2(400.0, 300.0);
let mut out = ctx.run_ui(input_with_pointer(pointer), |_| {});
for _ in 0..2 {
out = ctx.run_ui(input_with_pointer(pointer), |ui| {
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pt_test_drag"));
let mut lift = DragLift::default();
for (i, (rect, fill)) in rects.iter().zip(fills).enumerate() {
ui.scope_builder(egui::UiBuilder::new().layer_id(layer), |ui| {
ui.painter()
.rect_filled(*rect, egui::CornerRadius::ZERO, fill);
});
lift.add(layer, *rect, i == 0);
}
lift.follow_pointer(ui.ctx());
});
}
let painted = |fill: Color32| -> egui::Rect {
out.shapes
.iter()
.find_map(|clipped| match &clipped.shape {
egui::Shape::Rect(r) if r.fill == fill => Some(r.rect),
_ => None,
})
.unwrap_or_else(|| panic!("no rect painted with fill {fill:?}"))
};
let deltas: Vec<egui::Vec2> = rects
.iter()
.zip(fills)
.map(|(rect, fill)| painted(fill).min - rect.min)
.collect();
for (i, delta) in deltas.iter().enumerate() {
assert!(
(*delta - deltas[0]).length() < 0.5,
"row {i} moved by {delta:?}, head moved by {:?}",
deltas[0]
);
}
assert!(deltas[0].length() > 1.0, "the subtree never moved");
assert!(
(painted(fills[0]).center() - pointer).length() < 0.5,
"the head is not centred on the pointer"
);
}
#[test]
fn the_lift_records_the_whole_subtrees_silhouette_for_the_drop_marker() {
let ctx = egui::Context::default();
let rects = [
egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(120.0, 20.0)),
egui::Rect::from_min_size(egui::pos2(20.0, 34.0), egui::vec2(90.0, 20.0)),
egui::Rect::from_min_size(egui::pos2(10.0, 58.0), egui::vec2(60.0, 20.0)),
];
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pt_test_measure"));
let mut lift = DragLift::default();
for (i, rect) in rects.iter().enumerate() {
lift.add(layer, *rect, i == 0);
}
lift.follow_pointer(&ctx);
let stored: Vec<egui::Rect> = ctx
.data(|d| d.get_temp(lifted_shape_id()))
.expect("the lift stashed a silhouette");
assert_eq!(stored.len(), rects.len());
assert_eq!(stored[0].min, egui::pos2(0.0, 0.0));
assert_eq!(stored.last().unwrap().bottom(), 68.0);
assert_eq!(stored[1].min, egui::pos2(10.0, 24.0));
assert_eq!(stored[1].width(), 90.0);
DragLift::default().follow_pointer(&ctx);
assert_eq!(
ctx.data(|d| d.get_temp::<Vec<egui::Rect>>(lifted_shape_id())),
None
);
}
#[test]
fn the_drop_ghost_is_sized_to_the_block_being_dragged() {
let measure = |setup: &dyn Fn(&egui::Context)| -> f32 {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let mut h = 0.0;
for _ in 0..2 {
setup(&ctx);
let _ = ctx.run_ui(egui::RawInput::default(), |ui| h = dragged_block_h(ui));
}
h
};
let idle = measure(&|_| {});
let leaf = measure(&|ctx| egui::DragAndDrop::set_payload(ctx, NodeKind::Request));
let loop_kind = measure(&|ctx| egui::DragAndDrop::set_payload(ctx, NodeKind::ForFiles));
let moved_subtree = measure(&|ctx| {
egui::DragAndDrop::set_payload(ctx, DragItem::Row(vec![0]));
ctx.data_mut(|d| {
d.insert_temp(
lifted_shape_id(),
vec![egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(120.0, 137.0),
)],
)
});
});
assert!(idle > 0.0);
assert_eq!(leaf, idle, "a plain palette block opens a one-row gap");
assert_eq!(
loop_kind,
idle * 2.0,
"a FOR block inserts a head and an END, so its gap is two rows"
);
assert_eq!(
moved_subtree, 137.0,
"an existing block's gap matches its measured height"
);
}
#[test]
fn a_degenerate_measurement_never_shrinks_the_drop_ghost_below_one_block() {
let ctx = egui::Context::default();
ctx.all_styles_mut(|s| s.spacing.button_padding = egui::vec2(8.0, 4.0));
let mut h = 0.0;
let mut one = 0.0;
for _ in 0..2 {
egui::DragAndDrop::set_payload(&ctx, DragItem::Row(vec![0]));
ctx.data_mut(|d| {
d.insert_temp(
lifted_shape_id(),
vec![egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(120.0, 1.0),
)],
)
});
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
h = dragged_block_h(ui);
one = chip_h(ui) + 10.0;
});
}
assert_eq!(h, one);
}
}
#[cfg(test)]
mod loop_chip_tests {
use super::{ChipEdit, LoopEdit, node_chips};
use crate::gui::theme::GuiTheme;
use crate::i18n::{Language, Strings};
fn loop_edit(src: &str) -> LoopEdit {
let flow = crate::report::parser::parse_flow(src).expect("the fixture flow parses");
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
node_chips(&flow.nodes[0], None, &th, &s)
.into_iter()
.find_map(|c| match c.edit {
ChipEdit::Loop(l) => Some(l),
_ => None,
})
.expect("a loop renders a loop-head chip")
}
#[test]
fn a_files_loop_offers_boxes_for_its_variable_folder_and_glob() {
let l = loop_edit("FOR doc IN FILES \"cases\" MATCH \"*.json\"\n REQUEST A\nEND\n");
assert_eq!(l.var.as_deref(), Some("doc"));
assert_eq!(l.keyword, "IN FILES");
assert_eq!(
l.dir,
Some(("cases".to_string(), false)),
"the folder is editable and picked with a *folder* dialog"
);
assert_eq!(l.glob.as_deref(), Some("*.json"));
assert!(l.tail.is_empty(), "nothing is left over as dead text");
}
#[test]
fn a_files_loop_without_a_match_still_offers_an_empty_glob_box() {
let l = loop_edit("FOR f IN FILES \"cases\"\n REQUEST A\nEND\n");
assert_eq!(l.glob.as_deref(), Some(""));
}
#[test]
fn a_tuples_loop_picks_a_file_rather_than_a_folder() {
let l = loop_edit("FOR row IN TUPLES FROM \"rows.csv\"\n REQUEST A\nEND\n");
assert_eq!(l.keyword, "IN TUPLES FROM");
assert_eq!(l.dir, Some(("rows.csv".to_string(), true)));
assert_eq!(l.glob, None, "a tuples loop has no glob to offer");
}
#[test]
fn a_folders_loops_role_list_is_left_as_text_beside_its_editable_folder() {
let l = loop_edit("FOR d IN FOLDERS \"envs\" WITH req=\"*.hurl\"\n REQUEST A\nEND\n");
assert_eq!(l.dir, Some(("envs".to_string(), false)));
assert_eq!(l.glob, Some(String::new()));
assert_eq!(l.tail, "WITH req=\"*.hurl\"");
}
#[test]
fn a_folders_loop_shows_its_match_glob_in_the_glob_box() {
let l = loop_edit(
"FOR d IN FOLDERS \"cases\" MATCH \"**/case_*\" WITH front=\"*f.jpg\", back=\"*b.jpg\"?\n REQUEST A\nEND\n",
);
assert_eq!(l.glob, Some("**/case_*".to_string()));
assert_eq!(l.tail, "WITH front=\"*f.jpg\", back=\"*b.jpg\"?");
}
#[test]
fn a_destructuring_loop_offers_no_variable_box() {
let l = loop_edit("FOR (NAME, URL) IN DOCS\n REQUEST A\nEND\n");
assert_eq!(l.var, None);
assert_eq!(l.tail, "DOCS", "the source is still shown");
}
#[test]
fn a_list_literal_keeps_its_text_and_offers_no_folder_picker() {
let l = loop_edit("FOR x IN [\"a\", \"b\"]\n REQUEST A\nEND\n");
assert_eq!(l.var.as_deref(), Some("x"));
assert_eq!(l.keyword, "IN");
assert_eq!(l.dir, None, "there is no one folder to pick");
assert_eq!(l.tail, "[\"a\", \"b\"]");
}
#[test]
fn an_envs_loop_offers_its_variable_box_and_leaves_the_roles_to_their_chips() {
let l = loop_edit(
"FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
);
assert_eq!(l.var.as_deref(), Some("TARGET"));
assert_eq!(l.keyword, "IN ENVS");
assert_eq!(l.dir, None);
assert!(
l.tail.is_empty(),
"BASELINE/COMPARISON are chips, not text on the head"
);
}
#[test]
fn an_envs_loop_over_named_environments_still_shows_them() {
let l = loop_edit("FOR e IN ENVS \"dev\", \"prod\"\n REQUEST A\nEND\n");
assert_eq!(l.var.as_deref(), Some("e"));
assert_eq!(l.tail, "\"dev\", \"prod\"");
}
#[test]
fn a_parallel_loops_head_does_not_repeat_the_parallel_prefix() {
let l = loop_edit("PARALLEL(4) FOR f IN FILES \"cases\"\n REQUEST A\nEND\n");
assert_eq!(l.var.as_deref(), Some("f"));
assert_eq!(l.keyword, "IN FILES");
assert!(!l.tail.contains("PARALLEL"));
}
}
#[cfg(test)]
mod baseline_show_chip_tests {
use super::{
CHIP_RADIUS, Chip, Color32, DetachWhich, ROUND_CHIP, chip_corners, link_tethers,
node_chips, split_tether,
};
use crate::gui::theme::GuiTheme;
use crate::i18n::{Language, Strings};
fn chip_labels(src: &str) -> Vec<String> {
let flow = crate::report::parser::parse_flow(src).expect("the fixture flow parses");
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
node_chips(&flow.nodes[0], None, &th, &s)
.into_iter()
.map(|c| c.text)
.collect()
}
#[test]
fn a_baselines_show_clause_is_chipped_between_the_baseline_and_the_comparison() {
let chips = chip_labels(
"FOR TARGET IN ENVS BASELINE(\"prod\") SHOW(Time, Status), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
);
let at = |needle: &str| {
chips
.iter()
.position(|c| c.contains(needle))
.unwrap_or_else(|| panic!("expected a {needle} chip in {chips:?}"))
};
assert!(
at("prod") < at("SHOW(") && at("SHOW(") < at("stage"),
"the SHOW sits with the BASELINE it belongs to, not the comparison: {chips:?}"
);
assert!(
chips.iter().any(|c| c == "SHOW(Time, Status)"),
"and it names the fields it selects: {chips:?}"
);
}
#[test]
fn the_baselines_show_is_tethered_to_it_and_keeps_its_own_colour() {
let flow = crate::report::parser::parse_flow(
"FOR TARGET IN ENVS BASELINE(\"prod\") SHOW(Time), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
)
.expect("the fixture flow parses");
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let s = Strings::for_language(&Language::English);
let mut chips = node_chips(&flow.nodes[0], None, &th, &s);
let show_at = chips
.iter()
.position(|c| c.text.starts_with("SHOW("))
.expect("the SHOW is chipped");
assert!(
chips[show_at].tethered,
"it is tied to the chip before it, not left floating between three peers"
);
assert!(
chips[show_at - 1].text.contains("prod"),
"and the chip it is tied to is the BASELINE, so the tie says who owns it"
);
link_tethers(&mut chips);
let (baseline, show) = (&chips[show_at - 1], &chips[show_at]);
assert!(
baseline.join_next && show.join_prev,
"the pair is drawn as one segmented pill rather than two loose chips"
);
assert!(
!chips
.iter()
.any(|c| c.text.starts_with("COMPARISON(") && (c.join_prev || c.join_next)),
"but the COMPARISON stays a peer — it qualifies the loop, not the baseline"
);
assert_eq!(
show.color, th.ok,
"and the SHOW keeps the colour every SHOW has, so it stays recognisable \
as a SHOW — the pill is what says which chip it belongs to"
);
assert_ne!(
show.color, baseline.color,
"so the two halves are still told apart at a glance"
);
}
#[test]
fn a_tethered_chip_squares_off_only_the_edge_it_shares() {
let mut chips = vec![
Chip::modifier(
"BASELINE(prod)".into(),
Color32::RED,
DetachWhich::Statistics,
),
Chip::modifier(
"SHOW(Time)".into(),
Color32::GREEN,
DetachWhich::BaselineShow,
)
.tether(),
];
link_tethers(&mut chips);
let (left, right) = (chip_corners(&chips[0]), chip_corners(&chips[1]));
assert_eq!(
(left.nw, left.sw, right.ne, right.se),
(CHIP_RADIUS, CHIP_RADIUS, CHIP_RADIUS, CHIP_RADIUS),
"the outside of the pair stays rounded, so it reads as a single pill"
);
assert_eq!(
(left.ne, left.se, right.nw, right.sw),
(0, 0, 0, 0),
"and the meeting edges are square, so no gap or bulge shows between the halves"
);
}
#[test]
fn a_pair_being_pulled_apart_is_no_longer_drawn_joined() {
let mut chips = vec![
Chip::modifier(
"BASELINE(prod)".into(),
Color32::RED,
DetachWhich::Statistics,
),
Chip::modifier(
"SHOW(Time)".into(),
Color32::GREEN,
DetachWhich::BaselineShow,
)
.tether(),
];
link_tethers(&mut chips);
split_tether(&mut chips, 1);
assert!(
!chips[0].join_next && !chips[1].join_prev,
"a squared-off edge facing an empty slot would look like damage, not a join"
);
assert!(
!chips[1].tethered,
"and the pair falls back to the normal gap, so the row does not close up around the hole"
);
assert_eq!(
chip_corners(&chips[0]),
ROUND_CHIP,
"the chip left behind is a whole chip again"
);
}
#[test]
fn a_baseline_without_a_show_clause_gets_no_show_chip() {
let chips = chip_labels(
"FOR TARGET IN ENVS BASELINE(\"prod\"), COMPARISON(\"stage\")\n REQUEST A\nEND\n",
);
assert!(
!chips.iter().any(|c| c.starts_with("SHOW(")),
"no clause, no chip — an empty SHOW would claim a restriction that isn't there: {chips:?}"
);
}
}
#[cfg(test)]
mod results_table_tests {
use super::{MIN_COL_W, fit_column_widths};
fn spanned(widths: &[f32], spacing: f32) -> f32 {
widths.iter().sum::<f32>() + spacing * (widths.len() as f32 - 1.0)
}
#[test]
fn a_table_with_room_to_spare_grows_to_fill_the_whole_window() {
let widths = fit_column_widths(&[60.0, 100.0, 40.0], 800.0, 10.0);
assert!(
(spanned(&widths, 10.0) - 800.0).abs() < 0.01,
"the table spans the full width instead of huddling at the left edge: {widths:?}"
);
assert!(
widths[1] > widths[0] && widths[0] > widths[2],
"and the spare room is shared in proportion, so the column order is unchanged: {widths:?}"
);
}
#[test]
fn a_squeezed_table_takes_the_room_from_its_widest_columns_first() {
let widths = fit_column_widths(&[50.0, 600.0], 400.0, 10.0);
assert!(
(widths[0] - 50.0).abs() < 0.01,
"the column that was already narrow is left alone: {widths:?}"
);
assert!(
widths[1] < 600.0,
"and the wide one absorbs the whole squeeze: {widths:?}"
);
assert!(
spanned(&widths, 10.0) <= 400.01,
"so everything still fits on screen: {widths:?}"
);
}
#[test]
fn columns_that_all_want_the_same_width_are_squeezed_equally() {
let widths = fit_column_widths(&[300.0, 300.0, 300.0], 600.0, 0.0);
for w in &widths {
assert!(
(w - 200.0).abs() < 0.01,
"with nothing to choose between them they share the shortfall: {widths:?}"
);
}
}
#[test]
fn a_table_too_wide_even_for_its_minimums_overflows_so_it_can_be_scrolled() {
let widths = fit_column_widths(&[100.0; 20], 200.0, 0.0);
for w in &widths {
assert!(
*w >= MIN_COL_W,
"no column is shrunk past the point of showing anything: {widths:?}"
);
}
assert!(
spanned(&widths, 0.0) > 200.0,
"the overflow is what makes the scroll bar appear: {widths:?}"
);
}
#[test]
fn a_table_with_no_columns_is_not_a_division_by_zero() {
assert!(
fit_column_widths(&[], 500.0, 10.0).is_empty(),
"an empty report lays out to nothing rather than panicking"
);
}
}
#[cfg(test)]
mod results_render_tests {
use super::{MIN_COL_W, SPACING_X, fitted_column_widths, results_grid};
use crate::gui::theme::GuiTheme;
use crate::i18n::Language;
use crate::report::model::{OutputColumn, ReportResult, ReportRow};
use eframe::egui;
fn fixture(headers: &[&str], fills: &[&str], rows: usize) -> (ReportResult, Vec<OutputColumn>) {
let columns: Vec<OutputColumn> = headers
.iter()
.map(|h| OutputColumn {
header: h.to_string(),
sources: vec![h.to_string()],
stats: Vec::new(),
image: None,
truth: None,
detail: false,
})
.collect();
let mut result = ReportResult::default();
for _ in 0..rows {
let mut row = ReportRow::default();
for (h, v) in headers.iter().zip(fills) {
row.cells.insert(h.to_string(), v.to_string());
}
result.rows.push(row);
}
(result, columns)
}
#[test]
fn a_live_run_is_shown_unfiltered() {
let (result, columns) = fixture(&["A"], &["x"], 3);
let header = crate::report::flow::Header::default();
let mut sel = 0;
assert!(
super::visible_for_view(&result, &columns, &header, true, &mut sel, "").is_none(),
"a live run shows every row it has, filters and all"
);
}
#[test]
fn the_find_box_narrows_the_rows() {
let (mut result, columns) = fixture(&["A"], &["x"], 3);
result.rows[1]
.cells
.insert("A".to_string(), "needle".to_string());
let header = crate::report::flow::Header::default();
let mut sel = 0;
let all = super::visible_for_view(&result, &columns, &header, false, &mut sel, "").unwrap();
assert_eq!(all, vec![0, 1, 2], "no text means no narrowing");
let hit =
super::visible_for_view(&result, &columns, &header, false, &mut sel, "NEED").unwrap();
assert_eq!(hit, vec![1], "and the search is case-insensitive");
}
#[test]
fn a_stale_filter_selection_is_clamped_rather_than_panicking() {
let (result, columns) = fixture(&["A"], &["x"], 2);
let header = crate::report::flow::Header::default();
let mut sel = 3;
let rows =
super::visible_for_view(&result, &columns, &header, false, &mut sel, "").unwrap();
assert_eq!(sel, 0, "clamped back onto the only filter there is");
assert_eq!(rows, vec![0, 1], "which selects everything");
}
#[test]
fn the_filter_buttons_are_translated_but_a_matrix_cell_is_not() {
use crate::report::filter::RowFilter;
let s = crate::i18n::Strings::for_language(&Language::French);
assert_eq!(
super::filter_label(&s, &RowFilter::Incorrect),
s.report_filter_incorrect
);
let cell = RowFilter::MatrixCell {
column: "Verdict".to_string(),
truth: "pass".to_string(),
answer: "fail".to_string(),
};
assert_eq!(
super::filter_label(&s, &cell),
cell.label(),
"a matrix cell names the report's own data, which is not translatable"
);
}
#[test]
fn the_grid_drops_the_same_detail_columns_the_export_does() {
let (_, mut columns) = fixture(&["Name", "Body"], &["a", "{}"], 1);
columns[1].detail = true;
let (grid, detail) = crate::report::detail::split_columns(&columns);
assert_eq!(
grid.iter().map(|c| &c.header).collect::<Vec<_>>(),
vec!["Name"],
"the detail column is not in the grid"
);
assert_eq!(detail.len(), 1, "it is in the panel instead");
}
#[test]
fn only_rows_with_something_to_show_are_expandable() {
let (mut result, mut columns) = fixture(&["Name", "Body"], &["a", ""], 2);
columns[1].detail = true;
result.rows[1]
.cells
.insert("Body".to_string(), "{\"a\":1}".to_string());
let (grid, detail) = crate::report::detail::split_columns(&columns);
assert!(
crate::report::detail::sections(&result, 0, &grid, &detail).is_empty(),
"the row with a blank body has nothing to show"
);
assert!(
!crate::report::detail::sections(&result, 1, &grid, &detail).is_empty(),
"the row with a body does"
);
}
#[test]
fn detail_sections_share_the_width_they_have() {
let w = super::DETAIL_MIN_SECTION_W;
assert_eq!(
super::detail_layout_columns(w * 2.5, w, 4),
2,
"two whole sections fit, so two columns"
);
assert_eq!(
super::detail_layout_columns(w * 0.5, w, 4),
1,
"a pane narrower than one section still gets one column, not zero"
);
assert_eq!(
super::detail_layout_columns(w * 4.0, w, 2),
2,
"four would fit but there are only two sections to put in them"
);
assert_eq!(
super::detail_layout_columns(w * 4.0, w, 0),
1,
"an empty panel never asks for zero columns"
);
}
#[test]
fn the_highlight_key_notices_every_input_it_guards() {
use crate::tui::report_highlight::HlCtx;
let text = "REPORT REQUEST login AS l\n";
let ctx = HlCtx {
error_line: None,
collection_resolves: true,
loaded_envs: ["dev".to_string()].into_iter().collect(),
request_names: ["login".to_string()].into_iter().collect(),
};
let spec = crate::theme::default_preset();
let font = egui::FontId::monospace(12.0);
let key = |t: &str, c: &HlCtx, s: &crate::theme::ThemeSpec, f: &egui::FontId, w: f32| {
super::highlight_key(t, c, s, f, w)
};
let base = key(text, &ctx, &spec, &font, 800.0);
assert_ne!(
base,
key("REPORT REQUEST logout AS l\n", &ctx, &spec, &font, 800.0),
"the source text"
);
let mut c = ctx.clone();
c.error_line = Some(1);
assert_ne!(base, key(text, &c, &spec, &font, 800.0), "the error line");
c.error_line = None;
c.collection_resolves = false;
assert_ne!(
base,
key(text, &c, &spec, &font, 800.0),
"whether the collection binds — it is what makes a name green"
);
c.collection_resolves = true;
c.request_names = ["other".to_string()].into_iter().collect();
assert_ne!(
base,
key(text, &c, &spec, &font, 800.0),
"which requests exist"
);
c.request_names = ctx.request_names.clone();
c.loaded_envs = ["prod".to_string()].into_iter().collect();
assert_ne!(
base,
key(text, &c, &spec, &font, 800.0),
"which environments are loaded"
);
let other_theme = crate::theme::preset_for_language(&crate::i18n::Language::French);
assert_ne!(
base,
key(text, &ctx, &other_theme, &font, 800.0),
"the theme, which is where every colour comes from"
);
assert_ne!(
base,
key(text, &ctx, &spec, &egui::FontId::monospace(18.0), 800.0),
"the font size"
);
assert_ne!(
base,
key(text, &ctx, &spec, &font, 400.0),
"the wrap width, which the job carries"
);
let mut same = ctx.clone();
same.request_names.insert("zzz".to_string());
same.request_names.remove("zzz");
assert_eq!(
base,
key(text, &same, &spec, &font, 800.0),
"the same set is the same key"
);
}
fn widths_across_two_frames(
first: &(ReportResult, Vec<OutputColumn>),
then: impl FnOnce(&mut ReportResult),
) -> (Vec<f32>, Vec<f32>) {
let ctx = egui::Context::default();
let (mut result, columns) = (first.0.clone(), first.1.clone());
let mut a = Vec::new();
let mut b = Vec::new();
let draw = |ctx: &egui::Context, result: &ReportResult, out: &mut Vec<f32>| {
for _ in 0..2 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.set_max_width(4000.0);
ui.set_min_width(4000.0);
*out = super::cached_natural_widths(ui, result, &columns);
});
}
};
draw(&ctx, &result, &mut a);
then(&mut result);
draw(&ctx, &result, &mut b);
(a, b)
}
#[test]
fn cached_widths_follow_a_row_that_is_filled_in_place() {
let empty = fixture(&["Result"], &[""], 4);
let (before, after) = widths_across_two_frames(&empty, |result| {
for row in &mut result.rows {
row.cells.insert(
"Result".to_string(),
"a much longer value than the header".to_string(),
);
}
});
assert!(
after[0] > before[0] + 20.0,
"the column grew for the arriving values: {before:?} then {after:?}"
);
}
#[test]
fn cached_widths_are_stable_when_nothing_changes() {
let table = fixture(&["A", "B"], &["one", "two"], 5);
let (before, after) = widths_across_two_frames(&table, |_| {});
assert_eq!(before, after);
}
#[test]
fn the_width_fingerprint_notices_every_input_it_guards() {
let ctx = egui::Context::default();
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let (base_res, base_cols) = fixture(&["A"], &["one"], 2);
let base = super::widths_fingerprint(ui, &base_res, &base_cols);
let mut r = base_res.clone();
r.rows[0]
.cells
.insert("A".to_string(), "changed".to_string());
assert_ne!(
base,
super::widths_fingerprint(ui, &r, &base_cols),
"a cell's text"
);
let mut r = base_res.clone();
r.rows.push(ReportRow::default());
assert_ne!(
base,
super::widths_fingerprint(ui, &r, &base_cols),
"a new row"
);
let mut r = base_res.clone();
r.no_match_marker = "\u{2014}".to_string();
assert_ne!(
base,
super::widths_fingerprint(ui, &r, &base_cols),
"the no-match marker, which is what an empty cell renders as"
);
let mut r = base_res.clone();
r.rows[0].target = Some("staging".to_string());
assert_ne!(
base,
super::widths_fingerprint(ui, &r, &base_cols),
"the row's ENVS target"
);
let mut r = base_res.clone();
r.rows[0].vars.insert("v".to_string(), "x".to_string());
assert_ne!(
base,
super::widths_fingerprint(ui, &r, &base_cols),
"a variable a columns: directive could show"
);
let (_, wider) = fixture(&["A Much Longer Header"], &["one"], 2);
assert_ne!(
base,
super::widths_fingerprint(ui, &base_res, &wider),
"the column header"
);
let mut cols = base_cols.clone();
cols[0].stats = vec![crate::report::model::StatKind::Count];
assert_ne!(
base,
super::widths_fingerprint(ui, &base_res, &cols),
"statistics, which add summary rows to measure"
);
let (a, cols2) = fixture(&["A", "B"], &["one", "two"], 1);
let mut b = ReportResult::default();
let mut row = ReportRow::default();
row.cells.insert("B".to_string(), "two".to_string());
row.cells.insert("A".to_string(), "one".to_string());
b.rows.push(row);
assert_eq!(
super::widths_fingerprint(ui, &a, &cols2),
super::widths_fingerprint(ui, &b, &cols2),
"insertion order is not a difference"
);
});
}
fn widths_at(result: &ReportResult, columns: &[OutputColumn], avail: f32) -> (Vec<f32>, f32) {
let ctx = egui::Context::default();
let mut widths = Vec::new();
for _ in 0..2 {
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.set_max_width(avail);
ui.set_min_width(avail);
widths = fitted_column_widths(ui, result, columns, false);
});
}
let span = widths.iter().sum::<f32>() + SPACING_X * (widths.len() as f32 - 1.0);
(widths, span)
}
#[test]
fn a_narrow_table_is_stretched_across_the_whole_window() {
let (result, columns) = fixture(&["A", "B", "C"], &["1", "2", "3"], 3);
let (widths, span) = widths_at(&result, &columns, 900.0);
assert!(
span > 850.0,
"the table spans essentially the whole 900px window: {widths:?} ({span})"
);
}
#[test]
fn a_wide_table_is_squeezed_to_stay_inside_the_window() {
let long = "a value long enough to want a column all to itself";
let (result, columns) = fixture(
&["One", "Two", "Three", "Four", "Five", "Six"],
&[long; 6],
4,
);
let (widths, span) = widths_at(&result, &columns, 600.0);
assert!(
span <= 600.5,
"everything fits inside the window: {widths:?} ({span})"
);
}
#[test]
fn a_column_of_long_values_is_given_more_room_than_a_column_of_short_ones() {
let (result, columns) = fixture(
&["Id", "Body"],
&["7", "a considerably longer captured value"],
5,
);
let (widths, _) = widths_at(&result, &columns, 800.0);
assert!(
widths[1] > widths[0] * 2.0,
"width follows what a column actually has to show: {widths:?}"
);
}
#[test]
fn a_header_wider_than_its_values_still_gets_room_for_its_own_name() {
let (result, columns) = fixture(&["A", "AnUncommonlyLongHeader"], &["1", "2"], 3);
let (widths, _) = widths_at(&result, &columns, 900.0);
assert!(
widths[1] > widths[0],
"the long header claims the wider column: {widths:?}"
);
}
#[test]
fn a_table_with_more_columns_than_can_ever_fit_overflows_for_the_scroll_bar() {
let headers: Vec<String> = (0..30).map(|i| format!("Col{i}")).collect();
let heads: Vec<&str> = headers.iter().map(|h| h.as_str()).collect();
let (result, columns) = fixture(&heads, &["value"; 30], 2);
let (widths, span) = widths_at(&result, &columns, 400.0);
assert!(
widths.iter().all(|w| *w >= MIN_COL_W),
"every column keeps a readable minimum: {widths:?}"
);
assert!(
span > 400.0,
"and the overflow is what puts the scroll bar there: {span}"
);
}
#[test]
fn drawing_the_grid_itself_survives_a_window_too_narrow_for_one_column() {
let (result, columns) = fixture(&["A", "B", "C"], &["1", "2", "3"], 2);
let ctx = egui::Context::default();
let th = GuiTheme::from_spec(&crate::theme::preset_for_language(&Language::English));
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
ui.set_max_width(1.0);
let mut none = None;
results_grid(&th, ui, &result, &columns, None, None, &[], &mut none);
});
}
}
#[cfg(test)]
mod dry_run_view_tests {
use super::{EditorView, ReportEditor, ReportOrigin};
#[test]
fn reindenting_is_one_undo_step_and_keeps_body_comments() {
let before = "# collection: c\n\nFOR T IN FILES \"*.txt\"\n# keep me\nREQUEST a\nEND\n";
let report = crate::report::Report::from_text("r", before);
let mut ed = ReportEditor::new(ReportOrigin::Session(0), report);
assert!(matches!(ed.reformat(), Ok(true)), "text moved");
assert!(
ed.report.text.contains(" # keep me"),
"the comment is kept and indented: {:?}",
ed.report.text
);
assert!(
ed.report.text.contains(" REQUEST a"),
"the body is indented: {:?}",
ed.report.text
);
assert!(matches!(ed.reformat(), Ok(false)));
ed.undo();
assert_eq!(ed.report.text, before, "one undo restores the original");
}
#[test]
fn a_dry_run_switches_to_the_view_that_actually_shows_it() {
let report = crate::report::Report::scratch("r");
let mut ed = ReportEditor::new(ReportOrigin::Session(0), report);
assert!(
ed.view == EditorView::Blocks,
"an editor opens on the Blocks view"
);
ed.show_preview(Box::new(crate::report::dry_run::DryRunReport::from_result(
crate::report::ReportResult::default(),
crate::report::flow::Header::default(),
Vec::new(),
)));
assert!(
ed.view == EditorView::Results,
"the preview should be on screen, not waiting in a view nobody is looking at"
);
assert!(ed.dry_run.is_some(), "and the preview itself is held");
}
}
#[cfg(test)]
mod detail_verdict_tests {
use super::*;
use crate::gui::app::GuiApp;
use crate::report::model::{OutputColumn, ReportResult, ReportRow, StatKind, Verdict};
use crate::session::Session;
#[test]
fn a_ground_truthed_detail_section_shows_its_verdict() {
let mut column = OutputColumn {
header: "Raw".to_string(),
sources: vec!["Raw".to_string()],
stats: Vec::<StatKind>::new(),
image: None,
truth: Some("Low Risk".to_string()),
detail: true,
};
column.detail = true;
let mut row = ReportRow::default();
row.cells.insert("Raw".to_string(), "High Risk".to_string());
let mut result = ReportResult {
column_order: vec!["Raw".into()],
rows: vec![row],
..Default::default()
};
result
.verdicts
.insert((0, "Raw".into()), Verdict::Incorrect);
result.truths.insert((0, "Raw".into()), "Low Risk".into());
let columns = vec![column];
let (grid, detail) = crate::report::detail::split_columns(&columns);
let section = crate::report::detail::sections(&result, 0, &grid, &detail)
.into_iter()
.next()
.expect("the detail column has a section");
let app = GuiApp::for_test(Session::default());
let th = app.theme.clone();
let ctx = egui::Context::default();
let mut input = egui::RawInput::default();
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(600.0, 400.0),
));
let mut textures = std::collections::HashMap::new();
let out = ctx.run_ui(input, |ui| {
detail_section(&th, ui, &app, 0, section.clone(), &mut textures);
});
let mut text = String::new();
fn walk(s: &egui::epaint::Shape, out: &mut String) {
match s {
egui::epaint::Shape::Text(t) => {
out.push_str(t.galley.text());
out.push('\n');
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
for c in &out.shapes {
walk(&c.shape, &mut text);
}
assert!(
text.contains("incorrect"),
"the section says it is wrong: {text}"
);
assert!(
text.contains("expected Low Risk"),
"and what was wanted instead, which is otherwise only in the script: {text}"
);
}
}
#[cfg(test)]
mod toolbar_commit_tests {
use super::*;
use crate::gui::app::GuiApp;
fn painted(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, egui::Rect)> {
fn walk(s: &egui::epaint::Shape, out: &mut Vec<(String, egui::Rect)>) {
match s {
egui::epaint::Shape::Text(t) => {
out.push((t.galley.text().to_string(), t.visual_bounding_rect()))
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in shapes {
walk(&c.shape, &mut out);
}
out
}
fn input() -> egui::RawInput {
let mut i = egui::RawInput::default();
i.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(1000.0, 760.0),
));
i
}
fn click_at(pos: egui::Pos2) -> egui::RawInput {
let mut i = input();
i.events.push(egui::Event::PointerMoved(pos));
i.events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Default::default(),
});
i.events.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: Default::default(),
});
i
}
fn app_with_report(text: &str) -> GuiApp {
let mut session = crate::session::Session::default();
session.collections.clear();
let entry = crate::hurl::HurlEntry {
title: "A".into(),
url: "http://127.0.0.1:1/".into(),
..Default::default()
};
session.collections.push(crate::collection::Collection::new(
"api".into(),
vec![entry],
));
let mut app = GuiApp::for_test(session);
let mut report = crate::report::Report::scratch("r");
report.set_text(text.to_string());
app.report_editor = Some(ReportEditor::new(ReportOrigin::Session(0), report));
app
}
#[test]
fn the_highlighter_context_is_reused_until_a_name_it_shows_changes() {
let mut app = app_with_report("# collection: api\nREQUEST A\n");
if let Some(ed) = app.report_editor.as_mut() {
ed.flow = ed.report.flow().ok();
}
let mut ed = app.report_editor.take().expect("editor");
let first = super::highlight_ctx(&mut ed, &app);
assert!(
first.request_names.contains("A"),
"the bound collection's request is known: {:?}",
first.request_names
);
let again = super::highlight_ctx(&mut ed, &app);
assert!(
std::rc::Rc::ptr_eq(&first, &again),
"nothing changed, so the context is not rebuilt"
);
app.session.collections[0].entries[0].title = "B".into();
let renamed = super::highlight_ctx(&mut ed, &app);
assert!(
!std::rc::Rc::ptr_eq(&first, &renamed),
"a rename rebuilds the context"
);
assert!(
renamed.request_names.contains("B"),
"and the new name is the one highlighted: {:?}",
renamed.request_names
);
let before_env = super::highlight_ctx(&mut ed, &app);
app.session
.global_envs
.push(crate::environment::parse_vars("staging".into(), ""));
let with_env = super::highlight_ctx(&mut ed, &app);
assert!(
!std::rc::Rc::ptr_eq(&before_env, &with_env),
"a newly loaded environment rebuilds the context"
);
assert!(with_env.loaded_envs.iter().any(|n| n == "staging"));
}
#[test]
fn export_offers_the_report_s_own_name_the_way_the_tui_does() {
let mut app = app_with_report("# output: xlsx\n# collection: api\n");
let dir = std::env::temp_dir().join(format!("pb_exp_{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
let trail = dir.join("Face FR tickets.trail");
if let Some(ed) = app.report_editor.as_mut() {
ed.report.path = Some(trail.clone());
ed.flow = ed.report.flow().ok();
}
super::open_export_dialog(&mut app);
let path = match app.dialog {
Some(super::super::app::Dialog::ExportResults { path }) => path,
_ => panic!("Export opens PaperBoy's own dialog"),
};
std::fs::remove_dir_all(&dir).ok();
assert_eq!(
path,
trail.with_extension("xlsx").to_string_lossy(),
"the file is named for the report, in the format it declares, beside it"
);
}
#[test]
fn a_scratch_report_exports_into_its_own_base_directory() {
let dir = std::env::temp_dir().join(format!("pb_exp_root_{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
let mut app = app_with_report(&format!("# root: {}\n# collection: api\n", dir.display()));
if let Some(ed) = app.report_editor.as_mut() {
ed.flow = ed.report.flow().ok();
}
super::open_export_dialog(&mut app);
let path = match app.dialog {
Some(super::super::app::Dialog::ExportResults { path }) => path,
_ => panic!("Export opens PaperBoy's own dialog"),
};
std::fs::remove_dir_all(&dir).ok();
assert!(
std::path::Path::new(&path).starts_with(&dir),
"an unsaved report's results are offered in its base directory: {path}"
);
}
#[test]
fn a_field_commit_is_not_applied_to_a_block_that_moved_under_it() {
let mut app = app_with_report(
"# collection: api\nREPORT REQUEST A AS One\nREPORT REQUEST A AS Two\nREPORT REQUEST A AS Three\n",
);
let ctx = egui::Context::default();
let out = ctx.run_ui(input(), |ui| super::ui(&mut app, ui));
let painted = painted(&out.shapes);
let two = painted
.iter()
.find(|(t, _)| t == "Two")
.map(|(_, r)| *r)
.expect("the second block's alias field");
let trash = painted
.iter()
.find(|(t, _)| t.contains(app.strings.gui_report_delete_block))
.map(|(_, r)| *r)
.expect("the Delete block button");
let caret = egui::pos2(two.right() - 1.0, two.center().y);
let _ = ctx.run_ui(click_at(caret), |ui| super::ui(&mut app, ui));
let mut typing = input();
typing.events.push(egui::Event::Text("x".into()));
let _ = ctx.run_ui(typing, |ui| super::ui(&mut app, ui));
app.report_editor.as_mut().unwrap().selection = vec![0];
let _ = ctx.run_ui(input(), |ui| super::ui(&mut app, ui));
let _ = ctx.run_ui(click_at(trash.center()), |ui| super::ui(&mut app, ui));
let text = &app.report_editor.as_ref().expect("editor open").report.text;
assert!(!text.contains("AS One"), "the selected block is deleted");
assert!(
text.contains("AS Twox"),
"the edited block keeps its own edit: {text:?}"
);
assert!(
text.contains("AS Three"),
"and the block below it is left alone: {text:?}"
);
}
#[test]
fn a_toolbar_press_keeps_what_was_being_typed() {
let mut app = app_with_report("# collection: api\nREPORT REQUEST A AS Old\n");
let ctx = egui::Context::default();
let out = ctx.run_ui(input(), |ui| super::ui(&mut app, ui));
let painted = painted(&out.shapes);
let alias = painted
.iter()
.find(|(t, _)| t == "Old")
.map(|(_, r)| *r)
.expect("the alias field shows its current value");
let dry = painted
.iter()
.find(|(t, _)| t.contains(app.strings.gui_report_dry_run))
.map(|(_, r)| *r)
.expect("the Dry run button is on the toolbar");
let caret = egui::pos2(alias.right() - 1.0, alias.center().y);
let _ = ctx.run_ui(click_at(caret), |ui| super::ui(&mut app, ui));
let mut typing = input();
typing.events.push(egui::Event::Text("er".into()));
let _ = ctx.run_ui(typing, |ui| super::ui(&mut app, ui));
assert!(
app.report_editor
.as_ref()
.is_some_and(|ed| ed.report.text.contains("AS Old")),
"still uncommitted while the field has focus"
);
let _ = ctx.run_ui(click_at(dry.center()), |ui| super::ui(&mut app, ui));
let ed = app.report_editor.as_ref().expect("the editor stays open");
assert!(
ed.report.text.contains("AS Older"),
"the typing survives the button press: {:?}",
ed.report.text
);
assert!(ed.view == EditorView::Results, "and the button still acted");
}
}
#[cfg(test)]
mod param_view_tests {
use super::*;
use crate::report::Report;
use crate::session::Session;
const TRAIL: &str = "\
# name: Face
# collection: c.hurl
PARAM TEXT TICKET LABEL \"Ticket number\"
PARAM CHOICE(\"au\", \"eu\") REGION = \"au\"
REPORT REQUEST x
";
fn editor(text: &str) -> (GuiApp, ReportEditor) {
let mut app = GuiApp::for_test(Session::default());
app.open_report_editor(ReportOrigin::Workspace, Report::from_text("Face", text));
let ed = app.report_editor.take().expect("editor is open");
(app, ed)
}
fn painted(ctx: &egui::Context, app: &mut GuiApp) -> String {
let mut input = egui::RawInput::default();
input.screen_rect = Some(egui::Rect::from_min_size(
egui::pos2(0.0, 0.0),
egui::vec2(1000.0, 760.0),
));
let out = ctx.run_ui(input, |ui| super::ui(app, ui));
fn walk(s: &egui::epaint::Shape, out: &mut String) {
match s {
egui::epaint::Shape::Text(t) => {
out.push_str(t.galley.text());
out.push('\n');
}
egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut text = String::new();
for c in &out.shapes {
walk(&c.shape, &mut text);
}
text
}
#[test]
fn run_asks_the_questions_before_it_starts() {
let (mut app, mut ed) = editor(TRAIL);
assert!(ed.view == EditorView::Blocks, "opens where it is built");
assert!(ed.params_modal.is_none(), "and asks nothing unprompted");
ed.start_run(&mut app);
assert_eq!(ed.params_modal, Some(RunIntent::Run));
assert!(!ed.is_running(), "nothing has been run yet");
}
#[test]
fn a_dry_run_asks_the_same_questions() {
let (mut app, mut ed) = editor(TRAIL);
ed.start_dry_run(&mut app);
assert_eq!(ed.params_modal, Some(RunIntent::DryRun));
assert!(ed.dry_run.is_none(), "and previews nothing yet");
}
#[test]
fn once_answered_the_buttons_act_at_once() {
let (mut app, mut ed) = editor(TRAIL);
ed.seed_params(&app.session);
ed.param_values.insert("TICKET".into(), "FR-12".into());
ed.params_confirmed = true;
ed.start_run(&mut app);
assert!(ed.params_modal.is_none(), "no second interrogation");
}
#[test]
fn changing_a_declaration_asks_again() {
let (mut app, mut ed) = editor(TRAIL);
ed.params_confirmed = true;
ed.set_text(TRAIL.replace("\"au\", \"eu\"", "\"au\", \"eu\", \"us\""));
assert!(
!ed.params_confirmed,
"the choices are not the ones answered"
);
ed.seed_params(&app.session);
ed.param_values.insert("TICKET".into(), "FR-12".into());
ed.params_confirmed = true;
let same_questions = ed.report.text.replace("REQUEST x", "REQUEST y");
ed.set_text(same_questions);
assert!(ed.params_confirmed);
ed.start_run(&mut app);
assert!(ed.params_modal.is_none());
}
#[test]
fn the_form_asks_one_question_per_parameter() {
let (app, mut ed) = editor(TRAIL);
ed.seed_params(&app.session);
let rows = ed.param_rows(&app.strings);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "TICKET");
assert_eq!(rows[0].prompt, "Ticket number", "the LABEL is the prompt");
assert!(rows[0].problem.is_some(), "nothing given and no default");
assert_eq!(rows[1].value, "au", "the declared default fills the row");
assert!(rows[1].problem.is_none());
}
#[test]
fn the_toolbar_shows_the_answers_it_will_run_with() {
let (app, mut ed) = editor(TRAIL);
ed.seed_params(&app.session);
ed.param_values.insert("TICKET".into(), "FR-12".into());
let summary = ed.param_summary(&app.strings);
assert!(summary.contains("TICKET=FR-12"), "{summary}");
assert!(summary.contains("REGION=au"), "{summary}");
}
#[test]
fn the_dialog_paints_the_question_the_value_and_the_name() {
let mut app = GuiApp::for_test(Session::default());
app.open_report_editor(ReportOrigin::Workspace, Report::from_text("Face", TRAIL));
let lead = app.strings.param_view_lead;
let ctx = egui::Context::default();
let before = painted(&ctx, &mut app);
assert!(!before.contains(lead), "{before}");
if let Some(ed) = app.report_editor.as_mut() {
ed.params_modal = Some(RunIntent::Run);
}
painted(&ctx, &mut app);
let text = painted(&ctx, &mut app);
assert!(text.contains(lead), "the dialog is up: {text}");
assert!(text.contains("Ticket number"), "the prompt: {text}");
assert!(text.contains("TICKET"), "and the name the script uses");
assert!(
text.contains("au"),
"and the value the run would use: {text}"
);
}
#[test]
fn the_answers_travel_with_a_parked_run() {
let (app, mut ed) = editor(TRAIL);
ed.seed_params(&app.session);
ed.param_values.insert("TICKET".into(), "FR-12".into());
let parked = ed.park_run();
let (_app2, mut fresh) = editor(TRAIL);
fresh.adopt_run(parked);
assert_eq!(
fresh.param_values.get("TICKET").map(String::as_str),
Some("FR-12")
);
}
}
#[cfg(test)]
mod stop_run_tests {
use super::*;
use crate::gui::report_run::{RunUpdate, test_handle};
use crate::report::Report;
use crate::report::model::{ReportResult, ReportRow};
use crate::session::Session;
fn editor() -> (GuiApp, ReportEditor) {
let mut app = GuiApp::for_test(Session::default());
app.open_report_editor(ReportOrigin::Workspace, Report::scratch("nightly"));
let ed = app.report_editor.take().expect("editor is open");
(app, ed)
}
fn result_with(cell: &str) -> ReportResult {
let mut res = ReportResult::default();
let mut row = ReportRow::default();
row.cells.insert("A".to_string(), cell.to_string());
res.rows.push(row);
res
}
#[test]
fn stopping_frees_the_report_to_be_run_again_at_once() {
let (mut app, mut ed) = editor();
let (handle, tx) = test_handle();
ed.run = Some(handle);
assert!(ed.is_running(), "a live handle is a live run");
ed.stop_run(&mut app);
assert!(
!ed.is_running(),
"the run is retired immediately, without waiting for the worker"
);
assert!(matches!(app.session.status, Some(Status::ReportRunStopped)));
assert!(
tx.send(RunUpdate::Done(ReportResult::default())).is_err(),
"our end of the channel went with the handle, so late updates \
land nowhere rather than being folded in"
);
assert!(!ed.is_running());
}
#[test]
fn stopping_keeps_the_rows_that_already_arrived_but_clears_the_progress() {
let (mut app, mut ed) = editor();
let (handle, _tx) = test_handle();
ed.run = Some(handle);
ed.result = Some(result_with("200"));
ed.progress = Some(crate::gui::report_run::RunProgress {
states: vec![crate::gui::report_run::RowState::Running],
index: Default::default(),
done: 0,
total: 1,
});
ed.stop_run(&mut app);
assert_eq!(
ed.result.as_ref().map(|r| r.rows.len()),
Some(1),
"the partial grid survives the stop"
);
assert!(
ed.progress.is_none(),
"but no row is left rendering as still running"
);
}
#[test]
fn stopping_still_tells_the_worker_to_wind_down() {
let (mut app, mut ed) = editor();
let (handle, _tx) = test_handle();
let flag = handle.cancel_flag_for_test();
ed.run = Some(handle);
ed.stop_run(&mut app);
assert!(
flag.load(std::sync::atomic::Ordering::Relaxed),
"dropping our end alone would not stop the worker: it watches the flag"
);
}
}