use eframe::egui::{self, Color32, RichText};
use crate::i18n::Status;
use crate::report::Report;
use crate::report::context;
use crate::report::edit::{
self, CarriedMod, DetachWhich, InsertPos, Modifier, NodeKind, RowKind, attach_modifier,
attach_to_node, carry_modifier, detach_modifier, flatten, 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::flow::{FlowNode, ReportFlow, ReportStmt, WithItem};
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, RowState, RunHandle, 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 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 wizard: Option<super::report_wizard::Wizard>,
pub diag_h: f32,
pub palette_w: f32,
pub inspector: Option<CellInspector>,
pub dry_run: Option<Box<crate::report::dry_run::DryRunReport>>,
}
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(),
selection: Vec::new(),
palette: None,
undo: Vec::new(),
result: None,
progress: None,
run: None,
results_exported: false,
wizard: None,
diag_h: 132.0,
palette_w: 168.0,
inspector: None,
dry_run: None,
};
ed.reparse();
ed
}
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);
}
}
}
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 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);
}
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)
}
fn start_run(&mut self, app: &mut GuiApp) {
let Some(flow) = self.flow.clone() else {
return;
};
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) => {
self.result = None;
self.progress = None;
self.results_exported = false;
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;
};
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 ctx = RunContext {
entries: &inputs.entries,
base_vars: inputs.base_vars.clone(),
named_envs: inputs.named_envs.clone(),
root: inputs.root.clone(),
runner: &DryRunner,
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 {
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,
}
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>,
},
}
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,
),
}
}
fn base(text: String, color: Color32) -> Chip {
Chip {
text,
color,
is_base: true,
detach: None,
edit: ChipEdit::None,
help: "",
tethered: 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,
}
}
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,
}
}
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,
}
}
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,
}
}
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,
}
}
}
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::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({})", show.join(", ")),
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, ..
}) => {
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
}
FlowNode::Report(ReportStmt::Computed {
template,
name,
stats,
}) => {
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
}
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));
}
let full = node.label();
let head = match parallel {
Some(spec) => {
let prefix = match spec.degree {
None => "PARALLEL".to_string(),
Some(n) => format!("PARALLEL({n})"),
};
full.strip_prefix(&prefix)
.map(|s| s.trim_start().to_string())
.unwrap_or(full)
}
None => full,
};
if let FlowNode::ForEnvs {
clause:
crate::report::flow::EnvClause::Roles {
baseline,
comparisons,
baseline_show,
},
var,
..
} = node
{
chips.push(
Chip::base(format!("FOR {var} IN ENVS"), 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({})", baseline_show.join(", ")),
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 help = if matches!(node, FlowNode::ForEnvs { .. }) {
s.chip_help_for_envs
} else {
s.chip_help_for
};
chips.push(Chip::base(head, 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 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,
},
MoveUp,
MoveDown,
Delete,
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,
},
SetParallelDegree {
path: Vec<usize>,
degree: Option<u32>,
},
AddWith {
path: Vec<usize>,
},
EditWith {
path: Vec<usize>,
index: usize,
},
RemoveWith {
path: Vec<usize>,
index: usize,
},
SetHeader {
key: &'static str,
value: Option<String>,
},
PickHeaderFile {
key: &'static str,
},
}
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;
let running = ed.poll_run(app);
if running {
ui.ctx().request_repaint();
}
ed.diagnostics = match &ed.flow {
Some(flow) => context::report_diagnostics(
&app.session.collections,
&app.session.global_envs,
app.session.active_env_id,
flow,
ed.report.path.as_deref(),
&app.strings,
),
None => Vec::new(),
};
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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(format!("{} {}", super::icons::CLOSE, app.strings.gui_close))
.clicked()
{
close = true;
}
let save = ui.add_enabled(
ed.report.dirty,
egui::Button::new(format!("{} {}", super::icons::SAVE, app.strings.gui_save)),
);
if save.clicked() {
save_report(&mut ed, app);
}
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.start_run(app);
}
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.start_dry_run(app);
}
}
});
});
ui.horizontal(|ui| {
if super::widgets::selectable(
ui,
ed.view == EditorView::Blocks,
RichText::new(app.strings.gui_report_view_blocks),
)
.clicked()
{
ed.view = EditorView::Blocks;
}
if super::widgets::selectable(
ui,
ed.view == EditorView::Source,
RichText::new(app.strings.gui_report_view_source),
)
.clicked()
{
ed.view = EditorView::Source;
}
if super::widgets::selectable(
ui,
ed.view == EditorView::Results,
RichText::new(app.strings.gui_report_view_results),
)
.clicked()
{
ed.view = EditorView::Results;
}
});
ui.separator();
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),
}
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).strong().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 ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)) {
ed.undo();
}
if !close {
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;
}
if let Some(ins) = results_grid(&th, ui, &preview.result, &columns, None) {
opened = Some(ins);
}
opened
}
fn highlight_ctx(ed: &ReportEditor, app: &GuiApp) -> HlCtx {
let bound = ed.flow.as_ref().and_then(|flow| {
context::resolve_bound_collection(&app.session.collections, flow, ed.report.path.as_deref())
});
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| {
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.clone())
.collect()
})
.unwrap_or_default(),
}
}
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
}
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 = highlight_ctx(ed, app);
let spec = app.session.active_theme_spec();
let th = app.theme;
let mut layouter = |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
let font = egui::TextStyle::Monospace.resolve(ui.style());
let job = highlight_job(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 mut text = ed.report.text.clone();
let resp = ui.add(
egui::TextEdit::multiline(&mut text)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(20)
.layouter(&mut layouter),
);
if 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);
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;
}
ui.horizontal(|ui| {
if ed.is_running() {
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| {
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);
}
});
});
ui.separator();
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);
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());
ui.colored_label(th.dim, app.strings.gui_report_cell_hint);
ui.add_space(2.0);
if let Some(ins) = results_grid(&th, ui, result, &columns, states) {
ed.inspector = Some(ins);
}
}
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]>,
) -> Option<CellInspector> {
let show_icons = states.is_some();
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_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();
for (i, row) in result.rows.iter().enumerate() {
let state = states.and_then(|s| s.get(i)).copied();
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) {
opened = Some(ins);
}
});
}
ui.end_row();
}
for srow in result.summary_rows(columns) {
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 = 2.0;
fn paint_tether(painter: &egui::Painter, th: &GuiTheme, anchor: egui::Rect, hanger: egui::Rect) {
let stroke = egui::Stroke::new(1.0, mix(th.panel, th.text, 0.55));
let drop = 3.0;
let left = anchor.center().x;
let right = hanger.right() - 2.0;
for (dir, edge) in [
(1.0, anchor.bottom().max(hanger.bottom()) + 2.0),
(-1.0, anchor.top().min(hanger.top()) - 2.0),
] {
let near = edge - drop * dir;
painter.line_segment([egui::pos2(left, near), egui::pos2(left, edge)], stroke);
painter.line_segment([egui::pos2(left, edge), egui::pos2(right, edge)], stroke);
painter.line_segment([egui::pos2(right, edge), egui::pos2(right, near)], stroke);
}
}
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 = natural_column_widths(ui, result, columns);
let avail = (ui.available_width() - icon_w).max(0.0);
fit_column_widths(&natural, avail, SPACING_X)
}
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 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 result.summary_rows(columns) {
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) {
super::menu::save_via_picker(app, super::app::SaveKind::ReportResults);
}
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 titles: Vec<String> = bound
.map(|ci| {
app.session.collections[ci]
.entries
.iter()
.map(|e| e.title.clone())
.collect()
})
.unwrap_or_default();
let resolves = |name: &str| titles.iter().any(|t| t == name);
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::MoveUp);
}
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::MoveDown);
}
if ui
.add_enabled(
on_node,
egui::Button::new(format!(
"{} {}",
super::icons::TRASH,
app.strings.gui_report_delete_block
)),
)
.clicked()
{
acts.push(Act::Delete);
}
});
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();
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);
block_row(
ed, app, ui, row, i, selected, &drop_pos, &titles, &mut lift,
&mut acts,
);
}
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::Delete);
}
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(6);
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 frame = egui::Frame::NONE
.fill(mix(th.panel, base, 0.22))
.stroke(egui::Stroke::new(1.0, mix(th.panel, base, 0.5)))
.inner_margin(egui::Margin::symmetric(8, 4))
.corner_radius(6);
frame.show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(text).color(base)).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 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)
}
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 = 6.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(6)
.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);
}
}
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>,
) {
let th = app.theme;
let s = &app.strings;
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 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.accent,
s.chip_help_begin,
),
RowKind::LoopEnd => static_chip(ui, &th, "END", th.accent, s.chip_help_end),
RowKind::Leaf | RowKind::LoopHead => {
let chips = node
.as_ref()
.map(|n| node_chips(n, row.req_ok, &th, s))
.unwrap_or_default();
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.tethered {
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.tethered
&& !in_hand
&& 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(ui.painter(), &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;
}
let block = ui.vertical(block_body);
let cluster = block.inner;
let block_rect = block.response.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);
}
}
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;
let fill = mix(th.panel, th.subst, 0.22);
let stroke = egui::Stroke::new(1.0, mix(th.panel, th.subst, 0.5));
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 } => {
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
}
WithItem::ResponseFmt(fmt) => format!(
"RESPONSE {}",
match fmt {
crate::report::flow::ResponseFmt::Raw => "RAW",
crate::report::flow::ResponseFmt::Pretty => "PRETTY",
}
),
};
let lbl = chip_shell(ui, fill, stroke, true, |ui| {
let lbl = ui.add(
egui::Label::new(RichText::new(&text).color(th.subst))
.selectable(false)
.sense(egui::Sense::click()),
);
if detach_x(ui, th.subst) {
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,
});
}
});
}
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
}
struct HeaderSpec {
key: &'static str,
always_shown: bool,
required: bool,
kind: HeaderKind,
}
enum HeaderKind {
Collection,
Environment,
Format,
Path,
Text,
}
fn header_specs() -> [HeaderSpec; 6] {
[
HeaderSpec {
key: "collection",
always_shown: true,
required: true,
kind: HeaderKind::Collection,
},
HeaderSpec {
key: "output",
always_shown: true,
required: false,
kind: HeaderKind::Format,
},
HeaderSpec {
key: "environment",
always_shown: false,
required: false,
kind: HeaderKind::Environment,
},
HeaderSpec {
key: "root",
always_shown: false,
required: false,
kind: HeaderKind::Path,
},
HeaderSpec {
key: "baseline",
always_shown: false,
required: false,
kind: HeaderKind::Path,
},
HeaderSpec {
key: "columns",
always_shown: false,
required: false,
kind: HeaderKind::Text,
},
]
}
fn header_help(key: &str, s: &crate::i18n::Strings) -> &'static str {
match key {
"collection" => s.chip_help_hdr_collection,
"output" => s.chip_help_hdr_output,
"environment" => s.chip_help_hdr_environment,
"root" => s.chip_help_hdr_root,
"baseline" => s.chip_help_hdr_baseline,
_ => s.chip_help_hdr_columns,
}
}
#[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(key).unwrap_or_default().to_string(),
&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) -> 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.set_width(settings_width(ui));
for spec in &specs {
let value = value_of(spec.key);
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, &value, choices, collections, acts));
}
let missing: Vec<&HeaderSpec> = specs
.iter()
.filter(|sp| !sp.always_shown && value_of(sp.key).is_empty())
.collect();
if !missing.is_empty() {
header_add_menu(ui, s, &missing, acts);
}
});
}
fn settings_width(ui: &egui::Ui) -> f32 {
const SETTINGS_W: f32 = 460.0;
SETTINGS_W.min(ui.available_width())
}
fn header_add_menu(
ui: &mut egui::Ui,
s: &crate::i18n::Strings,
missing: &[&HeaderSpec],
acts: &mut Vec<Act>,
) {
let label = format!("{} {}", super::icons::PLUS, s.gui_report_add_setting);
ui.menu_button(label, |ui| {
for spec in missing {
if ui
.button(spec.key.to_uppercase())
.on_hover_text(header_help(spec.key, s))
.clicked()
{
acts.push(Act::SetHeader {
key: spec.key,
value: Some(header_placeholder(spec)),
});
ui.close();
}
}
})
.response
.on_hover_text(s.gui_report_settings_help);
}
fn header_placeholder(_spec: &HeaderSpec) -> String {
"?".to_string()
}
fn header_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
spec: &HeaderSpec,
value: &str,
choices: Option<&Vec<String>>,
collections: &dyn Fn() -> Vec<CollectionChoice>,
acts: &mut Vec<Act>,
) {
let unset = value.is_empty() || value == "?";
let color = if unset && spec.required {
th.err
} else {
th.dim
};
let fill = mix(th.panel, color, 0.18);
let stroke = egui::Stroke::new(1.0, mix(th.panel, color, 0.45));
let text_col = if unset && spec.required {
th.err
} else {
th.text
};
let key = spec.key;
let combo = matches!(
spec.kind,
HeaderKind::Collection | HeaderKind::Environment | HeaderKind::Format
);
let scope = ui.scope(|ui| {
chip_shell(ui, fill, stroke, !combo, |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.gui_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 });
}
if let Some(v) = picked
&& v != value
{
acts.push(Act::SetHeader {
key,
value: Some(v),
});
}
}
HeaderKind::Path | 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.gui_report_setting_unset, 150.0)
&& text != current
{
acts.push(Act::SetHeader {
key,
value: Some(text),
});
}
if matches!(spec.kind, HeaderKind::Path)
&& ui
.small_button(super::icons::FOLDER)
.on_hover_text(s.gui_report_browse)
.clicked()
{
acts.push(Act::PickHeaderFile { key });
}
}
}
if (!unset || !spec.always_shown) && detach_x(ui, color) {
acts.push(Act::SetHeader { key, 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(header_help(key, s));
}
}
fn pick_header_file(ed: &mut ReportEditor, app: &mut GuiApp, key: &'static str) {
let seed = ed
.flow
.as_ref()
.and_then(|f| f.header.get(key))
.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 = header_help(key, &app.strings);
let picked = match key {
"root" => super::filepick::pick_folder(title, seed.as_deref()),
"collection" => super::filepick::pick_file(
title,
seed.as_deref(),
&[("hurl", &["hurl"]), ("*", &["*"])],
),
_ => super::filepick::pick_file(
title,
seed.as_deref(),
&[("baseline", &["baseline", "json"]), ("*", &["*"])],
),
};
let Some(path) = picked else {
return;
};
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())
};
ed.edit_flow(|flow| {
edit::set_header(flow, key, Some(&text));
});
}
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.accent, 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::Button);
(sp.interact_size.y.max(row + 2.0 * sp.button_padding.y)).ceil()
}
fn chip_shell<R>(
ui: &mut egui::Ui,
fill: Color32,
stroke: egui::Stroke,
grow: bool,
content: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let h = chip_h(ui);
egui::Frame::NONE
.fill(fill)
.stroke(stroke)
.inner_margin(egui::Margin::symmetric(8, 3))
.corner_radius(6)
.show(ui, |ui| {
ui.horizontal(|ui| {
if grow {
ui.set_min_height(h);
}
content(ui)
})
.inner
})
.inner
}
fn chip_colors(th: &GuiTheme, chip: &Chip, selected: bool) -> (Color32, egui::Stroke, Color32) {
if chip.is_base && selected {
(
th.select_bg,
egui::Stroke::new(1.0, th.select_fg),
th.select_fg,
)
} else {
(
mix(th.panel, chip.color, 0.22),
egui::Stroke::new(1.0, mix(th.panel, chip.color, 0.5)),
chip.color,
)
}
}
fn detach_x(ui: &mut egui::Ui, col: Color32) -> bool {
ui.add(
egui::Button::new(RichText::new("×").color(col))
.small()
.frame(false),
)
.clicked()
}
fn inline_text_edit(
ui: &mut egui::Ui,
id: egui::Id,
current: &str,
hint: &str,
width: f32,
) -> Option<String> {
let mut buf = ui
.data(|d| d.get_temp::<String>(id))
.unwrap_or_else(|| current.to_string());
let resp = ui.add(
egui::TextEdit::singleline(&mut buf)
.hint_text(hint)
.background_color(ui.visuals().widgets.inactive.weak_bg_fill)
.desired_width(width),
);
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 fill = mix(th.panel, color, 0.22);
let stroke = egui::Stroke::new(1.0, mix(th.panel, color, 0.5));
let scope = ui.scope(|ui| {
chip_shell(ui, fill, stroke, true, |ui| {
ui.add(egui::Label::new(RichText::new(text).color(color)).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;
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;
}
_ => {}
}
let (fill, stroke, text_col) = chip_colors(th, chip, selected);
let handle = chip_shell(ui, fill, stroke, true, |ui| {
let handle = ui.add(
egui::Label::new(RichText::new(&chip.text).color(text_col))
.selectable(false)
.sense(egui::Sense::click_and_drag()),
);
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)
)
}
fn alias_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
path: &[usize],
current: &str,
acts: &mut Vec<Act>,
) {
let (fill, stroke, text_col) = chip_colors(th, chip, false);
let handle = chip_shell(ui, fill, stroke, true, |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)
&& 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 parallel_chip(
ui: &mut egui::Ui,
th: &GuiTheme,
s: &crate::i18n::Strings,
chip: &Chip,
path: &[usize],
current: Option<u32>,
acts: &mut Vec<Act>,
) {
let (fill, stroke, text_col) = chip_colors(th, chip, false);
let shown = current.map(|n| n.to_string()).unwrap_or_default();
let handle = chip_shell(ui, fill, stroke, true, |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,
) && 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 (fill, stroke, text_col) = chip_colors(th, chip, selected);
let mut picked: Option<String> = None;
let mut detached: Option<DetachWhich> = None;
let handle = chip_shell(ui, fill, stroke, false, |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(6)
.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>) {
for act in acts {
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::MoveUp | Act::MoveDown => {
let up = matches!(act, Act::MoveUp);
let path = ed.selection.clone();
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::Delete => {
let path = ed.selection.clone();
ed.edit_flow(|flow| {
remove_node(flow, &path);
});
ed.selection = Vec::new();
}
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::SetParallelDegree { path, degree } => {
ed.edit_flow(|flow| {
edit::set_parallel_degree(flow, &path, degree);
});
ed.selection = path;
}
Act::SetHeader { key, value } => {
ed.edit_flow(|flow| {
edit::set_header(flow, key, value.as_deref());
});
}
Act::PickHeaderFile { key } => {
pick_header_file(ed, app, key);
}
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;
}
}
}
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();
}
}
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 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 chip_height(build: impl Fn(&mut egui::Ui, &GuiTheme, &Strings, &mut Vec<Act>)) -> f32 {
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 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
}
#[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!(
(label - combo).abs() < 0.5,
"label {label} vs combo {combo}"
);
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!(
(label - alias).abs() < 0.5,
"label {label} vs alias {alias}"
);
assert!(
(label - parallel).abs() < 0.5,
"label {label} vs parallel {parallel}"
);
let flow_end = chip_height(|ui, th, s, _acts| flow_end_row(ui, th, s));
assert!(
(label - flow_end).abs() < 0.5,
"label {label} vs flow end {flow_end}"
);
}
#[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\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| 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() {
for spec in header_specs().iter().filter(|sp| !sp.always_shown) {
assert!(
!header_placeholder(spec).is_empty(),
"{} would be dropped again the moment it was added",
spec.key
);
}
}
#[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| matches!(sp.kind, HeaderKind::Path))
.map(|sp| sp.key)
.collect();
assert_eq!(paths, ["root", "baseline"]);
}
#[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, &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.gui_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.gui_report_add_setting.split_whitespace().count() >= 3,
"{lang:?} label {:?} names what it adds",
s.gui_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,
"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| {
owned
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.unwrap_or_default()
};
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.gui_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.gui_report_add_setting)),
"nothing is missing, so nothing offers to add it"
);
}
#[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()
);
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_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,
"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,
"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!["eapi_staging".to_string()];
let dev = vec!["eapi_dev".to_string()];
let dfa = vec!["dfa_result".to_string()];
let chips: Vec<(Chip, &[String])> = vec![
(Chip::base("FOR".into(), th.subst), &[]),
(Chip::env_role(true, 0, "eapi_staging", th.subst), &staging),
(Chip::env_role(false, 1, "eapi_dev", th.subst), &dev),
(
Chip::modifier(
"RESPONSE PRETTY".into(),
th.subst,
DetachWhich::Response,
),
&[],
),
(Chip::request("dfa_result", th.subst), &dfa),
(
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 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 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 only_show_hide_response_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(
"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 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 baseline_show_chip_tests {
use super::node_chips;
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 chips = node_chips(&flow.nodes[0], None, &th, &s);
let show = chips
.iter()
.find(|c| c.text.starts_with("SHOW("))
.expect("the SHOW is chipped");
assert!(
show.tethered,
"it is tied to the chip before it, not left floating between three peers"
);
assert_eq!(
show.color, th.ok,
"and it keeps SHOW's own colour — the tie is drawn, not implied by hue"
);
let baseline = chips
.iter()
.find(|c| c.text.contains("prod"))
.expect("the BASELINE is chipped");
assert_ne!(
show.color, baseline.color,
"so the two are still told apart at a glance"
);
}
#[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(),
})
.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)
}
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);
results_grid(&th, ui, &result, &columns, None);
});
}
}
#[cfg(test)]
mod dry_run_view_tests {
use super::{EditorView, ReportEditor, ReportOrigin};
#[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");
}
}