use escriba_core::Action;
use ishou_tokens::Rgb;
use serde::{Deserialize, Serialize};
use crate::chrome::ChromePalette;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SplashRole {
Art,
Tagline,
Rule,
MenuKey,
MenuLabel,
Footer,
}
impl SplashRole {
#[must_use]
pub fn color(self, c: &ChromePalette) -> Rgb {
match self {
Self::Art => c.info,
Self::Tagline | Self::MenuLabel => c.text,
Self::Rule | Self::Footer => c.text_dim,
Self::MenuKey => c.accent,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplashSpan {
pub text: String,
pub role: SplashRole,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplashRow {
pub row: u16,
pub col: u16,
pub spans: Vec<SplashSpan>,
}
impl SplashRow {
#[must_use]
pub fn plain(&self) -> String {
let mut s = String::new();
for span in &self.spans {
s.push_str(&span.text);
}
s
}
#[must_use]
pub fn width(&self) -> usize {
self.spans.iter().map(|s| s.text.chars().count()).sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplashEntry {
pub key: char,
pub label: String,
pub action: Action,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Splash {
pub art: Vec<String>,
pub tagline: String,
pub entries: Vec<SplashEntry>,
pub facts: Vec<String>,
}
const KEY_GAP: usize = 3;
const MARGIN: usize = 2;
const COMPACT_ART: &str = "escriba";
const FACT_SEP: &str = " · ";
impl Splash {
#[must_use]
pub fn is_empty(&self) -> bool {
self.art.is_empty()
&& self.tagline.is_empty()
&& self.entries.is_empty()
&& self.facts.is_empty()
}
#[must_use]
pub fn entry_for(&self, key: char) -> Option<&SplashEntry> {
self.entries.iter().find(|e| e.key == key)
}
#[must_use]
pub fn rows(&self, width: u16, height: u16) -> Vec<SplashRow> {
let (w, h) = (width as usize, height as usize);
if self.is_empty() || w <= MARGIN * 2 || h == 0 {
return Vec::new();
}
let usable = w - MARGIN * 2;
let mut blocks = self.blocks(usable);
if total_lines(&blocks) > h {
blocks.retain(|b| b.kind != BlockKind::Art);
}
while total_lines(&blocks) > h {
if !truncate_menu(&mut blocks) {
return Vec::new();
}
}
let total = total_lines(&blocks);
let top = (h - total) * 2 / 5;
let mut out = Vec::with_capacity(total);
let mut row = top;
for block in &blocks {
let bw = block.width();
let left = MARGIN + (usable.saturating_sub(bw)) / 2;
for line in &block.lines {
if !line.is_empty() {
out.push(SplashRow {
row: u16::try_from(row).unwrap_or(u16::MAX),
col: u16::try_from(left).unwrap_or(u16::MAX),
spans: line.clone(),
});
}
row += 1;
}
}
out
}
#[must_use]
pub fn screen_chunks(&self, width: u16, height: u16) -> Vec<SplashSpan> {
let rows = self.rows(width, height);
let mut out: Vec<SplashSpan> = Vec::new();
let mut cursor_row = 0u16;
for r in &rows {
for _ in cursor_row..r.row {
out.push(pad("\n"));
}
cursor_row = r.row + 1;
if r.col > 0 {
out.push(pad(&" ".repeat(r.col as usize)));
}
out.extend(r.spans.iter().cloned());
out.push(pad("\n"));
}
out
}
fn blocks(&self, usable: usize) -> Vec<Block> {
let mut blocks = Vec::new();
if !self.art.is_empty() {
let fits = self.art.iter().all(|l| l.chars().count() <= usable);
let lines: Vec<Vec<SplashSpan>> = if fits {
self.art
.iter()
.map(|l| span_line(l, SplashRole::Art))
.collect()
} else {
vec![span_line(COMPACT_ART, SplashRole::Art)]
};
blocks.push(Block::new(BlockKind::Art, lines));
}
if !self.tagline.is_empty() {
let tagline = clip(&self.tagline, usable);
let rule_width = tagline.chars().count().min(usable);
blocks.push(Block::new(
BlockKind::Head,
vec![
Vec::new(),
span_line(&tagline, SplashRole::Tagline),
span_line(&"─".repeat(rule_width), SplashRole::Rule),
],
));
}
if !self.entries.is_empty() {
let label_room = usable.saturating_sub(1 + KEY_GAP);
let mut lines = vec![Vec::new()];
for e in &self.entries {
let mut gap = String::with_capacity(KEY_GAP);
for _ in 0..KEY_GAP {
gap.push(' ');
}
gap.push_str(&clip(&e.label, label_room));
lines.push(vec![
SplashSpan {
text: e.key.to_string(),
role: SplashRole::MenuKey,
},
SplashSpan {
text: gap,
role: SplashRole::MenuLabel,
},
]);
}
blocks.push(Block::new(BlockKind::Menu, lines));
}
if !self.facts.is_empty() {
let strip = clip(&self.facts.join(FACT_SEP), usable);
blocks.push(Block::new(
BlockKind::Foot,
vec![Vec::new(), span_line(&strip, SplashRole::Footer)],
));
}
blocks
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockKind {
Art,
Head,
Menu,
Foot,
}
struct Block {
kind: BlockKind,
lines: Vec<Vec<SplashSpan>>,
}
impl Block {
fn new(kind: BlockKind, lines: Vec<Vec<SplashSpan>>) -> Self {
Self { kind, lines }
}
fn width(&self) -> usize {
self.lines
.iter()
.map(|l| l.iter().map(|s| s.text.chars().count()).sum::<usize>())
.max()
.unwrap_or(0)
}
}
fn total_lines(blocks: &[Block]) -> usize {
blocks.iter().map(|b| b.lines.len()).sum()
}
fn truncate_menu(blocks: &mut Vec<Block>) -> bool {
let Some(menu) = blocks.iter_mut().find(|b| b.kind == BlockKind::Menu) else {
return false;
};
if menu.lines.len() > 1 {
menu.lines.pop();
if menu.lines.len() == 1 {
blocks.retain(|b| b.kind != BlockKind::Menu);
}
return true;
}
blocks.retain(|b| b.kind != BlockKind::Menu);
!blocks.is_empty()
}
fn pad(text: &str) -> SplashSpan {
SplashSpan {
text: text.to_string(),
role: SplashRole::Footer,
}
}
fn span_line(text: &str, role: SplashRole) -> Vec<SplashSpan> {
vec![SplashSpan {
text: text.to_string(),
role,
}]
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use escriba_core::Mode;
fn sample() -> Splash {
Splash {
art: vec![" ___ ".into(), " |___| ".into()],
tagline: "a modal editor".into(),
entries: vec![
SplashEntry {
key: 'e',
label: "start editing".into(),
action: Action::ChangeMode(Mode::Normal),
},
SplashEntry {
key: 'q',
label: "quit".into(),
action: Action::Quit,
},
],
facts: vec!["v0.1.0".into(), "nord".into()],
}
}
#[test]
fn empty_splash_paints_nothing() {
assert!(Splash::default().rows(120, 40).is_empty());
}
#[test]
fn rows_stay_inside_the_canvas() {
for (w, h) in [(120u16, 40u16), (80, 24), (40, 12), (20, 8), (10, 4)] {
for r in sample().rows(w, h) {
assert!(r.row < h, "row {} outside height {h}", r.row);
assert!(
r.col as usize + r.width() <= w as usize,
"row {} overflows width {w}: col={} width={}",
r.row,
r.col,
r.width(),
);
}
}
}
#[test]
fn a_roomy_canvas_shows_art_tagline_menu_and_facts() {
let text: Vec<String> = sample()
.rows(120, 40)
.iter()
.map(SplashRow::plain)
.collect();
let joined = text.join("\n");
assert!(joined.contains("|___|"), "art missing: {joined}");
assert!(joined.contains("a modal editor"), "tagline missing");
assert!(joined.contains("start editing"), "menu missing");
assert!(joined.contains("nord"), "facts missing");
}
#[test]
fn a_narrow_canvas_falls_back_to_the_compact_wordmark() {
let s = Splash {
art: vec!["#".repeat(60)],
..sample()
};
let joined = s
.rows(30, 20)
.iter()
.map(SplashRow::plain)
.collect::<Vec<_>>()
.join("\n");
assert!(
joined.contains(COMPACT_ART),
"no compact wordmark: {joined}"
);
assert!(
!joined.contains("######"),
"wide art survived a narrow canvas"
);
}
#[test]
fn a_short_canvas_drops_the_art_before_the_menu() {
let joined = sample()
.rows(80, 7)
.iter()
.map(SplashRow::plain)
.collect::<Vec<_>>()
.join("\n");
assert!(
!joined.contains("|___|"),
"art should have dropped: {joined}"
);
assert!(
joined.contains("start editing"),
"the first menu entry must survive: {joined}",
);
}
#[test]
fn a_canvas_with_no_room_paints_nothing_rather_than_a_mangled_frame() {
assert!(sample().rows(120, 1).is_empty());
assert!(sample().rows(2, 40).is_empty());
}
#[test]
fn menu_entries_share_one_left_column() {
let rows = sample().rows(120, 40);
let cols: Vec<u16> = rows
.iter()
.filter(|r| {
r.spans
.first()
.is_some_and(|s| s.role == SplashRole::MenuKey)
})
.map(|r| r.col)
.collect();
assert_eq!(cols.len(), 2);
assert_eq!(cols[0], cols[1], "menu keys must share a column");
}
#[test]
fn entry_lookup_resolves_the_typed_action() {
let s = sample();
assert_eq!(s.entry_for('q').map(|e| &e.action), Some(&Action::Quit));
assert!(s.entry_for('z').is_none());
}
#[test]
fn roles_resolve_to_distinct_chrome_colors() {
let c = ChromePalette::prescribed();
assert_ne!(
SplashRole::MenuKey.color(&c).hex(),
SplashRole::MenuLabel.color(&c).hex(),
);
assert_ne!(
SplashRole::Art.color(&c).hex(),
SplashRole::Footer.color(&c).hex(),
);
}
#[test]
fn screen_chunks_reconstruct_the_same_screen_as_rows() {
let s = sample();
let chunks: String = s
.screen_chunks(80, 24)
.iter()
.map(|c| c.text.as_str())
.collect();
for r in s.rows(80, 24) {
let line = chunks.lines().nth(r.row as usize).unwrap_or("");
assert_eq!(
line,
format!("{}{}", " ".repeat(r.col as usize), r.plain()),
"row {} differs between rows() and screen_chunks()",
r.row,
);
}
}
#[test]
fn screen_chunks_are_a_complete_partition() {
let s = sample();
let chunks = s.screen_chunks(80, 24);
assert!(!chunks.is_empty());
assert!(
chunks.iter().all(|c| !c.text.is_empty()),
"an empty chunk is a wasted span",
);
let joined: String = chunks.iter().map(|c| c.text.as_str()).collect();
let mut expected = String::new();
let mut cursor_row = 0u16;
for r in s.rows(80, 24) {
for _ in cursor_row..r.row {
expected.push('\n');
}
cursor_row = r.row + 1;
for _ in 0..r.col {
expected.push(' ');
}
expected.push_str(&r.plain());
expected.push('\n');
}
assert_eq!(joined, expected, "chunk stream is not the laid-out screen");
}
#[test]
fn no_chunk_line_exceeds_the_canvas_width() {
for (w, h) in [(120u16, 40u16), (80, 24), (40, 12), (24, 10)] {
let joined: String = sample()
.screen_chunks(w, h)
.iter()
.map(|c| c.text.as_str())
.collect();
for line in joined.lines() {
assert!(
line.chars().count() <= w as usize,
"line of {} cells on a {w}-wide canvas: {line:?}",
line.chars().count(),
);
}
}
}
#[test]
fn clip_never_splits_a_multibyte_glyph() {
assert_eq!(clip("─────", 3), "───");
assert_eq!(clip("abc", 10), "abc");
}
}