use ratatui::{
prelude::*,
widgets::{Block, BorderType, List, ListItem, Paragraph, Wrap},
};
use crate::{
app::{App, GhState},
fmt,
types::{Language, LinkStyle, Palette, SettingRow, View},
};
const SETTINGS_ACCENT: Color = Color::Rgb(97, 218, 251);
fn pal(app: &App) -> Palette {
app.color_scheme.palette()
}
fn accent(app: &App) -> Color {
if app.favorites_mode {
pal(app).yellow
} else {
app.language.accent()
}
}
pub fn render(f: &mut Frame, app: &App) {
let [header, content, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(f.area());
draw_header(f, app, header);
match app.view {
View::List => draw_list_view(f, app, content),
View::Detail => draw_detail_view(f, app, content),
View::Settings => draw_settings_view(f, app, content),
View::DocsSearch => draw_docs_search_view(f, app, content),
}
draw_footer(f, app, footer);
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let [left, center, right] = Layout::horizontal([
Constraint::Length(26),
Constraint::Fill(1),
Constraint::Length(42),
])
.areas(area);
let p = pal(app);
let accent = accent(app);
let (badge_label, badge_color) = if app.loading {
(" ⟳ fetching… ", Color::Rgb(90, 88, 110)) } else if app.from_cache {
(" ◎ cached ", Color::Rgb(241, 250, 140)) } else {
(" ● live ", Color::Rgb(80, 250, 123)) };
let page_label = if app.page > 1 {
format!(" {} pkgs · p.{} ", app.packages.len(), app.page)
} else {
format!(" {} pkgs ", app.packages.len())
};
let logo_block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(accent))
.title(Line::from(vec![
Span::styled(" ✦ ", Style::new().fg(accent)),
Span::styled("hexplorer", Style::new().fg(p.white).bold()),
]))
.title_bottom(Line::from(vec![
Span::styled(page_label, Style::new().fg(accent)),
Span::styled(badge_label, Style::new().fg(badge_color).bold()),
]));
f.render_widget(logo_block, left);
draw_tab_bar(f, app, center);
let (search_txt, search_sty, search_border) = if app.view == View::DocsSearch {
if app.input.is_empty() {
(
" type to filter results…".to_string(),
Style::new().fg(p.dim).italic(),
Style::new().fg(accent),
)
} else {
(
format!(" /{}_", app.input),
Style::new().fg(p.yellow).bold(),
Style::new().fg(p.yellow),
)
}
} else if app.input_mode {
(
format!(" /{}_", app.input),
Style::new().fg(p.yellow).bold(),
Style::new().fg(p.yellow),
)
} else if app.input.is_empty() {
(
" press / to search…".to_string(),
Style::new().fg(p.dim).italic(),
Style::new().fg(accent),
)
} else {
(
format!(" /{}", app.input),
Style::new().fg(p.white),
Style::new().fg(accent),
)
};
let search_block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(search_border);
let lines = vec![
Line::from(Span::styled(search_txt, search_sty)),
Line::from(vec![
Span::styled(" sort: ", Style::new().fg(p.dim)),
Span::styled(app.sort.label(), Style::new().fg(accent)),
Span::styled(" [tab]", Style::new().fg(p.dim).italic()),
]),
];
f.render_widget(Paragraph::new(lines).block(search_block), right);
}
fn draw_tab_bar(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let bar_accent = accent(app);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(bar_accent));
let inner = block.inner(area);
f.render_widget(block, area);
let mut tab_spans: Vec<Span> = vec![Span::raw(" ")];
if app.favorites_mode {
tab_spans.push(Span::styled(
"[★ favorites]",
Style::new().fg(p.yellow).bold().underlined(),
));
tab_spans.push(Span::raw(" "));
for &lang in Language::all() {
tab_spans.push(Span::styled(lang.label(), Style::new().fg(p.dim)));
tab_spans.push(Span::raw(" "));
}
} else {
for &lang in Language::all() {
if lang == app.language {
tab_spans.push(Span::styled(
format!("[■ {}]", lang.label()),
Style::new().fg(lang.accent()).bold().underlined(),
));
} else {
tab_spans.push(Span::styled(lang.label(), Style::new().fg(p.dim)));
}
tab_spans.push(Span::raw(" "));
}
}
let hint = Line::from(Span::styled(
" l / L cycle language",
Style::new().fg(p.dim).italic(),
));
f.render_widget(Paragraph::new(vec![Line::from(tab_spans), hint]), inner);
}
fn draw_list_view(f: &mut Frame, app: &App, area: Rect) {
let [list_area, preview_area] =
Layout::horizontal([Constraint::Percentage(42), Constraint::Fill(1)]).areas(area);
draw_package_list(f, app, list_area);
draw_preview(f, app, preview_area);
}
fn draw_package_list(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let accent = accent(app);
let title = if app.favorites_mode {
format!(" ★ favorites ({}) ", app.packages.len())
} else {
format!(" packages ({}) ", app.language.label())
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(accent))
.title(Span::styled(title, Style::new().fg(accent).bold()));
let inner = block.inner(area);
f.render_widget(block, area);
if let Some(err) = &app.error {
f.render_widget(
Paragraph::new(format!("\n ✗ {err}"))
.style(Style::new().fg(Color::Red))
.wrap(Wrap { trim: true }),
inner,
);
return;
}
if app.loading {
f.render_widget(
Paragraph::new("\n ⟳ fetching from hex.pm…").style(Style::new().fg(p.dim).italic()),
inner,
);
return;
}
if app.packages.is_empty() {
f.render_widget(
Paragraph::new("\n no packages found").style(Style::new().fg(p.dim)),
inner,
);
return;
}
let show_badge = app.language == Language::All || app.favorites_mode;
let items: Vec<ListItem> = app
.packages
.iter()
.map(|pkg| {
let mut spans = vec![];
if app.favorites.contains_key(&pkg.name) {
spans.push(Span::styled("★ ", Style::new().fg(p.yellow)));
} else {
spans.push(Span::raw(" "));
}
if show_badge {
spans.push(Span::styled(
format!("[{}] ", pkg.language.badge()),
Style::new().fg(pkg.language.accent()),
));
}
spans.push(Span::styled(pkg.name.clone(), Style::new().fg(p.white)));
spans.push(Span::styled(
format!(" v{}", pkg.version),
Style::new().fg(p.dim),
));
spans.push(Span::styled(
format!(" {}⬇", fmt::dl_short(pkg.downloads_recent)),
Style::new().fg(accent),
));
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(Style::new().bg(p.bg_sel).fg(accent).bold());
let mut state = app.list_state.clone();
f.render_stateful_widget(list, inner, &mut state);
}
fn draw_preview(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let accent = accent(app);
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(p.dim));
let inner = block.inner(area);
f.render_widget(block, area);
let Some(pkg) = app.selected() else { return };
let w = inner.width as usize;
let name_color = if app.language == Language::All {
pkg.language.accent()
} else {
accent
};
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::styled(pkg.name.clone(), Style::new().fg(name_color).bold()),
Span::styled(format!(" v{}", pkg.version), Style::new().fg(accent)),
]),
Line::from(Span::styled("─".repeat(w.min(44)), Style::new().fg(p.dim))),
];
if !pkg.description.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
pkg.description.clone(),
Style::new().fg(p.white),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("⬇ ", Style::new().fg(accent)),
Span::styled(
fmt::dl_full(pkg.downloads_all),
Style::new().fg(p.white).bold(),
),
Span::styled(" total", Style::new().fg(p.dim)),
]));
lines.push(Line::from(vec![
Span::styled("⬇ ", Style::new().fg(accent)),
Span::styled(fmt::dl_full(pkg.downloads_recent), Style::new().fg(p.white)),
Span::styled(" recent", Style::new().fg(p.dim)),
]));
if let Some(entry) = app.preview_gh() {
lines.push(Line::from(vec![
Span::styled("★ ", Style::new().fg(p.yellow)),
Span::styled(entry.stars.to_string(), Style::new().fg(p.white).bold()),
Span::styled(" ⑂ ", Style::new().fg(accent)),
Span::styled(entry.forks.to_string(), Style::new().fg(p.white)),
Span::styled(
format!(" ({})", entry.age_label()),
Style::new().fg(p.dim).italic(),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("updated ", Style::new().fg(p.dim)),
Span::styled(
fmt::date(&pkg.updated_at).to_string(),
Style::new().fg(p.white),
),
]));
if !pkg.licenses.is_empty() {
lines.push(Line::from(vec![
Span::styled("license ", Style::new().fg(p.dim)),
Span::styled(pkg.licenses.join(", "), Style::new().fg(p.white)),
]));
}
if let Some(docs) = &pkg.docs_url {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("📖 ", Style::new().fg(accent)),
Span::styled(
fmt::truncate(docs, w.saturating_sub(4)),
Style::new().fg(accent),
),
]));
}
if let Some(repo) = &pkg.repo_url {
lines.push(Line::from(vec![
Span::styled("⌥ ", Style::new().fg(accent)),
Span::styled(
fmt::truncate(repo, w.saturating_sub(4)),
Style::new().fg(accent),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ↵ Enter for full detail",
Style::new().fg(p.dim).italic(),
)));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn draw_detail_view(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let accent = if app.favorites_mode {
p.yellow
} else if app.language == Language::All {
app.selected()
.map(|pkg| pkg.language.accent())
.unwrap_or(app.language.accent())
} else {
app.language.accent()
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(accent))
.title(Span::styled(" detail ", Style::new().fg(accent).bold()))
.title_bottom(Span::styled(" esc / q → back ", Style::new().fg(p.dim)));
let inner = block.inner(area);
f.render_widget(block, area);
let Some(pkg) = app.selected() else { return };
let w = inner.width as usize;
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(vec![
Span::styled(
pkg.name.clone(),
Style::new().fg(accent).bold().underlined(),
),
Span::styled(format!(" v{}", pkg.version), Style::new().fg(accent)),
Span::styled(
format!(" [{}]", pkg.language.label()),
Style::new().fg(pkg.language.accent()).bold(),
),
]));
lines.push(Line::from(Span::styled(
"═".repeat(w.min(54)),
Style::new().fg(p.dim),
)));
lines.push(Line::from(""));
if !pkg.description.is_empty() {
lines.push(section("description", p.dim));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
pkg.description.clone(),
Style::new().fg(p.white),
)));
lines.push(Line::from(""));
}
lines.push(section("downloads", p.dim));
lines.push(Line::from(""));
lines.push(kv(
" all-time ",
fmt::dl_full(pkg.downloads_all),
accent,
p.dim,
));
lines.push(kv(
" recent ",
fmt::dl_full(pkg.downloads_recent),
accent,
p.dim,
));
lines.push(Line::from(""));
lines.push(section("github", p.dim));
lines.push(Line::from(""));
match &app.gh {
GhState::Loading => {
lines.push(Line::from(Span::styled(
" loading…",
Style::new().fg(p.dim).italic(),
)));
}
GhState::Live(stats) => {
lines.push(kv(
" ★ stars ",
stats.stars.to_string(),
p.yellow,
p.dim,
));
lines.push(kv(" ⑂ forks ", stats.forks.to_string(), accent, p.dim));
lines.push(kv(
" ⊙ issues ",
stats.issues.to_string(),
p.white,
p.dim,
));
lines.push(Line::from(Span::styled(
" (live)",
Style::new().fg(p.green).italic(),
)));
}
GhState::Cached(entry) => {
lines.push(kv(
" ★ stars ",
entry.stars.to_string(),
p.yellow,
p.dim,
));
lines.push(kv(" ⑂ forks ", entry.forks.to_string(), accent, p.dim));
lines.push(kv(
" ⊙ issues ",
entry.issues.to_string(),
p.white,
p.dim,
));
lines.push(Line::from(Span::styled(
format!(" (cached {})", entry.age_label()),
Style::new().fg(p.dim).italic(),
)));
}
GhState::RateLimited => {
lines.push(Line::from(Span::styled(
" rate limited (60 req/h)",
Style::new().fg(p.yellow),
)));
lines.push(Line::from(Span::styled(
" set GITHUB_TOKEN to raise limit to 5000/h",
Style::new().fg(p.dim).italic(),
)));
}
GhState::BadToken => {
lines.push(Line::from(Span::styled(
" token invalid or expired (HTTP 401)",
Style::new().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(
" update via ? → settings or GITHUB_TOKEN env var",
Style::new().fg(p.dim).italic(),
)));
}
GhState::Unavailable => {
lines.push(Line::from(Span::styled(
" stats unavailable",
Style::new().fg(p.dim),
)));
}
GhState::NoRepo => {
lines.push(Line::from(Span::styled(
" no repository",
Style::new().fg(p.dim),
)));
}
}
lines.push(Line::from(""));
lines.push(section("links", p.dim));
lines.push(Line::from(""));
let mut link_idx: usize = 0;
if let Some(u) = &pkg.docs_url {
lines.push(url_line(
" 📖 docs ",
u.clone(),
accent,
p.dim,
app.link_cursor == Some(link_idx),
app.link_style,
));
link_idx += 1;
}
if let Some(u) = &pkg.hex_url {
lines.push(url_line(
" ◈ hex.pm ",
u.clone(),
accent,
p.dim,
app.link_cursor == Some(link_idx),
app.link_style,
));
link_idx += 1;
}
if let Some(u) = &pkg.repo_url {
lines.push(url_line(
" ⌥ repo ",
u.clone(),
accent,
p.dim,
app.link_cursor == Some(link_idx),
app.link_style,
));
let _ = link_idx;
}
lines.push(Line::from(""));
lines.push(section("versions", p.dim));
lines.push(Line::from(""));
if app.detail_loading {
lines.push(Line::from(Span::styled(
" loading…",
Style::new().fg(p.dim).italic(),
)));
} else if pkg.versions.is_empty() {
lines.push(Line::from(Span::styled(" —", Style::new().fg(p.dim))));
} else {
for v in pkg.versions.iter().take(10) {
lines.push(Line::from(vec![
Span::styled(" ", Style::new().fg(p.dim)),
Span::styled(v.clone(), Style::new().fg(p.white)),
]));
}
if pkg.versions.len() > 10 {
lines.push(Line::from(Span::styled(
format!(" … and {} more", pkg.versions.len() - 10),
Style::new().fg(p.dim).italic(),
)));
}
}
lines.push(Line::from(""));
lines.push(section("metadata", p.dim));
lines.push(Line::from(""));
if !pkg.build_tool.is_empty() {
lines.push(kv(" build tool ", pkg.build_tool.clone(), p.white, p.dim));
}
lines.push(kv(
" updated ",
fmt::date(&pkg.updated_at).to_string(),
p.white,
p.dim,
));
lines.push(kv(
" published ",
fmt::date(&pkg.inserted_at).to_string(),
p.white,
p.dim,
));
if !pkg.licenses.is_empty() {
lines.push(kv(" license ", pkg.licenses.join(", "), p.white, p.dim));
}
f.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((app.scroll, 0)),
inner,
);
}
fn draw_settings_view(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let ac = SETTINGS_ACCENT;
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(ac))
.title(Span::styled(" ⚙ settings ", Style::new().fg(ac).bold()))
.title_bottom(Span::styled(" esc / q → back ", Style::new().fg(p.dim)));
let inner = block.inner(area);
f.render_widget(block, area);
let rows = SettingRow::all();
let cursor = app.settings_cursor;
let mut lines: Vec<Line> = vec![Line::from("")];
lines.push(Line::from(Span::styled(
" github",
Style::new().fg(p.dim).bold(),
)));
lines.push(Line::from(""));
let is_token = rows[cursor] == SettingRow::GithubToken;
let prefix = if is_token { "▶ " } else { " " };
let row_color = if is_token { ac } else { p.white };
let token_val: Line = if app.settings_editing {
Line::from(vec![
Span::styled(prefix, Style::new().fg(ac).bold()),
Span::styled("token ", Style::new().fg(p.dim)),
Span::styled(
format!("[{}█]", app.settings_input),
Style::new().fg(p.yellow).bold(),
),
Span::styled(
" enter to confirm · esc to cancel",
Style::new().fg(p.dim).italic(),
),
])
} else {
let val = app
.settings_token
.as_deref()
.map(mask_token_ui)
.unwrap_or_else(|| "(not set)".to_string());
let hint = if app.settings_token.is_some() {
" enter to edit · d to clear"
} else {
" enter to set"
};
Line::from(vec![
Span::styled(prefix, Style::new().fg(ac).bold()),
Span::styled("token ", Style::new().fg(p.dim)),
Span::styled(val, Style::new().fg(row_color).bold()),
Span::styled(hint, Style::new().fg(p.dim).italic()),
])
};
lines.push(token_val);
lines.push(Line::from(Span::styled(
" ~/.config/hexplorer/credentials.json (0600)",
Style::new().fg(p.dim).italic(),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" appearance",
Style::new().fg(p.dim).bold(),
)));
lines.push(Line::from(""));
let is_cs = rows[cursor] == SettingRow::ColorScheme;
let (pre, col) = if is_cs {
("▶ ", ac)
} else {
(" ", p.white)
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("color_scheme ", Style::new().fg(p.dim)),
Span::styled(
app.settings_config.color_scheme.label(),
Style::new().fg(col).bold(),
),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
let is_ls = rows[cursor] == SettingRow::LinkStyle;
let (pre, col) = if is_ls {
("▶ ", ac)
} else {
(" ", p.white)
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("link_style ", Style::new().fg(p.dim)),
Span::styled(
app.settings_config.link_style.label(),
Style::new().fg(col).bold(),
),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
let is_dl = rows[cursor] == SettingRow::DefaultLanguage;
let (pre, col) = if is_dl {
("▶ ", ac)
} else {
(" ", p.white)
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("default_language ", Style::new().fg(p.dim)),
Span::styled(
app.settings_config.default_language.label(),
Style::new().fg(col).bold(),
),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" storage",
Style::new().fg(p.dim).bold(),
)));
lines.push(Line::from(""));
let is_kw = rows[cursor] == SettingRow::KeepWeeks;
let (pre, col) = if is_kw {
("▶ ", ac)
} else {
(" ", p.white)
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("keep_weeks ", Style::new().fg(p.dim)),
Span::styled(
format!("{} weeks", app.settings_config.keep_weeks),
Style::new().fg(col).bold(),
),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
let is_cmp = rows[cursor] == SettingRow::Compress;
let (pre, col) = if is_cmp {
("▶ ", ac)
} else {
(" ", p.white)
};
let compress_val = if app.settings_config.compress {
"on"
} else {
"off"
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("compress ", Style::new().fg(p.dim)),
Span::styled(compress_val, Style::new().fg(col).bold()),
Span::styled(" enter to toggle", Style::new().fg(p.dim).italic()),
]));
let is_dct = rows[cursor] == SettingRow::DocsCacheTtl;
let (pre, col) = if is_dct {
("▶ ", ac)
} else {
(" ", p.white)
};
let ttl_val = match app.settings_config.docs_cache_ttl_hours {
0 => "off".to_string(),
1 => "1h".to_string(),
h if h < 24 => format!("{h}h"),
168 => "1 week".to_string(),
h => format!("{}h", h),
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("docs cache ", Style::new().fg(p.dim)),
Span::styled(ttl_val, Style::new().fg(col).bold()),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
let is_lr = rows[cursor] == SettingRow::LogRetentionDays;
let (pre, col) = if is_lr {
("▶ ", ac)
} else {
(" ", p.white)
};
let lr_val = match app.settings_config.log_retention_days {
0 => "off".to_string(),
1 => "1 day".to_string(),
d => format!("{d} days"),
};
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("log retain ", Style::new().fg(p.dim)),
Span::styled(lr_val, Style::new().fg(col).bold()),
Span::styled(" ← →", Style::new().fg(p.dim).italic()),
]));
let is_gc = rows[cursor] == SettingRow::ClearGhCache;
let (pre, col) = if is_gc {
("▶ ", ac)
} else {
(" ", p.white)
};
let cache_count = app.cache.len();
lines.push(Line::from(vec![
Span::styled(pre, Style::new().fg(ac).bold()),
Span::styled("gh cache ", Style::new().fg(p.dim)),
Span::styled(
format!("{cache_count} entries"),
Style::new().fg(col).bold(),
),
Span::styled(" enter to clear", Style::new().fg(p.dim).italic()),
]));
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}
fn mask_token_ui(t: &str) -> String {
let chars: Vec<char> = t.chars().collect();
if chars.len() <= 8 {
"***".to_string()
} else {
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{}…{}", head, tail)
}
}
fn draw_docs_search_view(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let accent = accent(app);
let pkg = &app.docs_search_pkg;
let block = Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(accent))
.title(Span::styled(
format!(" hexdocs: {pkg} "),
Style::new().fg(accent).bold(),
))
.title_bottom(Span::styled(" esc / q → back ", Style::new().fg(p.dim)));
let inner = block.inner(area);
f.render_widget(block, area);
if app.docs_search_loading {
f.render_widget(
Paragraph::new("\n ⟳ searching hexdocs…").style(Style::new().fg(p.dim).italic()),
inner,
);
return;
}
if let Some(err) = &app.docs_search_error {
f.render_widget(
Paragraph::new(format!("\n ✗ {err}"))
.style(Style::new().fg(Color::Red))
.wrap(Wrap { trim: true }),
inner,
);
return;
}
if app.docs_search_results.is_empty() {
f.render_widget(
Paragraph::new("\n no results").style(Style::new().fg(p.dim)),
inner,
);
return;
}
let [list_area, snippet_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(4)]).areas(inner);
let items: Vec<ListItem> = app
.docs_search_results
.iter()
.map(|item| {
let badge_color = match item.item_type.as_str() {
"module" => p.dim,
"page" => p.white,
"callback" | "type" => p.yellow,
_ => accent, };
let badge = format!("[{:<8}]", item.item_type);
let spans = vec![
Span::styled(badge, Style::new().fg(badge_color)),
Span::styled(" ", Style::new()),
Span::styled(item.title.clone(), Style::new().fg(p.white).bold()),
Span::styled(" ", Style::new()),
Span::styled(item.parent_title.clone(), Style::new().fg(p.dim)),
];
ListItem::new(Line::from(spans))
})
.collect();
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(app.docs_search_cursor));
let list = List::new(items)
.highlight_symbol("▶ ")
.highlight_style(Style::new().bg(p.bg_sel).fg(accent).bold());
f.render_stateful_widget(list, list_area, &mut list_state);
if let Some(item) = app.docs_search_results.get(app.docs_search_cursor) {
let w = snippet_area.width as usize;
let snippet: String = item
.doc_text
.lines()
.flat_map(|l| l.split_whitespace())
.fold(String::new(), |mut acc, word| {
if acc.is_empty() {
acc.push_str(word);
} else if acc.len() + word.len() + 1 < w.saturating_sub(4) {
acc.push(' ');
acc.push_str(word);
}
acc
});
let url = format!("https://hexdocs.pm/{pkg}/{}", item.ref_url);
let lines = vec![
Line::from(Span::styled(
"─".repeat(snippet_area.width as usize),
Style::new().fg(p.dim),
)),
Line::from(Span::styled(
format!(
" {}",
if snippet.is_empty() {
"—".to_string()
} else {
snippet
}
),
Style::new().fg(p.dim),
)),
Line::from(Span::styled(
format!(" {url}"),
Style::new().fg(accent).underlined(),
)),
];
f.render_widget(Paragraph::new(lines), snippet_area);
}
}
fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
let p = pal(app);
let accent = accent(app);
if app.docs_search_mode {
let pkg_name = app.selected().map(|pkg| pkg.name.as_str()).unwrap_or("?");
let spans = vec![
Span::styled(" Search ", Style::new().fg(p.dim)),
Span::styled(pkg_name, Style::new().fg(accent).bold()),
Span::styled(" docs: ", Style::new().fg(p.dim)),
Span::styled(
format!("{}_", app.docs_search_input),
Style::new().fg(p.yellow).bold(),
),
Span::styled(" ↵ open · esc cancel", Style::new().fg(p.dim).italic()),
];
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::new().bg(p.bg_bar)),
area,
);
return;
}
let spans: Vec<Span> = match app.view {
View::List => {
let mut spans = vec![
Span::styled(" /", Style::new().fg(accent).bold()),
Span::styled(" search ", Style::new().fg(p.dim)),
Span::styled("↑↓ j k", Style::new().fg(accent).bold()),
Span::styled(" nav ", Style::new().fg(p.dim)),
Span::styled("↵", Style::new().fg(accent).bold()),
Span::styled(" detail ", Style::new().fg(p.dim)),
Span::styled("l / L", Style::new().fg(accent).bold()),
Span::styled(" lang ", Style::new().fg(p.dim)),
Span::styled("tab", Style::new().fg(accent).bold()),
Span::styled(" sort ", Style::new().fg(p.dim)),
];
if app.input.trim().is_empty() && (app.page > 1 || app.has_more) {
spans.push(Span::styled("[ ]", Style::new().fg(accent).bold()));
spans.push(Span::styled(" page ", Style::new().fg(p.dim)));
}
spans.push(Span::styled("s", Style::new().fg(p.yellow).bold()));
spans.push(Span::styled(" star ", Style::new().fg(p.dim)));
if !app.favorites.is_empty() || app.favorites_mode {
spans.push(Span::styled("f", Style::new().fg(p.yellow).bold()));
spans.push(Span::styled(" favorites ", Style::new().fg(p.dim)));
}
spans.push(Span::styled("D", Style::new().fg(p.green).bold()));
spans.push(Span::styled(" docs search ", Style::new().fg(p.dim)));
spans.push(Span::styled("r", Style::new().fg(accent).bold()));
spans.push(Span::styled(" refresh ", Style::new().fg(p.dim)));
spans.push(Span::styled("?", Style::new().fg(accent).bold()));
spans.push(Span::styled(" settings ", Style::new().fg(p.dim)));
spans.push(Span::styled("q", Style::new().fg(accent).bold()));
spans.push(Span::styled(" quit", Style::new().fg(p.dim)));
spans
}
View::Detail => vec![
Span::styled(" esc / q", Style::new().fg(accent).bold()),
Span::styled(" back ", Style::new().fg(p.dim)),
Span::styled("↑↓ j k", Style::new().fg(accent).bold()),
Span::styled(" scroll ", Style::new().fg(p.dim)),
Span::styled("PgUp/Dn", Style::new().fg(accent).bold()),
Span::styled(" fast ", Style::new().fg(p.dim)),
Span::styled("tab", Style::new().fg(accent).bold()),
Span::styled(" link ", Style::new().fg(p.dim)),
Span::styled("enter", Style::new().fg(accent).bold()),
Span::styled(" open ", Style::new().fg(p.dim)),
Span::styled("s", Style::new().fg(p.green).bold()),
Span::styled(" docs search ", Style::new().fg(p.dim)),
Span::styled("r", Style::new().fg(p.green).bold()),
Span::styled(" refresh pkg", Style::new().fg(p.dim)),
],
View::DocsSearch => vec![
Span::styled(" esc / q", Style::new().fg(accent).bold()),
Span::styled(" back ", Style::new().fg(p.dim)),
Span::styled("↑↓ j k", Style::new().fg(accent).bold()),
Span::styled(" navigate ", Style::new().fg(p.dim)),
Span::styled("enter", Style::new().fg(accent).bold()),
Span::styled(" open ", Style::new().fg(p.dim)),
Span::styled("type", Style::new().fg(p.yellow).bold()),
Span::styled(" filter results", Style::new().fg(p.dim)),
],
View::Settings => vec![
Span::styled(" esc / q", Style::new().fg(SETTINGS_ACCENT).bold()),
Span::styled(" back ", Style::new().fg(p.dim)),
Span::styled("↑↓ j k", Style::new().fg(SETTINGS_ACCENT).bold()),
Span::styled(" navigate ", Style::new().fg(p.dim)),
Span::styled("enter", Style::new().fg(SETTINGS_ACCENT).bold()),
Span::styled(" edit ", Style::new().fg(p.dim)),
Span::styled("← →", Style::new().fg(SETTINGS_ACCENT).bold()),
Span::styled(" cycle", Style::new().fg(p.dim)),
],
};
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::new().bg(p.bg_bar)),
area,
);
}
fn section(title: &'static str, dim: Color) -> Line<'static> {
Line::from(Span::styled(title, Style::new().fg(dim).bold()))
}
fn kv(key: &'static str, val: String, color: Color, dim: Color) -> Line<'static> {
Line::from(vec![
Span::styled(key, Style::new().fg(dim)),
Span::styled(val, Style::new().fg(color).bold()),
])
}
fn url_line(
label: &'static str,
url: String,
color: Color,
dim: Color,
selected: bool,
link_style: LinkStyle,
) -> Line<'static> {
let (_, label_rest) = label.split_at(2);
match (selected, link_style) {
(true, LinkStyle::Block) => Line::from(vec![
Span::styled(" ", Style::new().bg(color).fg(Color::Black)),
Span::styled(label_rest, Style::new().bg(color).fg(Color::Black).bold()),
Span::styled(url, Style::new().bg(color).fg(Color::Black).bold()),
]),
(true, LinkStyle::Cursor) => Line::from(vec![
Span::styled("▶ ", Style::new().fg(color).bold()),
Span::styled(label_rest, Style::new().fg(dim)),
Span::styled(url, Style::new().fg(color).bold().underlined()),
]),
(false, _) => Line::from(vec![
Span::styled(" ", Style::new().fg(dim)),
Span::styled(label_rest, Style::new().fg(dim)),
Span::styled(url, Style::new().fg(color).underlined()),
]),
}
}