use super::theme::{accent, secondary};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
pub(super) const NO_DATABASE_HEADLINE: &str = "No database is configured yet.";
pub(super) const NO_DATABASE_STEPS: [&str; 3] = [
"Run `saya config init` — it prints where it wrote the files.",
"Add your database there, then `saya connection test <name>`.",
"Restart saya to pick it up.",
];
pub(super) const NO_DATABASE_FOOTER: &str = "`saya config doctor` explains anything still missing.";
const SPLASH_ART: [&str; 8] = [
" \u{2584}\u{2584} \u{2584}\u{2584} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588} \u{2590} \u{2588}\u{2588}\u{2588} \u{258c} \u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{25bc}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2580}\u{2580} \u{2580}\u{2580} ",
];
const SPLASH_ART_COMPACT: [&str; 5] = [
" \u{2584} \u{2584} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588} \u{2590} \u{258c} \u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{25bc}\u{2588}\u{2588}\u{2588}\u{2588} ",
" \u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588} ",
];
pub(super) fn splash_art(
available_height: usize,
content_len: usize,
) -> Option<Vec<Line<'static>>> {
if available_height > content_len + SPLASH_ART.len() {
Some(art_lines(&SPLASH_ART))
} else if available_height > content_len + SPLASH_ART_COMPACT.len() {
Some(art_lines(&SPLASH_ART_COMPACT))
} else {
None
}
}
fn glyph_style(ch: char) -> Style {
match ch {
'\u{258c}' | '\u{2590}' => Style::default().add_modifier(Modifier::BOLD),
'\u{25bc}' | '\u{2580}' => Style::default().fg(secondary()),
_ => Style::default().fg(accent()),
}
}
fn art_lines(rows: &[&'static str]) -> Vec<Line<'static>> {
rows.iter()
.map(|row| {
let mut spans: Vec<Span<'static>> = Vec::new();
for ch in row.chars() {
let style = glyph_style(ch);
match spans.last_mut() {
Some(last) if last.style == style => last.content.to_mut().push(ch),
_ => spans.push(Span::styled(ch.to_string(), style)),
}
}
Line::from(spans)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splash_rows_are_padded_to_one_width() {
for art in [&SPLASH_ART[..], &SPLASH_ART_COMPACT[..]] {
let width = art[0].chars().count();
for row in art {
assert_eq!(row.chars().count(), width, "ragged row: {row:?}");
}
}
}
#[test]
fn splash_art_yields_to_the_text_when_the_terminal_is_short() {
assert_eq!(splash_art(40, 11).map(|a| a.len()), Some(SPLASH_ART.len()));
assert_eq!(
splash_art(11 + SPLASH_ART.len(), 11).map(|a| a.len()),
Some(SPLASH_ART_COMPACT.len())
);
assert!(splash_art(11 + SPLASH_ART_COMPACT.len(), 11).is_none());
assert!(splash_art(0, 11).is_none());
}
#[test]
fn first_run_guidance_names_no_config_path() {
for line in NO_DATABASE_STEPS
.iter()
.chain([&NO_DATABASE_HEADLINE, &NO_DATABASE_FOOTER])
{
assert!(
!line.contains(".saya"),
"guidance must let `config init` report the path: {line}"
);
}
}
#[test]
fn first_run_guidance_names_real_commands() {
let all = NO_DATABASE_STEPS.join(" ") + NO_DATABASE_FOOTER;
for command in [
"saya config init",
"saya connection test",
"saya config doctor",
] {
assert!(all.contains(command), "guidance should offer `{command}`");
}
}
#[test]
fn first_run_guidance_fits_a_narrow_terminal() {
for line in NO_DATABASE_STEPS
.iter()
.chain([&NO_DATABASE_HEADLINE, &NO_DATABASE_FOOTER])
{
assert!(line.chars().count() <= 64, "too wide to centre: {line}");
}
}
#[test]
fn both_pupils_are_styled_as_cursors() {
for art in [&SPLASH_ART[..], &SPLASH_ART_COMPACT[..]] {
let lines = art_lines(art);
let bold: Vec<String> = lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| span.style.add_modifier.contains(Modifier::BOLD))
.map(|span| span.content.to_string())
.collect();
assert_eq!(
bold,
vec!["\u{2590}", "\u{258c}"],
"expected exactly two pupils"
);
}
}
#[test]
fn styling_preserves_every_glyph_in_the_row() {
for art in [&SPLASH_ART[..], &SPLASH_ART_COMPACT[..]] {
for (row, line) in art.iter().zip(art_lines(art)) {
let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(&rendered, row, "row must survive styling unchanged");
}
}
}
#[test]
fn beak_and_talons_recede_behind_the_body() {
let secondary_style = Style::default().fg(secondary());
for ch in ['\u{25bc}', '\u{2580}'] {
assert_eq!(glyph_style(ch), secondary_style, "{ch} should recede");
}
assert_eq!(glyph_style('\u{2588}'), Style::default().fg(accent()));
}
}