use std::io::{self, Stdout, stdout};
use std::path::PathBuf;
use std::time::Duration;
use ratatui::Frame;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::{Alignment, Constraint, Direction, Layout as UiLayout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::templates::{Template, TemplateContext, for_context};
use crate::theme::Theme;
use crate::theme::presets as theme_presets;
use super::SettingsChoices;
use super::preview::TemplatePreview;
pub(crate) struct Needs {
pub(crate) templates: bool,
pub(crate) settings: bool,
}
pub(crate) struct Selection {
pub(crate) templates: Option<TemplatePair>,
pub(crate) settings: Option<SettingsChoices>,
}
pub(crate) struct TemplatePair {
pub(crate) home: &'static Template,
pub(crate) project: &'static Template,
}
pub(crate) fn run(
needs: Needs,
home_for_theme_preview: Option<&'static Template>,
) -> io::Result<Option<Selection>> {
let mut terminal = enter_terminal()?;
let result = run_screens(&mut terminal, needs, home_for_theme_preview);
exit_terminal(terminal)?;
result
}
fn run_screens(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
needs: Needs,
home_for_theme_preview: Option<&'static Template>,
) -> io::Result<Option<Selection>> {
let templates = if needs.templates {
let Some(pair) = run_template_picker_pair(terminal)? else {
return Ok(None);
};
Some(pair)
} else {
None
};
let settings = if needs.settings {
let preview_template = templates
.as_ref()
.map(|p| p.home)
.or(home_for_theme_preview)
.or_else(|| for_context(TemplateContext::Home).next());
let Some(choices) = run_settings_pickers(terminal, preview_template)? else {
return Ok(None);
};
Some(choices)
} else {
None
};
Ok(Some(Selection {
templates,
settings,
}))
}
fn enter_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
Terminal::new(backend)
}
fn exit_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
pub(crate) struct Summary {
pub(crate) shell: String,
pub(crate) home_template: String,
pub(crate) project_template: String,
pub(crate) theme: String,
pub(crate) bg: bool,
pub(crate) wait: bool,
pub(crate) files: Vec<PathBuf>,
pub(crate) rc_path: Option<PathBuf>,
}
pub(crate) fn confirm(summary: Summary) -> io::Result<bool> {
let mut terminal = enter_terminal()?;
let result = confirm_loop(&mut terminal, &summary);
exit_terminal(terminal)?;
result
}
fn confirm_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
summary: &Summary,
) -> io::Result<bool> {
loop {
terminal.draw(|frame| draw_confirmation(frame, summary))?;
if !event::poll(Duration::from_millis(250))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match (key.code, key.modifiers) {
(KeyCode::Enter, _) | (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => {
return Ok(true);
}
(KeyCode::Esc, _)
| (KeyCode::Char('q'), _)
| (KeyCode::Char('n'), _)
| (KeyCode::Char('N'), _)
| (KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(false),
_ => {}
}
}
}
fn draw_confirmation(frame: &mut Frame, summary: &Summary) {
let meta = SlotMeta {
title: "Review and confirm",
subtitle: "Existing files that differ get moved to a `.bak` sidecar before the new content lands.",
};
let area = frame.area();
let layout = UiLayout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(8), Constraint::Length(1), ])
.split(area);
draw_header(frame, layout[0], &meta, 1, 1);
let block = Block::default().borders(Borders::ALL).title(" summary ");
let inner = block.inner(layout[1]);
frame.render_widget(block, layout[1]);
let mut lines: Vec<Line> = Vec::new();
lines.push(summary_row("Shell", &summary.shell));
lines.push(summary_row("Home dashboard", &summary.home_template));
lines.push(summary_row("Project dashboard", &summary.project_template));
lines.push(summary_row("Theme", &summary.theme));
lines.push(summary_row(
"Paint splash bg",
if summary.bg {
"on"
} else {
"off (terminal bg shows through)"
},
));
lines.push(summary_row(
"Wait for fresh data",
if summary.wait { "on" } else { "off" },
));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Files to write:",
Style::default().fg(Color::White),
)));
for path in &summary.files {
lines.push(Line::from(Span::styled(
format!(" · {}", path.display()),
Style::default().fg(Color::DarkGray),
)));
}
if let Some(rc) = &summary.rc_path {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Shell rc block:",
Style::default().fg(Color::White),
)));
lines.push(Line::from(Span::styled(
format!(" · {}", rc.display()),
Style::default().fg(Color::DarkGray),
)));
}
frame.render_widget(Paragraph::new(lines), inner);
draw_footer(frame, layout[2], "Enter / y: write · Esc / q / n: cancel");
}
fn summary_row(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!(" {label:<22}"), Style::default().fg(Color::White)),
Span::styled(
value.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
])
}
fn run_template_picker_pair(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> io::Result<Option<TemplatePair>> {
let home_templates: Vec<&'static Template> = for_context(TemplateContext::Home).collect();
let project_templates: Vec<&'static Template> = for_context(TemplateContext::Project).collect();
if home_templates.is_empty() || project_templates.is_empty() {
return Ok(None);
}
let Some(home) = run_template_picker(terminal, &home_templates, SlotMeta::home())? else {
return Ok(None);
};
let Some(project) = run_template_picker(terminal, &project_templates, SlotMeta::project())?
else {
return Ok(None);
};
Ok(Some(TemplatePair { home, project }))
}
struct SlotMeta {
title: &'static str,
subtitle: &'static str,
}
impl SlotMeta {
fn home() -> Self {
Self {
title: "Pick your home dashboard",
subtitle: "Shown on new shells and when you cd outside any git project.",
}
}
fn project() -> Self {
Self {
title: "Pick your default project dashboard",
subtitle: "Shown automatically when you cd into the top of any git repo that doesn't ship its own .splashboard/.",
}
}
}
fn run_template_picker(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
templates: &[&'static Template],
meta: SlotMeta,
) -> io::Result<Option<&'static Template>> {
let previews: Vec<Option<TemplatePreview>> = templates
.iter()
.map(|t| TemplatePreview::from_body(t.body))
.collect();
let mut selected = 0usize;
loop {
terminal.draw(|frame| {
draw_template_frame(
frame,
&meta,
templates,
selected,
previews[selected].as_ref(),
);
})?;
match wait_for_action(templates.len(), selected)? {
Action::Cancel => return Ok(None),
Action::Confirm => return Ok(Some(templates[selected])),
Action::Move(to) => selected = to,
Action::Toggle | Action::Tick => {}
}
}
}
fn draw_template_frame(
frame: &mut Frame,
meta: &SlotMeta,
templates: &[&'static Template],
selected: usize,
preview: Option<&TemplatePreview>,
) {
let area = frame.area();
let layout = UiLayout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(8), Constraint::Length(templates.len() as u16 + 2), Constraint::Length(1), ])
.split(area);
draw_header(frame, layout[0], meta, selected + 1, templates.len());
draw_preview(frame, layout[1], templates[selected].name, preview);
draw_template_list(frame, layout[2], templates, selected);
draw_footer(frame, layout[3], "↑/↓ navigate · Enter select · Esc cancel");
}
fn draw_template_list(
frame: &mut Frame,
area: Rect,
templates: &[&'static Template],
selected: usize,
) {
let block = Block::default().borders(Borders::ALL).title(" templates ");
let inner = block.inner(area);
frame.render_widget(block, area);
let lines: Vec<Line> = templates
.iter()
.enumerate()
.map(|(i, t)| render_name_desc_row(t.name, t.description, i == selected))
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
fn run_settings_pickers(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
preview_template: Option<&'static Template>,
) -> io::Result<Option<SettingsChoices>> {
let theme = match run_theme_picker(terminal, preview_template)? {
ThemePick::Cancelled => return Ok(None),
ThemePick::Chose(key) => key,
};
let Some((bg, wait)) = run_toggle_picker(terminal)? else {
return Ok(None);
};
Ok(Some(SettingsChoices { theme, bg, wait }))
}
enum ThemePick {
Cancelled,
Chose(Option<&'static str>),
}
struct ThemeOption {
key: Option<&'static str>,
label: &'static str,
description: &'static str,
}
fn theme_options() -> Vec<ThemeOption> {
vec![
ThemeOption {
key: None,
label: "default",
description: "Splash signature — deep-ocean navy with coral titles.",
},
ThemeOption {
key: Some("tokyo_night"),
label: "tokyo_night",
description: "Tokyo Night — vibrant blue night, magenta accents.",
},
ThemeOption {
key: Some("tokyo_night_storm"),
label: "tokyo_night_storm",
description: "Tokyo Night Storm — softer storm-cloud variant.",
},
ThemeOption {
key: Some("catppuccin_mocha"),
label: "catppuccin_mocha",
description: "Catppuccin Mocha — pastel dark, lavender accents.",
},
ThemeOption {
key: Some("catppuccin_macchiato"),
label: "catppuccin_macchiato",
description: "Catppuccin Macchiato — slightly lighter than Mocha.",
},
ThemeOption {
key: Some("catppuccin_frappe"),
label: "catppuccin_frappe",
description: "Catppuccin Frappé — warmer pastel mid-dark.",
},
ThemeOption {
key: Some("dracula"),
label: "dracula",
description: "Dracula — dark purple base, vivid neon accents.",
},
ThemeOption {
key: Some("nord"),
label: "nord",
description: "Nord — cool blue-gray arctic palette.",
},
ThemeOption {
key: Some("gruvbox_dark"),
label: "gruvbox_dark",
description: "Gruvbox Dark — warm retro, earthy palette.",
},
ThemeOption {
key: Some("rose_pine"),
label: "rose_pine",
description: "Rosé Pine — soho dark with rose and pine highlights.",
},
ThemeOption {
key: Some("rose_pine_moon"),
label: "rose_pine_moon",
description: "Rosé Pine Moon — moonlit blue-violet variant.",
},
ThemeOption {
key: Some("kanagawa"),
label: "kanagawa",
description: "Kanagawa — Hokusai-inspired indigo with autumn accents.",
},
ThemeOption {
key: Some("everforest_dark"),
label: "everforest_dark",
description: "Everforest Dark — woodland green with warm muted accents.",
},
ThemeOption {
key: Some("one_dark"),
label: "one_dark",
description: "One Dark — Atom's flagship dark, balanced cyan/red.",
},
ThemeOption {
key: Some("solarized_dark"),
label: "solarized_dark",
description: "Solarized Dark — the precision-engineered classic.",
},
ThemeOption {
key: Some("monokai"),
label: "monokai",
description: "Monokai — punchy magenta and lime on warm gray.",
},
ThemeOption {
key: Some("night_owl"),
label: "night_owl",
description: "Night Owl — deep midnight blue, calm accents.",
},
ThemeOption {
key: Some("synthwave_84"),
label: "synthwave_84",
description: "SynthWave '84 — neon pink and cyan retro grid.",
},
ThemeOption {
key: Some("ayu_mirage"),
label: "ayu_mirage",
description: "Ayu Mirage — soft slate with bright sky and lime.",
},
ThemeOption {
key: Some("material_palenight"),
label: "material_palenight",
description: "Material Palenight — material design dark with lavender.",
},
ThemeOption {
key: Some("github_dark"),
label: "github_dark",
description: "GitHub Dark — the github.com dark mode palette.",
},
ThemeOption {
key: Some("catppuccin_latte"),
label: "catppuccin_latte",
description: "Catppuccin Latte (light) — pastel on cream.",
},
ThemeOption {
key: Some("rose_pine_dawn"),
label: "rose_pine_dawn",
description: "Rosé Pine Dawn (light) — warm cream with pine accents.",
},
ThemeOption {
key: Some("solarized_light"),
label: "solarized_light",
description: "Solarized Light — the classic light counterpart.",
},
ThemeOption {
key: Some("gruvbox_light"),
label: "gruvbox_light",
description: "Gruvbox Light — earthy retro on warm cream.",
},
ThemeOption {
key: Some("github_light"),
label: "github_light",
description: "GitHub Light — the github.com light mode palette.",
},
]
}
fn run_theme_picker(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
preview_template: Option<&'static Template>,
) -> io::Result<ThemePick> {
let options = theme_options();
let previews: Vec<Option<TemplatePreview>> = options
.iter()
.map(|opt| {
preview_template.and_then(|t| {
let theme = resolve_theme(opt.key);
TemplatePreview::from_body_with_theme(t.body, theme)
})
})
.collect();
let mut selected = 0usize;
loop {
terminal.draw(|frame| {
draw_theme_frame(frame, &options, selected, previews[selected].as_ref());
})?;
match wait_for_action(options.len(), selected)? {
Action::Cancel => return Ok(ThemePick::Cancelled),
Action::Confirm => return Ok(ThemePick::Chose(options[selected].key)),
Action::Move(to) => selected = to,
Action::Toggle | Action::Tick => {}
}
}
}
fn resolve_theme(key: Option<&'static str>) -> Theme {
key.and_then(theme_presets::by_name).unwrap_or_default()
}
fn draw_theme_frame(
frame: &mut Frame,
options: &[ThemeOption],
selected: usize,
preview: Option<&TemplatePreview>,
) {
let meta = SlotMeta {
title: "Pick your theme",
subtitle: "Applied to every splash you render. Override individual tokens later via settings.toml.",
};
let area = frame.area();
let layout = UiLayout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(8), Constraint::Length(options.len() as u16 + 2), Constraint::Length(1), ])
.split(area);
draw_header(frame, layout[0], &meta, selected + 1, options.len());
draw_preview(frame, layout[1], options[selected].label, preview);
draw_theme_list(frame, layout[2], options, selected);
draw_footer(frame, layout[3], "↑/↓ navigate · Enter select · Esc cancel");
}
fn draw_theme_list(frame: &mut Frame, area: Rect, options: &[ThemeOption], selected: usize) {
let block = Block::default().borders(Borders::ALL).title(" themes ");
let inner = block.inner(area);
frame.render_widget(block, area);
let lines: Vec<Line> = options
.iter()
.enumerate()
.map(|(i, opt)| render_name_desc_row(opt.label, opt.description, i == selected))
.collect();
frame.render_widget(Paragraph::new(lines), inner);
}
const TOGGLE_BG: usize = 0;
const TOGGLE_WAIT: usize = 1;
fn run_toggle_picker(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> io::Result<Option<(bool, bool)>> {
let mut values = [true, false];
let mut selected = 0usize;
loop {
terminal.draw(|frame| {
draw_toggle_frame(frame, selected, values);
})?;
match wait_for_action(values.len(), selected)? {
Action::Cancel => return Ok(None),
Action::Confirm => return Ok(Some((values[TOGGLE_BG], values[TOGGLE_WAIT]))),
Action::Move(to) => selected = to,
Action::Toggle => values[selected] = !values[selected],
Action::Tick => {}
}
}
}
fn draw_toggle_frame(frame: &mut Frame, selected: usize, values: [bool; 2]) {
let meta = SlotMeta {
title: "Pick your defaults",
subtitle: "Applied to every splash; edit settings.toml later to change.",
};
let area = frame.area();
let layout = UiLayout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(4), Constraint::Length(1), ])
.split(area);
draw_header(frame, layout[0], &meta, selected + 1, values.len());
let block = Block::default().borders(Borders::ALL).title(" options ");
let inner = block.inner(layout[1]);
frame.render_widget(block, layout[1]);
let lines = vec![
render_toggle_row(
"paint splash background",
"off = let the terminal's own background show through (light terminals, transparency)",
values[TOGGLE_BG],
selected == TOGGLE_BG,
),
render_toggle_row(
"wait for fresh data",
"on = block the first render until every widget has fetched at least once",
values[TOGGLE_WAIT],
selected == TOGGLE_WAIT,
),
];
frame.render_widget(Paragraph::new(lines), inner);
draw_footer(
frame,
layout[2],
"↑/↓ navigate · space toggle · Enter continue · Esc cancel",
);
}
fn render_toggle_row(label: &str, desc: &str, value: bool, active: bool) -> Line<'static> {
let marker = if active { "▶ " } else { " " };
let checkbox = if value { "[x] " } else { "[ ] " };
let name_style = if active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let desc_style = Style::default().fg(Color::DarkGray);
Line::from(vec![
Span::styled(format!("{marker}{checkbox}{label:<26}"), name_style),
Span::styled(format!(" {desc}"), desc_style),
])
}
enum Action {
Cancel,
Confirm,
Toggle,
Move(usize),
Tick,
}
fn wait_for_action(total: usize, current: usize) -> io::Result<Action> {
if !event::poll(Duration::from_millis(250))? {
return Ok(Action::Tick);
}
let Event::Key(key) = event::read()? else {
return Ok(Action::Tick);
};
if key.kind != KeyEventKind::Press {
return Ok(Action::Tick);
}
Ok(match (key.code, key.modifiers) {
(KeyCode::Esc, _)
| (KeyCode::Char('q'), _)
| (KeyCode::Char('c'), KeyModifiers::CONTROL) => Action::Cancel,
(KeyCode::Enter, _) => Action::Confirm,
(KeyCode::Char(' '), _) => Action::Toggle,
(KeyCode::Up, _) | (KeyCode::Char('k'), _) if current > 0 => Action::Move(current - 1),
(KeyCode::Down, _) | (KeyCode::Char('j'), _) if current + 1 < total => {
Action::Move(current + 1)
}
_ => Action::Tick,
})
}
fn draw_header(frame: &mut Frame, area: Rect, meta: &SlotMeta, n: usize, total: usize) {
let title = Line::from(vec![
Span::styled(
meta.title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("{n}/{total}"), Style::default().fg(Color::DarkGray)),
]);
let subtitle = Line::from(Span::styled(
meta.subtitle,
Style::default().fg(Color::DarkGray),
));
frame.render_widget(
Paragraph::new(vec![title, subtitle]).alignment(Alignment::Left),
area,
);
}
fn draw_preview(frame: &mut Frame, area: Rect, label: &str, preview: Option<&TemplatePreview>) {
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" preview · {label} "));
let inner = block.inner(area);
frame.render_widget(block, area);
match preview {
Some(p) => p.draw(frame, inner),
None => {
let msg = Paragraph::new("(preview unavailable)")
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(msg, inner);
}
}
}
fn render_name_desc_row(name: &str, desc: &str, active: bool) -> Line<'static> {
let marker = if active { "▶ " } else { " " };
let name_style = if active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let desc_style = Style::default().fg(Color::DarkGray);
Line::from(vec![
Span::styled(format!("{marker}{name:<28}"), name_style),
Span::styled(format!(" {desc}"), desc_style),
])
}
fn draw_footer(frame: &mut Frame, area: Rect, hint: &str) {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {hint}"),
Style::default().fg(Color::DarkGray),
))),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
fn draw_to_buffer(width: u16, height: u16, draw: impl FnOnce(&mut Frame)) -> Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
terminal.backend().buffer().clone()
}
fn buffer_text(buf: &Buffer) -> String {
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn theme_options_keep_default_first_and_map_to_known_presets() {
let options = theme_options();
assert!(matches!(
options.first(),
Some(ThemeOption {
key: None,
label: "default",
..
})
));
assert!(matches!(
options.last(),
Some(ThemeOption {
key: Some("github_light"),
..
})
));
assert!(
options
.iter()
.skip(1)
.all(|option| option.key.and_then(theme_presets::by_name).is_some())
);
assert_eq!(resolve_theme(None).bg, Theme::default().bg);
assert_eq!(
resolve_theme(Some("nord")).panel_title,
theme_presets::by_name("nord").unwrap().panel_title
);
assert_eq!(resolve_theme(Some("missing")).text, Theme::default().text);
}
#[test]
fn slot_meta_copy_stays_context_specific() {
let home = SlotMeta::home();
assert_eq!(home.title, "Pick your home dashboard");
assert!(home.subtitle.contains("new shells"));
let project = SlotMeta::project();
assert_eq!(project.title, "Pick your default project dashboard");
assert!(project.subtitle.contains(".splashboard/."));
}
#[test]
fn helper_rows_include_labels_markers_and_values() {
let summary = line_text(&summary_row("Theme", "default"));
assert!(summary.starts_with(" Theme"));
assert!(summary.ends_with("default"));
let active_name = line_text(&render_name_desc_row("alpha", "preview text", true));
assert!(active_name.starts_with("▶ alpha"));
assert!(active_name.ends_with(" preview text"));
let inactive_toggle = line_text(&render_toggle_row("wait", "fresh data", false, false));
assert!(inactive_toggle.starts_with(" [ ] wait"));
assert!(inactive_toggle.ends_with(" fresh data"));
let active_toggle = line_text(&render_toggle_row("paint", "terminal bg", true, true));
assert!(active_toggle.starts_with("▶ [x] paint"));
assert!(active_toggle.ends_with(" terminal bg"));
}
#[test]
fn confirmation_screen_renders_summary_and_rc_block() {
let summary = Summary {
shell: "zsh".into(),
home_template: "home_splash".into(),
project_template: "project_github".into(),
theme: "tokyo_night".into(),
bg: false,
wait: true,
files: vec![
PathBuf::from("/tmp/home.dashboard.toml"),
PathBuf::from("/tmp/settings.toml"),
],
rc_path: Some(PathBuf::from("/tmp/.zshrc")),
};
let buf = draw_to_buffer(100, 20, |frame| draw_confirmation(frame, &summary));
let text = buffer_text(&buf);
assert!(text.contains("Review and confirm"));
assert!(text.contains("home_splash"));
assert!(text.contains("off (terminal bg shows through)"));
assert!(text.contains("Shell rc block:"));
assert!(text.contains("/tmp/.zshrc"));
assert!(text.contains("Enter / y: write"));
}
#[test]
fn confirmation_screen_hides_rc_block_when_path_is_missing() {
let summary = Summary {
shell: "fish".into(),
home_template: "home_splash".into(),
project_template: "project_plain".into(),
theme: "default".into(),
bg: true,
wait: false,
files: vec![PathBuf::from("/tmp/config.toml")],
rc_path: None,
};
let buf = draw_to_buffer(100, 18, |frame| draw_confirmation(frame, &summary));
let text = buffer_text(&buf);
assert!(text.contains("/tmp/config.toml"));
assert!(text.contains("Paint splash bg"));
assert!(text.contains("default"));
assert!(!text.contains("Shell rc block:"));
}
#[test]
fn template_frame_renders_selected_template_and_preview() {
let templates: Vec<&'static Template> = for_context(TemplateContext::Home).collect();
let selected = 1;
let preview = TemplatePreview::from_body(templates[selected].body).unwrap();
let meta = SlotMeta::home();
let buf = draw_to_buffer(100, 28, |frame| {
draw_template_frame(frame, &meta, &templates, selected, Some(&preview));
});
let text = buffer_text(&buf);
assert!(text.contains("Pick your home dashboard"));
assert!(text.contains("preview"));
assert!(text.contains(templates[selected].name));
assert!(text.contains("navigate"));
assert!(!text.contains("(preview unavailable)"));
}
#[test]
fn helper_panels_render_titles_counts_and_placeholders() {
let buf = draw_to_buffer(80, 10, |frame| {
let layout = UiLayout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(6),
Constraint::Length(1),
])
.split(frame.area());
let meta = SlotMeta {
title: "Header title",
subtitle: "Header subtitle",
};
draw_header(frame, layout[0], &meta, 2, 5);
draw_preview(frame, layout[1], "demo", None);
draw_footer(frame, layout[2], "Esc cancel");
});
let text = buffer_text(&buf);
assert!(text.contains("Header title"));
assert!(text.contains("2/5"));
assert!(text.contains("Header subtitle"));
assert!(text.contains("preview · demo"));
assert!(text.contains("(preview unavailable)"));
assert!(text.contains("Esc cancel"));
}
#[test]
fn theme_frame_renders_default_preview_when_available() {
let options = theme_options();
let template = for_context(TemplateContext::Home).next().unwrap();
let preview =
TemplatePreview::from_body_with_theme(template.body, resolve_theme(None)).unwrap();
let buf = draw_to_buffer(100, 36, |frame| {
draw_theme_frame(frame, &options, 0, Some(&preview));
});
let text = buffer_text(&buf);
assert!(text.contains("Pick your theme"));
assert!(text.contains("preview · default"));
assert!(!text.contains("(preview unavailable)"));
}
#[test]
fn theme_and_toggle_frames_render_placeholder_and_switch_state() {
let options = theme_options();
let theme_buf = draw_to_buffer(100, 36, |frame| {
draw_theme_frame(frame, &options, 2, None);
});
let theme_text = buffer_text(&theme_buf);
assert!(theme_text.contains("Pick your theme"));
assert!(theme_text.contains("(preview unavailable)"));
assert!(theme_text.contains(options[2].label));
let toggle_buf = draw_to_buffer(100, 10, |frame| {
draw_toggle_frame(frame, TOGGLE_WAIT, [false, true]);
});
let toggle_text = buffer_text(&toggle_buf);
assert!(toggle_text.contains("Pick your defaults"));
assert!(toggle_text.contains("[ ] paint splash background"));
assert!(toggle_text.contains("[x] wait for fresh data"));
assert!(toggle_text.contains("space toggle"));
}
#[test]
fn toggle_frame_highlights_background_when_selected() {
let buf = draw_to_buffer(100, 10, |frame| {
draw_toggle_frame(frame, TOGGLE_BG, [true, false]);
});
let text = buffer_text(&buf);
assert!(text.contains("▶ [x] paint splash background"));
assert!(text.contains("[ ] wait for fresh data"));
assert!(text.contains("1/2"));
}
}