use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::shared::i18n::Locale;
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::theme::Palette;
use crate::shared::ui;
use crate::shared::wrap;
use crate::widgets::token_counter::TokenCounter;
const ESC_KEY: &str = "Esc";
const HELP_KEY: &str = "F1";
const HOTKEYS: [(&str, &str); 5] = [
("F1", "ui.status.hotkey.help"),
(ESC_KEY, "ui.status.hotkey.chats"),
("Ctrl+N", "ui.status.hotkey.new"),
("Ctrl+P", "ui.status.hotkey.settings"),
("Ctrl+Q", "ui.status.hotkey.quit"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EscTarget {
#[default]
ChatList,
SearchResults,
PreviousChat,
Tasks,
}
impl EscTarget {
fn hint_key(self) -> &'static str {
match self {
EscTarget::ChatList => "ui.status.hotkey.chats",
EscTarget::SearchResults => "ui.status.hotkey.results",
EscTarget::PreviousChat => "ui.status.hotkey.back_chat",
EscTarget::Tasks => "ui.status.hotkey.tasks",
}
}
}
fn esc_hint_key(model: &StatusModel) -> &'static str {
if model.generating && !model.background_run {
"ui.status.hotkey.cancel"
} else {
model.esc_target.hint_key()
}
}
pub struct StatusModel<'a> {
pub statuses: &'a ServerStatuses,
pub generating: bool,
pub counter: Option<TokenCounter>,
pub mouse_scroll: bool,
pub background: Option<&'a str>,
pub speaking: bool,
pub attachments: Option<&'a str>,
pub staged_images: Option<&'a str>,
pub esc_target: EscTarget,
pub read_only: bool,
pub background_run: bool,
}
const SPEAKING_GLYPH: char = '♪';
const ATTACH_GLYPH: char = '§';
const IMAGE_GLYPH: char = '#';
const TRANSCRIPT_GLYPH: char = '≡';
pub fn render(
frame: &mut Frame,
area: Rect,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) {
let lines = lines_within(
area.width as usize,
model,
palette,
loc,
area.height as usize,
);
frame.render_widget(Paragraph::new(lines), area);
}
pub fn height(
width: usize,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
max_rows: u16,
) -> u16 {
lines_within(width, model, palette, loc, usize::from(max_rows)).len() as u16
}
fn lines_within(
width: usize,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
max_rows: usize,
) -> Vec<Line<'static>> {
if max_rows == 0 {
return Vec::new();
}
let state = fit_pill(state_chips(model, palette, loc), width);
let state_w = spans_width(&state);
let hotkeys = hotkey_list(model, loc);
let accent_idx = model.mouse_scroll.then_some(0usize);
let cell_w = ui::hint_cell_widths(&hotkeys);
let avail = width.saturating_sub(state_w + ui::HINT_GAP);
let kept = trim_to_fit(&cell_w, avail, model.mouse_scroll, max_rows);
if !kept.is_empty() {
let sub: Vec<(&str, &str, bool)> = kept.iter().map(|&i| hotkeys[i]).collect();
let sub_w: Vec<usize> = kept.iter().map(|&i| cell_w[i]).collect();
let accent = accent_idx.and_then(|a| kept.iter().position(|&i| i == a));
let cols = ui::capped_hint_grid(&sub_w, avail, max_rows).unwrap_or(1);
return ui::render_hint_grid(
&ui::HintGrid {
items: &sub,
cell_w: &sub_w,
cols,
width,
lead: Some(state),
accent,
},
palette,
);
}
debug_assert_eq!(hotkeys[HELP_IDX].0, HELP_KEY);
let mut out = vec![Line::from(state)];
if max_rows >= 2 && cell_w[HELP_IDX] <= width {
out.extend(ui::hotkey_grid(
palette,
&hotkeys[HELP_IDX..=HELP_IDX],
width,
1,
));
}
out
}
pub const HINT_ROWS_MAX: u16 = 2;
const HELP_IDX: usize = 1;
const STOP_KEY: &str = "F6";
const STOP_IDX: usize = 6;
fn keep_order(mouse_pinned: bool, with_stop: bool) -> Vec<usize> {
let mut order: Vec<usize> = if mouse_pinned {
vec![0, HELP_IDX, 2, 5, 4, 3]
} else {
vec![HELP_IDX, 2, 5, 0, 4, 3]
};
if with_stop {
let after_help = order.iter().position(|&i| i == HELP_IDX).unwrap_or(0) + 1;
order.insert(after_help, STOP_IDX);
}
order
}
fn trim_to_fit(cell_w: &[usize], avail: usize, mouse_pinned: bool, max_rows: usize) -> Vec<usize> {
let order = keep_order(mouse_pinned, cell_w.len() > STOP_IDX);
let kept = ui::keep_hints(cell_w, avail, max_rows, order);
if !kept.contains(&HELP_IDX) {
return Vec::new();
}
kept
}
fn state_chips(
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) -> Vec<Vec<Span<'static>>> {
let statuses = model.statuses;
let sep = || Span::styled(" │ ", Style::new().fg(palette.border));
let muted = palette.muted_style();
let mut chips = vec![chat_chip(&statuses.chat, palette, loc)];
for chip in [
secondary_chip(loc.t("ui.status.chip.embed"), &statuses.embed, palette),
secondary_chip(
loc.t("ui.status.chip.imp"),
&statuses.impersonation,
palette,
),
]
.into_iter()
.flatten()
{
let mut spaced = vec![Span::raw(" ")]; spaced.extend(chip);
chips.push(spaced);
}
let mut behind_sep = |span: Span<'static>| chips.push(vec![sep(), span]);
if model.generating {
behind_sep(Span::styled(
format!(
"{} {}",
palette.glyphs().busy,
loc.t("ui.status.generating")
),
palette.accent_style(),
));
}
if let Some(counter) = model.counter.and_then(|c| c.span(palette, loc)) {
behind_sep(counter);
}
if let Some(hint) = model.background {
behind_sep(Span::styled(
format!("{} {hint}", palette.glyphs().background),
muted,
));
}
if model.speaking {
behind_sep(Span::styled(
format!("{SPEAKING_GLYPH} {}", loc.t("ui.status.speaking")),
muted,
));
}
if let Some(files) = model.attachments {
behind_sep(Span::styled(format!("{ATTACH_GLYPH} {files}"), muted));
}
if let Some(images) = model.staged_images {
behind_sep(Span::styled(format!("{IMAGE_GLYPH} {images}"), muted));
}
if model.read_only {
behind_sep(Span::styled(
format!("{TRANSCRIPT_GLYPH} {}", loc.t("ui.status.read_only")),
muted,
));
}
chips
}
fn fit_pill(chips: Vec<Vec<Span<'static>>>, width: usize) -> Vec<Span<'static>> {
let mut pill: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for (i, chip) in chips.into_iter().enumerate() {
let w = spans_width(&chip);
if used + w <= width {
used += w;
pill.extend(chip);
} else {
if i == 0 {
pill.extend(cut_chip(chip, width));
}
break;
}
}
pill
}
pub fn cut_chip(chip: Vec<Span<'static>>, width: usize) -> Vec<Span<'static>> {
let mut left = width;
chip.into_iter()
.filter_map(|span| {
let (text, w) = wrap::truncate_to_width(&span.content, left);
left -= w;
(!text.is_empty()).then(|| Span::styled(text, span.style))
})
.collect()
}
fn hotkey_list(
model: &StatusModel,
loc: &'static Locale,
) -> Vec<(&'static str, &'static str, bool)> {
let mouse_desc = if model.mouse_scroll {
loc.t("ui.status.mouse.scroll")
} else {
loc.t("ui.status.mouse.select")
};
let mut hotkeys: Vec<(&'static str, &'static str, bool)> = vec![("Ctrl+W", mouse_desc, false)];
hotkeys.extend(HOTKEYS.iter().map(|(key, default)| {
let desc_key = if *key == ESC_KEY {
esc_hint_key(model)
} else {
*default
};
(*key, loc.t(desc_key), false)
}));
if model.background_run {
hotkeys.push((STOP_KEY, loc.t("ui.status.hotkey.stop_run"), false));
}
hotkeys
}
fn status_glyph(status: &ServerStatus, palette: &Palette) -> (&'static str, Color) {
let glyphs = palette.glyphs();
match status {
ServerStatus::Ready => ("●", palette.success),
ServerStatus::Connecting => (glyphs.status_connecting, palette.warning),
ServerStatus::NotConfigured => (glyphs.status_off, palette.warning),
ServerStatus::Disconnected(_) => (glyphs.status_off, palette.error),
}
}
fn chip(glyph: &'static str, label: String, color: Color) -> Vec<Span<'static>> {
vec![
Span::styled(glyph, Style::new().fg(color).add_modifier(Modifier::BOLD)),
Span::styled(format!(" {label}"), Style::new().fg(color)),
]
}
fn chat_chip(status: &ServerStatus, palette: &Palette, loc: &'static Locale) -> Vec<Span<'static>> {
let (glyph, color) = status_glyph(status, palette);
let label = match status {
ServerStatus::Ready | ServerStatus::Connecting => loc.t("ui.status.chip.chat").to_string(),
ServerStatus::NotConfigured => loc.t("ui.status.chip.chat_off").to_string(),
ServerStatus::Disconnected(why) => loc.tf("ui.status.chip.chat_down", &[("why", why)]),
};
chip(glyph, label, color)
}
fn secondary_chip(
label: &str,
status: &ServerStatus,
palette: &Palette,
) -> Option<Vec<Span<'static>>> {
if matches!(status, ServerStatus::NotConfigured) {
return None;
}
let (glyph, color) = status_glyph(status, palette);
Some(chip(glyph, label.to_string(), color))
}
fn str_w(s: &str) -> usize {
wrap::display_width(&s.chars().collect::<Vec<_>>())
}
fn spans_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|s| str_w(&s.content)).sum()
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(
width: usize,
model: &StatusModel,
palette: &Palette,
loc: &'static Locale,
) -> Vec<Line<'static>> {
lines_within(width, model, palette, loc, usize::from(HINT_ROWS_MAX))
}
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn only_chat(chat: ServerStatus) -> ServerStatuses {
ServerStatuses {
chat,
embed: ServerStatus::NotConfigured,
impersonation: ServerStatus::NotConfigured,
}
}
fn ready() -> ServerStatuses {
only_chat(ServerStatus::Ready)
}
fn model<'a>(
statuses: &'a ServerStatuses,
generating: bool,
tokens: u64,
context: Option<u64>,
context_exact: bool,
mouse_scroll: bool,
) -> StatusModel<'a> {
StatusModel {
statuses,
generating,
counter: Some(TokenCounter {
generating,
tokens,
context,
context_exact,
..TokenCounter::default()
}),
mouse_scroll,
background: None,
speaking: false,
attachments: None,
staged_images: None,
esc_target: EscTarget::default(),
read_only: false,
background_run: false,
}
}
fn flat(
statuses: &ServerStatuses,
generating: bool,
tokens: u64,
context: Option<u64>,
context_exact: bool,
mouse_scroll: bool,
) -> String {
let m = model(
statuses,
generating,
tokens,
context,
context_exact,
mouse_scroll,
);
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
}
fn text(statuses: &ServerStatuses, generating: bool) -> String {
flat(statuses, generating, 0, None, false, false)
}
#[test]
fn shows_chat_state() {
assert!(text(&ready(), false).contains("чат"));
assert!(text(&only_chat(ServerStatus::NotConfigured), false).contains("не настроен"));
assert!(
text(&only_chat(ServerStatus::Disconnected("boom".into())), false).contains("boom")
);
}
#[test]
fn localized_for_all_langs() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let flat: String = lines(200, &m, &Palette::default(), loc)
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
flat.contains(loc.t("ui.status.chip.chat")),
"{lang:?}: {flat}"
);
assert!(
flat.contains(loc.t("ui.status.hotkey.quit")),
"{lang:?}: {flat}"
);
}
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
assert_eq!(en.t("ui.status.chip.chat"), "chat");
}
#[test]
fn secondary_chips_shown_only_when_configured() {
let all = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let t = text(&all, false);
assert!(
t.contains("чат") && t.contains("эмб") && t.contains("имп"),
"{t}"
);
let t = text(&ready(), false);
assert!(
t.contains("чат") && !t.contains("эмб") && !t.contains("имп"),
"{t}"
);
}
#[test]
fn compat_palette_swaps_chip_and_activity_glyphs() {
let compat = Palette::default().with_compat(true);
let flat_compat = |statuses: &ServerStatuses, generating: bool| -> String {
let m = StatusModel {
statuses,
generating,
counter: None,
mouse_scroll: false,
background: Some("рефлексия"),
speaking: false,
attachments: None,
staged_images: None,
esc_target: EscTarget::default(),
read_only: false,
background_run: false,
};
lines(200, &m, &compat, ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect()
};
let t = flat_compat(&only_chat(ServerStatus::Connecting), true);
assert!(t.contains("○ чат"), "connecting compat glyph: {t}");
assert!(t.contains("» генерация"), "generation compat glyph: {t}");
assert!(
t.contains("* рефлексия"),
"background-task compat glyph: {t}"
);
for banned in ['◐', '⟳', '✻'] {
assert!(!t.contains(banned), "{banned} left behind: {t}");
}
let t = flat_compat(&only_chat(ServerStatus::Disconnected("boom".into())), false);
assert!(t.contains("× чат"), "disconnect compat glyph: {t}");
let t = flat_compat(&ready(), false);
assert!(t.contains("● чат"), "readiness stays ●: {t}");
}
#[test]
fn chip_color_reflects_status() {
let palette = Palette::default();
let glyph_fg = |status: ServerStatus| {
chat_chip(&status, &palette, ru())
.first()
.and_then(|s| s.style.fg)
.unwrap()
};
assert_eq!(glyph_fg(ServerStatus::Ready), palette.success);
assert_eq!(
glyph_fg(ServerStatus::Disconnected("x".into())),
palette.error
);
assert_eq!(glyph_fg(ServerStatus::Connecting), palette.warning);
}
#[test]
fn generating_indicator_toggles() {
assert!(text(&ready(), true).contains("генерация"));
assert!(!text(&ready(), false).contains("генерация"));
}
#[test]
fn shows_mouse_mode() {
let mode = |scroll| flat(&ready(), false, 0, None, false, scroll);
assert!(mode(true).contains("мышь: прокрутка"));
assert!(mode(false).contains("мышь: выделение"));
}
#[test]
fn scroll_mode_highlights_only_value() {
let palette = Palette::default();
let span_fg = |scroll, needle: &str| {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, scroll);
lines(200, &m, &palette, ru())
.iter()
.flat_map(|l| l.spans.clone())
.find(|s| s.content.contains(needle))
.and_then(|s| s.style.fg)
};
assert_eq!(span_fg(true, "прокрутка"), Some(palette.accent));
assert_eq!(span_fg(true, "мышь:"), Some(palette.muted));
assert_eq!(span_fg(false, "мышь: выделение"), Some(palette.muted));
}
#[test]
fn the_pill_carries_the_counter_only_when_handed_one() {
let line = |tokens, context, exact| flat(&ready(), true, tokens, context, exact, false);
assert!(!line(0, None, false).contains("токены"));
let t = line(42, Some(1000), false);
assert!(t.contains("генерация… │ токены: ~1042"), "{t}");
assert!(line(42, Some(1000), true).contains("токены: 1042"));
let statuses = ready();
let mut m = model(&statuses, true, 42, Some(1000), true, false);
m.counter = None;
let text: String = lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
!text.contains("токены") && text.contains("генерация"),
"{text}"
);
}
#[test]
fn hotkeys_fit_on_one_line_when_wide() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
let n = lines(200, &m, &Palette::default(), ru()).len();
assert_eq!(n, 1);
}
#[test]
fn a_narrow_bar_wraps_to_two_rows_and_sheds_the_rest() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
assert_eq!(height(40, &m, &Palette::default(), ru(), HINT_ROWS_MAX), 2);
let text = rows_of(40, &m).join("\n");
for kept in ["F1", "справка", "чаты", "новый", "выход"] {
assert!(text.contains(kept), "{kept} kept: {text}");
}
for shed in ["мышь", "настройки"] {
assert!(!text.contains(shed), "{shed} shed: {text}");
}
let flat = flat(&ready(), false, 0, None, false, false);
assert!(flat.contains("Ctrl+W") && flat.contains("мышь: выделение"));
for (_, desc) in HOTKEYS {
assert!(flat.contains(ru().t(desc)));
}
}
fn busy(statuses: &ServerStatuses) -> StatusModel<'_> {
StatusModel {
generating: true,
counter: Some(TokenCounter {
generating: true,
tokens: 1218,
context: Some(36490),
context_exact: true,
reasoning: 1218,
..TokenCounter::default()
}),
attachments: Some("файлы: 2 (~532)"),
..model(statuses, false, 0, None, false, false)
}
}
fn rows(w: u16) -> Vec<String> {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
rows_of(w, &m)
}
fn rows_of(w: u16, m: &StatusModel) -> Vec<String> {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let p = Palette::default();
let h = height(w as usize, m, &p, ru(), HINT_ROWS_MAX);
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| render(f, f.area(), m, &p, ru())).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn chips_left_hotkeys_right_on_one_line() {
let r = rows(160);
assert_eq!(r.len(), 1);
let line = &r[0];
assert!(
line.trim_start().starts_with("●"),
"chip on the left: {line:?}"
);
assert!(
line.trim_end().ends_with("выход"),
"the last hotkey is right-aligned: {line:?}"
);
assert!(
line.contains("чат "),
"there's a gap after the chip: {line:?}"
);
}
#[test]
fn wrapped_grid_is_right_aligned_with_pill_on_top_line() {
let r = rows(100);
assert_eq!(r.len(), 2, "expected a wrap to 2 lines: {r:?}");
assert!(
r[0].trim_start().starts_with("●"),
"chip on top, on the left: {:?}",
r[0]
);
assert!(
r[1].trim_start().starts_with("Ctrl+Q") && r[1].starts_with(" "),
"the wrap is right-aligned, the left part is empty: {:?}",
r[1]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Ctrl+Q"),
char_col(&r[0], "Ctrl+P"),
"the wrap lands exactly under the column above:\n{:?}\n{:?}",
r[0],
r[1]
);
}
#[test]
fn trim_keeps_the_highest_ranked_hints_that_fit() {
let cells = [24, 12, 10, 14, 18, 14];
assert_eq!(trim_to_fit(&cells, 107, false, 2), vec![0, 1, 2, 3, 4, 5]);
assert_eq!(ui::capped_hint_grid(&cells, 107, 2), Some(6));
assert_eq!(trim_to_fit(&cells, 71, false, 2), vec![0, 1, 2, 3, 4, 5]);
assert_eq!(ui::capped_hint_grid(&cells, 71, 2), Some(3));
assert_eq!(trim_to_fit(&cells, 54, false, 2), vec![0, 1, 2, 5]);
assert_eq!(trim_to_fit(&cells, 32, false, 2), vec![1, 2, 3, 5]);
assert_eq!(trim_to_fit(&cells, 12, false, 2), vec![1, 2]);
assert!(trim_to_fit(&cells, 11, false, 2).is_empty());
assert_eq!(trim_to_fit(&cells, 25, true, 2), vec![0, 1]);
assert_eq!(trim_to_fit(&cells, 23, true, 2), vec![1, 2]);
}
#[test]
fn keep_order_matches_the_hotkey_list() {
let statuses = ready();
let m = model(&statuses, false, 0, None, false, false);
let keys: Vec<&str> = hotkey_list(&m, ru()).iter().map(|(k, _, _)| *k).collect();
assert_eq!(keys[HELP_IDX], HELP_KEY);
assert_eq!(
keys.len(),
STOP_IDX,
"the stop key is absent off a background run"
);
let named = |order: Vec<usize>| order.into_iter().map(|i| keys[i]).collect::<Vec<_>>();
assert_eq!(
named(keep_order(false, false)),
["F1", "Esc", "Ctrl+Q", "Ctrl+W", "Ctrl+P", "Ctrl+N"]
);
assert_eq!(
named(keep_order(true, false)),
["Ctrl+W", "F1", "Esc", "Ctrl+Q", "Ctrl+P", "Ctrl+N"]
);
let mut bg = model(&statuses, true, 0, None, false, false);
bg.background_run = true;
let keys: Vec<&str> = hotkey_list(&bg, ru()).iter().map(|(k, _, _)| *k).collect();
assert_eq!(keys[STOP_IDX], STOP_KEY);
let named = |order: Vec<usize>| order.into_iter().map(|i| keys[i]).collect::<Vec<_>>();
assert_eq!(
named(keep_order(false, true)),
["F1", "F6", "Esc", "Ctrl+Q", "Ctrl+W", "Ctrl+P", "Ctrl+N"]
);
assert_eq!(
named(keep_order(true, true)),
["Ctrl+W", "F1", "F6", "Esc", "Ctrl+Q", "Ctrl+P", "Ctrl+N"]
);
}
#[test]
fn a_background_transcript_advertises_the_stop_key_and_esc_goes_back() {
let statuses = ready();
let stop = ru().t("ui.status.hotkey.stop_run");
let cancel = ru().t("ui.status.hotkey.cancel");
let chats = ru().t("ui.status.hotkey.chats");
let mut m = model(&statuses, true, 0, None, false, false);
m.background_run = true;
m.read_only = true;
let text = rows_of(140, &m).join(" ");
assert!(text.contains("F6") && text.contains(stop), "{text}");
assert!(!text.contains(cancel), "{text}");
assert!(text.contains(chats), "{text}");
let turn = model(&statuses, true, 0, None, false, false);
let text = rows_of(140, &turn).join(" ");
assert!(!text.contains("F6"), "{text}");
assert!(text.contains(cancel), "{text}");
let mut ended = model(&statuses, false, 0, None, false, false);
ended.read_only = true;
let text = rows_of(140, &ended).join(" ");
assert!(!text.contains("F6"), "{text}");
}
#[test]
fn a_busy_pill_sheds_hints_but_keeps_the_corner_block() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let r = rows_of(120, &busy(&statuses));
assert_eq!(r.len(), 2, "pill plus a two-row corner block: {r:?}");
assert!(
r[0].trim_start().starts_with('●') && r[0].contains("токены"),
"the pill keeps the top row: {:?}",
r[0]
);
assert!(
r[0].contains("F1") && r[0].trim_end().ends_with(ru().t("ui.status.hotkey.cancel")),
"kept hints share the pill's row: {:?}",
r[0]
);
assert!(
r[1].starts_with(' ') && r[1].trim_end().ends_with("выход"),
"the wrapped row is right-aligned: {:?}",
r[1]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Ctrl+N"),
char_col(&r[0], "F1"),
"\n{:?}\n{:?}",
r[0],
r[1]
);
let text = r.join("\n");
assert!(
!text.contains("мышь") && !text.contains("настройки"),
"shed hints are hidden, not wrapped: {text}"
);
}
#[test]
fn the_generating_pill_keeps_the_hints_in_the_corner() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let m = StatusModel {
generating: true,
counter: Some(TokenCounter {
generating: true,
tokens: 4732,
context: Some(22597),
context_exact: true,
reasoning: 4732,
..TokenCounter::default()
}),
..model(&statuses, false, 0, None, false, false)
};
let r = rows_of(120, &m);
assert_eq!(r.len(), 2, "{r:?}");
assert!(r[0].contains("токены: 27329 (рассужд. 4732)"), "{:?}", r[0]);
assert!(
r[0].trim_end().ends_with("справка"),
"the mouse toggle and F1 share the pill's row: {:?}",
r[0]
);
let char_col = |line: &str, pat: &str| line.find(pat).map(|b| line[..b].chars().count());
assert_eq!(
char_col(&r[1], "Esc"),
char_col(&r[0], "Ctrl+W"),
"a right-aligned 2×2 block, nothing under the pill:\n{:?}\n{:?}",
r[0],
r[1]
);
let text = r.join("\n");
assert!(
!text.contains("новый") && !text.contains("настройки"),
"{text}"
);
}
#[test]
fn scroll_mode_pins_the_mouse_toggle_through_trimming() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let mut m = busy(&statuses);
m.mouse_scroll = true;
let p = Palette::default();
let ls = lines(120, &m, &p, ru());
assert_eq!(ls.len(), 2);
let accent_span = ls
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("прокрутка"))
.expect("the pinned mouse toggle is shown");
assert_eq!(accent_span.style.fg, Some(p.accent));
let flat: String = ls
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(flat.contains("F1"), "F1 rides along: {flat}");
assert!(!flat.contains("настройки"), "sheds still happen: {flat}");
}
#[test]
fn a_wall_of_pill_leaves_f1_alone_below() {
let statuses = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
};
let r = rows_of(96, &busy(&statuses));
assert_eq!(r.len(), 2, "{r:?}");
assert!(r[0].contains("файлы"), "the pill keeps its row: {:?}", r[0]);
let below = r[1].trim();
assert!(
below.starts_with("F1") && below.ends_with("справка"),
"F1 alone: {:?}",
r[1]
);
assert!(r[1].starts_with(' '), "right-aligned: {:?}", r[1]);
}
#[test]
fn the_bar_never_exceeds_two_rows_and_keeps_f1() {
let p = Palette::default();
let quiet_statuses = ready();
let quiet = model(&quiet_statuses, false, 0, None, false, false);
let scroll = model(&quiet_statuses, false, 0, None, false, true);
let busy_statuses = ServerStatuses {
chat: ServerStatus::Disconnected("connection refused".into()),
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let busy = busy(&busy_statuses);
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let f1_cell = 2 + 2 + 1 + str_w(loc.t("ui.status.hotkey.help"));
for w in 12..=240usize {
for m in [&quiet, &scroll, &busy] {
let ls = lines(w, m, &p, loc);
assert!(
ls.len() <= usize::from(HINT_ROWS_MAX),
"{lang:?} w={w}: {} rows",
ls.len()
);
let flat: String = ls
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect();
assert!(
w < f1_cell || flat.contains("F1"),
"{lang:?} w={w}: F1 missing: {flat}"
);
}
}
}
}
#[test]
fn render_does_not_panic() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(60, 1)).unwrap();
let all = ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::Connecting,
};
let m = StatusModel {
statuses: &all,
generating: true,
counter: Some(TokenCounter {
generating: true,
tokens: 123,
context: Some(456),
context_exact: true,
..TokenCounter::default()
}),
mouse_scroll: true,
background: Some("рефлексия"),
speaking: true,
attachments: Some("файлы: 2 (~3.1k)"),
staged_images: Some("изображения: 1 (~1.2k)"),
esc_target: EscTarget::SearchResults,
read_only: false,
background_run: false,
};
term.draw(|f| render(f, f.area(), &m, &Palette::default(), ru()))
.unwrap();
}
#[test]
fn staged_images_chip_is_separate_and_one_column() {
let statuses = ready();
let text = |files, images| {
let mut m = model(&statuses, false, 0, None, false, false);
m.attachments = files;
m.staged_images = images;
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect::<String>()
};
let empty = text(None, None);
assert!(!empty.contains(IMAGE_GLYPH), "{empty}");
let both = text(Some("файлы: 2 (~3.1k)"), Some("изображения: 1 (~1.2k)"));
assert!(both.contains("§ файлы: 2"), "{both}");
assert!(both.contains("# изображения: 1"), "{both}");
let alone = text(None, Some("изображения: 1 (~1.2k)"));
assert!(alone.contains("# изображения: 1"), "{alone}");
assert!(!alone.contains(ATTACH_GLYPH), "{alone}");
assert_eq!(str_w(&IMAGE_GLYPH.to_string()), 1, "one column");
}
#[test]
fn esc_hint_follows_where_back_goes() {
let statuses = ready();
let chats = ru().t("ui.status.hotkey.chats");
let results = ru().t("ui.status.hotkey.results");
assert_ne!(chats, results, "the two labels must be distinguishable");
let text = |target| {
let mut m = model(&statuses, false, 0, None, false, false);
m.esc_target = target;
lines(200, &m, &Palette::default(), ru())
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref())
.collect::<String>()
};
let ordinary = text(EscTarget::ChatList);
assert!(ordinary.contains(chats), "{ordinary}");
assert!(!ordinary.contains(results), "{ordinary}");
let from_results = text(EscTarget::SearchResults);
assert!(from_results.contains(results), "{from_results}");
assert!(!from_results.contains(chats), "{from_results}");
}
#[test]
fn esc_hint_says_cancel_while_a_turn_runs() {
let statuses = ready();
let cancel = ru().t("ui.status.hotkey.cancel");
let text = |generating, target| {
let mut m = model(&statuses, generating, 0, None, false, false);
m.esc_target = target;
rows_of(120, &m).join(" ")
};
for target in [
EscTarget::ChatList,
EscTarget::SearchResults,
EscTarget::PreviousChat,
] {
let goes_to = ru().t(target.hint_key());
let mid_turn = text(true, target);
assert!(mid_turn.contains(cancel), "{target:?}: {mid_turn}");
assert!(
!mid_turn.contains(goes_to),
"{target:?}: the navigation label outlived the turn: {mid_turn}"
);
let idle = text(false, target);
assert!(idle.contains(goes_to), "{target:?}: {idle}");
assert!(!idle.contains(cancel), "{target:?}: {idle}");
}
assert!(
ru().t("ui.chat.input.generating").contains(cancel),
"the bar and the input box must not use two words for one key"
);
}
#[test]
fn a_pill_wider_than_the_row_sheds_whole_chips_from_its_tail() {
let statuses = ready();
let m = StatusModel {
speaking: true,
..busy(&statuses)
};
let p = Palette::default();
let text = |width: usize| -> String {
let lines = lines_within(width, &m, &p, ru(), 1);
assert_eq!(lines.len(), 1);
let row: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(str_w(&row) <= width, "{width}: {row:?} runs past the edge");
row
};
let whole = text(200);
let chips = ["● чат", "генерация", "токены", "озвучка", "файлы"];
assert!(
chips.iter().all(|c| whole.contains(c)),
"the premise: {whole}"
);
assert!(whole.contains("F1"));
let pill_w = spans_width(&fit_pill(state_chips(&m, &p, ru()), 200));
let alone = text(pill_w);
assert!(
chips.iter().all(|c| alone.contains(c)) && !alone.contains("F1"),
"{alone}"
);
let short = text(pill_w - 1);
assert!(
!short.contains("файлы") && short.contains("озвучка"),
"{short}"
);
assert!(short.contains("F1"), "{short}");
let mut left = pill_w - 1;
let mut seen = 5usize;
while left > 0 {
let row = text(left);
let present = chips.iter().filter(|c| row.contains(*c)).count();
assert!(present <= seen, "{left}: a chip came back: {row}");
assert_eq!(
chips
.iter()
.take(present)
.filter(|c| row.contains(*c))
.count(),
present,
"{left}: a chip left from the middle: {row}"
);
if row.contains("токены") {
assert!(row.contains("37708"), "{left}: the counter whole: {row}");
}
seen = present;
left -= 1;
}
let cut = text(4);
assert!(cut.starts_with("● ч") && cut.ends_with('…'), "{cut:?}");
assert_eq!(str_w(&cut), 4);
assert_eq!(text(0), "");
}
#[test]
fn the_bar_lays_itself_out_within_the_rows_it_is_given() {
let statuses = ready();
let quiet = model(&statuses, false, 0, None, false, false);
let p = Palette::default();
let flat = |lines: Vec<Line<'static>>| -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
};
assert_eq!(height(40, &quiet, &p, ru(), HINT_ROWS_MAX), 2);
assert_eq!(height(40, &quiet, &p, ru(), 1), 1);
assert_eq!(height(40, &quiet, &p, ru(), 0), 0);
let one = flat(lines_within(40, &quiet, &p, ru(), 1));
assert_eq!(one.len(), 1);
assert!(
one[0].contains("F1") && one[0].starts_with("● чат"),
"{one:?}"
);
let two = flat(lines_within(40, &quiet, &p, ru(), 2));
assert_eq!(two.len(), 2);
assert!(
two.iter().flat_map(|r| r.matches(" ")).count() >= one[0].matches(" ").count(),
"one row holds no more than two did"
);
let m = busy(&statuses);
let tight = spans_width(&fit_pill(state_chips(&m, &p, ru()), 200)) + 2;
let below = flat(lines_within(tight, &m, &p, ru(), 2));
assert_eq!(below.len(), 2, "{below:?}");
assert!(below[1].contains("F1"), "{below:?}");
let capped = flat(lines_within(tight, &m, &p, ru(), 1));
assert_eq!(capped.len(), 1);
assert!(
!capped[0].contains("F1") && capped[0].contains("файлы"),
"{capped:?}"
);
assert!(lines_within(tight, &m, &p, ru(), 0).is_empty());
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(40, 1)).unwrap();
term.draw(|f| render(f, f.area(), &quiet, &p, ru()))
.unwrap();
let row = crate::shared::ui::tests::buffer_rows(term.backend().buffer()).remove(0);
assert_eq!(row, one[0]);
}
}