use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Clear, Paragraph};
use crate::shared::credits;
use crate::shared::i18n::Locale;
use crate::shared::keys;
use crate::shared::theme::Palette;
use crate::shared::ui::{centered_rect, render_scrollbar};
use crate::shared::wrap;
use crate::widgets::logo::{LOCKUP_COLS, LOCKUP_ROWS, lockup_lines};
const LOGO_INDENT: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpTab {
About,
Hotkeys,
Commands,
License,
Legal,
Components,
}
impl HelpTab {
pub const ALL: [HelpTab; 6] = [
Self::About,
Self::Hotkeys,
Self::Commands,
Self::License,
Self::Legal,
Self::Components,
];
fn index(self) -> usize {
Self::ALL.iter().position(|&t| t == self).unwrap()
}
pub fn label_key(self) -> &'static str {
match self {
Self::About => "ui.help.tab.about",
Self::Hotkeys => "ui.help.tab.hotkeys",
Self::Commands => "ui.help.tab.commands",
Self::License => "ui.help.tab.license",
Self::Legal => "ui.help.tab.legal",
Self::Components => "ui.help.tab.components",
}
}
}
pub const DEFAULT_HELP_TAB: HelpTab = HelpTab::Hotkeys;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpContext {
Chat,
ChatList,
Settings,
SelfModel,
Changes,
Search,
Tasks,
}
pub struct HelpState {
pub tab: HelpTab,
pub scroll: usize,
pub context: HelpContext,
pending_anchor: bool,
}
impl HelpState {
pub fn open(tab: HelpTab) -> Self {
Self {
tab,
scroll: 0,
context: HelpContext::Chat,
pending_anchor: false,
}
}
pub fn open_at(context: HelpContext) -> Self {
Self {
tab: HelpTab::Hotkeys,
scroll: 0,
context,
pending_anchor: true,
}
}
pub fn next_tab(&mut self) {
let n = HelpTab::ALL.len();
self.tab = HelpTab::ALL[(self.tab.index() + 1) % n];
self.scroll = 0;
}
pub fn prev_tab(&mut self) {
let n = HelpTab::ALL.len();
self.tab = HelpTab::ALL[(self.tab.index() + n - 1) % n];
self.scroll = 0;
}
pub fn handle_key(&mut self, key: &KeyEvent) -> HelpKeyOutcome {
if key.code == KeyCode::F(10)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& keys::hotkey_char(key) == Some('q'))
{
return HelpKeyOutcome::Quit;
}
match key.code {
KeyCode::Tab | KeyCode::Right => self.next_tab(),
KeyCode::BackTab | KeyCode::Left => self.prev_tab(),
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(HELP_PAGE_SCROLL),
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(HELP_PAGE_SCROLL),
KeyCode::Home => self.scroll = 0,
KeyCode::Esc | KeyCode::F(1) => return HelpKeyOutcome::Close,
_ => {}
}
HelpKeyOutcome::Handled
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpKeyOutcome {
Handled,
Close,
Quit,
}
const HELP_PAGE_SCROLL: usize = 8;
pub struct HelpSection {
pub title: &'static str,
pub context: Option<HelpContext>,
pub rows: &'static [(&'static str, &'static str)],
pub openers: &'static [&'static str],
}
pub static GLOBAL: HelpSection = HelpSection {
title: "ui.help.sec.global",
context: None,
rows: &[("F1", "ui.help.help"), ("Ctrl+Q / F10", "ui.help.quit")],
openers: &[],
};
pub const HELP_COMMANDS: &[(&str, &str)] = &[
("ui.help.k.file_attach", "ui.help.file_attach"),
("ui.help.k.file_remove", "ui.help.file_remove"),
("/file list", "ui.help.file_list"),
("ui.help.k.file_open", "ui.help.file_open"),
("/file folder", "ui.help.file_folder"),
("ui.help.k.image_attach", "ui.help.image_attach"),
("ui.help.k.image_remove", "ui.help.image_remove"),
("/image list", "ui.help.image_list"),
("/image paste", "ui.help.image_paste"),
("ui.help.k.project_attach", "ui.help.project_attach"),
("/project detach", "ui.help.project_detach"),
("/project status", "ui.help.project_status"),
("ui.help.k.project_cmd", "ui.help.project_cmd"),
("ui.help.k.project_clear", "ui.help.project_clear"),
("/changes", "ui.help.changes"),
("ui.help.k.rag_add", "ui.help.rag_add"),
("ui.help.k.rag_remove", "ui.help.rag_remove"),
("/rag list", "ui.help.rag_list"),
("/rag rebuild", "ui.help.rag_rebuild"),
("/reindex", "ui.help.reindex"),
("/compact", "ui.help.compact"),
("ui.help.k.tts", "ui.help.tts"),
("/tts stop", "ui.help.tts_stop"),
("/tts pause · resume", "ui.help.tts_pause"),
("ui.help.k.export", "ui.help.cmd_export"),
("/profile list", "ui.help.cmd_profile_list"),
("ui.help.k.profile_new", "ui.help.cmd_profile_new"),
("ui.help.k.profile_delete", "ui.help.cmd_profile_delete"),
("ui.help.k.profile_system", "ui.help.cmd_profile_system"),
("ui.help.k.profile_greeting", "ui.help.cmd_profile_greeting"),
("/impersonation list", "ui.help.cmd_imp_list"),
("ui.help.k.imp_new", "ui.help.cmd_imp_new"),
("ui.help.k.imp_delete", "ui.help.cmd_imp_delete"),
("ui.help.k.imp_use", "ui.help.cmd_imp_use"),
("ui.help.k.imp_system", "ui.help.cmd_imp_system"),
];
const HELP_COMMANDS_TAIL: &[(&str, &str)] = &[("/exit · /quit", "ui.help.exit")];
pub fn command_rows() -> Vec<(&'static str, &'static str)> {
HELP_COMMANDS
.iter()
.copied()
.chain(
crate::features::ui_command::COMMANDS
.iter()
.map(|s| (s.label, s.description)),
)
.chain(HELP_COMMANDS_TAIL.iter().copied())
.collect()
}
const COMMAND_GROUP_OPENERS: &[&str] = &[
"ui.help.k.image_attach",
"ui.help.k.project_attach",
"ui.help.k.rag_add",
"/reindex",
"ui.help.k.tts",
"ui.help.k.export",
"/profile list",
"/impersonation list",
"/settings",
"ui.help.k.new",
"ui.help.k.find",
"/thoughts",
"/exit · /quit",
];
pub const HELP_MIN_WIDTH: u16 = 76;
pub const HELP_MAX_WIDTH: u16 = 96;
const HELP_MIN_HEIGHT: u16 = 34;
const HELP_MAX_HEIGHT: u16 = 44;
const HELP_AIR: u16 = 6;
pub fn help_size(cols: u16, rows: u16) -> (u16, u16) {
(
cols.saturating_sub(HELP_AIR)
.clamp(HELP_MIN_WIDTH, HELP_MAX_WIDTH),
rows.saturating_sub(HELP_AIR)
.clamp(HELP_MIN_HEIGHT, HELP_MAX_HEIGHT),
)
}
pub fn render_help(
frame: &mut Frame,
help: &mut HelpState,
sections: &[&HelpSection],
palette: &Palette,
loc: &'static Locale,
) {
let full = frame.area();
let (content_w, content_h) = help_size(full.width, full.height);
let area = centered_rect(content_w + 2, content_h + 2, full);
frame.render_widget(Clear, area);
let title = format!(
"{}{} · {} v{}",
palette.glyphs().help_icon,
loc.t("ui.help.title"),
credits::APP_NAME,
env!("CARGO_PKG_VERSION"),
);
let block = palette.panel(title, true).title_bottom(
Line::from(Span::styled(
loc.t("ui.help.footer.tabs"),
palette.muted_style(),
))
.centered(),
);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width == 0 || inner.height == 0 {
return;
}
let inner_w = inner.width as usize;
let show_logo = inner.height >= LOCKUP_ROWS + 6 && inner.width > LOCKUP_COLS + LOGO_INDENT;
let mut header: Vec<Line> = Vec::new();
if show_logo {
let pad = " ".repeat(LOGO_INDENT as usize);
header.push(Line::raw("")); header.extend(lockup_lines(palette.text).into_iter().map(|line| {
let mut spans = vec![Span::raw(pad.clone())];
spans.extend(line.spans);
Line::from(spans)
}));
header.push(Line::raw(""));
}
header.push(help_tab_strip(help.tab, palette, loc));
header.push(Line::styled(
"─".repeat(inner_w),
palette.border_style(false),
));
let header_h = header.len() as u16;
let [head_area, body_area] =
Layout::vertical([Constraint::Length(header_h), Constraint::Min(0)]).areas(inner);
frame.render_widget(Paragraph::new(header), head_area);
let content = match help.tab {
HelpTab::About => about_lines(palette, loc, inner_w),
HelpTab::Hotkeys => {
let (lines, anchor) = hotkeys_tab(sections, help.context, palette, loc, inner_w);
if help.pending_anchor {
help.scroll = anchor;
help.pending_anchor = false;
}
lines
}
HelpTab::Commands => key_lines(
&command_rows(),
COMMAND_GROUP_OPENERS,
palette,
loc,
inner_w,
),
HelpTab::License => license_lines(palette, loc, inner_w),
HelpTab::Legal => legal_lines(palette, loc, inner_w),
HelpTab::Components => component_lines(palette, loc, inner_w),
};
let total = content.len();
let view_h = body_area.height as usize;
help.scroll = help.scroll.min(total.saturating_sub(view_h));
frame.render_widget(
Paragraph::new(Text::from(content)).scroll((help.scroll as u16, 0)),
body_area,
);
let bar_area = Rect {
x: area.x,
y: body_area.y,
width: area.width,
height: body_area.height,
};
render_scrollbar(frame, bar_area, total, view_h, help.scroll, true, palette);
}
pub fn help_tab_strip(active: HelpTab, palette: &Palette, loc: &'static Locale) -> Line<'static> {
let mut spans: Vec<Span<'static>> = vec![Span::raw(" ")];
for (i, tab) in HelpTab::ALL.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" │", palette.border_style(false)));
}
let label = loc.t(tab.label_key());
let style = if *tab == active {
Style::new().fg(palette.text).bg(palette.keycap_bg).bold()
} else {
palette.muted_style()
};
spans.push(Span::styled(format!(" {label} "), style));
}
Line::from(spans)
}
const HELP_PAD: &str = " ";
fn about_lines(palette: &Palette, loc: &'static Locale, width: usize) -> Vec<Line<'static>> {
let mut facts = vec![(
loc.t("ui.about.version"),
env!("CARGO_PKG_VERSION").to_string(),
palette.text,
)];
if let Some(date) = credits::build_date() {
facts.push((loc.t("ui.about.build"), date.to_string(), palette.text));
}
facts.extend([
(
loc.t("ui.about.license"),
credits::LICENSE_ID.to_string(),
palette.text,
),
(
loc.t("ui.about.platform"),
credits::platform(),
palette.text,
),
(
loc.t("ui.about.site"),
credits::SITE_URL.to_string(),
palette.accent,
),
(
loc.t("ui.about.crate"),
credits::CRATE_URL.to_string(),
palette.accent,
),
(
loc.t("ui.about.repo"),
credits::REPO_URL.to_string(),
palette.accent,
),
(
loc.t("ui.about.author"),
credits::AUTHOR.to_string(),
palette.text,
),
]);
let rows: Vec<(Span<'static>, Span<'static>)> = facts
.into_iter()
.map(|(label, value, color)| {
(
Span::styled(format!("{label}:"), palette.muted_style()),
Span::styled(value, Style::new().fg(color)),
)
})
.collect();
let value_col = width.saturating_sub(
HELP_PAD.chars().count() + rows.iter().map(|(_, v)| span_width(v)).max().unwrap_or(0),
);
let mut lines = vec![
Line::raw(""),
Line::from(Span::styled(
format!("{HELP_PAD}{}", credits::APP_NAME),
Style::new().fg(palette.assistant).bold(),
)),
Line::from(Span::styled(
format!("{HELP_PAD}{}", loc.t("ui.about.desc")),
palette.muted_style(),
)),
Line::raw(""),
];
for (label, value) in rows {
lines.push(Line::raw(""));
lines.push(leader_row(label, vec![value], value_col, palette));
}
lines
}
struct TabSection<'a> {
header: Option<(&'a str, bool)>,
rows: &'a [(&'a str, &'a str)],
openers: &'a [&'a str],
}
pub fn hotkeys_tab(
sections: &[&HelpSection],
context: HelpContext,
palette: &Palette,
loc: &'static Locale,
width: usize,
) -> (Vec<Line<'static>>, usize) {
let sections: Vec<TabSection<'_>> = sections
.iter()
.map(|s| TabSection {
header: Some((s.title, s.context == Some(context))),
rows: s.rows,
openers: s.openers,
})
.collect();
let (lines, marked) = table_lines(§ions, false, palette, loc, width);
(lines, marked.unwrap_or(0))
}
fn key_lines(
entries: &[(&str, &str)],
openers: &[&str],
palette: &Palette,
loc: &'static Locale,
width: usize,
) -> Vec<Line<'static>> {
let section = TabSection {
header: None,
rows: entries,
openers,
};
table_lines(std::slice::from_ref(§ion), true, palette, loc, width).0
}
fn table_lines(
sections: &[TabSection<'_>],
command_labels: bool,
palette: &Palette,
loc: &'static Locale,
width: usize,
) -> (Vec<Line<'static>>, Option<usize>) {
let resolved: Vec<(Option<(&str, bool)>, ResolvedRows)> = sections
.iter()
.map(|section| {
(
section.header,
resolve_rows(section, command_labels, palette, loc),
)
})
.collect();
let label_col = resolved
.iter()
.flat_map(|(_, rows)| rows.iter())
.map(|(_, span, _)| span_width(span))
.max()
.unwrap_or(0);
let indent = HELP_PAD.chars().count() + label_col + 1;
let mut lines = vec![Line::raw("")];
let mut marked_at = None;
for (si, (header, rows)) in resolved.iter().enumerate() {
if si > 0 {
lines.push(Line::raw("")); }
if let Some(at) = push_section(&mut lines, *header, rows, palette, loc, width, indent) {
marked_at = Some(at);
}
}
(lines, marked_at)
}
type ResolvedRows = Vec<(bool, Span<'static>, String)>;
fn resolve_rows(
section: &TabSection<'_>,
command_labels: bool,
palette: &Palette,
loc: &'static Locale,
) -> ResolvedRows {
section
.rows
.iter()
.map(|(k, d)| {
let key = loc.t(k).to_string();
let key_span = if command_labels && key.starts_with('/') {
Span::styled(format!(" {key} "), Style::new().fg(palette.warning))
} else {
palette.keycap(key)
};
(section.openers.contains(k), key_span, loc.t(d).to_string())
})
.collect()
}
fn push_section(
lines: &mut Vec<Line<'static>>,
header: Option<(&str, bool)>,
rows: &ResolvedRows,
palette: &Palette,
loc: &'static Locale,
width: usize,
indent: usize,
) -> Option<usize> {
let mut marked_at = None;
if let Some((title, marked)) = header {
if marked {
marked_at = Some(lines.len());
}
lines.push(section_header(title, marked, palette, loc, width));
}
for (opens_group, key_span, desc) in rows {
if *opens_group {
lines.push(Line::raw("")); }
push_key_entry(lines, key_span, desc, palette, width, indent);
}
marked_at
}
fn section_header(
title: &str,
marked: bool,
palette: &Palette,
loc: &'static Locale,
width: usize,
) -> Line<'static> {
let mut spans = vec![
Span::raw(HELP_PAD),
Span::styled(
format!("{} ", loc.t(title)),
Style::new().fg(palette.text).bold(),
),
];
if marked {
spans.push(Span::styled(
format!("· {} ", loc.t("ui.help.here")),
Style::new().fg(palette.accent).bold(),
));
}
let used: usize = spans.iter().map(span_width).sum();
spans.push(Span::styled(
"─".repeat(width.saturating_sub(used)),
palette.border_style(false),
));
Line::from(spans)
}
fn push_key_entry(
lines: &mut Vec<Line<'static>>,
key_span: &Span<'static>,
desc: &str,
palette: &Palette,
width: usize,
indent: usize,
) {
let gap = " ".repeat(indent - HELP_PAD.chars().count() - span_width(key_span));
let body = width.saturating_sub(indent).max(1);
let chars: Vec<char> = desc.chars().collect();
for (i, (from, to)) in wrap::wrap_ranges(&chars, body).into_iter().enumerate() {
let text: String = chars[from..to].iter().collect();
let text = text.trim_end().to_string();
lines.push(if i == 0 {
Line::from(vec![
Span::raw(HELP_PAD),
key_span.clone(),
Span::raw(gap.clone()),
Span::styled(text, Style::new().fg(palette.text)),
])
} else {
Line::from(vec![
Span::raw(" ".repeat(indent)),
Span::styled(text, Style::new().fg(palette.text)),
])
});
}
}
fn span_width(span: &Span<'_>) -> usize {
wrap::display_width(&span.content.chars().collect::<Vec<_>>())
}
fn license_lines(palette: &Palette, loc: &'static Locale, width: usize) -> Vec<Line<'static>> {
let body_w = width.saturating_sub(HELP_PAD.len()).max(1);
let mut paras: Vec<String> = Vec::new();
let mut cur = String::new();
for raw in credits::license_text(loc.lang()).lines() {
if raw.trim().is_empty() {
if !cur.is_empty() {
paras.push(std::mem::take(&mut cur));
}
} else {
if !cur.is_empty() {
cur.push(' ');
}
cur.push_str(raw.trim());
}
}
if !cur.is_empty() {
paras.push(cur);
}
let mut lines = vec![Line::raw("")];
for para in paras {
let src = Line::from(Span::styled(para, Style::new().fg(palette.text)));
for wrapped in crate::shared::wrap::wrap_line(&src, body_w) {
let mut spans = vec![Span::raw(HELP_PAD)];
spans.extend(wrapped.spans);
lines.push(Line::from(spans));
}
lines.push(Line::raw("")); }
lines
}
fn legal_lines(palette: &Palette, loc: &'static Locale, width: usize) -> Vec<Line<'static>> {
let body_w = width.saturating_sub(HELP_PAD.len()).max(1);
let both = format!(
"{}\n\n---\n\n{}",
credits::disclaimer_text(loc.lang()),
credits::privacy_text(loc.lang()),
);
let rendered = crate::shared::markdown::render(&both, body_w, palette);
let mut lines = vec![Line::raw("")];
for line in rendered.lines {
let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let hang = if plain.trim_start().starts_with("- ") {
LIST_HANG
} else {
0
};
for (i, wrapped) in
crate::shared::wrap::wrap_line(&line, body_w.saturating_sub(hang).max(1))
.into_iter()
.enumerate()
{
if wrapped.spans.iter().all(|s| s.content.trim().is_empty()) {
lines.push(Line::raw("")); continue;
}
let indent = if i == 0 { 0 } else { hang };
let mut spans = vec![Span::raw(format!("{HELP_PAD}{}", " ".repeat(indent)))];
let line_style = wrapped.style;
spans.extend(
wrapped
.spans
.into_iter()
.map(|s| Span::styled(s.content, line_style.patch(s.style))),
);
lines.push(Line::from(spans));
}
}
lines
}
const LIST_HANG: usize = 2;
fn component_lines(palette: &Palette, loc: &'static Locale, width: usize) -> Vec<Line<'static>> {
let mut lines = vec![
Line::raw(""),
Line::from(Span::styled(
format!("{HELP_PAD}{}", loc.t("ui.components.intro")),
palette.muted_style(),
)),
Line::raw(""),
];
lines.extend(leader_table(credits::COMPONENTS, palette, width));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
format!("{HELP_PAD}{}", loc.t("ui.components.grammars")),
palette.muted_style(),
)));
lines.push(Line::raw(""));
let grammars: Vec<(&str, &str, &str)> = credits::GRAMMARS.iter().copied().collect();
lines.extend(leader_table(&grammars, palette, width));
lines
}
fn leader_table(
rows: &[(&str, &str, &str)],
palette: &Palette,
width: usize,
) -> Vec<Line<'static>> {
let pad = HELP_PAD.chars().count();
let mid_w = rows
.iter()
.map(|(_, m, _)| m.chars().count())
.max()
.unwrap_or(0);
let right_w = rows
.iter()
.map(|(.., r)| r.chars().count())
.max()
.unwrap_or(0);
let right_col = width.saturating_sub(pad + right_w);
let mid_col = right_col.saturating_sub(2 + mid_w);
rows.iter()
.map(|(name, mid, right)| {
leader_row(
Span::styled((*name).to_string(), Style::new().fg(palette.text)),
vec![
Span::styled(format!("{mid:<mid_w$}"), palette.muted_style()),
Span::raw(" "),
Span::styled((*right).to_string(), palette.muted_style()),
],
mid_col,
palette,
)
})
.collect()
}
fn leader_row(
left: Span<'static>,
tail: Vec<Span<'static>>,
tail_col: usize,
palette: &Palette,
) -> Line<'static> {
let dots = tail_col.saturating_sub(HELP_PAD.chars().count() + span_width(&left) + 2);
let mut spans = vec![
Span::raw(HELP_PAD),
left,
Span::styled(
format!(" {} ", ".".repeat(dots)),
Style::new().fg(palette.keycap_bg),
),
];
spans.extend(tail);
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
fn line_width(line: &Line<'_>) -> usize {
line.spans.iter().map(span_width).sum()
}
const NO_SECTIONS: &[&HelpSection] = &[];
#[test]
fn help_rows_fit_the_dialog_in_every_locale() {
let palette = Palette::default();
let commands = command_rows();
for w in [HELP_MIN_WIDTH as usize, HELP_MAX_WIDTH as usize] {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
for line in key_lines(&commands, COMMAND_GROUP_OPENERS, &palette, loc, w) {
let width = line_width(&line);
assert!(
width <= w,
"commands-tab row is {width} columns wide, the dialog is {w} ({lang:?}): {}",
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
);
}
}
}
}
#[test]
fn descriptions_share_one_column_per_tab() {
let palette = Palette::default();
let commands = command_rows();
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let w = HELP_MAX_WIDTH as usize;
let lines = key_lines(&commands, COMMAND_GROUP_OPENERS, &palette, loc, w);
let starts: Vec<usize> = lines
.iter()
.filter(|l| l.spans.iter().any(|s| !s.content.trim().is_empty()))
.map(|l| {
l.spans[..l.spans.len() - 1]
.iter()
.map(span_width)
.sum::<usize>()
})
.collect();
assert!(!starts.is_empty(), "no rows rendered ({lang:?})");
assert!(
starts.iter().all(|s| s == &starts[0]),
"descriptions start at {starts:?} ({lang:?}) — not one column"
);
assert!(
starts[0] > HELP_PAD.chars().count(),
"the description column collapsed onto the margin ({lang:?})"
);
}
}
#[test]
fn the_dialog_follows_the_terminal_between_its_bounds() {
assert_eq!(help_size(80, 24), (HELP_MIN_WIDTH, HELP_MIN_HEIGHT));
assert_eq!(help_size(90, 42), (84, 36));
assert_eq!(help_size(200, 60), (HELP_MAX_WIDTH, HELP_MAX_HEIGHT));
}
#[test]
fn an_overlong_description_wraps_under_its_column() {
let palette = Palette::default();
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let long =
"a description far too long for the dialog to hold on one single row and then some";
let lines = key_lines(&[("/x", long)], &[], &palette, loc, HELP_MIN_WIDTH as usize);
let rows = &lines[1..];
assert!(rows.len() > 1, "expected a wrap, got {} row(s)", rows.len());
for line in rows {
assert!(line_width(line) <= HELP_MIN_WIDTH as usize, "{line:?}");
}
let indent = HELP_PAD.chars().count() + "/x".chars().count() + 2 + 1;
assert_eq!(rows[1].spans[0].content.as_ref(), " ".repeat(indent));
let joined: String = rows
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<String>();
for word in long.split_whitespace() {
assert!(joined.contains(word), "{word} was lost: {joined}");
}
}
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn dialog_text(help: &mut HelpState, loc: &'static Locale, w: u16, h: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let palette = Palette::default();
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| render_help(f, help, NO_SECTIONS, &palette, loc))
.unwrap();
let buf = term.backend().buffer();
let mut out = String::new();
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn commands_tab_text() -> String {
let mut out = String::new();
for scroll in [0usize, usize::MAX] {
let mut help = HelpState::open(HelpTab::Commands);
help.scroll = scroll;
out.push_str(&dialog_text(&mut help, ru(), 90, 50));
}
out
}
#[test]
fn group_openers_open_real_rows() {
let palette = Palette::default();
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let is_blank =
|l: &Line<'_>| -> bool { l.spans.iter().all(|s| s.content.trim().is_empty()) };
let commands = command_rows();
assert!(
!COMMAND_GROUP_OPENERS.is_empty(),
"commands tab lost its group breaks"
);
for opener in COMMAND_GROUP_OPENERS {
assert_eq!(
commands.iter().filter(|(k, _)| k == opener).count(),
1,
"commands tab: opener {opener:?} must name exactly one row"
);
}
assert!(
!COMMAND_GROUP_OPENERS.contains(&commands[0].0),
"commands tab: the first row cannot open a group"
);
let lines = key_lines(
&commands,
COMMAND_GROUP_OPENERS,
&palette,
loc,
HELP_MIN_WIDTH as usize,
);
assert_eq!(
lines.iter().filter(|l| is_blank(l)).count(),
COMMAND_GROUP_OPENERS.len() + 1,
"commands tab: openers + the leading spacer"
);
for opener in COMMAND_GROUP_OPENERS {
let label = loc.t(opener);
let at = lines
.iter()
.position(|l| l.spans.iter().any(|s| s.content.trim() == label))
.unwrap_or_else(|| panic!("commands tab: opener {opener:?} is not rendered"));
assert!(
is_blank(&lines[at - 1]),
"commands tab: no blank line before opener {opener:?}"
);
}
}
#[test]
fn about_rows_anchor_right_with_leaders() {
let palette = Palette::default();
for lang in [crate::shared::i18n::Lang::Ru, crate::shared::i18n::Lang::En] {
let loc = crate::shared::i18n::locale(lang);
for w in [HELP_MIN_WIDTH as usize, HELP_MAX_WIDTH as usize] {
let lines = about_lines(&palette, loc, w);
for line in &lines {
assert!(line_width(line) <= w, "row wider than the dialog: {line:?}");
}
let rows: Vec<&Line<'static>> =
lines.iter().filter(|l| l.spans.len() == 4).collect();
let expected = 7 + usize::from(credits::build_date().is_some());
assert_eq!(rows.len(), expected, "expected every fact row: {rows:?}");
let col_of = |line: &Line<'static>| -> usize {
line.spans[..3].iter().map(span_width).sum()
};
let value_col = col_of(rows[0]);
let mut value_end = 0;
for row in &rows {
assert_eq!(span_width(&row.spans[0]), HELP_PAD.chars().count());
assert_eq!(col_of(row), value_col, "the value column drifts: {row:?}");
let leader = &row.spans[2];
assert_eq!(
leader.style.fg,
Some(palette.keycap_bg),
"leader color: {row:?}"
);
assert!(
leader.content.trim().chars().all(|c| c == '.'),
"leader is not dots: {row:?}"
);
value_end = value_end.max(value_col + span_width(&row.spans[3]));
}
assert_eq!(
value_end,
w - HELP_PAD.chars().count(),
"the values are not anchored to the right margin at {w} ({lang:?})"
);
assert!(
rows.iter()
.any(|r| r.spans[2].content.matches('.').count() >= 3),
"no visible leaders at {w} ({lang:?})"
);
}
}
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let text: String = about_lines(&palette, loc, HELP_MAX_WIDTH as usize)
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
for fact in [
credits::LICENSE_ID,
&credits::platform(),
credits::SITE_URL,
credits::AUTHOR,
env!("CARGO_PKG_VERSION"),
] {
assert!(text.contains(fact), "the About tab lost {fact:?}");
}
if let Some(date) = credits::build_date() {
assert!(text.contains(date), "the About tab lost the build date");
}
}
#[test]
fn components_columns_anchor_right_with_leaders() {
let palette = Palette::default();
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru);
let dim = Some(palette.keycap_bg);
for w in [HELP_MIN_WIDTH as usize, HELP_MAX_WIDTH as usize] {
let lines = component_lines(&palette, loc, w);
for line in &lines {
assert!(line_width(line) <= w, "row wider than the dialog: {line:?}");
}
let heading = lines
.iter()
.position(|l| {
l.spans
.iter()
.any(|s| s.content.contains(loc.t("ui.components.grammars")))
})
.expect("the grammars heading");
let tables = [&lines[..heading], &lines[heading..]];
for (which, table) in tables.into_iter().enumerate() {
let rows: Vec<&Line<'static>> =
table.iter().filter(|l| l.spans.len() == 6).collect();
assert!(
rows.len() > 10,
"table {which} rendered {} rows",
rows.len()
);
let col_of = |line: &Line<'static>, span: usize| -> usize {
line.spans[..span].iter().map(span_width).sum()
};
let mid_col = col_of(rows[0], 3);
let right_col = col_of(rows[0], 5);
let mut right_end = 0;
for row in &rows {
assert_eq!(span_width(&row.spans[0]), HELP_PAD.chars().count());
assert_eq!(col_of(row, 3), mid_col, "mid column drifts: {row:?}");
assert_eq!(col_of(row, 5), right_col, "right column drifts: {row:?}");
let leader = &row.spans[2];
assert_eq!(leader.style.fg, dim, "leader color: {row:?}");
assert!(
leader.content.trim().chars().all(|c| c == '.'),
"leader is not dots: {row:?}"
);
right_end = right_end.max(col_of(row, 5) + span_width(&row.spans[5]));
}
assert_eq!(
right_end,
w - HELP_PAD.chars().count(),
"table {which} is not anchored to the right margin at {w}"
);
assert!(
rows.iter()
.any(|r| r.spans[2].content.matches('.').count() >= 3),
"table {which} has no visible leaders"
);
}
}
}
#[test]
fn image_commands_are_listed_in_the_commands_tab() {
let at = |label: &str| {
HELP_COMMANDS
.iter()
.position(|(k, _)| *k == label)
.unwrap_or_else(|| panic!("{label} is missing from HELP_COMMANDS"))
};
let files = at("/file folder");
assert_eq!(at("ui.help.k.image_attach"), files + 1);
assert_eq!(at("ui.help.k.image_remove"), files + 2);
assert_eq!(at("/image list"), files + 3);
let text = commands_tab_text();
for label in ["/image attach", "/image remove", "/image list"] {
assert!(text.contains(label), "the command label {label}: {text}");
}
assert!(
text.contains("к следующему сообщению"),
"the staged-for-the-next-message wording: {text}"
);
}
#[test]
fn reindex_is_listed_in_the_commands_tab() {
let pos = HELP_COMMANDS
.iter()
.position(|(k, _)| *k == "/reindex")
.expect("/reindex is missing from HELP_COMMANDS");
let rebuild = HELP_COMMANDS
.iter()
.position(|(k, _)| *k == "/rag rebuild")
.expect("/rag rebuild is missing from HELP_COMMANDS");
assert_eq!(
pos,
rebuild + 1,
"/reindex belongs next to the /rag entries"
);
let text = commands_tab_text();
assert!(text.contains("/reindex"), "the command label: {text}");
assert!(
text.contains("пересобрать все векторы"),
"the localized description: {text}"
);
}
#[test]
fn compact_is_listed_in_the_commands_tab() {
let pos = HELP_COMMANDS
.iter()
.position(|(k, _)| *k == "/compact")
.expect("/compact is missing from HELP_COMMANDS");
let reindex = HELP_COMMANDS
.iter()
.position(|(k, _)| *k == "/reindex")
.expect("/reindex is missing from HELP_COMMANDS");
assert_eq!(
pos,
reindex + 1,
"/compact belongs with the other housekeeping commands"
);
let text = commands_tab_text();
assert!(text.contains("/compact"), "the command label: {text}");
assert!(
text.contains("свернуть раннюю часть"),
"the localized description: {text}"
);
}
#[test]
fn exit_is_listed_in_the_commands_tab() {
let label = "/exit · /quit";
let rows = command_rows();
assert_eq!(
rows.iter()
.position(|(k, _)| *k == label)
.expect("the quit commands are missing from the commands tab"),
rows.len() - 1,
"the quit commands close the list, whatever is inserted in front of them"
);
for alias in crate::features::exit_command::ALIASES {
assert!(
label.contains(alias),
"{alias} is missing from the help label {label:?}"
);
}
let text = commands_tab_text();
assert!(text.contains(label), "the command label: {text}");
assert!(
text.contains("перехватывающих Ctrl+Q"),
"the localized description: {text}"
);
}
#[test]
fn help_keys_navigate_scroll_close_and_quit() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
let mut h = HelpState::open(DEFAULT_HELP_TAB);
assert_eq!(h.handle_key(&key(KeyCode::Down)), HelpKeyOutcome::Handled);
assert_eq!(
h.handle_key(&key(KeyCode::PageDown)),
HelpKeyOutcome::Handled
);
assert_eq!(h.scroll, 1 + HELP_PAGE_SCROLL);
h.handle_key(&key(KeyCode::Up));
assert_eq!(h.scroll, HELP_PAGE_SCROLL);
h.handle_key(&key(KeyCode::Home));
assert_eq!(h.scroll, 0);
h.handle_key(&key(KeyCode::Down));
h.handle_key(&key(KeyCode::Tab));
assert_eq!((h.tab, h.scroll), (HelpTab::Commands, 0));
h.handle_key(&key(KeyCode::Left));
assert_eq!(h.tab, HelpTab::Hotkeys);
for code in [KeyCode::Char('x'), KeyCode::Char('?')] {
assert_eq!(h.handle_key(&key(code)), HelpKeyOutcome::Handled);
}
assert_eq!((h.tab, h.scroll), (HelpTab::Hotkeys, 0));
assert_eq!(h.handle_key(&key(KeyCode::Esc)), HelpKeyOutcome::Close);
assert_eq!(h.handle_key(&key(KeyCode::F(1))), HelpKeyOutcome::Close);
assert_eq!(h.handle_key(&key(KeyCode::F(10))), HelpKeyOutcome::Quit);
assert_eq!(
h.handle_key(&KeyEvent::new(KeyCode::Char('й'), KeyModifiers::CONTROL)),
HelpKeyOutcome::Quit
);
}
#[test]
fn help_scroll_clamps_and_draws_scrollbar_on_short_terminal() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let palette = Palette::default();
let mut help = HelpState::open(HelpTab::Commands);
help.scroll = 10_000; let mut term = Terminal::new(TestBackend::new(90, 12)).unwrap();
term.draw(|f| render_help(f, &mut help, NO_SECTIONS, &palette, ru()))
.unwrap();
let lines = key_lines(
&command_rows(),
COMMAND_GROUP_OPENERS,
&palette,
ru(),
HELP_MIN_WIDTH as usize,
);
assert!(help.scroll < lines.len(), "scroll clamps to the maximum");
let buf = term.backend().buffer();
let mut thumb = false;
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
thumb |= buf[(x, y)].symbol() == "█";
}
}
assert!(thumb, "on a short terminal help has a scrollbar thumb");
}
#[test]
fn help_shows_logo_when_terminal_is_tall() {
use crate::widgets::logo::LOGO_COLS;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
const ORANGE: Color = Color::Rgb(0xc2, 0x5a, 0x27);
let palette = Palette::default();
let mut help = HelpState::open(DEFAULT_HELP_TAB);
let mut term = Terminal::new(TestBackend::new(90, 52)).unwrap();
term.draw(|f| render_help(f, &mut help, NO_SECTIONS, &palette, ru()))
.unwrap();
let buf = term.backend().buffer();
let mut orange: Vec<(u16, u16)> = Vec::new();
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
let c = &buf[(x, y)];
if c.style().fg == Some(ORANGE) || c.style().bg == Some(ORANGE) {
orange.push((x, y));
}
}
}
assert!(
orange.len() > LOCKUP_ROWS as usize,
"too little brand color — the mark isn't drawn (found {})",
orange.len()
);
let corner = (buf.area.top()..buf.area.bottom())
.flat_map(|y| (buf.area.left()..buf.area.right()).map(move |x| (x, y)))
.filter(|&(x, y)| buf[(x, y)].symbol() == "╭")
.max_by_key(|&(x, _)| x)
.expect("the popup's border");
let left = orange.iter().map(|(x, _)| *x).min().unwrap();
assert_eq!(
left,
corner.0 + 1 + 2 + 4,
"the glyph's stem is not on the key list's left margin"
);
let right = orange.iter().map(|(x, _)| *x).max().unwrap();
assert!(
right > corner.0 + 1 + 2 + LOGO_COLS,
"the wordmark's \"fork\" is not drawn to the right of the glyph"
);
}
#[test]
fn help_hides_logo_when_terminal_is_short() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
const ORANGE: Color = Color::Rgb(0xc2, 0x5a, 0x27);
let palette = Palette::default();
let mut help = HelpState::open(DEFAULT_HELP_TAB);
let mut term = Terminal::new(TestBackend::new(90, 13)).unwrap();
term.draw(|f| render_help(f, &mut help, NO_SECTIONS, &palette, ru()))
.unwrap();
let buf = term.backend().buffer();
for y in buf.area.top()..buf.area.bottom() {
for x in buf.area.left()..buf.area.right() {
let c = &buf[(x, y)];
assert_ne!(c.style().fg, Some(ORANGE), "the logo must not be drawn");
assert_ne!(c.style().bg, Some(ORANGE), "the logo must not be drawn");
}
}
}
#[test]
fn the_help_tab_strip_fits_the_dialog_in_every_locale() {
use crate::shared::i18n::{Lang, locale};
let palette = Palette::default();
for lang in Lang::ALL {
let strip = help_tab_strip(HelpTab::About, &palette, locale(*lang));
let chars: Vec<char> = strip.spans.iter().flat_map(|s| s.content.chars()).collect();
let width = crate::shared::wrap::display_width(&chars);
assert!(
width <= HELP_MIN_WIDTH as usize,
"the {} tab strip is {width} columns wide, the dialog is {HELP_MIN_WIDTH}",
lang.code()
);
}
}
#[test]
fn the_legal_tab_carries_both_documents_in_the_interface_language() {
use crate::shared::i18n::{Lang, locale};
let text_for = |lang: Lang| -> String {
legal_lines(&Palette::default(), locale(lang), 88)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let cases = [
(
Lang::En,
"mindfork is a client",
"Privacy policy",
"Неофициальный перевод",
),
(
Lang::Ru,
"это клиент",
"Политика конфиденциальности",
"no telemetry",
),
];
for (lang, disclaimer, privacy, unwanted) in cases {
let text = text_for(lang);
for wanted in [disclaimer, privacy] {
assert!(
text.contains(wanted),
"the {} Legal tab does not show {wanted:?}",
lang.code()
);
}
assert!(
!text.contains(unwanted),
"the {} Legal tab shows {unwanted:?} from the other language",
lang.code()
);
}
}
#[test]
fn the_legal_tabs_follow_the_interface_language() {
use crate::shared::i18n::{Lang, locale};
let text_for = |lang: Lang, tab: HelpTab| -> String {
let mut help = HelpState::open(tab);
dialog_text(&mut help, locale(lang), 90, 60)
};
let cases = [
(Lang::En, HelpTab::License, "MIT License", "перевод"),
(
Lang::Ru,
HelpTab::License,
"неофициальный перевод",
"MIT License",
),
];
for (lang, tab, wanted, unwanted) in cases {
let text = text_for(lang, tab);
assert!(
text.contains(wanted),
"the {} {tab:?} tab does not show {wanted:?}",
lang.code()
);
assert!(
!text.contains(unwanted),
"the {} {tab:?} tab shows the other language's text ({unwanted:?})",
lang.code()
);
}
}
#[test]
fn the_legal_tab_indent_does_not_inherit_the_heading_style() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::Modifier;
let palette = Palette::default();
let mut help = HelpState::open(HelpTab::Legal);
let mut term = Terminal::new(TestBackend::new(90, 40)).unwrap();
term.draw(|f| render_help(f, &mut help, NO_SECTIONS, &palette, ru()))
.unwrap();
let buf = term.backend().buffer();
let (y, x) = (buf.area.top()..buf.area.bottom())
.find_map(|y| {
(buf.area.left()..buf.area.right())
.find(|&x| buf[(x, y)].symbol() == "#")
.map(|x| (y, x))
})
.expect("the disclaimer heading is not on screen");
assert!(
buf[(x, y)]
.style()
.add_modifier
.contains(Modifier::UNDERLINED),
"the heading itself lost its underline"
);
for dx in 1..=2 {
let cell = &buf[(x - dx, y)];
assert_eq!(cell.symbol(), " ", "the indent is not blank");
assert!(
!cell.style().add_modifier.contains(Modifier::UNDERLINED),
"the indent column {dx} left of the heading is underlined"
);
}
}
#[test]
fn help_tabs_render_distinct_content() {
let scrolled_to_end = |tab: HelpTab| -> String {
let mut help = HelpState::open(tab);
help.scroll = usize::MAX;
dialog_text(&mut help, ru(), 90, 60)
};
let text_for = |tab: HelpTab| -> String {
let mut help = HelpState::open(tab);
dialog_text(&mut help, ru(), 90, 60)
};
let about = text_for(HelpTab::About);
for label in [
"О программе",
"Клавиши",
"Команды",
"Лицензия",
"Правовое",
"Компоненты",
] {
assert!(about.contains(label), "missing the \"{label}\" tab label");
}
assert!(about.contains("Vladimir Shylov"), "missing the author");
assert!(
about.contains(env!("CARGO_PKG_VERSION")),
"missing the version"
);
assert!(
about.contains("https://mindfork.io"),
"missing the site link"
);
assert!(
about.contains("https://crates.io/crates/mindfork"),
"missing the crate link"
);
let commands = format!(
"{}{}",
text_for(HelpTab::Commands),
scrolled_to_end(HelpTab::Commands)
);
assert!(
commands.contains("/rag add"),
"missing the /rag add command"
);
assert!(commands.contains("/tts"), "missing the /tts command");
assert!(
commands.contains("/file attach"),
"missing the /file attach command"
);
assert!(
commands.find("/file attach") < commands.find("/rag add"),
"the /file commands must come before /rag: {commands}"
);
for label in ["/file open", "/file folder"] {
assert!(commands.contains(label), "missing the {label} command");
}
let license = text_for(HelpTab::License);
assert!(license.contains("MIT"), "missing the license header");
assert!(license.contains("ГАРАНТИЙ"), "missing the license body");
assert!(
!license.contains("это клиент"),
"the disclaimer leaked into the license tab"
);
let legal = text_for(HelpTab::Legal);
assert!(
legal.contains("Дисклеймер"),
"missing the disclaimer heading"
);
assert!(legal.contains("это клиент"), "missing the disclaimer body");
assert!(
!legal.contains("**"),
"raw markdown emphasis markers on screen — the renderer was bypassed"
);
let components = text_for(HelpTab::Components);
assert!(components.contains("ansi-to-tui"), "missing the component");
assert!(
components.contains("8.0.1"),
"missing the component version"
);
assert!(
components.contains("Zlib OR Apache-2.0 OR MIT"),
"missing the component license"
);
}
#[test]
fn components_tab_lists_the_vendored_grammars() {
let mut help = HelpState::open(HelpTab::Components);
help.scroll = usize::MAX / 2; let out = dialog_text(&mut help, ru(), 100, 40);
assert!(
out.contains("грамматики"),
"missing the grammars section header: {out}"
);
let (lang, repo, licence) = *crate::shared::credits::GRAMMARS
.last()
.expect("the manifest lists grammars");
assert!(out.contains(lang), "missing the grammar {lang}: {out}");
assert!(out.contains(repo), "missing its upstream {repo}");
assert!(out.contains(licence), "missing its licence {licence}");
}
}