use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::entities::profile::CharacterNames;
use crate::features::demo;
use crate::screens::chat::ChatScreen;
use crate::screens::chat_list::ChatListScreen;
use crate::screens::self_model::SelfModelScreen;
use crate::screens::settings::SettingsScreen;
use crate::shared::config::{InterfaceSettings, NoteOrder, Theme, ThemeMode};
use crate::shared::i18n::{Lang, locale};
use crate::shared::server::{ServerStatus, ServerStatuses};
use crate::shared::shot::{self, ShotFrame};
use crate::shared::theme::Palette;
use crate::shared::ui::finish_frame;
pub const SHOT_W: u16 = 116;
pub const HERO_H: u16 = 44;
pub const PANEL_H: u16 = 33;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Look {
System(Theme),
Full(&'static str),
Mono,
}
impl Look {
fn dress(self, interface: &mut InterfaceSettings) {
match self {
Look::System(theme) => interface.theme = theme,
Look::Full(name) => {
interface.set_mode(ThemeMode::Full);
interface.full_theme = name.to_string();
}
Look::Mono => interface.set_mode(ThemeMode::Mono),
}
}
fn palette(self) -> Palette {
let mut interface = InterfaceSettings::default();
self.dress(&mut interface);
Palette::for_interface(&interface)
}
fn slug(self) -> String {
match self {
Look::System(Theme::Dark) => "dark".into(),
Look::System(Theme::Light) => "light".into(),
Look::System(Theme::Auto) => unreachable!("Auto theme is not capturable"),
Look::Full(name) => format!("full-{name}"),
Look::Mono => "mono".into(),
}
}
}
pub const THEMES: [Look; 2] = [Look::System(Theme::Dark), Look::System(Theme::Light)];
fn ready_statuses() -> ServerStatuses {
ServerStatuses {
chat: ServerStatus::Ready,
embed: ServerStatus::Ready,
impersonation: ServerStatus::NotConfigured,
}
}
fn capture(
screen_id: &str,
look: Look,
height: u16,
draw: impl FnOnce(&mut ratatui::Frame),
) -> ShotFrame {
let palette = look.palette();
let mut term = Terminal::new(TestBackend::new(SHOT_W, height)).unwrap();
term.draw(|frame| {
draw(frame);
finish_frame(frame.buffer_mut(), &palette);
})
.unwrap();
shot::capture(
term.backend().buffer(),
&palette,
screen_id,
&look.slug(),
"en",
)
}
pub fn chat_frame(look: Look) -> ShotFrame {
let mut screen = ChatScreen::new();
let mut config = crate::shared::config::AppConfig::default();
look.dress(&mut config.interface);
config.interface.language = Lang::En;
screen.set_settings(
config,
Vec::new(),
Vec::new(),
Default::default(),
Vec::new(),
);
screen.set_server_status(ready_statuses());
screen.activate_chat(
demo::chat_id(),
demo::CHAT_TITLE.into(),
&demo::showcase_messages(),
demo::INPUT_DRAFT,
demo::feed_view(),
None,
None,
);
capture("chat", look, HERO_H, |f| screen.render(f))
}
pub fn list_frame(look: Look) -> ShotFrame {
let mut screen = ChatListScreen::new(
demo::chat_summaries(),
Some(demo::chat_id()),
look.palette(),
locale(Lang::En),
);
capture("chat-list", look, PANEL_H, |f| screen.render(f))
}
pub fn settings_frame(look: Look, tools: bool) -> ShotFrame {
let mut config = demo::app_config();
look.dress(&mut config.interface);
let mut screen = SettingsScreen::new(config, vec![demo::profile()], Vec::new());
screen.set_server_statuses(ready_statuses());
if tools {
for _ in 0..2 {
screen.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
}
let id = if tools {
"settings-tools"
} else {
"settings-model"
};
capture(id, look, PANEL_H, |f| screen.render(f))
}
pub fn self_model_frame(look: Look) -> ShotFrame {
let mut screen = SelfModelScreen::new(
Some(demo::self_model()),
CharacterNames::default(),
look.palette(),
locale(Lang::En),
NoteOrder::default(),
);
capture("self-model", look, PANEL_H, |f| screen.render(f))
}
pub fn all_frames(look: Look) -> Vec<ShotFrame> {
vec![
chat_frame(look),
list_frame(look),
settings_frame(look, false),
settings_frame(look, true),
self_model_frame(look),
]
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::{Path, PathBuf};
fn frame_text(frame: &ShotFrame) -> String {
frame
.rows
.iter()
.map(|row| row.iter().map(|c| c.s.as_str()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn dumps_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/screenshots/dumps")
}
fn dump_name(frame: &ShotFrame) -> String {
format!("{}-{}-{}.json", frame.screen, frame.theme, frame.locale)
}
const REGEN_HINT: &str = "regenerate: `cargo test dump_demo_frames -- --ignored`, then `python tools/screenshots.py`, and commit both";
#[test]
fn frames_are_deterministic() {
for theme in THEMES {
let a: Vec<String> = all_frames(theme)
.iter()
.map(|f| serde_json::to_string(f).unwrap())
.collect();
let b: Vec<String> = all_frames(theme)
.iter()
.map(|f| serde_json::to_string(f).unwrap())
.collect();
assert_eq!(a, b, "{theme:?} frames are not deterministic");
}
}
#[test]
fn frame_rows_cover_the_grid() {
for frame in all_frames(Look::System(Theme::Dark)) {
assert_eq!(frame.rows.len(), frame.height as usize, "{}", frame.screen);
for (y, row) in frame.rows.iter().enumerate() {
let total: u16 = row.iter().map(|c| c.w as u16).sum();
assert_eq!(
total, frame.width,
"{} row {y} does not cover the grid",
frame.screen
);
}
}
}
#[test]
fn frames_show_their_showcase() {
#[rustfmt::skip]
let needles = |screen: &str| -> &'static [&'static str] {
match screen {
"chat" => &["note_save", "Q5_K_M", "sizing", "sweet spot", "How much context?", "KV headroom", "1B draft"],
"chat-list" => &["Speculative", "Dolomites", "Mermaid", "First week with mindfork"],
"settings-model" => &["llama-server.exe", "16384", "draft-simple", "gemma-4-1B"],
"settings-tools" => &["Agentic loop", "Wasmer sandbox", "Web search"],
"self-model" => &["Assistant", "reproducible", "User", "methodical", "receipts beat repetition"],
other => panic!("no needles for {other}"),
}
};
for frame in all_frames(Look::System(Theme::Dark)) {
let text = frame_text(&frame);
if matches!(frame.screen.as_str(), "chat" | "chat-list") {
assert!(
text.contains(demo::CHAT_TITLE),
"{}: title missing",
frame.screen
);
}
for needle in needles(&frame.screen) {
assert!(
text.contains(needle),
"{}: {needle:?} is not on screen:\n{text}",
frame.screen
);
}
}
}
#[test]
fn committed_dumps_match_the_code() {
let root = dumps_dir();
for theme in THEMES {
for frame in all_frames(theme) {
let name = dump_name(&frame);
let committed = fs::read_to_string(root.join(&name)).unwrap_or_else(|e| {
panic!("{name}: cannot read the committed dump ({e}) — {REGEN_HINT}")
});
let committed = committed.replace("\r\n", "\n");
let fresh = serde_json::to_string_pretty(&frame).unwrap();
if committed.trim_end() != fresh.trim_end() {
let line = committed
.lines()
.zip(fresh.lines())
.position(|(a, b)| a != b)
.map(|i| i + 1);
panic!(
"{name} drifted from the code (first differing line: {line:?}) — {REGEN_HINT}"
);
}
}
}
}
#[test]
fn the_full_mode_is_the_system_frame_on_its_canvas() {
for (system, full) in [
(Look::System(Theme::Dark), Look::Full("dark")),
(Look::System(Theme::Light), Look::Full("light")),
] {
for (on_terminal, painted) in all_frames(system).iter().zip(&all_frames(full)) {
let at = format!("{} / {}", painted.screen, painted.theme);
assert_eq!(painted.screen, on_terminal.screen);
assert_eq!(
(painted.width, painted.height),
(on_terminal.width, on_terminal.height),
"{at}"
);
assert_eq!(painted.canvas_bg, on_terminal.canvas_bg, "{at}");
assert_eq!(painted.canvas_fg, on_terminal.canvas_fg, "{at}");
let mut filled = 0usize;
for (y, (before, after)) in on_terminal.rows.iter().zip(&painted.rows).enumerate() {
assert_eq!(before.len(), after.len(), "{at} row {y}");
for (x, (b, a)) in before.iter().zip(after).enumerate() {
let cell = format!("{at} row {y} cell {x} {:?}", a.s);
assert_eq!((&a.s, a.w, &a.m), (&b.s, b.w, &b.m), "{cell}");
let want_fg = b.fg.clone().unwrap_or(on_terminal.canvas_fg.clone());
let want_bg = b.bg.clone().unwrap_or(on_terminal.canvas_bg.clone());
assert_eq!(a.fg.as_deref(), Some(want_fg.as_str()), "{cell}");
assert_eq!(a.bg.as_deref(), Some(want_bg.as_str()), "{cell}");
filled += usize::from(b.bg.is_none());
}
}
let cells: usize = painted.rows.iter().map(Vec::len).sum();
assert!(
filled * 10 > cells * 8,
"{at}: only {filled} of {cells} cells were the terminal's to colour"
);
}
}
}
#[test]
fn the_monochrome_mode_leaves_no_styling_on_any_screen() {
let styled = |frame: &ShotFrame| -> usize {
frame
.rows
.iter()
.flatten()
.filter(|c| c.fg.is_some() || c.bg.is_some() || !c.m.is_empty())
.count()
};
let system = all_frames(Look::System(Theme::Dark));
for (mono, system) in all_frames(Look::Mono).iter().zip(&system) {
let at = &mono.screen;
assert_eq!(&mono.screen, &system.screen);
for (y, row) in mono.rows.iter().enumerate() {
for cell in row {
assert_eq!(
(cell.fg.as_deref(), cell.bg.as_deref(), cell.m.as_str()),
(None, None, ""),
"{at} row {y} {:?}",
cell.s
);
}
}
let cells: usize = system.rows.iter().map(Vec::len).sum();
assert!(
styled(system) * 4 > cells,
"{at}: the control — only {} of {cells} cells are styled",
styled(system)
);
let text = frame_text(mono);
assert!(text.contains("[F1]"), "{at}:\n{text}");
assert!(!frame_text(system).contains("[F1]"), "{at}");
}
let by_screen = |screen: &str| -> String {
let frames = all_frames(Look::Mono);
frame_text(frames.iter().find(|f| f.screen == screen).unwrap())
};
let chat = by_screen("chat");
assert!(chat.contains("\n│║ ") || chat.contains("│║ "), "{chat}");
assert!(chat.contains("**"), "{chat}");
let list = by_screen("chat-list");
assert_eq!(list.matches('●').count(), 1, "{list}");
assert!(list.matches('○').count() >= 1, "{list}");
}
#[test]
#[ignore = "writes assets/screenshots/dumps/"]
fn dump_demo_frames() {
let root = dumps_dir();
fs::create_dir_all(&root).unwrap();
for theme in THEMES {
for frame in all_frames(theme) {
let name = dump_name(&frame);
let json = serde_json::to_string_pretty(&frame).unwrap();
fs::write(root.join(&name), json).unwrap();
eprintln!("wrote {name}");
}
}
}
}