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::{Language, Strings};
use crate::request;
use crate::tree;
use super::app::*;
use super::editor::*;
use super::git_save::*;
use super::new_request::*;
use super::remote::*;
use super::selection;
use super::theme::*;
use super::wrapcache::{PanelWrap, TextPos};
use std::sync::Arc;
pub(crate) const GIT_ICON: &str = "\u{2387}";
pub(crate) const LINK_ICON: &str = "\u{1F517}";
pub(crate) const SHADOW_ICON: &str = "!";
pub(crate) const FOLDER_ICON: &str = "\u{1F4C1}";
const COLLECTION_OPEN_ICON: &str = "\u{25BE}"; const COLLECTION_CLOSED_ICON: &str = "\u{25B8}";
enum LeftRow {
Up,
Folder(String),
Collection { name: String, open: bool },
Entry { idx: usize, indent: bool },
}
impl LeftRow {
fn build(col: &Collection) -> Vec<LeftRow> {
if col.is_workspace() {
col.ws_rows()
.into_iter()
.map(|r| match r {
WsRow::Up => LeftRow::Up,
WsRow::Folder(name) => LeftRow::Folder(name),
WsRow::Collection { name, open, .. } => LeftRow::Collection { name, open },
WsRow::Request(idx) => LeftRow::Entry { idx, indent: true },
})
.collect()
} else {
col.rows()
.into_iter()
.map(|r| match r {
tree::Row::Up => LeftRow::Up,
tree::Row::Folder(name) => LeftRow::Folder(name),
tree::Row::Entry(idx) => LeftRow::Entry { idx, indent: false },
})
.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.dim };
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 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)
}
pub(crate) fn draw(f: &mut Frame, app: &mut TuiApp) {
let s = Strings::for_language(&app.language);
let th = theme(&app.language);
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::Length(3),
Constraint::Min(3),
Constraint::Length(1),
])
.split(f.area());
draw_menu(f, rows[0], app, &s, &th);
draw_topbar(f, rows[1], app, &s, &th);
draw_tabs(f, rows[2], app, &s, &th);
draw_body(f, rows[3], app, &s, &th);
draw_footer(f, rows[4], &s, &th, app.can_copy());
paint_selection_highlight(f, app, &th);
if app.overlay.is_some() {
draw_overlay(f, app, &s, &th);
} else {
app.prompt_editor_area = Rect::default();
}
}
fn paint_selection_highlight(f: &mut Frame, app: &TuiApp, th: &Theme) {
if app.text_selection.is_none() && app.extra_selections.is_empty() {
return;
}
let buf = f.buffer_mut();
let style = Style::default().bg(th.select_bg).fg(th.select_fg);
for sel in app
.extra_selections
.iter()
.copied()
.chain(app.text_selection)
{
let (area, scroll, wrap) = match sel.pane {
Pane::Main => (app.main_text_area, app.main_scroll, app.main_wrap.as_ref()),
Pane::Response => (app.resp_text_area, app.resp_scroll, app.resp_wrap.as_ref()),
_ => continue,
};
let Some(wrap) = wrap else { continue };
let cells = selection::highlight_cells(sel.anchor, sel.cursor, wrap, area, scroll);
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(" ")];
spans.extend(mnemonic_spans(s.file_menu_label, base));
spans.push(Span::raw(" "));
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)));
}
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(th.panel)),
area,
);
}
pub(crate) fn draw_topbar(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let lang = match app.language {
Language::English => s.lang_english,
Language::French => s.lang_french,
Language::Danish => s.lang_danish,
};
let mut spans = vec![
Span::styled(
s.app_heading,
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("[{lang}]"), Style::default().fg(th.dim)),
Span::raw(" "),
Span::styled(s.base_url, Style::default().fg(th.text)),
Span::raw(" "),
Span::styled(
app.vars.base_url.clone(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" (b {})", s.hint_edit_base_url),
Style::default().fg(th.dim),
),
];
let error = { app.response.lock().unwrap().error.clone() };
if !error.is_empty() {
spans.push(Span::styled(
format!(" {} {error}", s.req_error_prefix),
Style::default().fg(th.err).add_modifier(Modifier::BOLD),
));
}
f.render_widget(
Paragraph::new(Line::from(spans)).block(panel(String::new(), false, th)),
area,
);
}
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
}
pub(crate) fn draw_tabs(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let focused = app.focus == Pane::Tabs;
let mut spans: Vec<Span> = Vec::new();
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))
}
};
for (i, col) in app.collections.iter().enumerate() {
if i > 0 {
spans.push(Span::raw("│"));
}
let name = if i == 0 {
s.tab_request.to_string()
} else {
format!("{}{}", tab_icons(col), col.name)
};
spans.push(mk(name, app.active_tab == i));
}
f.render_widget(
Paragraph::new(Line::from(spans)).block(panel(s.tabs_heading.to_string(), focused, th)),
area,
);
}
pub(crate) fn draw_body(f: &mut Frame, area: Rect, app: &mut TuiApp, s: &Strings, th: &Theme) {
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);
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 env = app.effective_env(ci);
let smap = crate::request::subst_map(col, env.as_ref());
let url_w = panes[0].width.saturating_sub(2 + 2 + 2 + 5);
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| crate::request::subst_display(&e.url, &smap).chars().count())
.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::Up => ListItem::new(Line::from(Span::styled(
s.list_up_row.to_string(),
Style::default().fg(th.dim),
))),
LeftRow::Folder(name) => ListItem::new(Line::from(Span::styled(
format!("{FOLDER_ICON} {name}/"),
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
))),
LeftRow::Collection { name, open } => {
let chevron = if *open {
COLLECTION_OPEN_ICON
} else {
COLLECTION_CLOSED_ICON
};
ListItem::new(Line::from(Span::styled(
format!("{chevron} {name}"),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
)))
}
LeftRow::Entry { idx, indent } => {
let e = &col.entries[*idx];
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 *indent {
spans.push(Span::raw(" "));
}
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(match e.last_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(" "),
});
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 {
items
};
let mut 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)
};
if col.is_workspace() {
if !col.workspace_browse.is_empty() {
title = format!("{title} › {}", col.workspace_browse.join(" › "));
}
} else if !col.folder.is_empty() {
title = format!("{title} › {}", col.folder.join(" › "));
}
let mut title_spans = vec![Span::styled(
title.clone(),
Style::default().fg(th.text).add_modifier(Modifier::BOLD),
)];
let mut title_len = title.chars().count();
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_len +=
link_part.chars().count() + env.name.chars().count() + env_suffix.chars().count();
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)));
}
if col.workspace_root.is_some() {
let workspace_title_hint = format!(" · w {}", s.foot_workspace);
if title_len + workspace_title_hint.chars().count() < panes[0].width as usize {
title_spans.push(Span::styled(
workspace_title_hint,
Style::default().fg(th.dim),
));
}
}
let run_key = if app.enhanced_keys { "^Enter/F5" } else { "F5" };
let mut run_hint = format!("{run_key} {} · Alt+F5 {}", s.foot_run, s.foot_run_all);
let link_hint = format!(" · p {}", s.foot_env_link);
if (run_hint.chars().count() + link_hint.chars().count()) < panes[0].width as usize {
run_hint.push_str(&link_hint);
}
if col.workspace_root.is_some() {
let workspace_hint = format!(" · w {}", s.foot_workspace);
if (run_hint.chars().count() + workspace_hint.chars().count()) < panes[0].width as usize {
run_hint.push_str(&workspace_hint);
}
}
let border = if focused { th.accent } else { th.dim };
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),
)
.highlight_symbol("› ");
let mut st = ListState::default();
if !view_rows.is_empty() {
st.select(Some(sel));
}
f.render_stateful_widget(list, panes[0], &mut st);
draw_env_panel(f, panes[1], app, s, th);
}
pub(crate) fn draw_env_panel(f: &mut Frame, area: Rect, app: &TuiApp, s: &Strings, th: &Theme) {
let focused = app.focus == Pane::GlobalEnv;
let activate_hint = format!("a {}", s.foot_env_activate);
let block = panel(s.env_heading.to_string(), focused, th)
.title_bottom(Line::styled(activate_hint, Style::default().fg(th.dim)));
if app.global_envs.is_empty() {
let p = Paragraph::new(vec![
Line::styled(s.env_no_envs.to_string(), Style::default().fg(th.dim)),
Line::styled(
format!("{} \u{2192} {}", s.file_menu, s.load_environment),
Style::default().fg(th.dim),
),
])
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(p, area);
return;
}
let sel = app
.global_env_idx
.min(app.global_envs.len().saturating_sub(1));
let text_w = area.width.saturating_sub(2 + 2 + 2);
app.global_env_scroll_w.set(text_w);
let sel_len = app
.global_envs
.get(sel)
.map(|e| e.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> = app
.global_envs
.iter()
.enumerate()
.map(|(i, env)| {
let is_active = app.active_env_id == Some(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 { th.text };
let selected = i == sel;
let mark_fg = if selected { th.bg } else { marker_fg };
let git_prefix = if env.git_origin.is_some() {
format!("{GIT_ICON} ")
} else {
String::new()
};
let mut spans = vec![Span::styled(marker, Style::default().fg(mark_fg))];
let full = format!("{git_prefix}{}", env.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 list = List::new(items)
.block(block)
.highlight_style(Style::default().bg(th.accent).add_modifier(Modifier::BOLD))
.highlight_symbol("› ");
let mut st = ListState::default();
st.select(Some(sel));
f.render_stateful_widget(list, area, &mut st);
}
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 mut st = ListState::default();
st.select(Some(sel));
f.render_stateful_widget(list, area, &mut st);
}
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::Pending => th.pending, SubstKind::Failed => th.err, }
}
#[derive(Default)]
struct SubstSeen {
loaded: bool,
literal: bool,
pending: bool,
failed: 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::Literal => self.literal = true,
SubstKind::Pending => self.pending = true,
SubstKind::Failed => self.failed = true,
}
}
fn any(&self) -> bool {
self.loaded || self.literal || self.pending || self.failed
}
}
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 => {
let piece = rest[open..end].to_string();
cur_len += piece.chars().count();
spans.push(Span::styled(piece, Style::default().fg(th.text)));
}
}
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 wrap_line(line: Line<'static>, width: usize) -> Vec<Line<'static>> {
if width == 0 {
return vec![line];
}
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
for span in line.spans {
let mut remaining: &str = span.content.as_ref();
loop {
if remaining.is_empty() {
break;
}
let avail = width - cur_w;
if avail == 0 {
out.push(Line::from(std::mem::take(&mut cur)));
cur_w = 0;
continue;
}
let take: String = remaining.chars().take(avail).collect();
cur_w += take.chars().count();
remaining = &remaining[take.len()..];
cur.push(Span::styled(take, span.style));
}
}
out.push(Line::from(cur));
out
}
pub(crate) fn wrap_line_window(
text: &str,
width: usize,
skip_rows: usize,
max_rows: usize,
) -> Vec<Line<'static>> {
if max_rows == 0 {
return Vec::new();
}
if width == 0 {
return if skip_rows == 0 {
vec![Line::raw(text.to_string())]
} else {
Vec::new()
};
}
let skip_chars = skip_rows.saturating_mul(width);
let take_chars = max_rows.saturating_mul(width);
let windowed: String = text.chars().skip(skip_chars).take(take_chars).collect();
if windowed.is_empty() {
return Vec::new();
}
wrap_line(Line::raw(windowed), width)
}
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>> {
const KEY_COL: usize = 17;
let indent = shortcut.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!("{shortcut:<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 wrapped_row_count(char_len: usize, width: usize) -> usize {
if width == 0 || char_len == 0 {
1
} else {
char_len.div_ceil(width)
}
}
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_scroll = 0;
app.main_text_area = Rect::default();
app.main_wrap = None;
app.main_shadow_icon_positions.clear();
app.main_scrollbar_area = Rect::default();
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 (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 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));
}
Line::from(spans)
})
.collect();
let mut display_text: String = body_lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
if buf.ends_with('\n') {
display_text.push('\n');
}
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),
];
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));
}
if !captures.is_empty() {
top_lines.push(Line::styled(
"[Captures]",
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
for (name, expr) in &captures {
top_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() {
top_lines.push(Line::styled(
"[Asserts]",
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
));
for a in &asserts {
top_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(a.clone(), Style::default().fg(th.dim)),
]));
}
}
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;
let wrap = PanelWrap::build(Arc::from(display_text.as_str()), width);
let total_lines = wrap.total_rows().min(u16::MAX as u32) as u16;
let max_scroll = total_lines.saturating_sub(text_area.height);
app.main_max_scroll = max_scroll;
app.main_scroll = app.main_scroll.min(max_scroll);
let scroll = app.main_scroll;
let start = wrap.row_col_to_textpos(scroll as u32, 0);
let row_in_start_line = start.col.checked_div(width).unwrap_or(0);
let height = text_area.height as usize;
let mut visible_wrapped: Vec<Line<'static>> = Vec::with_capacity(height);
'outer: for (idx, line) in body_lines.iter().enumerate().skip(start.line) {
let skip = if idx == start.line {
row_in_start_line
} else {
0
};
for row in wrap_line(line.clone(), width).into_iter().skip(skip) {
visible_wrapped.push(row);
if visible_wrapped.len() >= height {
break 'outer;
}
}
}
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;
} else {
app.main_scrollbar_area = Rect::default();
}
app.main_text_area = text_area;
app.main_wrap = Some(wrap);
app.main_shadow_icon_positions = shadow_positions;
f.render_widget(
Paragraph::new(visible_wrapped).style(Style::default().fg(th.text)),
text_area,
);
}
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 = panel(s.response_heading.to_string(), focused, th);
let inner = block.inner(area);
f.render_widget(block, area);
let loading = app.response.lock().unwrap().loading;
let entry = app
.collections
.get(ci)
.and_then(|col| col.entries.get(col.selected_entry));
let (status, status_text, body, error, asserts) =
match entry.and_then(|e| e.last_response.as_ref()) {
Some(r) => (
r.status,
r.status_text.clone(),
r.body.clone(),
r.error.clone(),
r.assert_results.clone(),
),
None => (0, String::new(), Arc::from(""), String::new(), Vec::new()),
};
if loading {
app.resp_max_scroll = 0;
app.resp_text_area = Rect::default();
app.resp_wrap = None;
app.resp_scrollbar_area = Rect::default();
f.render_widget(
Paragraph::new(Line::styled(
format!("⟳ {}", s.sending),
Style::default().fg(th.accent),
)),
inner,
);
return;
}
if !error.is_empty() {
app.resp_max_scroll = 0;
app.resp_text_area = Rect::default();
app.resp_wrap = None;
app.resp_scrollbar_area = Rect::default();
f.render_widget(
Paragraph::new(format!("{} {error}", s.req_error_prefix))
.style(Style::default().fg(th.err))
.wrap(Wrap { trim: false }),
inner,
);
return;
}
if status == 0 {
app.resp_max_scroll = 0;
app.resp_text_area = Rect::default();
app.resp_wrap = None;
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),
));
}
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 assert_h = (assert_lines.len() as u16).min(inner.height.saturating_sub(2));
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(assert_h),
Constraint::Min(1),
])
.split(inner);
f.render_widget(Paragraph::new(Line::from(status_spans)), rows[0]);
if assert_h > 0 {
f.render_widget(Paragraph::new(assert_lines), rows[1]);
}
let body_area = rows[2];
let width = body_area.width as usize;
PanelWrap::rebuild_if_needed(&mut app.resp_wrap, &body, width);
let wrap = app.resp_wrap.as_ref().expect("just rebuilt above");
let total_lines = wrap.total_rows().min(u16::MAX as u32) as u16;
let max_scroll = total_lines.saturating_sub(body_area.height);
app.resp_max_scroll = max_scroll;
app.resp_scroll = app.resp_scroll.min(max_scroll);
let visible_wrapped = wrap.visible_window(app.resp_scroll, 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,
app.resp_scroll as usize,
th,
);
app.resp_scrollbar_area = bar_area;
} else {
app.resp_scrollbar_area = Rect::default();
}
app.resp_text_area = body_area;
f.render_widget(
Paragraph::new(visible_wrapped).style(Style::default().fg(th.text)),
body_area,
);
}
pub(crate) fn draw_footer(f: &mut Frame, area: Rect, s: &Strings, th: &Theme, can_copy: bool) {
let mut hint = vec![
format!("Tab {}", s.foot_focus),
format!("↑↓ {}", s.foot_move),
format!("Enter {}", s.foot_edit),
format!("n {}", s.foot_new),
format!("r {}", s.foot_reload_var),
format!("f {}", s.foot_file),
format!("s {}", s.foot_options),
format!("[ / ], ^←/→ {}", s.help_prev_next_tab),
format!("F2 {}", s.foot_rename),
format!("x {}", s.foot_close),
];
if can_copy {
hint.push(format!("y {}", s.foot_copy_selection));
}
hint.push(format!("? {}", s.foot_help));
hint.push(format!("q {}", s.foot_quit));
let hint = hint.join(" · ");
f.render_widget(
Paragraph::new(Line::styled(hint, Style::default().fg(th.dim)))
.style(Style::default().bg(th.panel)),
area,
);
}
pub(crate) fn draw_overlay(f: &mut Frame, app: &mut TuiApp, s: &Strings, th: &Theme) {
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);
}
Overlay::FileLoadMenu(sel) => {
let items = file_load_items(s);
draw_menu_popup(f, s.file_load_menu, &items, *sel, th);
}
Overlay::FileSaveMenu(sel) => {
let items = file_save_items(s);
draw_menu_popup(f, s.file_save_menu, &items, *sel, th);
}
Overlay::FileLoadSource(kind, sel) => {
let items = file_load_source_items(s);
let title = format!("{} {}", s.file_load_menu, kind.name(s));
draw_menu_popup(f, &title, &items, *sel, th);
}
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);
}
Overlay::Options(sel) => {
let items = [s.language_label, s.preferences_menu, s.clear_all];
draw_menu_popup(f, s.options_menu, &items, *sel, th);
}
Overlay::Preferences(sel) => {
let mark = |b: bool| if b { "[x]" } else { "[ ]" };
let exit_item = format!("{} {}", mark(app.confirm_on_exit), s.confirm_on_exit);
let clear_item = format!("{} {}", mark(app.confirm_on_clear), s.confirm_on_clear);
let view_label = match app.default_request_view {
request::RequestView::Json => "JSON",
request::RequestView::Hurl => "Hurl",
};
let view_item = format!("{}: {view_label}", s.default_request_view_label);
let always_save_item = format!(
"{} {}",
mark(app.always_save_when_prompted),
s.always_save_when_prompted
);
let items = [
exit_item.as_str(),
clear_item.as_str(),
view_item.as_str(),
always_save_item.as_str(),
];
draw_menu_popup(f, s.preferences_menu, &items, *sel, th);
}
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);
}
q
}
ConfirmAction::Clear => s.confirm_clear_q.to_string(),
ConfirmAction::Save(FileAction::SaveEnv) => s.confirm_save_env_q.replace(
"{e}",
&app.current_env_id()
.map(|id| app.changed_env_count(id))
.unwrap_or(0)
.to_string(),
),
ConfirmAction::Save(_) => s.confirm_save_collection_q.replace(
"{r}",
&app.changed_request_count(app.active_tab).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(),
};
draw_confirm_popup(f, &question, &[s.confirm_yes, s.confirm_no], *sel, th);
}
Overlay::LanguageMenu(sel) => {
let items = [s.lang_english, s.lang_french, s.lang_danish];
draw_menu_popup(f, s.language_label, &items, *sel, th);
}
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);
}
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 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(s.help_tab_switch_hint, Style::default().fg(th.dim)),
])
};
let shortcuts_body = || {
let groups: [(&str, &[(&str, &str)]); 7] = [
(
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", 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),
("u (List pane)", s.help_restore_request),
],
),
(
s.help_group_menus,
&[
("f / s", s.help_menus),
("\u{2190} / \u{2192} (File menu)", s.help_menu_submenu_nav),
("w", s.help_workspace_browse),
("\u{2192} / Enter (Workspace)", s.help_workspace_open),
("^r (File browser)", s.help_browser_reset),
],
),
(
s.help_group_environments,
&[
("r (Env popup)", s.help_reload_var),
("F2 (Env panel)", s.help_env_rename),
("a", s.help_env_activate),
("x", s.help_env_delete),
("p (List pane)", s.help_env_link),
("v", s.help_env_view_linked),
],
),
(
s.help_group_editing,
&[
("", s.help_row_toggle_delete),
("y", s.help_copy_selection),
("Alt+Click+Drag", s.help_multi_select),
("F2", s.help_save_editor),
],
),
(
s.help_group_panels,
&[
("+ / -", s.help_resize),
("< / >", s.help_resize_width),
("Esc", s.help_cancel),
("q, ^C", s.help_quit),
],
),
];
let mut body = Vec::new();
for (i, (heading, entries)) in groups.iter().enumerate() {
if i > 0 {
body.push(Line::raw(""));
}
body.push(help_section_divider(heading, inner_w, th));
for &(shortcut, desc) in entries.iter() {
body.extend(help_entry_lines(shortcut, desc, inner_w));
}
}
body
};
let glossary_body = || {
let mut body = vec![help_section_divider(s.glossary_heading, inner_w, th)];
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,
),
];
for (icon, color, label, desc) in entries {
body.extend(glossary_entry_lines(icon, color, label, desc, inner_w));
}
body.push(Line::raw(""));
body.push(help_section_divider(s.glossary_heading_icons, inner_w, th));
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,
),
];
for (icon, color, label, desc) in icon_entries {
body.extend(glossary_entry_lines(icon, color, label, desc, inner_w));
}
body
};
let mut lines = vec![
Line::styled(
s.help_heading,
Style::default().fg(th.accent).add_modifier(Modifier::BOLD),
),
tab_bar(*tab),
];
lines.extend(if *tab == 0 {
shortcuts_body()
} else {
glossary_body()
});
let other_len = 2 + if *tab == 0 {
glossary_body().len()
} else {
shortcuts_body().len()
};
let content_len = lines.len().max(other_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.help_title,
if *tab == 0 {
s.help_tab_shortcuts
} else {
s.help_tab_glossary
}
);
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);
}
}
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 w = if ml {
(f.area().width * 8 / 10).max(30)
} else {
64
};
let area = centered_rect(w, h, f.area());
f.render_widget(Clear, area);
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 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()
};
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,
);
}
}
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);
ex.widget().render_ref(area, f.buffer_mut());
}
Overlay::NewRequest(form) => draw_new_request(f, form, s, th, app.enhanced_keys),
Overlay::EnvVarForm(form) => draw_env_var_form(f, form, s, th),
Overlay::RemoteGit(w) => draw_remote_wizard(f, w, s, th),
Overlay::GitSave(w) => draw_git_save_wizard(f, w, s, th),
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),
Overlay::WorkspacePicker(picker) => draw_workspace_picker(f, picker, s, th),
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);
}
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);
}
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);
}
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);
}
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);
}
}
}
pub(crate) fn draw_env_var_form(f: &mut Frame, form: &EnvVarForm, s: &Strings, th: &Theme) {
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);
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);
}
pub(crate) fn draw_env_collision(f: &mut Frame, collision: &EnvCollision, s: &Strings, th: &Theme) {
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);
}
pub(crate) fn draw_workspace_picker(
f: &mut Frame,
picker: &WorkspacePickerState,
s: &Strings,
th: &Theme,
) {
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 {
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 mut st = ListState::default();
st.select(Some(picker.selected));
f.render_stateful_widget(list, rows[0], &mut st);
let hint = Paragraph::new(Line::styled(
if picker.adding_request {
s.workspace_picker_hint_add
} else {
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) {
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);
}
pub(crate) fn draw_confirm_popup(
f: &mut Frame,
question: &str,
choices: &[&str],
sel: usize,
th: &Theme,
) {
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],
);
}