use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, WidgetRef, Wrap,
};
use crate::collection::{Collection, WsRow};
use crate::environment::ValueSource;
use crate::hurl::RunStatus;
use crate::i18n::Strings;
use crate::request;
use crate::tree;
use super::app::*;
use super::editor::*;
use super::git_save::*;
use super::listscroll::ListScroll;
use super::new_request::*;
use super::postman::*;
use super::remote::*;
use super::theme::*;
use std::sync::Arc;
use tui_panel_select::WrapMarker;
use tui_panel_select::wrapcache::TextPos;
pub(crate) const GIT_ICON: &str = "\u{2387}";
pub(crate) const WORKSPACE_ICON: &str = "\u{2302}";
pub(crate) const LINK_ICON: &str = "\u{1F517}";
pub(crate) const SHADOW_ICON: &str = "!";
pub(crate) fn wrap_marker(th: &Theme) -> WrapMarker {
WrapMarker {
glyph: '↵',
style: Style::default().fg(th.dim),
}
}
fn char_display_width(ch: char) -> usize {
Span::raw(ch.to_string()).width().max(1)
}
pub(crate) fn wrap_lines_with_marker(
lines: Vec<Line<'static>>,
width: u16,
th: &Theme,
) -> Vec<Line<'static>> {
let width = width as usize;
if width < 2 {
return lines;
}
let limit = width - 1; let marker = wrap_marker(th);
let marker_glyph = marker.glyph.to_string();
let mut out: Vec<Line<'static>> = Vec::new();
for line in lines {
if line.width() <= width {
out.push(line);
continue;
}
let mut row: Vec<Span<'static>> = Vec::new();
let mut col = 0usize;
for span in line.spans {
let style = span.style;
let mut buf = String::new();
for ch in span.content.chars() {
let cw = char_display_width(ch);
if col + cw > limit {
if !buf.is_empty() {
row.push(Span::styled(std::mem::take(&mut buf), style));
}
row.push(Span::styled(marker_glyph.clone(), marker.style));
out.push(Line::from(std::mem::take(&mut row)));
col = 0;
}
buf.push(ch);
col += cw;
}
if !buf.is_empty() {
row.push(Span::styled(buf, style));
}
}
out.push(Line::from(row));
}
out
}
pub(crate) const FOLDER_ICON: &str = "\u{1F4C1}";
const COLLECTION_OPEN_ICON: &str = "\u{25BE}"; pub(crate) const COLLECTION_CLOSED_ICON: &str = "\u{25B8}"; pub(crate) const REPORT_ICON: &str = "\u{1F4CA}"; pub(crate) const ENV_ICON: &str = "\u{1F310}";
enum LeftRow {
WsFolder {
name: String,
depth: usize,
expanded: bool,
},
Collection {
name: String,
depth: usize,
open: bool,
loaded: bool,
edited: bool,
},
Report {
name: String,
depth: usize,
},
Environment {
name: String,
depth: usize,
},
Entry {
idx: usize,
depth: usize,
},
WsRequestName {
name: String,
method: String,
depth: usize,
edited: bool,
run: RunStatus,
},
}
fn run_marker_span(run: RunStatus, th: &crate::tui::theme::Theme) -> Span<'static> {
match run {
RunStatus::Passed => Span::styled("\u{2713} ", Style::default().fg(th.ok)),
RunStatus::Failed => Span::styled("\u{2717} ", Style::default().fg(th.err)),
RunStatus::Running => Span::styled("\u{2026} ", Style::default().fg(th.pending)),
RunStatus::NotRun => Span::raw(" "),
}
}
impl LeftRow {
fn build(col: &Collection) -> Vec<LeftRow> {
if col.is_workspace() {
col.ws_rows()
.into_iter()
.map(|r| match r {
WsRow::Folder {
name,
depth,
expanded,
..
} => LeftRow::WsFolder {
name,
depth,
expanded,
},
WsRow::Collection {
path,
name,
depth,
open,
} => {
let loaded = col.path.as_deref() == Some(path.as_path());
let edited = col.workspace_file_edited(&path);
LeftRow::Collection {
name,
depth,
open,
loaded,
edited,
}
}
WsRow::Report { name, depth, .. } => LeftRow::Report { name, depth },
WsRow::Environment { name, depth, .. } => LeftRow::Environment { name, depth },
WsRow::RequestFolder {
name,
depth,
expanded,
..
} => LeftRow::WsFolder {
name,
depth,
expanded,
},
WsRow::Request {
idx,
depth,
loaded: true,
..
} => LeftRow::Entry { idx, depth },
WsRow::Request {
collection,
idx,
name,
method,
depth,
loaded: false,
} => LeftRow::WsRequestName {
name,
method,
depth,
edited: col.workspace_request_edited(&collection, idx),
run: col.workspace_run_status(&collection, idx),
},
})
.collect()
} else {
let entries = &col.entries;
col.rows()
.into_iter()
.map(|r| {
let depth = r.depth(entries);
match r {
tree::Row::Folder { path, expanded } => LeftRow::WsFolder {
name: path.last().cloned().unwrap_or_default(),
depth,
expanded,
},
tree::Row::Entry(idx) => LeftRow::Entry { idx, depth },
}
})
.collect()
}
}
fn entry_idx(&self) -> Option<usize> {
match self {
LeftRow::Entry { idx, .. } => Some(*idx),
_ => None,
}
}
}
pub(crate) fn panel(title: String, focused: bool, th: &Theme) -> Block<'static> {
let border = if focused { th.accent } else { th.line };
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(Span::styled(
title,
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
))
.style(Style::default().bg(th.panel))
}
pub(crate) fn fit_border_hint(parts: &[String], sep: &str, width: u16) -> String {
let usable = width.saturating_sub(3) as usize;
let mut n = parts.len();
while n > 0 {
let joined = parts[..n].join(sep);
if joined.chars().count() <= usable {
return joined;
}
n -= 1;
}
String::new()
}
pub(crate) fn label_column<'a>(labels: impl IntoIterator<Item = &'a str>) -> u16 {
labels
.into_iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0) as u16
+ 2
}
pub(crate) fn panel_hinted(title: String, hint: &str, th: &Theme) -> Block<'static> {
panel_hinted_styled(title, hint, Style::default().fg(th.dim), th)
}
pub(crate) fn panel_hinted_styled(
title: String,
hint: &str,
style: Style,
th: &Theme,
) -> Block<'static> {
panel(title, true, th)
.title_bottom(Line::from(Span::styled(format!(" {hint} "), style)).centered())
}
pub(crate) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let w = width.min(area.width);
let h = height.min(area.height);
let x = area.x + (area.width - w) / 2;
let y = area.y + (area.height - h) / 2;
Rect::new(x, y, w, h)
}
fn draw_report_columns_overlay(
f: &mut Frame,
picker: &super::reports::ColumnPicker,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let title = format!("{} ({})", s.report_columns_title, s.report_columns_hint);
let n = picker.rows.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let scroll = if picker.selected >= inner_h {
picker.selected + 1 - inner_h
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
for (i, row) in picker.rows.iter().enumerate().skip(scroll).take(inner_h) {
let mark = if row.included { "[x]" } else { "[ ]" };
let base = if i == picker.selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else if row.included {
Style::default().fg(th.text)
} else {
Style::default().fg(th.dim)
};
let mut spans = vec![Span::styled(format!("{mark} {}", row.header), base)];
let src = row.sources.join("|");
if src != row.header {
spans.push(Span::styled(
format!(" ← {src}"),
Style::default().fg(th.dim),
));
}
lines.push(Line::from(spans));
}
f.render_widget(Paragraph::new(lines).block(panel(title, true, th)), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..picker.rows.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn draw_report_node_request_overlay(
f: &mut Frame,
form: &super::report_nodes::RequestForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::FormRow;
let rows = form.visible_rows();
let n = rows.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let selected = form.selected.min(n.saturating_sub(1));
let scroll = if selected >= inner_h {
selected + 1 - inner_h
} else {
0
};
let response_label = match form.response {
None => s.report_node_response_default,
Some(crate::report::flow::ResponseFmt::Raw) => "RAW",
Some(crate::report::flow::ResponseFmt::Pretty) => "PRETTY",
};
let alias_shown = if form.alias.is_empty() {
s.report_node_alias_none
} else {
form.alias.as_str()
};
let mut lines: Vec<Line> = Vec::new();
for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_h) {
let is_sel = i == selected;
let base = if is_sel {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let line = match *row {
FormRow::Name => {
let shown = if form.request.is_empty() {
s.report_node_name_none
} else {
form.request.as_str()
};
Line::from(Span::styled(
format!("{}: ‹{}›", s.report_node_name_label, shown),
base,
))
}
FormRow::Report => {
let mark = if form.report { "[x]" } else { "[ ]" };
Line::from(Span::styled(
format!("{mark} {}", s.report_node_report_label),
base,
))
}
FormRow::Response => Line::from(Span::styled(
format!("{}: ‹{}›", s.report_node_response_label, response_label),
base,
)),
FormRow::Alias => {
let mut text = format!("{}: {}", s.report_node_alias_label, alias_shown);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
FormRow::Param(pi) => {
let pr = &form.params[pi];
let mark = if pr.required { "[x]" } else { "[ ]" };
let style = if is_sel {
base
} else if pr.required {
Style::default().fg(th.subst)
} else {
Style::default().fg(th.dim)
};
let note = match &pr.default {
Some(v) => format!(" = {v}"),
None => format!(" ({})", s.report_node_param_undeclared),
};
Line::from(vec![
Span::styled(format!("{mark} USING {}", pr.name), style),
Span::styled(
note,
if is_sel {
base
} else if pr.default.is_some() {
Style::default().fg(th.dim)
} else {
Style::default().fg(th.err)
},
),
])
}
FormRow::Field(fi) => {
let fr = &form.fields[fi];
let mark = if fr.included { "[x]" } else { "[ ]" };
let style = if is_sel {
base
} else if fr.included {
Style::default().fg(th.text)
} else {
Style::default().fg(th.dim)
};
Line::from(Span::styled(format!("{mark} SHOW {}", fr.name), style))
}
FormRow::Hidden(fi) => {
let fr = &form.hide_fields[fi];
let mark = if fr.included { "[x]" } else { "[ ]" };
let style = if is_sel {
base
} else if fr.included {
Style::default().fg(th.text)
} else {
Style::default().fg(th.dim)
};
Line::from(Span::styled(format!("{mark} HIDE {}", fr.name), style))
}
FormRow::With(wi) => {
let text = match form.with.get(wi) {
Some(crate::report::flow::WithItem::Field {
name, query, stats, ..
}) => {
let stats = if stats.is_empty() {
String::new()
} else {
format!(
" {}({})",
s.report_node_with_stats_label,
stats
.iter()
.map(|k| k.keyword())
.collect::<Vec<_>>()
.join(", ")
)
};
format!("WITH {name}: {query}{stats}")
}
_ => "WITH RESPONSE".to_string(),
};
Line::from(Span::styled(text, base))
}
FormRow::AddWith => Line::from(Span::styled(
s.report_node_with_add.to_string(),
if is_sel {
base
} else {
Style::default().fg(th.dim)
},
)),
FormRow::OverrideTarget(oi) => {
let row = &form.overrides[oi];
let shown = if row.target.is_empty() {
s.report_node_override_target_none
} else {
row.target.as_str()
};
let mut text = format!("USING {} {shown}", s.report_node_override_target_label);
if is_sel {
text.push('▏');
}
let style = if is_sel {
base
} else if crate::report::edit::override_target_valid(&row.target) {
Style::default().fg(th.subst)
} else {
Style::default().fg(th.err)
};
Line::from(Span::styled(text, style))
}
FormRow::OverrideValue(oi) => {
let row = &form.overrides[oi];
let shown = if row.value.is_empty() {
s.report_node_override_value_none
} else {
row.value.as_str()
};
let mut text = format!(" {} {shown}", s.report_node_override_value_label);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(
text,
if is_sel {
base
} else {
Style::default().fg(th.dim)
},
))
}
FormRow::AddOverride => Line::from(Span::styled(
s.report_node_override_add.to_string(),
if is_sel {
base
} else {
Style::default().fg(th.dim)
},
)),
};
lines.push(line);
}
let hint = if form.params.is_empty() {
format!(
" {} · {} ",
s.report_node_request_hint, s.report_node_using_none
)
} else {
format!(" {} ", s.report_node_request_hint)
};
let block = panel(s.report_node_config_title.to_string(), true, th)
.title_bottom(Line::from(Span::styled(hint, Style::default().fg(th.dim))).centered());
f.render_widget(Paragraph::new(lines).block(block), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..rows.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn degree_row_text(degree: &str, s: &Strings, is_sel: bool) -> String {
let shown = if degree.trim().is_empty() {
s.report_node_parallel_degree_none.to_string()
} else {
degree.to_string()
};
let mut text = format!(" {}: {shown}", s.report_node_parallel_degree_label);
if is_sel {
text.push('▏');
}
text
}
fn draw_report_node_vars_overlay(
f: &mut Frame,
form: &super::report_nodes::VarsForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::VarsRow;
let lines: Vec<(usize, String)> = form
.visible_rows()
.iter()
.enumerate()
.map(|(i, row)| {
let text = match row {
VarsRow::Clause(cr) => clause_text(*cr, &form.clauses, s, i == form.selected),
VarsRow::Var(vi) => {
let r = &form.vars[*vi];
let mark = if r.included {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
format!("{mark} {}", r.name)
}
VarsRow::Other => caretted(
format!("{}: {}", s.report_node_vars_other_label, form.other),
i == form.selected,
),
VarsRow::Alias => caretted(
format!("{}: {}", s.report_node_alias_label, form.alias),
i == form.selected,
),
VarsRow::Stat(si) => {
let (kind, on) = form.stats[*si];
let mark = if on {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
format!(
" {mark} {}({})",
s.report_node_with_stats_label,
kind.keyword()
)
}
};
(i, text)
})
.collect();
let note = form.vars.is_empty().then_some(s.report_node_vars_none);
draw_simple_form_overlay(
f,
s.report_node_vars_title,
s.report_node_vars_hint,
&lines,
form.selected,
note,
th,
app,
);
}
fn draw_report_node_computed_overlay(
f: &mut Frame,
form: &super::report_nodes::ComputedForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::ComputedRow;
let lines: Vec<(usize, String)> = form
.visible_rows()
.iter()
.enumerate()
.map(|(i, row)| {
let text = match row {
ComputedRow::Clause(cr) => clause_text(*cr, &form.clauses, s, i == form.selected),
ComputedRow::Template => caretted(
format!(
"{}: {}",
s.report_node_computed_template_label, form.template
),
i == form.selected,
),
ComputedRow::Alias => caretted(
format!("{}: {}", s.report_node_computed_name_label, form.alias),
i == form.selected,
),
ComputedRow::Stat(si) => {
let (kind, on) = form.stats[*si];
let mark = if on {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
format!(
" {mark} {}({})",
s.report_node_with_stats_label,
kind.keyword()
)
}
};
(i, text)
})
.collect();
draw_simple_form_overlay(
f,
s.report_node_computed_title,
s.report_node_computed_hint,
&lines,
form.selected,
None,
th,
app,
);
}
fn draw_report_node_assign_overlay(
f: &mut Frame,
form: &super::report_nodes::AssignForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::AssignRow;
let rows = form.visible_rows();
let lines: Vec<(usize, String)> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let text = match row {
AssignRow::Key => format!("{}: {}", s.node_form_var, form.key),
AssignRow::Value => format!("{}: {}", s.node_form_value, form.value),
};
(i, caretted(text, i == form.selected))
})
.collect();
draw_simple_form_overlay(
f,
s.node_assign_title,
s.report_node_assign_hint,
&lines,
form.selected,
None,
th,
app,
);
}
fn draw_report_node_list_overlay(
f: &mut Frame,
form: &super::report_nodes::ListForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::ListRow;
let rows = form.visible_rows();
let lines: Vec<(usize, String)> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let sel = i == form.selected;
let text = match row {
ListRow::Name => caretted(format!("{}: {}", s.node_form_list_name, form.name), sel),
ListRow::Value(vi) => caretted(format!(" {}", form.values[*vi]), sel),
ListRow::Add => s.report_node_list_add.to_string(),
};
(i, text)
})
.collect();
draw_simple_form_overlay(
f,
s.node_list_title,
s.report_node_list_hint,
&lines,
form.selected,
None,
th,
app,
);
}
fn caretted(mut text: String, selected: bool) -> String {
if selected {
text.push('▏');
}
text
}
#[allow(clippy::too_many_arguments)]
fn draw_simple_form_overlay(
f: &mut Frame,
title: &str,
hint: &str,
lines: &[(usize, String)],
selected: usize,
note: Option<&str>,
th: &Theme,
app: Option<&TuiApp>,
) {
let note_h = usize::from(note.is_some());
let n = lines.len() + note_h;
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let selected = selected.min(lines.len().saturating_sub(1));
let sel_line = selected + note_h;
let scroll = if sel_line >= inner_h {
sel_line + 1 - inner_h
} else {
0
};
let mut all: Vec<Line> = Vec::with_capacity(n);
if let Some(note) = note {
all.push(Line::from(Span::styled(
format!(" {note}"),
Style::default().fg(th.dim),
)));
}
all.extend(lines.iter().map(|(i, text)| {
let style = if *i == selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
Line::from(Span::styled(text.clone(), style))
}));
let rendered: Vec<Line> = all.into_iter().skip(scroll).take(inner_h).collect();
let block = panel(title.to_string(), true, th).title_bottom(
Line::from(Span::styled(
format!(" {hint} "),
Style::default().fg(th.dim),
))
.centered(),
);
f.render_widget(Paragraph::new(rendered).block(block), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for line in scroll..n.min(scroll + inner_h) {
let Some(row) = line.checked_sub(note_h) else {
continue;
};
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (line - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn clause_text(
row: super::report_nodes::ClauseRow,
c: &crate::report::edit::ClauseForm,
s: &Strings,
is_sel: bool,
) -> String {
use super::report_nodes::ClauseRow;
let check = |on: bool| {
if on {
s.checkbox_checked
} else {
s.checkbox_unchecked
}
};
let text = match row {
ClauseRow::Truth => {
let shown = if c.truth.trim().is_empty() && !is_sel {
s.report_node_clause_truth_none.to_string()
} else {
c.truth.clone()
};
let mut t = format!("{}: {shown}", s.report_node_clause_truth_label);
if is_sel {
t.push('▏');
}
t
}
ClauseRow::Detail => format!("{} {}", check(c.detail), s.report_node_clause_detail_toggle),
ClauseRow::Image => format!(
"{} {}",
check(c.image_on),
s.report_node_clause_image_toggle
),
ClauseRow::Fit => format!(" {} {}", check(c.fit), s.report_node_clause_fit),
ClauseRow::Height | ClauseRow::Width => {
let (label, value) = if matches!(row, ClauseRow::Height) {
(s.report_node_clause_height, &c.height)
} else {
(s.report_node_clause_width, &c.width)
};
let shown = if value.is_empty() && !is_sel {
s.report_node_clause_size_auto.to_string()
} else {
value.clone()
};
let mut t = format!(" {label}: {shown}");
if is_sel {
t.push('▏');
}
t
}
};
text
}
fn draw_report_node_with_field_overlay(
f: &mut Frame,
form: &super::report_nodes::WithFieldForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::WithFieldRow;
let rows = form.visible_rows();
let n = rows.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let selected = form.selected.min(n.saturating_sub(1));
let scroll = if selected >= inner_h {
selected + 1 - inner_h
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_h) {
let is_sel = i == selected;
let base = if is_sel {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let line = match *row {
WithFieldRow::Name => {
let mut text = format!("{}: {}", s.report_node_with_name_label, form.name);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
WithFieldRow::Query => {
let shown = if form.query.trim().is_empty() && !is_sel {
s.report_node_with_query_none.to_string()
} else {
form.query.clone()
};
let mut text = format!("{}: {shown}", s.report_node_with_query_label);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
WithFieldRow::Clause(cr) => Line::from(Span::styled(
clause_text(cr, &form.clauses, s, is_sel),
base,
)),
WithFieldRow::Stats => {
let mark = if form.stats_on {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
Line::from(Span::styled(
format!("{mark} {}", s.report_node_with_stats_toggle),
base,
))
}
WithFieldRow::Stat(si) => {
let (kind, on) = form.stats[si];
let mark = if on {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
let style = if is_sel {
base
} else if on {
Style::default().fg(th.text)
} else {
Style::default().fg(th.dim)
};
Line::from(Span::styled(
format!(" {mark} {}", kind.keyword()),
style,
))
}
};
lines.push(line);
}
let block = panel(s.report_node_with_title.to_string(), true, th).title_bottom(
Line::from(Span::styled(
format!(" {} ", s.report_node_with_hint),
Style::default().fg(th.dim),
))
.centered(),
);
f.render_widget(Paragraph::new(lines).block(block), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..rows.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn draw_report_node_envs_overlay(
f: &mut Frame,
form: &super::report_nodes::EnvsForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::EnvsRow;
let rows = form.visible_rows();
let n = rows.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let selected = form.selected.min(n.saturating_sub(1));
let scroll = if selected >= inner_h {
selected + 1 - inner_h
} else {
0
};
let mode_label = if form.compare {
s.report_node_envs_mode_roles
} else {
s.report_node_envs_mode_plain
};
let mut lines: Vec<Line> = Vec::new();
for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_h) {
let is_sel = i == selected;
let base = if is_sel {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let line = match *row {
EnvsRow::Var => {
let mut text = format!("{}: {}", s.report_node_envs_var_label, form.var);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
EnvsRow::Mode => Line::from(Span::styled(
format!("{}: ‹{}›", s.report_node_envs_mode_label, mode_label),
base,
)),
EnvsRow::Parallel => {
let mark = if form.parallel {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
Line::from(Span::styled(
format!("{} {}", mark, s.report_node_parallel_label),
base,
))
}
EnvsRow::Degree => {
Line::from(Span::styled(degree_row_text(&form.degree, s, is_sel), base))
}
EnvsRow::BaselineShow(fi) => {
let row = &form.baseline_show[fi];
let mark = if row.included {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
Line::from(Span::styled(
format!(
" {} {} {}",
mark, s.report_node_baseline_show_label, row.name
),
base,
))
}
EnvsRow::Env(ei) => {
let entry = &form.entries[ei];
let shown = if entry.name.is_empty() {
s.report_node_envs_none
} else {
entry.name.as_str()
};
let value = if entry.file {
format!("{}(‹{shown}›)", s.report_node_envs_file)
} else {
format!("‹{shown}›")
};
let text = if form.compare {
let role = if entry.baseline {
s.report_node_envs_baseline
} else {
s.report_node_envs_comparison
};
format!(" [{role}] {value}")
} else {
format!(" {value}")
};
Line::from(Span::styled(text, base))
}
};
lines.push(line);
}
let block = panel(s.report_node_envs_title.to_string(), true, th).title_bottom(
Line::from(Span::styled(
format!(" {} ", s.report_node_envs_hint),
Style::default().fg(th.dim),
))
.centered(),
);
f.render_widget(Paragraph::new(lines).block(block), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..rows.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn draw_report_node_files_overlay(
f: &mut Frame,
form: &super::report_nodes::FilesForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::report_nodes::FilesRow;
let rows = form.visible_rows();
let n = rows.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let selected = form.selected.min(n.saturating_sub(1));
let scroll = if selected >= inner_h {
selected + 1 - inner_h
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_h) {
let is_sel = i == selected;
let base = if is_sel {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let line = match *row {
FilesRow::Var => {
let mut text = format!("{}: {}", s.report_node_files_var_label, form.var);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
FilesRow::Folder => {
let shown = if form.dir.trim().is_empty() {
s.report_node_files_none
} else {
form.dir.as_str()
};
Line::from(Span::styled(
format!("{}: ‹{shown}›", s.report_node_files_folder_label),
base,
))
}
FilesRow::Match => {
let mut text = format!("{}: {}", s.report_node_files_match_label, form.glob);
if is_sel {
text.push('▏');
}
Line::from(Span::styled(text, base))
}
FilesRow::Parallel => {
let mark = if form.parallel {
s.checkbox_checked
} else {
s.checkbox_unchecked
};
Line::from(Span::styled(
format!("{} {}", mark, s.report_node_parallel_label),
base,
))
}
FilesRow::Degree => {
Line::from(Span::styled(degree_row_text(&form.degree, s, is_sel), base))
}
};
lines.push(line);
}
let title = if form.folders {
s.report_node_folders_title
} else {
s.report_node_files_title
};
let block = panel(title.to_string(), true, th).title_bottom(
Line::from(Span::styled(
format!(" {} ", s.report_node_files_hint),
Style::default().fg(th.dim),
))
.centered(),
);
f.render_widget(Paragraph::new(lines).block(block), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..rows.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn draw_report_bind_overlay(
f: &mut Frame,
picker: &super::reports::ReportBindPicker,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let title = format!("{} ({})", s.report_bind_title, s.report_bind_hint);
let n = picker.options.len();
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let scroll = if picker.selected >= inner_h {
picker.selected + 1 - inner_h
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
for (i, opt) in picker.options.iter().enumerate().skip(scroll).take(inner_h) {
let base = if i == picker.selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
let mut spans = vec![Span::styled(opt.name.clone(), base)];
let detail = opt
.path
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| s.report_bind_unsaved.to_string());
spans.push(Span::styled(
format!(" {detail}"),
Style::default().fg(th.dim),
));
lines.push(Line::from(spans));
}
f.render_widget(Paragraph::new(lines).block(panel(title, true, th)), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..picker.options.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
fn draw_report_setting_menu_overlay(
f: &mut Frame,
menu: &super::report_nodes::SettingMenu,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let typed = if menu.filter.is_empty() {
String::new()
} else {
format!(" /{}", menu.filter)
};
let title = format!("{}{typed} ({})", menu.title(s), s.report_setting_menu_hint);
let visible = menu.visible();
let n = visible.len().max(1);
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let scroll = menu.selected.saturating_sub(inner_h.saturating_sub(1));
let mut lines: Vec<Line> = Vec::new();
if visible.is_empty() {
lines.push(Line::from(Span::styled(
s.report_setting_menu_no_match.to_string(),
Style::default().fg(th.dim),
)));
}
for (i, opt) in visible.iter().enumerate().skip(scroll).take(inner_h) {
let style = if i == menu.selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
lines.push(Line::from(Span::styled((*opt).clone(), style)));
}
f.render_widget(Paragraph::new(lines).block(panel(title, true, th)), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..visible.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
}
fn draw_probe_menu_overlay(
f: &mut Frame,
menu: &super::probe_menu::ProbeMenu,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
use super::probe_menu::ProbeStep;
use crate::probe::verb_label;
let step_one = menu.step == ProbeStep::PickSubject;
let typed = if menu.filter.is_empty() || !step_one {
String::new()
} else {
format!(" /{}", menu.filter)
};
let hint = if step_one {
s.probe_menu_hint
} else {
s.probe_verb_hint
};
let visible = menu.visible();
let n = menu.row_count().max(1);
let box_w = f.area().width.saturating_sub(6).clamp(40, 100);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let inner_w = area.width.saturating_sub(2) as usize;
let scroll = menu.selected.saturating_sub(inner_h.saturating_sub(1));
let position = if menu.row_count() > inner_h {
format!(" {}/{}", menu.selected + 1, menu.row_count())
} else {
String::new()
};
let title = format!("{}{typed}{position} ({hint})", menu.title(s));
let mut lines: Vec<Line> = Vec::new();
let row_style = |i: usize| {
if i == menu.selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
}
};
if menu.row_count() == 0 {
lines.push(Line::from(Span::styled(
s.probe_menu_no_match.to_string(),
Style::default().fg(th.dim),
)));
}
if step_one {
let name_w = visible
.iter()
.map(|row| row.label(s).chars().count())
.max()
.unwrap_or(0)
.min(inner_w.saturating_sub(12).max(8));
for (i, row) in visible.iter().enumerate().skip(scroll).take(inner_h) {
let name = row.label(s);
let name = if name.chars().count() > name_w {
let tail: String = name
.chars()
.skip(name.chars().count() - name_w.saturating_sub(1))
.collect();
format!("…{tail}")
} else {
format!("{name:name_w$}")
};
let value = row.value(inner_w.saturating_sub(name_w + 2).max(4), s);
let style = row_style(i);
lines.push(Line::from(vec![
Span::styled(name, style),
Span::styled(" ", style),
Span::styled(
value,
if i == menu.selected {
style
} else {
Style::default().fg(th.dim)
},
),
]));
}
} else {
let subject = menu.chosen.as_ref().map(|p| p.subject.clone());
for (i, verb) in menu.verbs.iter().enumerate().skip(scroll).take(inner_h) {
let label = match &subject {
Some(subject) => verb_label(subject, verb, s),
None => String::new(),
};
lines.push(Line::from(Span::styled(label, row_style(i))));
}
}
f.render_widget(Paragraph::new(lines).block(panel(title, true, th)), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..menu.row_count().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
}
fn draw_report_node_menu_overlay(
f: &mut Frame,
menu: &super::report_nodes::NodeMenu,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let hint = match menu.step {
super::report_nodes::NodeMenuStep::PickKind => s.node_menu_hint,
super::report_nodes::NodeMenuStep::PickRequest => s.node_pick_request_hint,
};
let title = format!("{} ({})", menu.title(s), hint);
let n = menu.options.len().max(1);
let box_w = f.area().width.saturating_sub(6).clamp(40, 90);
let box_h = (n as u16 + 2).min(f.area().height.saturating_sub(2)).max(3);
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let inner_h = area.height.saturating_sub(2) as usize;
let scroll = if menu.selected >= inner_h {
menu.selected + 1 - inner_h
} else {
0
};
let mut lines: Vec<Line> = Vec::new();
if menu.options.is_empty() {
lines.push(Line::from(Span::styled(
s.node_pick_request_none.to_string(),
Style::default().fg(th.dim),
)));
}
for (i, opt) in menu.options.iter().enumerate().skip(scroll).take(inner_h) {
let style = if i == menu.selected {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
lines.push(Line::from(Span::styled(opt.clone(), style)));
}
f.render_widget(Paragraph::new(lines).block(panel(title, true, th)), area);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
for row in scroll..menu.options.len().min(scroll + inner_h) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - scroll) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
if n > inner_h {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: inner_h as u16,
};
draw_scrollbar(f, bar_area, n, inner_h, scroll, th);
}
}
pub(crate) fn draw(f: &mut Frame, app: &mut TuiApp) {
app.begin_mouse_frame();
let s = Strings::for_language(&app.language);
let th = app.theme();
app.refresh_json(app.active_tab);
app.maybe_auto_open_workspace_picker();
f.render_widget(Block::default().style(Style::default().bg(th.bg)), f.area());
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(1),
])
.split(f.area());
draw_menu(f, rows[0], app, &s, &th);
draw_tabs(f, rows[1], app, &s, &th);
draw_body(f, rows[2], app, &s, &th);
draw_footer(f, rows[3], app, &s, &th);
paint_selection_highlight(f, app, &th);
paint_probe_anchor(f, app, &th);
if let Some(idx) = app.active_report_index()
&& app.reports[idx].params_open
{
super::reports::draw_report_run_settings_overlay(f, app, idx, &s, &th);
}
if app.overlay.is_some() {
draw_overlay(f, app, &s, &th);
} else {
app.prompt_editor_area = Rect::default();
}
}
fn paint_probe_anchor(f: &mut Frame, app: &TuiApp, th: &Theme) {
let Some((start, end)) = app.resp_probe_anchor else {
return;
};
let Some(wrap) = app.resp_panel.wrap() else {
return;
};
let cells = tui_panel_select::selection::highlight_cells(
start,
end,
wrap,
app.resp_text_area,
app.resp_panel.scroll(),
);
let buf = f.buffer_mut();
for (row, from, to) in cells {
for col in from..to {
if let Some(cell) = buf.cell_mut((col, row)) {
cell.set_style(
Style::default()
.fg(th.accent)
.add_modifier(Modifier::UNDERLINED | Modifier::BOLD),
);
}
}
}
}
fn paint_selection_highlight(f: &mut Frame, app: &TuiApp, th: &Theme) {
if !app.has_any_selection() {
return;
}
let style = Style::default().bg(th.select_bg).fg(th.select_fg);
let mut cells = app.main_panel.highlight_regions(app.main_text_area);
cells.extend(app.resp_panel.highlight_regions(app.resp_text_area));
if let Some(rt) = app.active_report() {
use crate::tui::reports::ReportPane;
cells.extend(
rt.source_panel
.highlight_regions(app.report_pane_areas[ReportPane::Source.idx()]),
);
cells.extend(
rt.validation_panel
.highlight_regions(app.report_pane_areas[ReportPane::Validation.idx()]),
);
cells.extend(
rt.results_panel
.highlight_regions(app.report_pane_areas[ReportPane::Results.idx()]),
);
}
let buf = f.buffer_mut();
for (row, from, to) in cells {
for col in from..to {
if let Some(cell) = buf.cell_mut((col, row)) {
cell.set_style(style);
}
}
}
}
pub(crate) fn draw_menu(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let base = Style::default().fg(th.accent).add_modifier(Modifier::BOLD);
let mut spans = vec![Span::raw(" ")];
let file_w = Line::from(mnemonic_spans(s.file_menu_label, base)).width() as u16;
spans.extend(mnemonic_spans(s.file_menu_label, base));
spans.push(Span::raw(" "));
let settings_w = Line::from(mnemonic_spans(s.options_menu_label, base)).width() as u16;
spans.extend(mnemonic_spans(s.options_menu_label, base));
if let Some(st) = &app.status {
spans.push(Span::raw(" "));
let c = if st.is_ok() { th.ok } else { th.err };
spans.push(Span::styled(st.text(s), Style::default().fg(c)));
spans.push(Span::styled(
format!(" ({} {})", s.status_copy_key, s.status_copy_hint),
Style::default().fg(th.dim),
));
}
let error = { app.response.lock().unwrap().error_text(s) };
let echoes_status = app.status.as_ref().is_some_and(|st| st.text(s) == error);
if !error.is_empty() && !echoes_status {
spans.push(Span::styled(
format!(" {error}"),
Style::default().fg(th.err).add_modifier(Modifier::BOLD),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(th.panel)),
area,
);
let file_x = area.x.saturating_add(1);
if file_x < area.right() {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(file_x, area.y, file_w.min(area.right() - file_x), 1),
MouseHitTarget::MenuFile,
);
}
let settings_x = file_x.saturating_add(file_w).saturating_add(3);
if settings_x < area.right() {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(
settings_x,
area.y,
settings_w.min(area.right() - settings_x),
1,
),
MouseHitTarget::MenuSettings,
);
}
}
fn tab_icons(col: &crate::collection::Collection) -> String {
let mut icons = String::new();
if col.git_origin.is_some() || col.workspace_downloaded_from_git {
icons.push_str(GIT_ICON);
icons.push(' ');
}
if col.workspace_root.is_some() {
icons.push_str(FOLDER_ICON);
icons.push(' ');
}
icons
}
fn tab_dirty_marker(col: &crate::collection::Collection, s: &Strings) -> String {
if col.unsaved_edit_count() > 0 {
format!(" {}", s.tab_unsaved_marker)
} else {
String::new()
}
}
pub(crate) fn draw_tabs(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let focused = !app.active_is_strip_report() && app.focus == Pane::Tabs;
let mk = |label: String, active: bool| -> Span {
if active {
Span::styled(
format!(" {label} "),
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(format!(" {label} "), Style::default().fg(th.dim))
}
};
let mut spans: Vec<Span> = Vec::new();
let mut tab_hits: Vec<(usize, usize, usize)> = Vec::new();
let mut pos = 0usize;
let mut active_start = 0usize;
let mut active_w = 0usize;
for (i, col) in app.collections.iter().enumerate() {
if i > 0 {
spans.push(Span::raw("│"));
pos += 1;
}
let name = if i == 0 {
format!("{}{}", s.tab_request, tab_dirty_marker(col, s))
} else {
format!("{}{}{}", tab_icons(col), col.name, tab_dirty_marker(col, s))
};
let w = name.chars().count() + 2; if app.active_tab == i {
active_start = pos;
active_w = w;
}
tab_hits.push((i, pos, w));
spans.push(mk(name, app.active_tab == i));
pos += w;
}
let report_base = app.collections.len();
for (slot, ri) in app.standalone_report_indices().into_iter().enumerate() {
let rt = &app.reports[ri];
spans.push(Span::raw("│"));
pos += 1;
let marker = if rt.report.dirty {
format!(" {}", s.report_dirty_marker)
} else {
String::new()
};
let name = format!("{REPORT_ICON} {}{}", rt.report.name, marker);
let idx = report_base + slot;
let w = name.chars().count() + 2;
if app.active_tab == idx {
active_start = pos;
active_w = w;
}
tab_hits.push((idx, pos, w));
spans.push(mk(name, app.active_tab == idx));
pos += w;
}
let total_w = pos;
let avail = area.width.saturating_sub(2) as usize;
let (line, hit_scroll, hit_content_x, hit_content_w) = if total_w <= avail || avail == 0 {
(Line::from(spans), 0usize, area.x.saturating_add(1), avail)
} else {
let target_w = avail.saturating_sub(2).max(1);
let mut scroll = 0usize;
if active_start + active_w > scroll + target_w {
scroll = (active_start + active_w).saturating_sub(target_w);
}
if active_start < scroll {
scroll = active_start;
}
let show_left = scroll > 0;
let content_w_before_right = avail.saturating_sub(show_left as usize);
let remaining = total_w.saturating_sub(scroll);
let show_right = remaining > content_w_before_right;
let content_w = content_w_before_right.saturating_sub(show_right as usize);
let mut out: Vec<Span> = Vec::new();
if show_left {
out.push(Span::styled("\u{2039}", Style::default().fg(th.accent)));
}
out.extend(take_display(skip_display(spans, scroll), content_w));
if show_right {
out.push(Span::styled("\u{203a}", Style::default().fg(th.accent)));
}
let content_x = area.x + 1 + u16::from(show_left);
(Line::from(out), scroll, content_x, content_w)
};
f.render_widget(
Paragraph::new(line).block(panel(s.tabs_heading.to_string(), focused, th)),
area,
);
let hit_y = area.y.saturating_add(1);
for (idx, start, w) in tab_hits {
let end = start + w;
let win_start = hit_scroll;
let win_end = hit_scroll + hit_content_w;
let from = start.max(win_start);
let to = end.min(win_end);
if from < to {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(
hit_content_x + (from - win_start) as u16,
hit_y,
(to - from) as u16,
1,
),
MouseHitTarget::Tab(idx),
);
}
}
}
pub(crate) fn draw_body(f: &mut Frame, area: Rect, app: &mut TuiApp, s: &Strings, th: &Theme) {
if app.active_is_strip_report() {
super::reports::draw_report_body(f, area, app, s, th);
return;
}
let cols =
Layout::horizontal([Constraint::Length(app.list_width), Constraint::Min(10)]).split(area);
let ci = app.active_tab;
draw_collection_left(f, cols[0], app, ci, s, th);
if let Some(idx) = app.active_report_index() {
super::reports::draw_report_content(f, cols[1], app, idx, s, th);
return;
}
let right = Layout::vertical([Constraint::Min(4), Constraint::Percentage(app.response_pct)])
.split(cols[1]);
draw_collection_main(f, right[0], app, ci, s, th);
draw_response(f, right[1], app, ci, s, th);
}
pub(crate) fn draw_collection_left(
f: &mut Frame,
area: Rect,
app: &TuiApp,
ci: usize,
s: &Strings,
th: &Theme,
) {
let panes = Layout::vertical([Constraint::Min(3), Constraint::Percentage(app.response_pct)])
.split(area);
let focused = app.focus == Pane::List;
let col = &app.collections[ci];
let view_rows = LeftRow::build(col);
let sel = col.list_cursor.min(view_rows.len().saturating_sub(1));
let filtering = !col.list_query.is_empty() || (app.list_filter_typing && focused);
let (list_area, filter_area) = if filtering {
let parts = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).split(panes[0]);
(parts[0], Some(parts[1]))
} else {
(panes[0], None)
};
let env = app.effective_env(ci);
let smap = crate::request::subst_map(col, env.as_ref());
let any_computed = col.entries.iter().any(|e| !e.generators.is_empty());
let url_w = list_area
.width
.saturating_sub(2 + 2 + 5 + if any_computed { 2 } else { 0 });
app.list_scroll_w.set(url_w);
let sel_len = view_rows
.get(sel)
.and_then(LeftRow::entry_idx)
.and_then(|idx| col.entries.get(idx))
.map(|e| {
if crate::tree::entry_path(&e.title)
.pop()
.unwrap_or_default()
.is_empty()
{
crate::request::subst_display(&e.url, &smap).chars().count()
} else {
0
}
})
.unwrap_or(0);
let max_scroll = sel_len.saturating_sub((url_w as usize).saturating_sub(1));
let hscroll = (app.list_hscroll as usize).min(max_scroll);
let items: Vec<ListItem> = view_rows
.iter()
.enumerate()
.map(|(i, row)| match row {
LeftRow::WsFolder {
name,
depth,
expanded,
} => {
let indent = " ".repeat(*depth);
let chevron = if *expanded {
COLLECTION_OPEN_ICON
} else {
COLLECTION_CLOSED_ICON
};
ListItem::new(Line::from(Span::styled(
format!("{indent}{chevron} {name}/"),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
)))
}
LeftRow::Collection {
name,
depth,
open,
loaded,
edited,
} => {
let indent = " ".repeat(*depth);
let chevron = if *open {
COLLECTION_OPEN_ICON
} else {
COLLECTION_CLOSED_ICON
};
let colour = if *loaded { th.accent } else { th.dim };
let mut spans = vec![Span::styled(
format!("{indent}{chevron} {name}"),
Style::default().fg(colour).add_modifier(Modifier::BOLD),
)];
if *edited {
spans.push(Span::styled(
format!(" {}", s.tab_unsaved_marker),
Style::default().fg(th.accent),
));
}
ListItem::new(Line::from(spans))
}
LeftRow::Report { name, depth } => {
let indent = " ".repeat(*depth);
ListItem::new(Line::from(Span::styled(
format!("{indent}{REPORT_ICON} {name}"),
Style::default().fg(th.accent),
)))
}
LeftRow::Environment { name, depth } => {
let indent = " ".repeat(*depth);
ListItem::new(Line::from(Span::styled(
format!("{indent}{ENV_ICON} {name}"),
Style::default().fg(th.accent),
)))
}
LeftRow::WsRequestName {
name,
method,
depth,
edited,
run,
} => {
let indent = " ".repeat(*depth);
let marker = if *edited {
Span::styled(
format!("{} ", s.tab_unsaved_marker),
Style::default().fg(th.accent),
)
} else {
Span::raw(" ".to_string())
};
ListItem::new(Line::from(vec![
Span::raw(indent),
marker,
Span::styled(
format!("{method:<5}"),
Style::default()
.fg(method_color(method))
.add_modifier(Modifier::BOLD),
),
run_marker_span(*run, th),
Span::styled(name.clone(), Style::default().fg(th.dim)),
]))
}
LeftRow::Entry { idx, depth } => {
let e = &col.entries[*idx];
if e.is_unreadable() {
let indent = " ".repeat(*depth);
let name = if e.title.trim().is_empty() {
s.entry_unreadable.to_string()
} else {
e.title.trim().to_string()
};
return ListItem::new(Line::from(vec![
Span::raw(indent),
Span::styled("\u{26a0} ", Style::default().fg(th.err)),
Span::styled(name, Style::default().fg(th.err)),
Span::styled(
format!(" [{}]", s.entry_unreadable),
Style::default().fg(th.dim),
),
]));
}
let (marker, marker_fg) = if e.user_added {
("\u{271a} ", th.ok)
} else if e.modified {
("\u{270e} ", th.accent)
} else {
(" ", th.text)
};
let mut spans = Vec::new();
if *depth > 0 {
spans.push(Span::raw(" ".repeat(*depth)));
}
spans.push(Span::styled(marker, Style::default().fg(marker_fg)));
spans.push(Span::styled(
format!("{:<5}", e.method),
Style::default()
.fg(method_color(&e.method))
.add_modifier(Modifier::BOLD),
));
spans.push(run_marker_span(e.last_run, th));
if any_computed {
spans.push(if e.generators.is_empty() {
Span::raw(" ")
} else {
Span::styled("\u{0192} ", Style::default().fg(th.computed))
});
}
let name = if col.list_filter_active() {
e.title.trim().to_string()
} else {
crate::tree::entry_path(&e.title).pop().unwrap_or_default()
};
if !name.is_empty() {
spans.push(Span::styled(name, Style::default().fg(th.text)));
ListItem::new(Line::from(spans))
} else {
let mut seen = SubstSeen::default();
let url_spans = highlight_spans(&e.url, &smap, th, &mut seen, None, None);
if i == sel {
let avail = url_w as usize;
let show_left = hscroll > 0;
let content_w_before_right = avail.saturating_sub(show_left as usize);
let remaining = sel_len.saturating_sub(hscroll);
let show_right = remaining > content_w_before_right;
let content_w = content_w_before_right.saturating_sub(show_right as usize);
if show_left {
spans.push(Span::styled("\u{2039}", Style::default().fg(th.dim))); }
spans.extend(take_display(skip_display(url_spans, hscroll), content_w));
if show_right {
spans.push(Span::styled("\u{203a}", Style::default().fg(th.dim)));
}
} else {
spans.extend(url_spans);
}
ListItem::new(Line::from(spans))
}
}
})
.collect();
let items: Vec<ListItem> =
if items.is_empty() && col.workspace_root.is_some() && col.path.is_none() {
vec![ListItem::new(Line::styled(
s.workspace_empty_state.to_string(),
Style::default().fg(th.dim),
))]
} else if items.is_empty() && col.list_filter_active() {
vec![
ListItem::new(Line::styled(
s.list_filter_no_matches.to_string(),
Style::default().fg(th.dim),
)),
ListItem::new(Line::styled(
s.list_filter_no_matches_hint.to_string(),
Style::default().fg(th.dim),
)),
]
} else {
items
};
let title = if ci == 0 {
s.tab_request.to_string()
} else {
let display_name = if col.workspace_root.is_some() {
col.path
.as_deref()
.map(|p| collection_name_from_path(&p.to_string_lossy(), &col.name))
.unwrap_or_else(|| col.name.clone())
} else {
col.name.clone()
};
format!("{}{}", tab_icons(col), display_name)
};
let mut title_spans = vec![Span::styled(
title.clone(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
)];
if let Some(env) = col
.linked_env_id
.and_then(|id| app.global_envs.iter().find(|e| e.id == id))
{
let link_part = format!(" {LINK_ICON} ");
let env_suffix = " (v)";
title_spans.push(Span::styled(link_part, Style::default().fg(th.dim)));
title_spans.push(Span::styled(
env.name.clone(),
Style::default().fg(th.ok).add_modifier(Modifier::BOLD),
));
title_spans.push(Span::styled(env_suffix, Style::default().fg(th.dim)));
}
let run_key = if app.enhanced_keys { "^Enter/F5" } else { "F5" };
let run_primary_hint = format!("{run_key} {}", s.foot_run);
let hint_parts = [
run_primary_hint.clone(),
format!("Alt+F5 {}", s.foot_run_all),
format!("p {}", s.foot_env_link),
];
let run_hint = fit_border_hint(&hint_parts, " \u{00b7} ", list_area.width);
let run_hit_w = if run_hint.starts_with(run_primary_hint.as_str()) {
(Line::from(run_primary_hint.clone())
.width()
.min(u16::MAX as usize) as u16)
.min(list_area.width.saturating_sub(2))
} else {
0
};
let border = if focused { th.accent } else { th.line };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(Line::from(title_spans))
.style(Style::default().bg(th.panel))
.title_bottom(Line::styled(run_hint, Style::default().fg(th.dim)));
let list = List::new(items).block(block).highlight_style(
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
);
let sel = if view_rows.is_empty() {
None
} else {
Some(sel)
};
let list_offset =
app.list_scroll
.render_ctx(f, list_area, list, sel, view_rows.len(), ci as u64);
let list_inner = Rect {
x: list_area.x.saturating_add(1),
y: list_area.y.saturating_add(1),
width: list_area.width.saturating_sub(2),
height: list_area.height.saturating_sub(2),
};
app.push_mouse_hit(
MouseLayer::Base,
list_inner,
MouseHitTarget::FocusPane(Pane::List),
);
app.push_mouse_hit(
MouseLayer::Base,
list_inner,
MouseHitTarget::Scroll(MouseScrollTarget::List),
);
let first = list_offset;
let visible = list_inner.height as usize;
for row in first..view_rows.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(
list_inner.x,
list_inner.y + (row - first) as u16,
list_inner.width,
1,
),
MouseHitTarget::SelectListRow(row),
);
}
if run_hit_w > 0 {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(
list_area.x.saturating_add(1),
list_area.y + list_area.height.saturating_sub(1),
run_hit_w,
1,
),
MouseHitTarget::RunRequest,
);
}
if let Some(strip) = filter_area {
let mut spans = vec![
Span::styled(s.list_filter_label, Style::default().fg(th.dim)),
Span::styled(
col.list_query.clone(),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
];
if app.list_filter_typing && focused {
spans.push(Span::styled(
"\u{2588}",
Style::default()
.fg(th.accent)
.add_modifier(Modifier::SLOW_BLINK),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(th.panel)),
strip,
);
}
draw_env_panel(f, panes[1], app, s, th);
}
fn active_env_line(app: &TuiApp, s: &Strings, th: &Theme, width: u16) -> Line<'static> {
let active = app
.active_env_id
.and_then(|id| app.global_envs.iter().find(|e| e.id == id));
let mut spans = vec![Span::styled(
s.env_active_label,
Style::default().fg(th.dim),
)];
match active {
Some(env) => {
let icon = if env.git_origin.is_some() {
format!("{GIT_ICON} ")
} else {
String::new()
};
let used = s.env_active_label.chars().count() + 2 + icon.chars().count();
let avail = (width as usize).saturating_sub(used);
spans.push(Span::styled(
format!("\u{2713} {icon}{}", elide(&env.name, avail)),
Style::default().fg(th.ok).add_modifier(Modifier::BOLD),
));
}
None => spans.push(Span::styled(s.env_active_none, Style::default().fg(th.dim))),
}
Line::from(spans)
}
fn elide(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let keep = max.saturating_sub(1);
text.chars()
.take(keep)
.chain(std::iter::once('\u{2026}'))
.collect()
}
pub(crate) fn draw_env_panel(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let focused = app.focus == Pane::GlobalEnv;
let mut hint_parts = vec![
format!("a {}", s.foot_env_activate),
format!("/ {}", s.foot_env_filter),
];
let source = app.effective_env_source();
let source_label = match source {
crate::env_panel::EnvSource::Both => s.env_source_all,
crate::env_panel::EnvSource::Global => s.env_source_global,
crate::env_panel::EnvSource::Workspace => s.env_source_workspace,
};
if app.active_env_id.is_some() {
hint_parts.push(format!("g {}", s.foot_env_goto_active));
}
let title = if app.has_workspace_env_source() {
hint_parts.push(format!("o {}", s.foot_env_source));
format!("{} · {}", s.env_heading, source_label)
} else {
s.env_heading.to_string()
};
let activate_hint = fit_border_hint(&hint_parts, " ", area.width);
let block = panel(title, focused, th)
.title_bottom(Line::styled(activate_hint, Style::default().fg(th.dim)));
let rows = app.env_rows();
if rows.is_empty() {
let all_rows =
crate::env_panel::rows(&app.global_envs, &app.workspace_env_files(), "", source);
let any_rows = crate::env_panel::rows(
&app.global_envs,
&app.workspace_env_files(),
"",
crate::env_panel::EnvSource::Both,
);
let (first, hint) = if any_rows.is_empty() {
(
s.env_no_envs.to_string(),
format!("{} \u{2192} {}", s.file_menu, s.load_environment),
)
} else if all_rows.is_empty() {
(
format!("{}{}", s.env_source_label, source_label),
s.env_source_no_matches.to_string(),
)
} else {
(
format!("{}{}", s.env_filter_label, app.env_query),
s.env_filter_no_matches.to_string(),
)
};
let p = Paragraph::new(vec![
active_env_line(app, s, th, area.width.saturating_sub(2)),
Line::styled(first, Style::default().fg(th.dim)),
Line::styled(hint, Style::default().fg(th.dim)),
])
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(p, area);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::FocusPane(Pane::GlobalEnv),
);
return;
}
let show_source_strip =
app.has_workspace_env_source() && source != crate::env_panel::EnvSource::Both;
let (list_area, filter_area) =
if app.env_query.is_empty() && !(app.env_filter_typing && focused) && !show_source_strip {
(area, None)
} else {
let parts = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).split(area);
(parts[0], Some(parts[1]))
};
let sel = app.global_env_idx.min(rows.len().saturating_sub(1));
let text_w = list_area.width.saturating_sub(2 + 2 + 2);
app.global_env_scroll_w.set(text_w);
let sel_len = rows.get(sel).map(|r| r.name.chars().count()).unwrap_or(0);
let max_scroll = sel_len.saturating_sub((text_w as usize).saturating_sub(1));
let hscroll = (app.global_env_hscroll as usize).min(max_scroll);
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(i, row)| {
let env = row
.env_id()
.and_then(|id| app.global_envs.iter().find(|e| e.id == id));
let is_active = row.env_id().is_some() && app.active_env_id == row.env_id();
let (marker, marker_fg) = if is_active {
("\u{2713} ", th.ok)
} else {
(" ", th.dim)
};
let name_color = if is_active {
th.ok
} else if env.is_none() {
th.dim
} else {
th.text
};
let selected = i == sel;
let mark_fg = if selected { th.bg } else { marker_fg };
let dirty = env.is_some_and(|e| app.changed_env_count(e.id) > 0);
let (pencil, pencil_fg) = if dirty {
("\u{270e} ", th.accent)
} else {
(" ", th.dim)
};
let pencil_fg = if selected { th.bg } else { pencil_fg };
let prefix = if row.workspace {
format!("{WORKSPACE_ICON} ")
} else if env.is_some_and(|e| e.git_origin.is_some()) {
format!("{GIT_ICON} ")
} else {
String::new()
};
let mut spans = vec![
Span::styled(pencil, Style::default().fg(pencil_fg)),
Span::styled(marker, Style::default().fg(mark_fg)),
];
let full = format!("{prefix}{}", row.name);
if selected {
let avail = text_w as usize;
let show_left = hscroll > 0;
let content_w_before_right = avail.saturating_sub(show_left as usize);
let remaining = sel_len.saturating_sub(hscroll);
let show_right = remaining > content_w_before_right;
let content_w = content_w_before_right.saturating_sub(show_right as usize);
let visible: String = full.chars().skip(hscroll).take(content_w).collect();
let mut text = String::new();
if show_left {
text.push('\u{2039}');
}
text.push_str(&visible);
if show_right {
text.push('\u{203a}');
}
let fg = if selected { th.bg } else { name_color };
spans.push(Span::styled(text, Style::default().fg(fg)));
} else {
spans.push(Span::styled(full, Style::default().fg(name_color)));
}
ListItem::new(Line::from(spans))
})
.collect();
let inner_all = block.inner(list_area);
f.render_widget(block, list_area);
let (pin_area, items_area) = if inner_all.height > 1 {
let parts = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner_all);
(Some(parts[0]), parts[1])
} else {
(None, inner_all)
};
if let Some(pin) = pin_area {
f.render_widget(Paragraph::new(active_env_line(app, s, th, pin.width)), pin);
}
let list = List::new(items)
.highlight_style(Style::default().bg(th.accent).add_modifier(Modifier::BOLD));
let env_offset = app
.env_list_scroll
.render(f, items_area, list, Some(sel), rows.len());
if let Some(strip) = filter_area {
let mut spans = vec![
Span::styled(s.env_source_label, Style::default().fg(th.dim)),
Span::styled(source_label, Style::default().fg(th.accent)),
Span::raw(" "),
Span::styled(s.env_filter_label, Style::default().fg(th.dim)),
Span::styled(
app.env_query.clone(),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
];
if app.env_filter_typing && focused {
spans.push(Span::styled(
"\u{2588}",
Style::default()
.fg(th.accent)
.add_modifier(Modifier::SLOW_BLINK),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(th.panel)),
strip,
);
}
app.push_mouse_hit(
MouseLayer::Base,
inner_all,
MouseHitTarget::FocusPane(Pane::GlobalEnv),
);
app.push_mouse_hit(
MouseLayer::Base,
inner_all,
MouseHitTarget::Scroll(MouseScrollTarget::GlobalEnv),
);
if let Some(pin) = pin_area
&& let Some(row) = rows
.iter()
.position(|r| r.env_id().is_some() && r.env_id() == app.active_env_id)
{
app.push_mouse_hit(
MouseLayer::Base,
pin,
MouseHitTarget::SelectGlobalEnvRow(row),
);
}
let first = env_offset;
let visible = items_area.height as usize;
for row in first..rows.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Base,
Rect::new(
items_area.x,
items_area.y + (row - first) as u16,
items_area.width,
1,
),
MouseHitTarget::SelectGlobalEnvRow(row),
);
}
}
pub(crate) fn draw_env_popup(
f: &mut Frame,
app: &TuiApp,
popup: &EnvPopupState,
s: &Strings,
th: &Theme,
) {
let Some(env) = app.global_envs.iter().find(|e| e.id == popup.env_id) else {
return;
};
let area = centered_rect(78, 20.min(f.area().height.saturating_sub(2)), f.area());
f.render_widget(Clear, area);
let title = if env.git_origin.is_some() {
format!("{} — {GIT_ICON} {}", s.env_heading, env.name)
} else {
format!("{} — {}", s.env_heading, env.name)
};
if env.vars.is_empty() {
let block = panel(title, true, th);
let p = Paragraph::new(Line::styled(
s.env_no_env.to_string(),
Style::default().fg(th.dim),
))
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(p, area);
return;
}
let sel = popup.idx.min(env.vars.len().saturating_sub(1));
let text_w = area.width.saturating_sub(2 + 2 + 2 + 2);
popup.scroll_w.set(text_w);
let sel_len = env
.vars
.get(sel)
.map(|v| v.key.chars().count() + 3 + v.display_value().chars().count())
.unwrap_or(0);
let max_scroll = sel_len.saturating_sub((text_w as usize).saturating_sub(1));
let hscroll = (popup.hscroll as usize).min(max_scroll);
let items: Vec<ListItem> = env
.vars
.iter()
.enumerate()
.map(|(i, v)| {
let dot = if v.loading {
th.pending
} else if v.resolved {
match v.source {
ValueSource::Literal => th.subst,
_ => th.ok,
}
} else {
th.err
};
let val_color = if v.resolved { th.text } else { th.pending };
let shown = if v.loading {
s.env_loading.to_string()
} else {
v.display_value()
};
let (marker, marker_fg) = if v.user_added {
("\u{271a} ", th.ok)
} else if v.modified {
("\u{270e} ", th.accent)
} else {
(" ", th.dim)
};
let selected = i == sel;
let mark_fg = if selected { th.bg } else { marker_fg };
let mut spans = vec![
Span::styled(marker, Style::default().fg(mark_fg)),
Span::styled("● ", Style::default().fg(dot)),
];
if selected {
let combined = format!("{} = {}", v.key, shown);
let avail = text_w as usize;
let show_left = hscroll > 0;
let content_w_before_right = avail.saturating_sub(show_left as usize);
let remaining = sel_len.saturating_sub(hscroll);
let show_right = remaining > content_w_before_right;
let content_w = content_w_before_right.saturating_sub(show_right as usize);
let visible: String = combined.chars().skip(hscroll).take(content_w).collect();
let mut text = String::new();
if show_left {
text.push('\u{2039}');
}
text.push_str(&visible);
if show_right {
text.push('\u{203a}');
}
spans.push(Span::styled(text, Style::default().fg(th.bg)));
} else {
spans.push(Span::styled(v.key.clone(), Style::default().fg(th.text)));
spans.push(Span::styled(" = ", Style::default().fg(th.dim)));
spans.push(Span::styled(shown, Style::default().fg(val_color)));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(panel(title, true, th))
.highlight_style(Style::default().bg(th.accent).add_modifier(Modifier::BOLD))
.highlight_symbol("› ");
let var_offset =
app.env_var_scroll
.render_ctx(f, area, list, Some(sel), env.vars.len(), popup.env_id);
app.set_mouse_layer(MouseLayer::Overlay);
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
app.push_mouse_hit(
MouseLayer::Overlay,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::OverlayList),
);
let first = var_offset;
let visible = inner.height as usize;
for row in first..env.vars.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - first) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
fn subst_color(kind: crate::request::SubstKind, th: &Theme) -> Color {
use crate::request::SubstKind;
match kind {
SubstKind::Literal => th.subst, SubstKind::Loaded => th.ok, SubstKind::Computed => th.computed, SubstKind::Pending => th.pending, SubstKind::Failed => th.err, SubstKind::Undefined => th.err, }
}
#[derive(Default)]
struct SubstSeen {
loaded: bool,
literal: bool,
pending: bool,
failed: bool,
undefined: bool,
computed: bool,
shadowed: bool,
}
impl SubstSeen {
fn mark(&mut self, kind: crate::request::SubstKind) {
use crate::request::SubstKind;
match kind {
SubstKind::Loaded => self.loaded = true,
SubstKind::Computed => self.computed = true,
SubstKind::Literal => self.literal = true,
SubstKind::Pending => self.pending = true,
SubstKind::Failed => self.failed = true,
SubstKind::Undefined => self.undefined = true,
}
}
fn any(&self) -> bool {
self.loaded
|| self.literal
|| self.pending
|| self.failed
|| self.undefined
|| self.computed
}
}
fn recolor_base(
spans: Vec<Span<'static>>,
to: Color,
bold: bool,
base: Color,
) -> Vec<Span<'static>> {
spans
.into_iter()
.map(|mut sp| {
if sp.style.fg.is_none() || sp.style.fg == Some(base) {
sp.style = sp.style.fg(to);
}
if bold {
sp.style = sp.style.add_modifier(Modifier::BOLD);
}
sp
})
.collect()
}
fn highlight_spans(
text: &str,
vars: &std::collections::HashMap<String, crate::request::SubstInfo>,
th: &Theme,
seen: &mut SubstSeen,
shadowed: Option<&std::collections::HashSet<String>>,
mut icon_cols: Option<&mut Vec<usize>>,
) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut cur_len: usize = 0;
let mut rest = text;
while let Some(open) = rest.find("{{") {
let Some(close_rel) = rest[open + 2..].find("}}") else {
break;
};
let close = open + 2 + close_rel; let end = close + 2; let inner = rest[open + 2..close].trim();
if open > 0 {
let piece = rest[..open].to_string();
cur_len += piece.chars().count();
spans.push(Span::styled(piece, Style::default().fg(th.text)));
}
match vars.get(inner) {
Some(info) => {
seen.mark(info.kind);
let color = subst_color(info.kind, th);
match &info.shown {
Some(val) => {
if shadowed.is_some_and(|s| s.contains(inner)) {
if let Some(cols) = icon_cols.as_deref_mut() {
cols.push(cur_len);
}
cur_len += SHADOW_ICON.chars().count();
spans.push(Span::styled(SHADOW_ICON, Style::default().fg(th.pending)));
seen.shadowed = true;
}
cur_len += val.chars().count();
spans.push(Span::styled(val.clone(), Style::default().fg(color)));
}
None => {
let piece = rest[open..end].to_string();
cur_len += piece.chars().count();
spans.push(Span::styled(piece, Style::default().fg(color)));
}
}
}
None => {
seen.mark(crate::request::SubstKind::Undefined);
let piece = rest[open..end].to_string();
cur_len += piece.chars().count();
spans.push(Span::styled(
piece,
Style::default().fg(subst_color(crate::request::SubstKind::Undefined, th)),
));
}
}
rest = &rest[end..];
}
if !rest.is_empty() {
spans.push(Span::styled(rest.to_string(), Style::default().fg(th.text)));
}
spans
}
fn skip_display(spans: Vec<Span<'static>>, mut skip: usize) -> Vec<Span<'static>> {
if skip == 0 {
return spans;
}
let mut out: Vec<Span<'static>> = Vec::new();
for sp in spans {
let len = sp.content.chars().count();
if skip >= len {
skip -= len;
continue;
}
if skip > 0 {
let kept: String = sp.content.chars().skip(skip).collect();
out.push(Span::styled(kept, sp.style));
skip = 0;
} else {
out.push(sp);
}
}
out
}
fn take_display(spans: Vec<Span<'static>>, mut take: usize) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::new();
for sp in spans {
if take == 0 {
break;
}
let len = sp.content.chars().count();
if len <= take {
take -= len;
out.push(sp);
} else {
let kept: String = sp.content.chars().take(take).collect();
out.push(Span::styled(kept, sp.style));
take = 0;
}
}
out
}
pub(crate) fn word_wrap(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
if word.chars().count() > width {
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
}
let mut rest = word;
while rest.chars().count() > width {
let take: String = rest.chars().take(width).collect();
let take_len = take.len();
lines.push(take);
rest = &rest[take_len..];
}
cur = rest.to_string();
continue;
}
let candidate_len = if cur.is_empty() {
word.chars().count()
} else {
cur.chars().count() + 1 + word.chars().count()
};
if candidate_len > width {
lines.push(std::mem::take(&mut cur));
}
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(word);
}
if !cur.is_empty() || lines.is_empty() {
lines.push(cur);
}
lines
}
pub(crate) fn help_section_divider(title: &str, width: usize, th: &Theme) -> Line<'static> {
const LEFT_RULE: usize = 2;
let title_w = Span::raw(title.to_string()).width();
let right_rule = width.saturating_sub(LEFT_RULE + 2 + title_w).max(2);
Line::from(vec![
Span::styled("─".repeat(LEFT_RULE), Style::default().fg(th.dim)),
Span::raw(" "),
Span::styled(
title.to_string(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("─".repeat(right_rule), Style::default().fg(th.dim)),
])
}
pub(crate) fn help_entry_lines(shortcut: &str, desc: &str, width: usize) -> Vec<Line<'static>> {
help_entry_lines_col(shortcut, desc, 17, width)
}
pub(crate) fn help_entry_lines_col(
key: &str,
desc: &str,
key_col: usize,
width: usize,
) -> Vec<Line<'static>> {
let indent = key.chars().count().max(key_col) + 1;
let desc_width = width.saturating_sub(indent).max(1);
let wrapped = word_wrap(desc, desc_width);
let mut out = Vec::with_capacity(wrapped.len().max(1));
for (i, chunk) in wrapped.iter().enumerate() {
if i == 0 {
out.push(Line::raw(format!("{key:<key_col$} {chunk}")));
} else {
out.push(Line::raw(format!("{:indent$}{chunk}", "")));
}
}
out
}
pub(crate) fn glossary_entry_lines(
icon: &str,
color: Color,
label: &str,
desc: &str,
width: usize,
) -> Vec<Line<'static>> {
const KEY_COL: usize = 17;
let header = format!("{icon} {label}");
let header_w = Span::raw(header.clone()).width();
let indent = header_w.max(KEY_COL) + 1;
let desc_width = width.saturating_sub(indent).max(1);
let wrapped = word_wrap(desc, desc_width);
let pad = " ".repeat(indent.saturating_sub(header_w));
let mut out = Vec::with_capacity(wrapped.len().max(1));
for (i, chunk) in wrapped.iter().enumerate() {
if i == 0 {
out.push(Line::from(vec![
Span::styled(
header.clone(),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::raw(pad.clone()),
Span::raw(chunk.clone()),
]));
} else {
out.push(Line::raw(format!("{:indent$}{chunk}", "")));
}
}
out
}
pub(crate) fn draw_collection_main(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
ci: usize,
s: &Strings,
th: &Theme,
) {
let focused = app.focus == Pane::Main;
let hurl_view = app.default_request_view == request::RequestView::Hurl;
let title = if hurl_view {
s.entry_request_hurl
} else {
s.entry_request_json
};
let block = panel(title.to_string(), focused, th);
let inner = block.inner(area);
f.render_widget(block, area);
if app.collections[ci].entries.is_empty() {
app.main_max_scroll = 0;
app.main_panel
.set_content(Arc::from(""), inner.width.max(1) as usize);
app.main_panel.clear();
app.main_panel.set_scroll(0);
app.main_text_area = Rect::default();
app.main_shadow_icon_positions.clear();
app.main_scrollbar_area = Rect::default();
app.push_mouse_hit(
MouseLayer::Base,
inner,
MouseHitTarget::FocusPane(Pane::Main),
);
f.render_widget(
Paragraph::new(Line::styled(
s.no_requests_hint.to_string(),
Style::default().fg(th.dim),
)),
inner,
);
return;
}
let col = &app.collections[ci];
let idx = col.selected_entry.min(col.entries.len() - 1);
let entry = &col.entries[idx];
let method = entry.method.clone();
let url = entry.url.clone();
let captures = entry.captures.clone();
let asserts = entry.asserts.clone();
let generators = entry.generators.clone();
let expected_status = entry.expected_status;
let body_form_conflict = entry.body_form_conflict();
let (buf, valid) = if hurl_view {
(entry.to_hurl(), true)
} else {
let buf = col.request_json_buf.clone();
let valid = serde_json::from_str::<serde_json::Value>(&buf).is_ok();
(buf, valid)
};
let env = app.effective_env(ci);
let dvars = crate::request::subst_map(&app.collections[ci], env.as_ref());
let shadowed = app.shadowed_env_keys(ci);
let mut seen = SubstSeen::default();
let mut top_lines: Vec<Line> = Vec::new();
let mut first: Vec<Span> = vec![Span::styled(
format!("{method} "),
Style::default()
.fg(method_color(&method))
.add_modifier(Modifier::BOLD),
)];
first.extend(highlight_spans(
&url,
&dvars,
th,
&mut seen,
Some(&shadowed),
None,
));
first.push(Span::styled(
format!(
" [ {} — {} ]",
s.run_entry.trim(),
if app.enhanced_keys {
"^Enter / F5"
} else {
"F5"
}
),
Style::default().fg(th.accent),
));
top_lines.push(Line::from(first));
let mut shadow_positions: std::collections::HashSet<TextPos> = std::collections::HashSet::new();
let mut gen_rows_left = 0usize;
let mut body_lines: Vec<Line> = buf
.lines()
.enumerate()
.map(|(li, l)| {
let mut cols = Vec::new();
let spans = highlight_spans(l, &dvars, th, &mut seen, Some(&shadowed), Some(&mut cols));
for c in cols {
shadow_positions.insert(TextPos::new(li, c));
}
let marker = crate::hurl::parse_gen_marker(l).filter(|n| *n > 0);
let row = gen_rows_left > 0 && crate::hurl::parse_gen_row(l).is_some();
if row {
gen_rows_left -= 1;
} else if let Some(n) = marker {
gen_rows_left = n;
} else {
gen_rows_left = 0;
}
if marker.is_some() || row {
let bold = marker.is_some() && !row;
return Line::from(recolor_base(spans, th.computed, bold, th.text));
}
Line::from(spans)
})
.collect();
if buf.ends_with('\n') {
body_lines.push(Line::from(""));
}
if body_form_conflict {
top_lines.push(Line::styled(
s.body_form_conflict_hint.to_string(),
Style::default().fg(th.err),
));
}
if entry.stale_body_notes().is_some() {
top_lines.push(Line::styled(
s.body_notes_stale_hint.to_string(),
Style::default().fg(th.pending),
));
}
top_lines.push(if valid {
Line::styled(
format!("Enter {}", s.json_enter_to_edit),
Style::default().fg(th.dim),
)
} else {
Line::styled(s.json_invalid.to_string(), Style::default().fg(th.err))
});
if seen.any() {
let segments = [
(seen.loaded, s.subst_hint_loaded, th.ok),
(seen.literal, s.subst_hint_literal, th.subst),
(seen.pending, s.subst_hint_loading, th.pending),
(seen.failed, s.subst_hint_missing, th.err),
(seen.undefined, s.subst_hint_undefined, th.err),
(seen.computed, s.subst_hint_generated, th.computed),
];
let mut spans: Vec<Span<'static>> = Vec::new();
for (present, word, color) in segments {
if !present {
continue;
}
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("\u{25cf} {word}"),
Style::default().fg(color),
));
}
if seen.shadowed {
if !spans.is_empty() {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!("{SHADOW_ICON} {}", s.subst_hint_shadowed),
Style::default().fg(th.pending),
));
}
top_lines.push(Line::from(spans));
}
let mut meta_lines: Vec<Line> = Vec::new();
let mut meta_counts: Vec<(String, usize)> = Vec::new();
if !captures.is_empty() {
meta_counts.push(("[Captures]".to_string(), captures.len()));
meta_lines.push(Line::styled(
"[Captures]",
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
for (name, expr) in &captures {
meta_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), Style::default().fg(th.text)),
Span::styled(" ← ", Style::default().fg(th.dim)),
Span::styled(expr.clone(), Style::default().fg(th.dim)),
]));
}
}
if !asserts.is_empty() || expected_status.is_some() {
meta_counts.push((
"[Asserts]".to_string(),
asserts.len() + usize::from(expected_status.is_some()),
));
meta_lines.push(Line::styled(
"[Asserts]",
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
if let Some(code) = expected_status {
meta_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("status == {code}"), Style::default().fg(th.dim)),
]));
}
for a in &asserts {
meta_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(a.clone(), Style::default().fg(th.dim)),
]));
}
}
let shown_gens: Vec<&(String, String)> = generators
.iter()
.filter(|(name, expr)| !name.trim().is_empty() || !expr.trim().is_empty())
.collect();
if !shown_gens.is_empty() {
meta_counts.push((format!("[{}]", s.field_generated), shown_gens.len()));
meta_lines.push(Line::styled(
format!("[{}]", s.field_generated),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
for (name, expr) in shown_gens {
meta_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(name.clone(), Style::default().fg(th.text)),
Span::styled(" = ", Style::default().fg(th.dim)),
Span::styled(expr.clone(), Style::default().fg(th.dim)),
]));
}
}
let folded = !meta_lines.is_empty() && app.request_meta_folded.unwrap_or(true);
app.request_meta_folded_now = folded;
if folded {
let mut spans: Vec<Span<'static>> = Vec::new();
for (label, n) in &meta_counts {
if !spans.is_empty() {
spans.push(Span::styled(" · ", Style::default().fg(th.dim)));
}
spans.push(Span::styled(
label.clone(),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(format!(" {n}"), Style::default().fg(th.dim)));
}
spans.push(Span::styled(
format!(" z {}", s.foot_meta_show),
Style::default().fg(th.dim),
));
top_lines.push(Line::from(spans));
} else {
let room =
(inner.height.saturating_sub(3).max(2) as usize).saturating_sub(top_lines.len() + 1);
if room >= 2 && meta_lines.len() > room {
let hidden = meta_lines.len() - (room - 1);
meta_lines.truncate(room - 1);
meta_lines.push(Line::styled(
format!(" … +{hidden}"),
Style::default().fg(th.dim),
));
}
if let Some(first) = meta_lines.first_mut() {
first.spans.push(Span::styled(
format!(" z {}", s.foot_meta_hide),
Style::default().fg(th.dim),
));
}
top_lines.extend(meta_lines);
}
top_lines.push(Line::from(vec![Span::styled(
"─".repeat(area.width as usize),
Style::default().fg(th.dim),
)]));
let cap = inner.height.saturating_sub(3).max(2);
let top_h = (top_lines.len() as u16).clamp(2, cap);
let split = Layout::vertical([Constraint::Length(top_h), Constraint::Min(1)]).split(inner);
f.render_widget(Paragraph::new(top_lines), split[0]);
let text_area = split[1];
let width = text_area.width as usize;
app.main_panel.set_wrap_marker(Some(wrap_marker(th)));
app.main_panel.set_styled_content(&body_lines, width);
let total_lines = app.main_panel.total_rows().min(u16::MAX as u32) as u16;
let max_scroll = app.main_panel.clamp_scroll(text_area.height);
app.main_max_scroll = max_scroll;
let scroll = app.main_panel.scroll();
let height = text_area.height as usize;
let visible_wrapped = app.main_panel.visible_rows(text_area.height);
if max_scroll > 0 {
let bar_area = Rect {
x: area.x + area.width - 1,
y: text_area.y,
width: 1,
height: text_area.height,
};
draw_scrollbar(
f,
bar_area,
total_lines as usize,
height,
scroll as usize,
th,
);
app.main_scrollbar_area = bar_area;
app.push_mouse_hit(
MouseLayer::Base,
bar_area,
MouseHitTarget::Scroll(MouseScrollTarget::Main),
);
} else {
app.main_scrollbar_area = Rect::default();
}
app.main_text_area = text_area;
app.main_shadow_icon_positions = shadow_positions;
app.push_mouse_hit(
MouseLayer::Base,
text_area,
MouseHitTarget::FocusPane(Pane::Main),
);
app.push_mouse_hit(
MouseLayer::Base,
text_area,
MouseHitTarget::Scroll(MouseScrollTarget::Main),
);
f.render_widget(
Paragraph::new(visible_wrapped).style(Style::default().fg(th.text)),
text_area,
);
}
fn probe_anchor(
app: &TuiApp,
body: &str,
headers: &[(String, String)],
) -> Option<(TextPos, TextPos)> {
use crate::probe::Subject;
let Some(Overlay::ProbeMenu(menu)) = app.overlay.as_ref() else {
return None;
};
let probe = menu.chosen.clone().or_else(|| menu.choice())?;
match (&probe.subject, app.response_section) {
(Subject::Json(_) | Subject::JsonCount(_), ResponseSection::Body) => {
if app.response_compact {
return None;
}
let span = crate::probe::span_of(body, &probe.subject)?;
Some((text_pos_at(body, span.start), text_pos_at(body, span.end)))
}
(Subject::Header(name), ResponseSection::Headers) => {
let line = headers.iter().position(|(k, _)| k == name)?;
let start = name.chars().count() + 2;
let end = start + headers[line].1.chars().count();
Some((TextPos::new(line, start), TextPos::new(line, end)))
}
_ => None,
}
}
fn text_pos_at(text: &str, offset: usize) -> TextPos {
let before = &text[..offset.min(text.len())];
let line = before.matches('\n').count();
let col = before
.rsplit_once('\n')
.map(|(_, last)| last)
.unwrap_or(before)
.chars()
.count();
TextPos::new(line, col)
}
pub(crate) fn draw_response(
f: &mut Frame,
area: Rect,
app: &mut TuiApp,
ci: usize,
s: &Strings,
th: &Theme,
) {
let focused = app.focus == Pane::Response;
let block = response_panel_block(app, focused, s, th);
let inner = block.inner(area);
f.render_widget(block, area);
let entry = app
.collections
.get(ci)
.and_then(|col| col.entries.get(col.selected_entry));
let loading = entry
.map(|e| e.last_run == RunStatus::Running)
.unwrap_or(false);
let (status, status_text, body, error, asserts, duration, headers) =
match entry.and_then(|e| e.last_response.as_ref()) {
Some(r) => (
r.status,
r.status_text.clone(),
r.body.clone(),
r.error_detail(s),
r.assert_results.clone(),
r.duration_ms,
r.headers.clone(),
),
None => (
0,
String::new(),
Arc::from(""),
String::new(),
Vec::new(),
None,
Vec::new(),
),
};
app.resp_full_body = Arc::from("");
app.resp_compact_line_maps = Vec::new();
app.resp_probe_anchor = None;
if loading {
app.resp_max_scroll = 0;
app.resp_text_area = Rect::default();
app.resp_panel
.set_content(Arc::from(""), area.width.max(1) as usize);
app.resp_panel.clear();
app.resp_panel.set_scroll(0);
app.resp_scrollbar_area = Rect::default();
f.render_widget(
Paragraph::new(Line::styled(
format!("⟳ {}", s.sending),
Style::default().fg(th.accent),
)),
inner,
);
return;
}
if status == 0 {
if !error.is_empty() {
let content: Arc<str> = Arc::from(error.clone());
app.resp_panel.set_wrap_marker(Some(wrap_marker(th)));
app.resp_panel
.set_content(content, inner.width.max(1) as usize);
app.resp_max_scroll = app.resp_panel.clamp_scroll(inner.height);
app.resp_scrollbar_area = Rect::default();
let visible_wrapped = app.resp_panel.visible_rows(inner.height);
app.resp_text_area = inner;
f.render_widget(
Paragraph::new(visible_wrapped).style(Style::default().fg(th.err)),
inner,
);
return;
}
app.resp_max_scroll = 0;
app.resp_text_area = Rect::default();
app.resp_panel
.set_content(Arc::from(""), area.width.max(1) as usize);
app.resp_panel.clear();
app.resp_panel.set_scroll(0);
app.resp_scrollbar_area = Rect::default();
f.render_widget(
Paragraph::new(Line::styled(
s.no_response_yet.to_string(),
Style::default().fg(th.dim),
)),
inner,
);
return;
}
let color = match status {
200..=299 => th.ok,
400..=599 => th.err,
_ => Color::Gray,
};
let mut status_spans = vec![
Span::styled(format!("{} ", s.status_label), Style::default().fg(th.text)),
Span::styled(
format!("{status} {status_text}"),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
];
let passed = asserts.iter().filter(|a| a.passed).count();
let total = asserts.len();
if total > 0 {
let all_ok = passed == total;
let badge = if all_ok { th.ok } else { th.err };
let mark = if all_ok { "\u{2713}" } else { "\u{2717}" };
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(
format!("[Asserts] {mark} {passed}/{total}"),
Style::default().fg(badge).add_modifier(Modifier::BOLD),
));
}
if let Some(ms) = duration {
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(
format!("{} {ms} ms", s.response_time_label),
Style::default().fg(th.dim),
));
}
let folded = !asserts.is_empty()
&& app
.response_asserts_folded
.unwrap_or(passed == total && error.is_empty());
app.response_asserts_folded_now = folded;
let assert_lines: Vec<Line> = asserts
.iter()
.map(|a| {
let (mark, c) = if a.passed {
("\u{2713}", th.ok)
} else {
("\u{2717}", th.err)
};
let mut spans = vec![
Span::styled(format!(" {mark} "), Style::default().fg(c)),
Span::styled(a.expr.clone(), Style::default().fg(th.text)),
];
if !a.detail.is_empty() {
spans.push(Span::styled(
format!(" {}", a.detail),
Style::default().fg(th.dim),
));
}
Line::from(spans)
})
.collect();
let show_err_line = !error.is_empty() && passed == total;
let err_h: u16 = u16::from(show_err_line);
let assert_h = if folded {
0
} else {
(assert_lines.len() as u16).min(inner.height.saturating_sub(2).saturating_sub(err_h))
};
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(err_h),
Constraint::Length(assert_h),
Constraint::Min(1),
])
.split(inner);
f.render_widget(Paragraph::new(Line::from(status_spans)), rows[0]);
if show_err_line {
f.render_widget(
Paragraph::new(Line::styled(error.clone(), Style::default().fg(th.err))),
rows[1],
);
}
if assert_h > 0 {
f.render_widget(Paragraph::new(assert_lines), rows[2]);
}
let headers_text: Arc<str> = Arc::from(
headers
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join("\n"),
);
let (content, empty_note) = match app.response_section {
ResponseSection::Body => (body.clone(), s.resp_empty_body),
ResponseSection::Headers => (headers_text, s.resp_no_headers),
};
let body_area = rows[3];
let width = body_area.width as usize;
app.resp_panel.set_wrap_marker(Some(wrap_marker(th)));
if app.response_compact && app.response_section == ResponseSection::Body {
app.resp_full_body = content.clone();
let (compacted, line_maps) = crate::shared_utils::compact_long_strings_mapped(&content);
app.resp_compact_line_maps = line_maps;
app.resp_panel.set_content(Arc::from(compacted), width);
} else {
app.resp_panel.set_content(content.clone(), width);
}
let total_lines = app.resp_panel.total_rows().min(u16::MAX as u32) as u16;
let anchor = probe_anchor(app, &body, &headers);
if let Some((start, _)) = anchor
&& let Some(row) = app.resp_panel.wrap().map(|w| w.textpos_to_row_col(start).0)
{
let scroll = u32::from(app.resp_panel.scroll());
let height = u32::from(body_area.height);
if row < scroll || row >= scroll + height {
let want = row.saturating_sub(height / 3).min(u32::from(u16::MAX));
app.resp_panel.set_scroll(want as u16);
}
}
app.resp_probe_anchor = anchor;
let max_scroll = app.resp_panel.clamp_scroll(body_area.height);
app.resp_max_scroll = max_scroll;
let scroll = app.resp_panel.scroll();
let visible_wrapped = app.resp_panel.visible_rows(body_area.height);
if max_scroll > 0 {
let bar_area = Rect {
x: area.x + area.width - 1,
y: body_area.y,
width: 1,
height: body_area.height,
};
draw_scrollbar(
f,
bar_area,
total_lines as usize,
body_area.height as usize,
scroll as usize,
th,
);
app.resp_scrollbar_area = bar_area;
app.push_mouse_hit(
MouseLayer::Base,
bar_area,
MouseHitTarget::Scroll(MouseScrollTarget::Response),
);
} else {
app.resp_scrollbar_area = Rect::default();
}
app.resp_text_area = body_area;
app.push_mouse_hit(
MouseLayer::Base,
body_area,
MouseHitTarget::FocusPane(Pane::Response),
);
app.push_mouse_hit(
MouseLayer::Base,
body_area,
MouseHitTarget::Scroll(MouseScrollTarget::Response),
);
f.render_widget(
Paragraph::new(visible_wrapped).style(Style::default().fg(th.text)),
body_area,
);
if content.trim().is_empty() {
f.render_widget(
Paragraph::new(Line::styled(
empty_note.to_string(),
Style::default().fg(th.dim),
)),
body_area,
);
}
}
fn response_panel_block(app: &TuiApp, focused: bool, s: &Strings, th: &Theme) -> Block<'static> {
let border = if focused { th.accent } else { th.line };
let mut spans = vec![
Span::styled(
s.response_heading.to_string(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default().fg(border)),
];
for sec in ResponseSection::ALL {
let style = if sec == app.response_section && focused {
Style::default()
.fg(th.bg)
.bg(th.accent)
.add_modifier(Modifier::BOLD)
} else if sec == app.response_section {
Style::default().fg(th.accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
};
spans.push(Span::styled(format!(" {} ", sec.label(s)), style));
}
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(Line::from(spans))
.style(Style::default().bg(th.panel))
}
pub(crate) fn draw_footer(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let mut hint = vec![
format!("Tab {}", s.foot_focus),
format!("? {}", s.foot_help),
format!("q {}", s.foot_quit),
format!("n {}", s.foot_new),
];
let can_rename = match app.focus {
Pane::Tabs => app.active_tab != 0,
Pane::GlobalEnv => !app.env_rows().is_empty(),
_ => false,
};
if can_rename {
hint.push(format!("F2 {}", s.foot_rename));
}
let can_delete = match app.focus {
Pane::Tabs => app.active_tab != 0,
Pane::List => true,
Pane::GlobalEnv => !app.env_rows().is_empty(),
_ => false,
};
if can_delete {
hint.push(format!("x {}", s.foot_close));
}
if app.can_copy() {
hint.push(format!("y {}", s.foot_copy_selection));
}
if app.focus == Pane::Response && app.response_section == ResponseSection::Body {
hint.push(format!("c {}", s.foot_compact));
}
if app.focus == Pane::Response
&& app
.collections
.get(app.active_tab)
.and_then(|c| c.entries.get(c.selected_entry))
.is_some_and(|e| e.last_response.is_some())
{
hint.push(format!("a {}", s.foot_probe));
}
let response_asserts = app.focus == Pane::Response
&& app
.collections
.get(app.active_tab)
.and_then(|c| {
c.entries
.get(c.selected_entry.min(c.entries.len().saturating_sub(1)))
})
.and_then(|e| e.last_response.as_ref())
.is_some_and(|r| !r.assert_results.is_empty());
if response_asserts {
hint.push(format!(
"z {}",
if app.response_asserts_folded_now {
s.foot_meta_show
} else {
s.foot_meta_hide
}
));
}
let has_meta = app
.collections
.get(app.active_tab)
.and_then(|c| {
c.entries
.get(c.selected_entry.min(c.entries.len().saturating_sub(1)))
})
.is_some_and(|e| {
!e.captures.is_empty()
|| !e.asserts.is_empty()
|| e.expected_status.is_some()
|| e.generators
.iter()
.any(|(n, v)| !n.trim().is_empty() || !v.trim().is_empty())
});
if has_meta && !response_asserts {
hint.push(format!(
"z {}",
if app.request_meta_folded_now {
s.foot_meta_show
} else {
s.foot_meta_hide
}
));
}
if app.focus == Pane::Response {
hint.push(format!("\u{2190}/\u{2192} {}", s.foot_response_section));
}
if app.focus == Pane::List
&& app.collections.get(app.active_tab).is_some_and(|c| {
!c.is_workspace()
&& matches!(
c.rows().get(c.list_cursor),
Some(crate::tree::Row::Entry(_))
)
})
{
hint.push(format!("c {}", s.foot_duplicate));
}
if app.focus == Pane::List
&& app
.collections
.get(app.active_tab)
.is_some_and(|c| c.ws_transfer_target().is_some())
{
hint.push(format!("c {}", s.foot_copy_request));
}
if app.focus == Pane::List
&& app.collections.get(app.active_tab).is_some_and(|c| {
c.entries.len() > 1
&& !c.list_filter_active()
&& matches!(
c.rows().get(c.list_cursor),
Some(crate::tree::Row::Entry(_))
)
})
{
hint.push(format!("Alt+\u{2191}\u{2193} {}", s.foot_reorder));
}
if app.focus == Pane::List
&& app
.collections
.get(app.active_tab)
.is_some_and(|c| c.list_query.is_empty() && (c.is_workspace() || c.entries.len() > 1))
{
hint.push(format!("/ {}", s.foot_find_request));
}
if app
.collections
.get(app.active_tab)
.is_some_and(|c| c.workspace_root.is_some())
{
hint.push(format!("w {}", s.foot_workspace));
}
let hint = hint.join(" · ");
f.render_widget(
Paragraph::new(Line::styled(hint, Style::default().fg(th.dim)))
.style(Style::default().bg(th.panel)),
area,
);
}
fn draw_browser_list(
f: &mut Frame,
app: &TuiApp,
ex: &ratatui_explorer::FileExplorer,
area: Rect,
s: &Strings,
th: &Theme,
) {
if !ex.files().is_empty() {
ex.widget().render_ref(area, f.buffer_mut());
return;
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.accent))
.style(Style::default().bg(th.panel))
.title(Line::from(ex.cwd().display().to_string()))
.title_bottom(Line::from(app.browser_hint_line.clone()));
f.render_widget(
Paragraph::new(Line::from(Span::styled(
s.browser_no_matches,
Style::default().fg(th.dim).add_modifier(Modifier::ITALIC),
)))
.block(block)
.style(Style::default().bg(th.panel)),
area,
);
}
fn draw_browser_filter_strip(f: &mut Frame, row: Rect, query: &str, s: &Strings, th: &Theme) {
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(s.browser_filter_label, Style::default().fg(th.dim)),
Span::styled(
query.to_string(),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
]))
.style(Style::default().bg(th.panel)),
row,
);
}
fn draw_export_format_strip(f: &mut Frame, row: Rect, filename: &str, s: &Strings, th: &Theme) {
use crate::report::writer::OUTPUT_EXTENSIONS;
let cur = std::path::Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase());
let known = cur
.as_deref()
.map(|c| OUTPUT_EXTENSIONS.contains(&c))
.unwrap_or(false);
let mut spans = vec![Span::styled(
format!(" {} ", s.report_export_format_hint),
Style::default().fg(th.dim),
)];
for ext in OUTPUT_EXTENSIONS {
let active = cur.as_deref() == Some(ext) || (!known && ext == "csv");
let style = if active {
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text)
};
spans.push(Span::styled(format!(" {} ", ext.to_uppercase()), style));
spans.push(Span::raw(" "));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(th.panel)),
row,
);
}
pub(crate) fn draw_overlay(f: &mut Frame, app: &mut TuiApp, s: &Strings, th: &Theme) {
app.set_mouse_layer(MouseLayer::Overlay);
if let Some(Overlay::ReportCellPopup {
title,
content,
panel,
}) = app.overlay.as_mut()
{
let inner = super::reports::draw_result_cell_popup_overlay(f, title, content, panel, s, th);
app.push_mouse_hit(
MouseLayer::Overlay,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::ReportCellPopup),
);
return;
}
match app.overlay.as_ref().unwrap() {
Overlay::FileMenu(sel) => {
let items = file_menu_items(s);
draw_menu_popup(f, s.file_menu, &items, *sel, th, Some(app));
}
Overlay::FileLoadMenu(sel) => {
let items = file_load_items(s);
draw_menu_popup(f, s.file_load_menu, &items, *sel, th, Some(app));
}
Overlay::FileImportMenu(sel) => {
let items = file_import_items(s);
draw_menu_popup(f, s.file_import_menu, &items, *sel, th, Some(app));
}
Overlay::FileSaveMenu(sel) => {
let items = app.file_save_items();
let labels: Vec<&str> = items.iter().map(|it| it.label(s)).collect();
draw_menu_popup(f, s.file_save_menu, &labels, *sel, th, Some(app));
}
Overlay::FileLoadSource(kind, sel) => {
let items = file_load_source_items(*kind, s);
let title = format!("{} {}", s.file_load_menu, kind.name(s));
draw_menu_popup(f, &title, &items, *sel, th, Some(app));
}
Overlay::FileSaveDest(kind, sel) => {
let items = file_save_dest_items(*kind, s);
let title = format!("{} {}", s.file_save_menu, kind.name(s));
draw_menu_popup(f, &title, &items, *sel, th, Some(app));
}
Overlay::Options(sel) => {
let items = [
s.settings_item_language,
s.settings_item_theme,
s.settings_item_preferences,
s.settings_item_clear,
];
draw_menu_popup(f, s.options_menu, &items, *sel, th, Some(app));
}
Overlay::Preferences(sel) => {
let mark = |b: bool| if b { "[x]" } else { "[ ]" };
let exit_item = format!("{} {}", mark(app.confirm_on_exit), s.pref_item_confirm_exit);
let clear_item = format!(
"{} {}",
mark(app.confirm_on_clear),
s.pref_item_confirm_clear
);
let delete_env_item = format!(
"{} {}",
mark(app.confirm_on_delete_env),
s.pref_item_confirm_delete_env
);
let delete_request_item = format!(
"{} {}",
mark(app.confirm_on_delete_request),
s.pref_item_confirm_delete_request
);
let view_label = match app.default_request_view {
request::RequestView::Json => "JSON",
request::RequestView::Hurl => "Hurl",
};
let view_item = format!("{}: {view_label}", s.pref_item_default_view);
let always_save_item = format!(
"{} {}",
mark(app.always_save_when_prompted),
s.pref_item_always_save
);
let run_all_batch_item = format!(
"{} {}",
mark(app.run_all_batch_mode),
s.pref_item_run_all_batch
);
let discard_on_esc_item = format!(
"{} {}",
mark(app.discard_request_edits_on_esc),
s.pref_item_discard_on_esc
);
let items = [
exit_item.as_str(),
clear_item.as_str(),
delete_env_item.as_str(),
delete_request_item.as_str(),
always_save_item.as_str(),
run_all_batch_item.as_str(),
discard_on_esc_item.as_str(),
view_item.as_str(),
];
draw_menu_popup(f, s.preferences_menu, &items, *sel, th, Some(app));
}
Overlay::Confirm { action, sel } => {
let question: String = match action {
ConfirmAction::Exit => {
let mut q = s.confirm_exit_q.to_string();
if app.has_unsaved_secret_changes() {
q.push(' ');
q.push_str(s.confirm_exit_secrets);
}
let edits = app.unsaved_request_edits();
if edits > 0 {
q.push(' ');
q.push_str(&s.confirm_exit_edits.replace("{n}", &edits.to_string()));
}
q
}
ConfirmAction::Clear => s.confirm_clear_q.to_string(),
ConfirmAction::Overwrite(_) => {
let name = app
.pending_save_path
.as_ref()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.to_string_lossy().into_owned())
})
.unwrap_or_default();
s.confirm_overwrite_q.replace("{f}", &name)
}
ConfirmAction::DeleteEnv(_) => s.env_delete_confirm.to_string(),
ConfirmAction::DeleteRequest => s.request_delete_confirm.to_string(),
ConfirmAction::RevertWorkspaceFile(_, path) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
s.confirm_revert_file_q.replace("{f}", &name)
}
ConfirmAction::RevertRequest(ci, ei) => {
let name = app
.collections
.get(*ci)
.and_then(|c| c.entries.get(*ei))
.map(|e| {
let leaf = crate::tree::entry_path(&e.title).pop().unwrap_or_default();
if leaf.is_empty() { e.url.clone() } else { leaf }
})
.unwrap_or_default();
s.confirm_revert_request_q.replace("{r}", &name)
}
ConfirmAction::RevertEnv(env_id) => {
let (name, n) = app
.global_envs
.iter()
.find(|e| e.id == *env_id)
.map(|e| {
(
e.name.clone(),
e.vars.iter().filter(|v| v.user_added || v.modified).count(),
)
})
.unwrap_or_default();
s.confirm_revert_env_q
.replace("{e}", &name)
.replace("{n}", &n.to_string())
}
ConfirmAction::RerunReport => s.confirm_rerun_report_q.to_string(),
};
draw_confirm_popup(
f,
&question,
&[s.confirm_yes, s.confirm_no],
*sel,
th,
Some(app),
);
}
Overlay::LanguageMenu(sel) => {
let items = [s.lang_english, s.lang_french, s.lang_danish];
draw_menu_popup(f, s.language_label, &items, *sel, th, Some(app));
}
Overlay::ThemeEditor(state) => {
let entries = app.theme_picker_entries(s);
super::theme_editor::draw_theme_editor(f, state, &entries, s, th, Some(app));
}
Overlay::RequestViewMenu(sel) => {
let items = [s.view_json_label, s.view_hurl_label];
draw_menu_popup(f, s.default_request_view_label, &items, *sel, th, Some(app));
}
Overlay::Help(tab) => {
let box_w = f.area().width.saturating_sub(6).clamp(64, 100);
let inner_w = (box_w as usize).saturating_sub(2);
let query = app.help_query.to_ascii_lowercase();
let filtering = !query.is_empty();
let matches = |texts: &[&str]| -> bool {
query.is_empty()
|| texts
.iter()
.any(|t| t.to_ascii_lowercase().contains(query.as_str()))
};
let tab_bar = |active: usize| {
Line::from(vec![
Span::styled(
format!(" {} ", s.help_tab_shortcuts),
if active == 0 {
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
},
),
Span::raw(" "),
Span::styled(
format!(" {} ", s.help_tab_glossary),
if active == 1 {
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
},
),
Span::raw(" "),
Span::styled(
format!(" {} ", s.help_tab_reports),
if active == 2 {
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.dim)
},
),
Span::raw(" "),
Span::styled(s.help_tab_switch_hint, Style::default().fg(th.dim)),
])
};
let shortcuts_body = || {
let groups: [(&str, &[(&str, &str)]); 8] = [
(
s.help_group_navigation,
&[
("Tab / Shift+Tab", s.help_focus),
("↑ ↓ / j k", s.help_move),
("^\u{2191} ^\u{2193}", s.help_page_response),
("← \u{2192} / h l", s.help_switch_tabs),
],
),
(
s.help_group_tabs,
&[
("[ / ], PgUp/PgDn, ^\u{2190}/\u{2192}", s.help_prev_next_tab),
("F2 / x", s.help_rename_close),
("^W / u (Tab bar)", s.help_tab_manage),
("^Shift+\u{2190} \u{2192}", s.help_tab_reorder),
],
),
(
s.help_group_requests,
&[
("Enter", s.help_select),
(
if app.enhanced_keys {
"^Enter, F5"
} else {
"F5"
},
s.help_run,
),
("Alt+F5", s.help_run_all),
("n", s.help_new),
("Shift+H", s.help_raw_mode),
("Shift+J", s.help_raw_json),
("b", s.help_base_url),
("z", s.help_fold_meta),
("u (List pane)", s.help_restore_request),
("^r (List pane)", s.help_revert_request),
("m (workspace, List pane)", s.help_move_request),
("c (workspace, List pane)", s.help_copy_request),
("c (List pane)", s.help_duplicate_request),
(
"Alt+\u{2191} / Alt+\u{2193} (List pane)",
s.help_reorder_request,
),
("/ (List pane)", s.help_find_request),
],
),
(
s.help_group_menus,
&[
("f / s", s.help_menus),
("Ctrl+S", s.help_save_active),
("\u{2190} / \u{2192} (File menu)", s.help_menu_submenu_nav),
("w", s.help_workspace_browse),
("\u{2192} / Enter (Workspace)", s.help_workspace_open),
("Shift+N (Workspace)", s.help_workspace_new_item),
("Shift+M (Workspace)", s.help_workspace_move_item),
("^r (File browser)", s.help_browser_reset),
],
),
(
s.help_group_environments,
&[
("r (Env popup)", s.help_reload_var),
("^r (Env popup)", s.help_revert_env),
("F2 (Env panel)", s.help_env_rename),
("a", s.help_env_activate),
(
"a / right-click (workspace, List pane)",
s.help_env_activate_workspace,
),
("/", s.help_env_filter),
("o", s.help_env_source),
("g", s.help_env_goto_active),
("x", s.help_env_delete),
("u (Env panel)", s.help_env_reopen),
("p (List pane)", s.help_env_link),
("v", s.help_env_view_linked),
],
),
(
s.help_group_editing,
&[
("", s.help_row_toggle_delete),
("Ctrl+Z", s.help_text_undo),
("y", s.help_copy_selection),
("Ctrl+C", s.help_ctrl_c),
("a (Response pane)", s.help_text_probe),
("c (Response pane)", s.help_compact),
("\u{2192} / i (Response pane)", s.help_response_section),
("Alt+Click+Drag", s.help_multi_select),
("F2", s.help_save_editor),
],
),
(
s.help_group_reports,
&[
("Shift+R", s.help_report_new),
("e (report)", s.help_report_edit),
("Enter (report)", s.help_report_nodes),
("r / F5 (report)", s.help_report_run),
("d (report)", s.help_report_dry_run),
("v (report)", s.help_report_view),
("a / Del / Shift+↑↓ (nodes)", s.help_report_nodes_edit),
("Enter (nodes)", s.help_report_nodes_forms),
("Tab / Shift+Tab (report)", s.help_report_focus_cycle),
("↑↓ / Enter (ws tree)", s.help_report_workspace_tree),
("/ (report)", s.help_report_filter),
("Ctrl+E (report)", s.help_report_export),
("Ctrl+O (report)", s.help_report_open_export),
("B (report)", s.help_report_baseline),
("c (report)", s.help_report_columns),
("b (report)", s.help_report_bind),
("p (report)", s.help_report_params),
("Esc (report)", s.help_report_leave_edit),
("Ctrl+←/→ (report)", s.help_report_word_move),
("→ (report)", s.help_report_complete),
],
),
(
s.help_group_panels,
&[
("+ / -", s.help_resize),
("< / >", s.help_resize_width),
("Esc", s.help_cancel),
("q / Esc", s.help_quit),
],
),
];
let mut body = Vec::new();
for (heading, entries) in groups.iter() {
let kept: Vec<&(&str, &str)> = entries
.iter()
.filter(|(k, d)| matches(&[k, d, heading]))
.collect();
if kept.is_empty() {
continue;
}
if !body.is_empty() {
body.push(Line::raw(""));
}
body.push(help_section_divider(heading, inner_w, th));
for &(shortcut, desc) in kept {
body.extend(help_entry_lines(shortcut, desc, inner_w));
}
}
body
};
let glossary_body = || {
let render_group =
|body: &mut Vec<Line<'static>>,
heading: &str,
entries: &[(&str, Color, &str, &str)]| {
let kept: Vec<&(&str, Color, &str, &str)> = entries
.iter()
.filter(|(_, _, label, desc)| matches(&[label, desc, heading]))
.collect();
if kept.is_empty() {
return;
}
if !body.is_empty() {
body.push(Line::raw(""));
}
body.push(help_section_divider(heading, inner_w, th));
for &&(icon, color, label, desc) in kept.iter() {
body.extend(glossary_entry_lines(icon, color, label, desc, inner_w));
}
};
let mut body: Vec<Line<'static>> = Vec::new();
let entries: [(&str, Color, &str, &str); 5] = [
(
"\u{25cf}",
th.subst,
s.glossary_label_literal,
s.glossary_desc_literal,
),
(
"\u{25cf}",
th.ok,
s.glossary_label_loaded,
s.glossary_desc_loaded,
),
(
"\u{25cf}",
th.pending,
s.glossary_label_pending,
s.glossary_desc_pending,
),
(
"\u{25cf}",
th.err,
s.glossary_label_failed,
s.glossary_desc_failed,
),
(
SHADOW_ICON,
th.pending,
s.glossary_label_shadowed,
s.glossary_desc_shadowed,
),
];
render_group(&mut body, s.glossary_heading, &entries);
let icon_entries: [(&str, Color, &str, &str); 9] = [
(
"\u{270e}",
th.accent,
s.glossary_label_modified,
s.glossary_desc_modified,
),
(
"\u{271a}",
th.ok,
s.glossary_label_added,
s.glossary_desc_added,
),
(
"\u{2713}",
th.ok,
s.glossary_label_passed,
s.glossary_desc_passed,
),
(
"\u{2717}",
th.err,
s.glossary_label_run_failed,
s.glossary_desc_run_failed,
),
(
"\u{2026}",
th.pending,
s.glossary_label_running,
s.glossary_desc_running,
),
(GIT_ICON, th.text, s.glossary_label_git, s.glossary_desc_git),
(
LINK_ICON,
th.dim,
s.glossary_label_linked,
s.glossary_desc_linked,
),
(
FOLDER_ICON,
th.accent,
s.glossary_label_folder,
s.glossary_desc_folder,
),
(
"\u{2039}\u{203a}",
th.dim,
s.glossary_label_scroll_hint,
s.glossary_desc_scroll_hint,
),
];
render_group(&mut body, s.glossary_heading_icons, &icon_entries);
body
};
let reports_body = || {
let group = |body: &mut Vec<Line<'static>>,
heading: &'static str,
entries: &[(&'static str, &'static str)]| {
let kept: Vec<&(&'static str, &'static str)> = entries
.iter()
.filter(|(k, d)| matches(&[k, d, heading]))
.collect();
if kept.is_empty() {
return;
}
if !body.is_empty() {
body.push(Line::raw(""));
}
body.push(help_section_divider(heading, inner_w, th));
let key_col = kept
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
.clamp(6, 34);
for &&(code, desc) in kept.iter() {
body.extend(help_entry_lines_col(code, desc, key_col, inner_w));
}
};
let mut body: Vec<Line<'static>> = Vec::new();
if !filtering {
body.push(help_section_divider(
s.help_reports_about_heading,
inner_w,
th,
));
for para in [s.help_reports_about_1, s.help_reports_about_2] {
body.push(Line::from(Span::styled(para, Style::default().fg(th.text))));
body.push(Line::raw(""));
}
body.pop();
}
group(
&mut body,
s.help_reports_shortcuts_heading,
&[
("Shift+R", s.help_report_new),
("e", s.help_report_edit),
("Enter", s.help_report_nodes),
("r / F5", s.help_report_run),
("d", s.help_report_dry_run),
("v", s.help_report_view),
("a / Del / Shift+↑↓ (nodes)", s.help_report_nodes_edit),
("Enter (nodes)", s.help_report_nodes_forms),
("Tab / Shift+Tab", s.help_report_focus_cycle),
("↑↓ / Enter (ws tree)", s.help_report_workspace_tree),
("/", s.help_report_filter),
("Ctrl+E", s.help_report_export),
("Ctrl+O", s.help_report_open_export),
("B", s.help_report_baseline),
("c", s.help_report_columns),
("Esc", s.help_report_leave_edit),
("Ctrl+←/→", s.help_report_word_move),
("→", s.help_report_complete),
],
);
group(
&mut body,
s.help_reports_grammar_heading,
&[
("# collection: PATH", s.help_grammar_collection),
("# name: TEXT / TEXT_{time}", s.help_grammar_name),
("# environment: NAME", s.help_grammar_environment),
("KEY = value", s.help_grammar_assign),
("REQUEST NAME", s.help_grammar_request),
("REPORT REQUEST NAME [AS COL]", s.help_grammar_report),
("REPORT REQUEST NAME SHOW(a, b)", s.help_grammar_show),
("REPORT REQUEST NAME HIDE(a, b)", s.help_grammar_hide),
(
"REPORT COL AS N STATISTICS(MEAN)",
s.help_grammar_statistics,
),
("REPORT COL AS N IMAGE(HEIGHT 110)", s.help_grammar_image),
("WITH Name: query [STATISTICS]", s.help_grammar_with),
("Result", s.help_grammar_result),
("PARALLEL[(n)] FOR …", s.help_grammar_parallel),
],
);
group(
&mut body,
s.help_reports_loops_heading,
&[
("FOR VAR IN SRC … END", s.help_grammar_for),
("FOR (A, B) IN SRC", s.help_grammar_for_tuple),
("FOR (A, _, ...) IN SRC", s.help_grammar_pattern),
("LIST NAME = SRC", s.help_grammar_list),
("[ \"a\", (\"x\",\"y\") ]", s.help_grammar_list_literal),
("FILES \"dir\" [MATCH \"g\"]", s.help_grammar_files),
(
"FOLDERS \"dir\" [MATCH \"g\"] [WITH r=\"g\"[?]]",
s.help_grammar_folders,
),
("TUPLES FROM \"data.csv\"", s.help_grammar_tuples),
("ZIP(a, b, …)", s.help_grammar_zip),
("CONCAT(a, b, …)", s.help_grammar_concat),
("ENVS \"au\", \"eu\"", s.help_grammar_envs),
(
"BASELINE(FILE(\"snap.baseline\"))",
s.help_grammar_baseline_file,
),
],
);
body
};
let mut lines = vec![
Line::styled(
s.help_heading,
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
tab_bar(*tab),
];
if filtering {
lines.push(Line::from(vec![
Span::styled(s.help_filter_label, Style::default().fg(th.dim)),
Span::styled(
app.help_query.clone(),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
]));
}
let body = match *tab {
0 => shortcuts_body(),
1 => glossary_body(),
_ => reports_body(),
};
if filtering && body.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
s.help_filter_no_matches,
Style::default().fg(th.dim),
));
} else {
lines.extend(body);
}
let content_len = if filtering {
lines.len()
} else {
lines
.len()
.max(2 + shortcuts_body().len())
.max(2 + glossary_body().len())
.max(2 + reports_body().len())
};
let box_h = content_len as u16 + 2;
let area = centered_rect(box_w, box_h, f.area());
f.render_widget(Clear, area);
let title = format!(
"{} {} — {} — {}",
s.app_heading,
env!("CARGO_PKG_VERSION"),
s.help_title,
match *tab {
0 => s.help_tab_shortcuts,
1 => s.help_tab_glossary,
_ => s.help_tab_reports,
}
);
let visible_rows = area.height.saturating_sub(2) as usize;
let max_scroll = content_len.saturating_sub(visible_rows) as u16;
if app.help_scroll > max_scroll {
app.help_scroll = max_scroll;
}
let scroll = app.help_scroll;
f.render_widget(
Paragraph::new(lines)
.block(panel(title, true, th))
.wrap(Wrap { trim: false })
.scroll((scroll, 0)),
area,
);
if max_scroll > 0 {
let bar_area = Rect {
x: area.x + area.width - 1,
y: area.y + 1,
width: 1,
height: visible_rows as u16,
};
draw_scrollbar(f, bar_area, content_len, visible_rows, scroll as usize, th);
}
if scroll <= 1 {
let tab_y = area.y + 1 + (1 - scroll);
if tab_y < area.bottom().saturating_sub(1) {
let labels = [
(0, s.help_tab_shortcuts),
(1, s.help_tab_glossary),
(2, s.help_tab_reports),
];
let mut x = area.x.saturating_add(1);
for (i, label) in labels {
let w = label.chars().count() as u16 + 2;
if x < area.right().saturating_sub(1) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(x, tab_y, w.min(area.right() - 1 - x), 1),
MouseHitTarget::HelpTab(i),
);
}
x = x.saturating_add(w).saturating_add(1);
}
}
}
app.push_mouse_hit(
MouseLayer::Overlay,
area,
MouseHitTarget::Scroll(MouseScrollTarget::Help),
);
}
Overlay::ReportColumns(picker) => {
draw_report_columns_overlay(f, picker, s, th, Some(app));
}
Overlay::ReportBind(picker) => {
draw_report_bind_overlay(f, picker, s, th, Some(app));
}
Overlay::ProbeMenu(menu) => {
draw_probe_menu_overlay(f, menu, s, th, Some(app));
}
Overlay::ReportNodeMenu(menu) => {
draw_report_node_menu_overlay(f, menu, s, th, Some(app));
}
Overlay::ReportSettingMenu(menu) => {
draw_report_setting_menu_overlay(f, menu, s, th, Some(app));
}
Overlay::ReportNodeRequest(form) => {
draw_report_node_request_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeEnvs(form) => {
draw_report_node_envs_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeWithField(form) => {
draw_report_node_with_field_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeAssign(form) => {
draw_report_node_assign_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeList(form) => {
draw_report_node_list_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeVars(form) => {
draw_report_node_vars_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeComputed(form) => {
draw_report_node_computed_overlay(f, form, s, th, Some(app));
}
Overlay::ReportNodeFiles(form) => {
draw_report_node_files_overlay(f, form, s, th, Some(app));
}
Overlay::Prompt {
kind,
editor,
title,
mask,
reset_to,
secret_intact,
secret_checkbox,
} => {
let ml = editor.multiline;
let h = if ml {
14
} else if secret_checkbox.is_some() {
5
} else {
3
};
let mut hint = if matches!(kind, PromptKind::Raw { .. }) {
format!("{title} ({})", s.raw_mode_hint)
} else if matches!(kind, PromptKind::RawJson(_)) {
format!("{title} ({})", s.raw_json_hint)
} else if ml {
format!("{title} ({})", s.prompt_save_hint_ml)
} else {
format!("{title} ({})", s.prompt_save_hint_sl)
};
if reset_to
.as_deref()
.is_some_and(|orig| orig != editor.text())
{
hint.push_str(&format!(" · {}", s.prompt_reset_hint));
}
if secret_checkbox.is_some() {
hint.push_str(&format!(" · {}", s.env_still_secret_hint));
}
let base_w = if ml {
(f.area().width * 8 / 10).max(30)
} else {
64
};
let title_w = Line::from(hint.as_str()).width() as u16;
let w = base_w.max(title_w.saturating_add(4)).min(f.area().width);
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
let block = panel(hint, true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let (editor_area, checkbox_area) = if let Some(checked) = secret_checkbox {
let rows =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
(rows[0], Some((rows[1], checked)))
} else {
(inner, None)
};
if *mask && *secret_intact {
f.render_widget(
Paragraph::new(crate::environment::SECRET_MASK)
.style(Style::default().fg(th.text)),
editor_area,
);
let cx = editor_area.x + crate::environment::SECRET_MASK.chars().count() as u16;
f.set_cursor_position(ratatui::layout::Position::new(
cx.min(editor_area.right().saturating_sub(1)),
editor_area.y,
));
} else {
render_editor(f, editor_area, editor, *mask, th);
let ghost = kind.save_ghost();
if !ml && !*mask && !ghost.is_empty() {
let text = editor.text();
let len = text.chars().count() as u16;
if !text.ends_with(ghost) && len < editor_area.width {
let gx = editor_area.x + len;
let avail = editor_area.right().saturating_sub(gx);
if avail > 0 {
let shown: String = ghost.chars().take(avail as usize).collect();
f.render_widget(
Paragraph::new(shown).style(Style::default().fg(th.dim)),
Rect::new(gx, editor_area.y, avail, 1),
);
}
}
}
}
app.prompt_editor_area =
if matches!(kind, PromptKind::Raw { .. } | PromptKind::RawJson(_)) {
editor_area
} else {
Rect::default()
};
app.push_mouse_hit(
MouseLayer::Overlay,
editor_area,
MouseHitTarget::PromptEditor,
);
if let Some((area, checked)) = checkbox_area {
let mark = if *checked { "[x]" } else { "[ ]" };
let fg = if *checked { th.pending } else { th.ok };
f.render_widget(
Paragraph::new(format!("{mark} {}", s.env_still_secret))
.style(Style::default().fg(fg)),
area,
);
app.push_mouse_hit(
MouseLayer::Overlay,
area,
MouseHitTarget::PromptSecretCheckbox,
);
}
}
Overlay::Browser(action, ex) => {
let w = (f.area().width * 7 / 10).max(50);
let h = (f.area().height * 7 / 10).max(10);
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
app.set_mouse_layer(MouseLayer::Overlay);
if action.is_save_to_folder() {
let show_formats = *action == FileAction::SaveReportCsvChooseFolder;
let show_query = !app.browser_query.is_empty();
let mut heights = vec![Constraint::Min(3)];
if show_query {
heights.push(Constraint::Length(1));
}
if show_formats {
heights.push(Constraint::Length(1));
}
heights.push(Constraint::Length(3));
let rows = Layout::vertical(heights).split(area);
draw_browser_list(f, app, ex, rows[0], s, th);
let mut next = 1;
if show_query {
draw_browser_filter_strip(f, rows[next], &app.browser_query, s, th);
next += 1;
}
if show_formats {
draw_export_format_strip(f, rows[next], &app.browser_name.text(), s, th);
next += 1;
}
let name_row = rows[next];
let focused = app.browser_name_focused;
let label = if *action == FileAction::SaveWorkspaceChooseFolder {
s.browser_foldername_label
} else {
s.browser_filename_label
};
let title = if focused {
format!("{label} ({})", s.browser_name_hint)
} else {
label.to_string()
};
let block = panel(title, focused, th);
let inner = block.inner(name_row);
f.render_widget(block, name_row);
if focused {
render_editor(f, inner, &app.browser_name, false, th);
} else {
f.render_widget(
Paragraph::new(app.browser_name.text()).style(Style::default().fg(th.dim)),
inner,
);
}
app.push_mouse_hit(MouseLayer::Overlay, inner, MouseHitTarget::BrowserNameField);
register_browser_hits(app, ex, rows[0]);
} else if !app.browser_query.is_empty() {
let rows =
Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).split(area);
draw_browser_list(f, app, ex, rows[0], s, th);
register_browser_hits(app, ex, rows[0]);
draw_browser_filter_strip(f, rows[1], &app.browser_query, s, th);
} else {
draw_browser_list(f, app, ex, area, s, th);
register_browser_hits(app, ex, area);
}
}
Overlay::NewRequest(form) => {
draw_new_request_with_hits(f, form, s, th, app.enhanced_keys, Some(app))
}
Overlay::EnvVarForm(form) => draw_env_var_form(f, form, s, th, Some(app)),
Overlay::RemoteGit(w) => draw_remote_wizard_with_hits(f, w, s, th, Some(app)),
Overlay::PostmanImport(w) => draw_postman_wizard(f, w, s, th),
Overlay::GitSave(w) => draw_git_save_wizard_with_hits(f, w, s, th, Some(app)),
Overlay::EnvPopup(popup) => draw_env_popup(f, app, popup, s, th),
Overlay::EnvLinkPicker(picker) => draw_env_link_picker(f, app, picker, s, th),
Overlay::EnvCollision(collision) => draw_env_collision(f, collision, s, th, Some(app)),
Overlay::WorkspacePicker(picker) => draw_workspace_picker(f, picker, s, th, Some(app)),
Overlay::CloseGitWorkspace { path, sel, .. } => {
let question = s
.close_git_workspace_q
.replace("{p}", &path.to_string_lossy());
let choices = [
s.close_git_workspace_keep,
s.close_git_workspace_delete,
s.close_git_workspace_cancel,
];
draw_confirm_popup(f, &question, &choices, *sel, th, Some(app));
}
Overlay::WorkspaceReloadConfirm { reload, sel, .. } => {
let ref_label = if reload.origin.ref_kind == crate::git_remote::RefKind::Branch {
s.git_branches
} else {
s.git_tags
};
let question = s
.workspace_reload_confirm_q
.replace("{name}", &reload.tab_name)
.replace(
"{ref}",
&format!("[{ref_label}] {}", reload.origin.ref_name),
)
.replace("{url}", &reload.origin.repo_url);
draw_confirm_popup(
f,
&question,
&[s.confirm_yes, s.confirm_no],
*sel,
th,
Some(app),
);
}
Overlay::WorkspaceReloadLoading { idx } => {
let name = app
.collections
.get(*idx)
.map(|c| c.name.as_str())
.unwrap_or("");
let text = format!("{} ({name})", s.workspace_reload_loading);
let w = (text.chars().count() as u16 + 4).max(30);
let area = centered_rect(w, 5, f.area());
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL);
f.render_widget(
Paragraph::new(text)
.block(block)
.wrap(Wrap { trim: true })
.alignment(ratatui::layout::Alignment::Center),
area,
);
}
Overlay::WorkspaceStorageChoice { sel, .. } => {
let choices = [s.git_workspace_storage_temp, s.git_workspace_storage_choose];
draw_confirm_popup(f, s.git_workspace_storage_q, &choices, *sel, th, Some(app));
}
Overlay::WorkspaceGitSaveUnsaved { sel, .. } => {
let choices = [
s.git_save_ws_unsaved_save,
s.git_save_ws_unsaved_ignore,
s.git_save_ws_unsaved_cancel,
];
draw_confirm_popup(f, s.git_save_ws_unsaved_q, &choices, *sel, th, Some(app));
}
Overlay::WorkspaceSwitchUnsaved { sel, .. } => {
let choices = [
s.ws_switch_unsaved_save,
s.ws_switch_unsaved_discard,
s.ws_switch_unsaved_cancel,
];
draw_confirm_popup(f, s.ws_switch_unsaved_q, &choices, *sel, th, Some(app));
}
Overlay::StaleBodyNotes { ci, ei, sel } => {
let adopt = app
.collections
.get(*ci)
.and_then(|c| c.entries.get(*ei))
.is_some_and(|e| e.can_adopt_body_notes());
let choices = [
if adopt {
s.notes_stale_adopt
} else {
s.notes_stale_adopt_blocked
},
s.notes_stale_discard,
s.notes_stale_cancel,
];
draw_confirm_popup(f, s.notes_stale_title, &choices, *sel, th, Some(app));
}
Overlay::ReportCellPopup { .. } => unreachable!("ReportCellPopup is drawn above"),
}
}
fn register_browser_hits(app: &TuiApp, ex: &ratatui_explorer::FileExplorer, area: Rect) {
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
app.push_mouse_hit(
MouseLayer::Overlay,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::BrowserList),
);
let len = ex.files().len();
if len == 0 || inner.height == 0 {
return;
}
let visible = inner.height as usize;
let selected = ex.selected_idx().min(len - 1);
let first = if selected >= visible {
selected + 1 - visible
} else {
0
};
for row in first..len.min(first + visible) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - first) as u16, inner.width, 1),
MouseHitTarget::BrowserListRow(row),
);
}
}
pub(crate) fn draw_env_var_form(
f: &mut Frame,
form: &EnvVarForm,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let area = centered_rect(70, 7, f.area());
f.render_widget(Clear, area);
let block = panel(s.env_add_var_title.to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
let split = |r: Rect| {
Layout::horizontal([Constraint::Percentage(40), Constraint::Percentage(60)])
.spacing(2)
.split(r)
};
let head = split(rows[0]);
let cells = split(rows[1]);
let lbl = |t: &str| {
Paragraph::new(Span::styled(
t.to_string(),
Style::default().fg(th.dim).add_modifier(Modifier::BOLD),
))
};
f.render_widget(lbl(s.hdr_key), head[0]);
f.render_widget(lbl(s.hdr_value), head[1]);
render_line_field(f, cells[0], &form.key, !form.on_value, false, th);
render_line_field(f, cells[1], &form.value, form.on_value, false, th);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
app.push_mouse_hit(
MouseLayer::Overlay,
cells[0],
MouseHitTarget::EnvVarField(false),
);
app.push_mouse_hit(
MouseLayer::Overlay,
cells[1],
MouseHitTarget::EnvVarField(true),
);
}
let hint = format!("Tab {} · {}", s.env_var_switch, s.prompt_save_hint_sl);
f.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(th.dim))),
rows[3],
);
}
pub(crate) fn draw_env_link_picker(
f: &mut Frame,
app: &TuiApp,
picker: &EnvLinkPicker,
s: &Strings,
th: &Theme,
) {
let linked = app.collections.get(picker.ci).and_then(|c| c.linked_env_id);
let mut labels: Vec<String> = vec![s.env_link_none.to_string()];
labels.extend(app.global_envs.iter().map(|e| {
if Some(e.id) == linked {
format!("\u{2713} {}", e.name)
} else {
e.name.clone()
}
}));
let items: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
draw_menu_popup(
f,
s.env_link_picker_title,
&items,
picker.sel,
th,
Some(app),
);
}
pub(crate) fn draw_env_collision(
f: &mut Frame,
collision: &EnvCollision,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let items = [
s.env_collision_replace,
s.env_collision_keep_both,
s.env_collision_abort,
s.env_collision_rename,
];
draw_menu_popup(f, s.env_collision_title, &items, collision.sel, th, app);
}
pub(crate) fn draw_workspace_picker(
f: &mut Frame,
picker: &WorkspacePickerState,
s: &Strings,
th: &Theme,
app: Option<&TuiApp>,
) {
let w = (f.area().width * 7 / 10).max(50);
let h = (f.area().height * 7 / 10).max(10);
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
let filter_label = if picker.filter_hurl_json {
s.workspace_filter_on
} else {
s.workspace_filter_off
};
let title = format!(
"{} [{filter_label}] — {}",
s.workspace_picker_title,
picker.root.display()
);
let block = panel(title, true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
if picker.entries.is_empty() {
f.render_widget(
Paragraph::new(s.workspace_no_files).style(Style::default().fg(th.dim)),
rows[0],
);
return;
}
let items: Vec<ListItem> = picker
.entries
.iter()
.map(|e| {
let indent = " ".repeat(e.depth);
if e.is_dir {
ListItem::new(Line::styled(
format!("{indent}{FOLDER_ICON} {}/", e.display_name),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
))
} else if crate::workspace::is_report_file(&e.path) {
ListItem::new(Line::styled(
format!("{indent}{REPORT_ICON} {}", e.display_name),
Style::default().fg(th.accent),
))
} else {
ListItem::new(Line::styled(
format!("{indent}{}", e.display_name),
Style::default().fg(th.text),
))
}
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("› ");
let ctx = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
picker.root.hash(&mut h);
picker.filter_hurl_json.hash(&mut h);
h.finish()
};
let fallback = ListScroll::default();
let scroll = app.map_or(&fallback, |a| &a.ws_picker_scroll);
let picker_offset = scroll.render_ctx(
f,
rows[0],
list,
Some(picker.selected),
picker.entries.len(),
ctx,
);
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
app.push_mouse_hit(
MouseLayer::Overlay,
rows[0],
MouseHitTarget::Scroll(MouseScrollTarget::WorkspacePicker),
);
let first = picker_offset;
let visible = rows[0].height as usize;
for row in first..picker.entries.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(
rows[0].x,
rows[0].y + (row - first) as u16,
rows[0].width,
1,
),
MouseHitTarget::WorkspacePickerRow(row),
);
}
}
let hint = Paragraph::new(Line::styled(
match picker.mode {
WsPickerMode::AddRequest => s.workspace_picker_hint_add,
WsPickerMode::MoveRequest => s.workspace_picker_hint_move,
WsPickerMode::CopyRequest => s.workspace_picker_hint_copy,
WsPickerMode::Browse => s.workspace_picker_hint,
},
Style::default().fg(th.dim),
));
f.render_widget(hint, rows[1]);
}
pub(crate) fn mnemonic_spans(label: &str, style: Style) -> Vec<Span<'static>> {
if let Some(open) = label.find('(')
&& let Some(close) = label[open + 1..]
.find(')')
.map(|r| open + 1 + r)
.filter(|&close| close == open + 2)
{
let before = label[..open].to_string();
let letter = label[open + 1..close].to_string();
let after = label[close + 1..].to_string();
let mut spans = Vec::with_capacity(3);
if !before.is_empty() {
spans.push(Span::styled(before, style));
}
spans.push(Span::styled(
letter,
style.add_modifier(Modifier::UNDERLINED),
));
if !after.is_empty() {
spans.push(Span::styled(after, style));
}
return spans;
}
vec![Span::styled(label.to_string(), style)]
}
pub(crate) fn draw_menu_popup(
f: &mut Frame,
title: &str,
items: &[&str],
sel: usize,
th: &Theme,
app: Option<&TuiApp>,
) {
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
}
let width = items
.iter()
.map(|i| i.len())
.max()
.unwrap_or(10)
.max(title.len()) as u16
+ 6;
let height = items.len() as u16 + 2;
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let list_items: Vec<ListItem> = items
.iter()
.map(|i| ListItem::new(Line::from(mnemonic_spans(i, Style::default().fg(th.text)))))
.collect();
let list = List::new(list_items)
.block(panel(title.to_string(), true, th))
.highlight_style(
Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("› ");
let mut st = ListState::default();
st.select(Some(sel));
f.render_stateful_widget(list, area, &mut st);
if let Some(app) = app {
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
app.push_mouse_hit(
MouseLayer::Overlay,
inner,
MouseHitTarget::Scroll(MouseScrollTarget::OverlayList),
);
let first = st.offset();
let visible = inner.height as usize;
for row in first..items.len().min(first + visible) {
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(inner.x, inner.y + (row - first) as u16, inner.width, 1),
MouseHitTarget::OverlayRow(row),
);
}
}
}
pub(crate) fn draw_confirm_popup(
f: &mut Frame,
question: &str,
choices: &[&str],
sel: usize,
th: &Theme,
app: Option<&TuiApp>,
) {
if let Some(app) = app {
app.set_mouse_layer(MouseLayer::Overlay);
}
let choices_len: usize = choices.iter().map(|c| c.len() + 3).sum();
let q_lines: Vec<&str> = question.split('\n').collect();
let max_line_len = q_lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let width = max_line_len.max(choices_len + 4).clamp(24, 76) as u16 + 4;
let width = width.min(f.area().width.max(1));
let text_w = width.saturating_sub(2).max(1) as usize;
let mut lines = 0usize;
for q_line in &q_lines {
let mut line_wraps = 1usize;
let mut col = 0usize;
for word in q_line.split_whitespace() {
let wlen = word.chars().count();
if col == 0 {
col = wlen;
} else if col + 1 + wlen <= text_w {
col += 1 + wlen;
} else {
line_wraps += 1;
col = wlen;
}
}
lines += line_wraps;
}
let height = (lines as u16).saturating_add(4);
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let block = panel("".to_string(), true, th);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
f.render_widget(
Paragraph::new(question)
.style(Style::default().fg(th.text))
.wrap(Wrap { trim: true }),
rows[0],
);
let hl = Style::default()
.bg(th.accent)
.fg(th.bg)
.add_modifier(Modifier::BOLD);
let normal = Style::default().fg(th.text);
let mut spans = Vec::with_capacity(choices.len() * 2);
for (i, choice) in choices.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
format!(" {choice} "),
if sel == i { hl } else { normal },
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).alignment(ratatui::layout::Alignment::Center),
rows[1],
);
if let Some(app) = app {
let line_w = choices.iter().map(|c| c.chars().count() + 2).sum::<usize>()
+ 3 * choices.len().saturating_sub(1);
let mut x = rows[1]
.x
.saturating_add(rows[1].width.saturating_sub(line_w as u16) / 2);
for (i, choice) in choices.iter().enumerate() {
if i > 0 {
x = x.saturating_add(3);
}
let w = choice.chars().count() as u16 + 2;
app.push_mouse_hit(
MouseLayer::Overlay,
Rect::new(x, rows[1].y, w, 1),
MouseHitTarget::ConfirmChoice(i),
);
x = x.saturating_add(w);
}
}
}