diff --git a/build.rs b/build.rs new file mode 100644 index 00000000..7c5b0a2e --- /dev/null +++ b/build.rs @@ -0,0 +1,63 @@ +//! Stamps the build's commit into `NIGHTCROW_COMMIT` so a running binary can +//! say which source it came from — the splash and the empty pane both show it. +//! +//! A missing or unreadable repository is not a build failure: the crate is also +//! built from a crates.io package and from `cargo install --git` checkouts, and +//! only the latter carries git metadata. Those builds report `unknown`. + +use std::path::Path; +use std::process::Command; + +fn main() { + println!("cargo:rustc-env=NIGHTCROW_COMMIT={}", commit()); + watch_head(); +} + +fn commit() -> String { + let Some(sha) = git(&["rev-parse", "--short=9", "HEAD"]) else { + return "unknown".to_string(); + }; + // Only meaningful once the sha resolved: with no repository at all, the + // diff below fails too and would read as "dirty". + let dirty = Command::new("git") + .args(["diff", "--quiet", "HEAD"]) + .status() + .is_ok_and(|status| !status.success()); + if dirty { format!("{sha}+") } else { sha } +} + +fn git(args: &[&str]) -> Option { + let out = Command::new("git").args(args).output().ok()?; + if !out.status.success() { + return None; + } + let text = String::from_utf8(out.stdout).ok()?.trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} + +/// Re-run when the checked-out commit moves. Both files are needed: `HEAD` +/// changes on a branch switch, the branch's ref file on a new commit. +/// +/// Only existing paths are declared — cargo treats a missing one as changed and +/// would rebuild on every invocation. +fn watch_head() { + let head = Path::new(".git/HEAD"); + if !head.exists() { + return; + } + println!("cargo:rerun-if-changed=.git/HEAD"); + + let Ok(contents) = std::fs::read_to_string(head) else { + return; + }; + if let Some(reference) = contents.strip_prefix("ref: ") { + let path = Path::new(".git").join(reference.trim()); + if path.exists() { + println!("cargo:rerun-if-changed={}", path.display()); + } + } + // A packed ref has no loose file; the pack itself is then the thing to watch. + if Path::new(".git/packed-refs").exists() { + println!("cargo:rerun-if-changed=.git/packed-refs"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c0c5b227..2bafae21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,6 +72,10 @@ OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 `#[cfg(test)] mod tests;`로 별도 파일/디렉터리에 분리한다(아래 트리에서는 생략). ``` +build.rs # stamps NIGHTCROW_COMMIT: the commit a binary was built from, + # shown beside the version on the splash and the empty pane. + # No git metadata (crates.io package) → "unknown"; a dirty + # work tree → a trailing "+" src/ ├── main.rs # entry point: dispatch to daemon / attach / serve / init ├── cli.rs, cli/ # Cli/Commands + attach/daemon/init/stop/plugin command handlers @@ -92,7 +96,8 @@ src/ │ │ # daemon-owned tab list │ ├── terminal_guard.rs # raw mode + alternate screen, restored on the way out │ ├── bootstrap.rs, event_loop.rs, splash.rs # App construction + startup commands, -│ │ # main_loop (poll/render/input drain), first-run overlay +│ │ # main_loop (poll/render/input drain), splash loop (timed +│ │ # sky frames, dismissed only by a key press) │ └── input/ # dispatch, ViewMode handlers, prefix follow-up, │ # mouse, paste, repo-dialog keys ├── platform/ # OS-adjacent services shared by domain layers: @@ -129,12 +134,18 @@ src/ │ ├── status_view.rs, log_view/, tree_view/ # per-ViewMode state (filter/search cache, │ │ # commits + drill-down, child cache + expanded set) │ ├── file_list.rs, commit_list/, tree_list.rs # the three upper-left row renderers -│ ├── path_tree.rs, file_view.rs, search.rs, splash.rs, wall_clock.rs # repo-dialog -│ │ # browser, file preview state, SearchQuery newtype, first-run -│ │ # overlay, unix epoch → HH:MM without a date crate +│ ├── path_tree.rs, file_view.rs, search.rs, splash/, wall_clock.rs # repo-dialog +│ │ # browser, file preview state, SearchQuery newtype, the night +│ │ # scene (scene.rs: sprites as glyph art + an aligned ink map +│ │ # — shading in ░▒▓█, surface in the map — plus the only two +│ │ # things that move: twinkling stars and a blink; night.rs: +│ │ # ink → 256-colour palette, bottom-anchored crop, build id; +│ │ # the startup splash and the empty terminal pane both draw +│ │ # it), unix epoch → HH:MM without a date crate │ ├── diff_pane/, diff_viewer/ # DiffPane state (hunks/scroll/search/split); the │ │ # upper-right widget, gutter, split view, file preview -│ └── terminal_tab/, project_tab/ # pane grid + tab bar + recovery markers; +│ └── terminal_tab/, project_tab/ # pane grid + tab bar + recovery markers (with no +│ # pane the grid gives way to splash::draw_idle); │ # project tab row rendering + click targets ├── backend/ │ ├── mod.rs # TerminalBackend trait + BackendEvent diff --git a/src/application/splash.rs b/src/application/splash.rs index 7cdcfbed..10453107 100644 --- a/src/application/splash.rs +++ b/src/application/splash.rs @@ -1,12 +1,17 @@ use crate::application::terminal_guard::TuiTerminal; +use crate::ui::splash::TWINKLE_FRAME; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use std::time::Instant; pub(crate) enum SplashOutcome { Enter, Quit, } -/// Run the splash until it times out or a key dismisses it. +/// Show the night scene until the user presses a key. +/// +/// There is no dismissal timer — only the twinkling sky is timed, so the splash +/// waits as long as the user does. /// /// `accent_idx` is the session's, read from its file rather than taken from the /// daemon: the splash draws before this client has attached, so the broadcast @@ -16,16 +21,22 @@ pub(crate) fn splash_loop( terminal: &mut TuiTerminal, accent_idx: usize, ) -> anyhow::Result { - let splash = crate::ui::splash::SplashState::new(); let accent = crate::config::Accent::from_index(accent_idx).color(); + let mut tick = 0usize; + let mut next_frame = Instant::now(); + loop { - terminal.draw(|frame| { - crate::ui::splash::draw(frame, &splash, accent); - })?; - if splash.is_done() { - break; + if Instant::now() >= next_frame { + terminal.draw(|frame| { + crate::ui::splash::draw(frame, accent, tick); + })?; + tick = tick.wrapping_add(1); + next_frame = Instant::now() + TWINKLE_FRAME; } - if event::poll(std::time::Duration::from_millis(16))? { + + // Wait out the rest of the frame rather than a fixed slice, so input + // stays responsive and mouse traffic cannot race the animation ahead. + if event::poll(next_frame.saturating_duration_since(Instant::now()))? { match event::read()? { // Honour Esc so the user can abort during the splash instead // of being forced to wait for it to clear and quit from the @@ -38,11 +49,15 @@ pub(crate) fn splash_loop( } break; } - Event::Resize(_, _) => terminal.clear()?, + Event::Resize(_, _) => { + terminal.clear()?; + next_frame = Instant::now(); + } _ => {} } } } + terminal.clear()?; Ok(SplashOutcome::Enter) } diff --git a/src/ui/splash.rs b/src/ui/splash.rs deleted file mode 100644 index 6ef9249c..00000000 --- a/src/ui/splash.rs +++ /dev/null @@ -1,119 +0,0 @@ -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Paragraph}, -}; -use std::time::{Duration, Instant}; - -const LOGO: &[&str] = &[ - "███╗ ██╗██╗ ██████╗ ██╗ ██╗████████╗ ██████╗██████╗ ██████╗ ██╗ ██╗", - "████╗ ██║██║██╔════╝ ██║ ██║╚══██╔══╝██╔════╝██╔══██╗██╔═══██╗██║ ██║", - "██╔██╗ ██║██║██║ ███╗███████║ ██║ ██║ ██████╔╝██║ ██║██║ █╗ ██║", - "██║╚██╗██║██║██║ ██║██╔══██║ ██║ ██║ ██╔══██╗██║ ██║██║███╗██║", - "██║ ╚████║██║╚██████╔╝██║ ██║ ██║ ╚██████╗██║ ██║╚██████╔╝╚███╔███╔╝", - "╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝", -]; - -const SPLASH_DURATION: Duration = Duration::from_millis(1600); -const BAR_WIDTH: usize = 44; - -pub struct SplashState { - start: Instant, -} - -impl Default for SplashState { - fn default() -> Self { - Self::new() - } -} - -impl SplashState { - pub fn new() -> Self { - Self { - start: Instant::now(), - } - } - - pub fn is_done(&self) -> bool { - self.start.elapsed() >= SPLASH_DURATION - } - - fn progress(&self) -> f64 { - (self.start.elapsed().as_secs_f64() / SPLASH_DURATION.as_secs_f64()).min(1.0) - } -} - -pub fn draw(frame: &mut Frame, state: &SplashState, accent: Color) { - let area = frame.area(); - - frame.render_widget( - Block::default().style(Style::default().bg(Color::Black)), - area, - ); - - let logo_h = LOGO.len() as u16; - let content_h = logo_h + 1 + 1 + 1 + 1; - - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), - Constraint::Length(content_h), - Constraint::Min(0), - ]) - .split(area); - - let inner = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(logo_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(outer[1]); - - // Logo — brighten as loading completes - let progress = state.progress(); - let logo_style = if progress < 0.5 { - Style::default().fg(accent).add_modifier(Modifier::DIM) - } else { - Style::default().fg(accent) - }; - let logo_lines: Vec = LOGO - .iter() - .map(|&row| Line::from(Span::styled(row, logo_style))) - .collect(); - frame.render_widget( - Paragraph::new(logo_lines).alignment(Alignment::Center), - inner[0], - ); - - let version = env!("CARGO_PKG_VERSION"); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Agent-adjacent TUI", Style::default().fg(Color::DarkGray)), - Span::styled( - format!(" v{version}"), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - ), - ])) - .alignment(Alignment::Center), - inner[2], - ); - - let filled = ((progress * BAR_WIDTH as f64) as usize).min(BAR_WIDTH); - let empty = BAR_WIDTH - filled; - let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - - frame.render_widget( - Paragraph::new(Line::from(Span::styled(bar, Style::default().fg(accent)))) - .alignment(Alignment::Center), - inner[4], - ); -} diff --git a/src/ui/splash/mod.rs b/src/ui/splash/mod.rs new file mode 100644 index 00000000..afaaccf5 --- /dev/null +++ b/src/ui/splash/mod.rs @@ -0,0 +1,120 @@ +mod night; +mod scene; +#[cfg(test)] +mod tests; + +pub use night::{TWINKLE_FRAME, draw_idle}; + +use night::{SCENE_HEIGHT, draw_scene}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Paragraph}, +}; + +/// Rows the splash needs under the scene: gap, name, tagline, gap, prompt. +const FOOTER_HEIGHT: u16 = 5; + +/// Draw the splash screen: the night scene, which build this is, and how to +/// leave. +/// +/// `tick` advances once per twinkle frame and drives the stars; it wraps, so any +/// value is valid. Nothing here dismisses the splash — it stays until the user +/// presses a key (handled by [`crate::application::splash::splash_loop`]). +pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { + let area = frame.area(); + + frame.render_widget( + Block::default().style(Style::default().bg(Color::Black)), + area, + ); + + // The scene gives up rows before the text does: a prompt nobody can see is + // worse than a cropped sky. + let scene_h = SCENE_HEIGHT.min(area.height.saturating_sub(FOOTER_HEIGHT)); + + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(0), + Constraint::Length(scene_h + FOOTER_HEIGHT), + Constraint::Min(0), + ]) + .split(area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(scene_h), // night scene + Constraint::Length(1), // gap + Constraint::Length(1), // name + version + commit + Constraint::Length(1), // tagline + Constraint::Length(1), // gap + Constraint::Length(1), // prompt + ]) + .split(outer[1]); + + draw_scene(frame, inner[0], accent, tick); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + "nightcrow", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", build_id()), + Style::default().fg(Color::DarkGray), + ), + ])) + .alignment(Alignment::Center), + inner[2], + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Agent-adjacent TUI", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ))) + .alignment(Alignment::Center), + inner[3], + ); + + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + "Press any key to continue", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + ))) + .alignment(Alignment::Center), + inner[5], + ); +} + +/// Version and the commit it was built from, e.g. `v0.1.1 · 816b8c3f3`. A `+` +/// after the commit means the work tree had uncommitted changes; `unknown` +/// stands in when the build had no git metadata to read (see `build.rs`). +pub(crate) fn build_id() -> String { + format!( + "v{} · {}", + env!("CARGO_PKG_VERSION"), + env!("NIGHTCROW_COMMIT") + ) +} + +/// The build id as its own dim line, for screens that show nothing else. +fn build_line() -> Line<'static> { + Line::from(Span::styled( + build_id(), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + )) +} diff --git a/src/ui/splash/night.rs b/src/ui/splash/night.rs new file mode 100644 index 00000000..47ab1ed2 --- /dev/null +++ b/src/ui/splash/night.rs @@ -0,0 +1,209 @@ +use super::scene::{self, Cell, Ink}; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// How long one twinkle frame is held. Slow on purpose: the sky is background, +/// not an animation to watch. +pub const TWINKLE_FRAME: Duration = Duration::from_millis(400); + +pub(super) const SCENE_HEIGHT: u16 = scene::HEIGHT as u16; + +/// Below this the bird and its bough no longer fit, so the scene is left out. +const MIN_SCENE_HEIGHT: u16 = scene::SUBJECT_HEIGHT as u16; + +/// Rows between the scene and whatever a caller draws under it. +const GAP: u16 = 1; + +/// Which frame `elapsed` since the sky's origin falls in. +fn twinkle_frame(elapsed: Duration) -> usize { + (elapsed.as_millis() / TWINKLE_FRAME.as_millis()) as usize +} + +/// One origin for every sky, so two of them on screen twinkle together. Callers +/// with their own frame counter (the startup splash) don't need it. +fn sky_phase() -> Duration { + static ORIGIN: OnceLock = OnceLock::new(); + ORIGIN.get_or_init(Instant::now).elapsed() +} + +/// Draw the night scene into `area`, aligned to its bottom. +/// +/// A short `area` drops sky rows from the top — the bird and its bough are the +/// last thing to go, and below [`MIN_SCENE_HEIGHT`] nothing is drawn at all. +pub(super) fn draw_scene(frame: &mut Frame, area: Rect, accent: Color, tick: usize) { + if area.height < MIN_SCENE_HEIGHT || area.width < scene::WIDTH as u16 { + return; + } + + let rows = scene::frame(tick); + let dropped = rows.len().saturating_sub(area.height as usize); + let lines: Vec = rows + .iter() + .skip(dropped) + .map(|row| paint_row(row, accent)) + .collect(); + + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +/// One row as a `Line`, with a span per run of same-coloured cells. +fn paint_row(row: &[Cell], accent: Color) -> Line<'static> { + let mut spans: Vec = Vec::new(); + let mut text = String::new(); + let mut current = row.first().map_or(Ink::Sky, |&(_, ink)| ink); + + for &(ch, ink) in row { + if ink != current { + spans.push(Span::styled( + std::mem::take(&mut text), + style(current, accent), + )); + current = ink; + } + text.push(ch); + } + spans.push(Span::styled(text, style(current, accent))); + Line::from(spans) +} + +/// The palette. 256-colour indices rather than the sixteen named colours: a crow +/// needs several near-blacks that read apart on a black background, and the sky +/// and the bough need hues the named set does not have. Terminals limited to +/// sixteen colours approximate them. +fn style(ink: Ink, accent: Color) -> Style { + let fg = match ink { + Ink::Sky => return Style::default(), + // The eye is the one cell in the accent, so the session's colour is on + // screen without painting the bird itself an unbirdlike hue. + Ink::Eye => accent, + Ink::Star { bright: true } => Color::Indexed(255), + Ink::Star { bright: false } => Color::Indexed(244), + Ink::Moon => Color::Indexed(230), + Ink::BirdShade => Color::Indexed(236), + Ink::Bird => Color::Indexed(240), + Ink::BirdWing => Color::Indexed(245), + Ink::BirdLit => Color::Indexed(252), + Ink::BoughShade => Color::Indexed(58), + Ink::Bough => Color::Indexed(94), + Ink::BoughLit => Color::Indexed(137), + }; + Style::default().fg(fg) +} + +/// Fill an empty terminal pane: the night scene over `hint` and the build id. +/// +/// The sky runs off the shared clock, so this needs no frame counter from the +/// caller — it twinkles as long as the event loop keeps redrawing. +pub fn draw_idle(frame: &mut Frame, area: Rect, accent: Color, hint: Line<'_>) { + if area.height == 0 || area.width == 0 { + return; + } + + let scene_h = scene_height(area); + let gap = if scene_h > 0 { GAP } else { 0 }; + // The build id is what tells a stale client apart from a fresh one, so it is + // dropped only when there is genuinely no room for a second line. + let build = u16::from(area.height >= scene_h + gap + 2); + let block_h = scene_h + gap + 1 + build; + let top = area.y + area.height.saturating_sub(block_h) / 2; + + if scene_h > 0 { + draw_scene( + frame, + Rect::new(area.x, top, area.width, scene_h), + accent, + twinkle_frame(sky_phase()), + ); + } + + let hint_y = top + scene_h + gap; + frame.render_widget( + Paragraph::new(hint).alignment(Alignment::Center), + Rect::new(area.x, hint_y, area.width, 1), + ); + if build == 1 { + frame.render_widget( + Paragraph::new(super::build_line()).alignment(Alignment::Center), + Rect::new(area.x, hint_y + 1, area.width, 1), + ); + } +} + +/// Rows to give the scene in `area`, leaving room for the footer; 0 when the +/// pane cannot hold the bird at all. +fn scene_height(area: Rect) -> u16 { + let spare = area.height.saturating_sub(GAP + 2); + if area.width < scene::WIDTH as u16 || spare < MIN_SCENE_HEIGHT { + return 0; + } + spare.min(SCENE_HEIGHT) +} + +#[cfg(test)] +mod tests { + use super::{ + Ink, MIN_SCENE_HEIGHT, SCENE_HEIGHT, TWINKLE_FRAME, paint_row, scene, scene_height, + twinkle_frame, + }; + use ratatui::layout::Rect; + use ratatui::style::Color; + use std::time::Duration; + + #[test] + fn the_sky_advances_one_frame_per_interval() { + assert_eq!(twinkle_frame(Duration::ZERO), 0); + assert_eq!(twinkle_frame(TWINKLE_FRAME - Duration::from_millis(1)), 0); + assert_eq!(twinkle_frame(TWINKLE_FRAME), 1); + assert_eq!(twinkle_frame(TWINKLE_FRAME * 7), 7); + } + + #[test] + fn a_roomy_pane_gets_the_whole_scene() { + assert_eq!(scene_height(Rect::new(0, 0, 80, 40)), SCENE_HEIGHT); + } + + #[test] + fn a_short_pane_crops_the_sky_instead_of_overflowing() { + let height = MIN_SCENE_HEIGHT + 4; + let shown = scene_height(Rect::new(0, 0, 80, height)); + assert!(shown < SCENE_HEIGHT, "expected a cropped sky, got {shown}"); + assert!( + shown + 3 <= height, + "the scene and its footer must fit in {height}" + ); + } + + #[test] + fn a_pane_too_small_for_the_bird_gets_no_scene() { + assert_eq!(scene_height(Rect::new(0, 0, 80, MIN_SCENE_HEIGHT)), 0); + assert_eq!(scene_height(Rect::new(0, 0, 80, 1)), 0); + assert_eq!(scene_height(Rect::new(0, 0, 0, 0)), 0); + assert_eq!( + scene_height(Rect::new(0, 0, scene::WIDTH as u16 - 1, 40)), + 0 + ); + } + + #[test] + fn a_row_keeps_its_width_and_splits_at_colour_changes() { + let row = scene::frame(0); + let moon_row = &row[3]; + let line = paint_row(moon_row, Color::Yellow); + assert_eq!(line.width(), scene::WIDTH, "the row must stay padded"); + assert!( + line.spans.len() > 1, + "a row holding both sky and moon needs more than one span" + ); + assert!( + moon_row.iter().any(|&(_, ink)| ink == Ink::Moon), + "row 3 is expected to hold the moon" + ); + } +} diff --git a/src/ui/splash/scene.rs b/src/ui/splash/scene.rs new file mode 100644 index 00000000..33e1504b --- /dev/null +++ b/src/ui/splash/scene.rs @@ -0,0 +1,478 @@ +//! The night scene: a crow perched on a bough under a crescent moon. +//! +//! The bird is fixed art and stays put: between frames only the stars twinkle +//! and, now and then, the eye blinks. That is what keeps the screen alive without +//! turning it into an animation. +//! +//! A sprite is a grid of glyphs plus an aligned ink map, one legend letter per +//! glyph (see [`ink_of`]). The glyphs carry shape and shading — `█ ▓ ▒` are +//! decreasing density, `▄ ▀` are the contour — and the map says which surface a +//! cell belongs to, leaving the colour to the renderer. The two tables must line +//! up exactly; `sprite_ink_maps_line_up_with_their_art` enforces it. + +use std::sync::OnceLock; + +/// What a cell belongs to, rather than how it looks. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Ink { + Sky, + Star { + bright: bool, + }, + Moon, + /// The crow, unlit. + Bird, + /// The crow's edges facing the moon. + BirdLit, + /// The folded wing, a plane of its own. + BirdWing, + /// The crow's underside, away from the moon. + BirdShade, + Eye, + Bough, + BoughLit, + BoughShade, +} + +impl Ink { + /// Part of the fixed drawing rather than the sky. + pub(super) fn is_fixed(self) -> bool { + !matches!(self, Ink::Sky | Ink::Star { .. }) + } + + /// Only the invariants that guard the art care which surface a cell is on; + /// the renderer matches every ink separately to give it a colour. + #[cfg(test)] + fn is_bird(self) -> bool { + matches!( + self, + Ink::Bird | Ink::BirdLit | Ink::BirdWing | Ink::BirdShade | Ink::Eye + ) + } + + #[cfg(test)] + fn is_bough(self) -> bool { + matches!(self, Ink::Bough | Ink::BoughLit | Ink::BoughShade) + } +} + +/// The ink map alphabet. +fn ink_of(code: char) -> Option { + Some(match code { + 'b' => Ink::Bird, + 'l' => Ink::BirdLit, + 'w' => Ink::BirdWing, + 'd' => Ink::BirdShade, + 'e' => Ink::Eye, + 'r' => Ink::Bough, + 'R' => Ink::BoughLit, + 'x' => Ink::BoughShade, + _ => return None, + }) +} + +struct Sprite { + at: (usize, usize), + art: &'static [&'static str], + paint: Paint, +} + +enum Paint { + /// Every glyph takes one ink. + Flat(Ink), + /// One legend letter per glyph, aligned with the art. + Map(&'static [&'static str]), +} + +/// Crescent moon, horns opening right. +const MOON: Sprite = Sprite { + at: (0, 37), + art: &[ + " ▗▄▄▄▖", + " ▟███▀▘", + "▟███▘", + "▐███", + "▐███", + "▝███▖", + " ▜███▄▖", + " ▝▀▀▀▘", + ], + paint: Paint::Flat(Ink::Moon), +}; + +/// The crow in profile, facing the moon: beak out past the eye, folded wing over +/// the flank, tail swept back and down. Its last row are the legs, and they land +/// on the bough's top edge — see `the_crow_stands_on_the_bough`. +const CROW: Sprite = Sprite { + at: (2, 4), + art: &[ + " ▄▄▄▄", + " ▄███████▄", + " ▐████●████▄▄▄", + " ▀███████▀", + " ▄▄▄▓▓▓▓▓▓▓████▄", + " ▄██▒▒▒▒▒▒▒▒▒████▓", + " ▄▄▄██▒▒▒▒▒▒▒▒█████▀", + " ▀▀▀▀ ▀▀████████▀", + " ██ ██", + ], + paint: Paint::Map(&[ + " llll", + " bbbblllll", + " bbbbbebblllll", + " bbbbbllll", + " bbbwwwwwwwbbbll", + " bbbwwwwwwwwwbbbll", + " dddbbwwwwwwwwbbbbbl", + " dddd dddddddddbd", + " bb bb", + ]), +}; + +/// The bough, with a twig rising away from the bird. Its `▄` edges catch the +/// moon and its `▀` underside stays in shadow. +const BOUGH: Sprite = Sprite { + at: (9, 2), + art: &[ + " ▄▄▄▀▀", + " ▄▄▀▀", + "▄▄▄▄▄██████████████████████████████▄▄▄▄▄▄▄▄▄", + " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", + ], + paint: Paint::Map(&[ + " RRRxx", + " RRxx", + "RRRRRrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrRRRRRRRRR", + " xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ]), +}; + +const SPRITES: &[&Sprite] = &[&MOON, &BOUGH, &CROW]; + +/// `(row, column, phase)`. The phase spreads the twinkle out so the sky does not +/// blink in unison. +const STARS: &[(usize, usize, usize)] = &[ + (0, 6, 0), + (1, 20, 3), + (2, 11, 6), + (0, 30, 4), + (3, 2, 8), + (5, 28, 1), + (1, 46, 5), + (7, 46, 2), + (8, 2, 7), + (0, 34, 9), + (6, 33, 6), + (9, 45, 3), + (3, 31, 2), + (8, 31, 8), + (2, 44, 4), +]; + +/// Frames in one twinkle cycle. +const CYCLE: usize = 10; + +/// The eye shuts for a single frame this often — rare enough to read as a blink +/// rather than a tic. `BLINK_AT` is where in the cycle it falls, and it is not 0 +/// so that frame 0, the first thing anyone sees, has the bird looking back. +const BLINK_CYCLE: usize = 15; +const BLINK_AT: usize = 7; +const EYE_SHUT: char = '─'; + +/// Frames before the whole scene repeats: the twinkle and the blink together. +#[cfg(test)] +const PERIOD: usize = CYCLE * BLINK_CYCLE; + +pub(super) const WIDTH: usize = 48; +pub(super) const HEIGHT: usize = 13; + +/// The bird and the bough it stands on, i.e. what a cropped scene must keep. +/// Rows above this are sky and are dropped first when the area is short. +pub(super) const SUBJECT_HEIGHT: usize = HEIGHT - CROW.at.0; + +pub(super) type Cell = (char, Ink); + +/// The scene at animation frame `tick`, `HEIGHT` rows of `WIDTH` cells. +pub(super) fn frame(tick: usize) -> Vec> { + let mut grid = fixed().clone(); + + for &(row, col, phase) in STARS { + // The sky is behind everything: a star shows only where no sprite claimed + // the cell. + if !grid[row][col].1.is_fixed() + && let Some(cell) = star(tick, phase) + { + grid[row][col] = cell; + } + } + + if tick % BLINK_CYCLE == BLINK_AT + && let Some((row, col)) = eye() + { + grid[row][col].0 = EYE_SHUT; + } + + grid +} + +/// Where the eye sits, found once from the painted scene. `None` only if the art +/// lost its eye, which `the_crow_has_an_eye_to_blink` rules out. +fn eye() -> Option<(usize, usize)> { + static EYE: OnceLock> = OnceLock::new(); + *EYE.get_or_init(|| { + fixed().iter().enumerate().find_map(|(row, cells)| { + cells + .iter() + .position(|&(_, ink)| ink == Ink::Eye) + .map(|col| (row, col)) + }) + }) +} + +/// Everything that never changes, drawn once. +fn fixed() -> &'static Vec> { + static FIXED: OnceLock>> = OnceLock::new(); + FIXED.get_or_init(|| { + let mut grid = vec![vec![(' ', Ink::Sky); WIDTH]; HEIGHT]; + for sprite in SPRITES { + paint(&mut grid, sprite); + } + grid + }) +} + +/// A star's look this frame, or `None` while it is out. +fn star(tick: usize, phase: usize) -> Option { + match (tick.wrapping_add(phase)) % CYCLE { + 0 | 1 => Some(('*', Ink::Star { bright: true })), + 2..=5 => Some(('·', Ink::Star { bright: false })), + _ => None, + } +} + +/// Stamp a sprite onto the grid, its spaces left transparent. Art that would +/// fall off the canvas is clipped rather than wrapped; `art_fits_the_canvas` +/// guards that the shipped sprites never need it. +fn paint(grid: &mut [Vec], sprite: &Sprite) { + let (row0, col0) = sprite.at; + for (i, line) in sprite.art.iter().enumerate() { + for (j, ch) in line.chars().enumerate() { + if ch == ' ' { + continue; + } + let Some(ink) = sprite.ink_at(i, j) else { + continue; + }; + if let Some(cell) = grid.get_mut(row0 + i).and_then(|row| row.get_mut(col0 + j)) { + *cell = (ch, ink); + } + } + } +} + +impl Sprite { + fn ink_at(&self, row: usize, col: usize) -> Option { + match self.paint { + Paint::Flat(ink) => Some(ink), + Paint::Map(map) => map.get(row)?.chars().nth(col).and_then(ink_of), + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + BOUGH, CROW, CYCLE, HEIGHT, Ink, PERIOD, Paint, SPRITES, SUBJECT_HEIGHT, WIDTH, frame, + }; + + fn inks(tick: usize) -> Vec> { + frame(tick) + .into_iter() + .map(|row| row.into_iter().map(|(_, ink)| ink).collect()) + .collect() + } + + #[test] + fn every_frame_fills_the_same_canvas() { + for tick in 0..CYCLE { + let rows = frame(tick); + assert_eq!(rows.len(), HEIGHT, "frame {tick} height"); + for (i, row) in rows.iter().enumerate() { + assert_eq!(row.len(), WIDTH, "frame {tick} row {i} width"); + } + } + } + + #[test] + fn art_fits_the_canvas() { + for sprite in SPRITES { + let (row0, col0) = sprite.at; + assert!( + row0 + sprite.art.len() <= HEIGHT, + "a sprite at row {row0} runs past the last row" + ); + for (i, line) in sprite.art.iter().enumerate() { + let end = col0 + line.chars().count(); + assert!(end <= WIDTH, "sprite row {i} runs past the canvas: {end}"); + } + } + } + + #[test] + fn sprite_ink_maps_line_up_with_their_art() { + for sprite in SPRITES { + let Paint::Map(map) = sprite.paint else { + continue; + }; + assert_eq!(map.len(), sprite.art.len(), "row count"); + for (i, (art, inks)) in sprite.art.iter().zip(map.iter()).enumerate() { + let art: Vec = art.chars().collect(); + let inks: Vec = inks.chars().collect(); + assert_eq!(art.len(), inks.len(), "row {i} length:\n{art:?}\n{inks:?}"); + for (j, (glyph, code)) in art.iter().zip(inks.iter()).enumerate() { + assert_eq!( + *glyph == ' ', + *code == ' ', + "row {i} column {j}: glyph {glyph:?} against ink {code:?}" + ); + if *code != ' ' { + assert!( + super::ink_of(*code).is_some(), + "row {i} column {j}: {code:?} is not in the ink legend" + ); + } + } + } + } + } + + /// The wing must read as its own surface, or the bird is a blob. + #[test] + fn the_crow_is_shaded_in_every_surface_it_has() { + let inks = inks(0); + for wanted in [ + Ink::Bird, + Ink::BirdLit, + Ink::BirdWing, + Ink::BirdShade, + Ink::Eye, + ] { + let count = inks.iter().flatten().filter(|&&ink| ink == wanted).count(); + assert!(count > 0, "{wanted:?} never reaches the canvas"); + } + for wanted in [Ink::Bough, Ink::BoughLit, Ink::BoughShade] { + assert!( + inks.iter().flatten().any(|&ink| ink == wanted), + "{wanted:?} never reaches the canvas" + ); + } + } + + /// The bird holds still: the sky twinkles and the eye blinks, and nothing else + /// may differ from one frame to the next. + #[test] + fn only_the_sky_and_the_blink_change_between_frames() { + let first = frame(0); + for tick in 1..PERIOD { + let other = frame(tick); + for (row, (a, b)) in first.iter().zip(other.iter()).enumerate() { + for (col, (x, y)) in a.iter().zip(b.iter()).enumerate() { + if !x.1.is_fixed() && !y.1.is_fixed() { + continue; + } + if x.1 == Ink::Eye && y.1 == Ink::Eye { + continue; + } + assert_eq!( + x, y, + "frame {tick} moved a fixed cell at row {row}, column {col}" + ); + } + } + } + } + + #[test] + fn the_crow_has_an_eye_to_blink() { + let (row, col) = super::eye().expect("the crow has an eye"); + assert_eq!(frame(0)[row][col], ('●', Ink::Eye)); + } + + #[test] + fn the_eye_shuts_for_one_frame_and_opens_again() { + let (row, col) = super::eye().unwrap(); + let glyph = |tick: usize| frame(tick)[row][col].0; + + let shut: Vec = (0..PERIOD) + .filter(|&tick| glyph(tick) == super::EYE_SHUT) + .collect(); + assert_eq!( + shut.len(), + PERIOD / super::BLINK_CYCLE, + "expected one blink per blink cycle, got {shut:?}" + ); + for tick in shut { + assert_eq!(glyph(tick + 1), '●', "the eye stayed shut after {tick}"); + assert_eq!(glyph(tick - 1), '●', "the eye was shut before {tick}"); + } + } + + #[test] + fn the_sky_does_change_between_frames() { + assert_ne!(frame(0), frame(3), "nothing twinkled"); + } + + #[test] + fn the_crow_stands_on_the_bough() { + let inks = inks(0); + let legs = CROW.at.0 + CROW.art.len() - 1; + let bough_top = BOUGH.at.0 + 2; + assert_eq!( + legs + 1, + bough_top, + "the legs must sit on the row above the bough's top edge" + ); + + let feet: Vec = (0..WIDTH) + .filter(|&col| inks[legs][col].is_bird()) + .collect(); + assert!(!feet.is_empty(), "no legs found on row {legs}"); + for col in feet { + assert!( + inks[bough_top][col].is_bough(), + "the leg at column {col} does not land on the bough" + ); + } + } + + #[test] + fn a_cropped_scene_keeps_the_bird_and_the_bough() { + let inks = inks(0); + let kept = HEIGHT - SUBJECT_HEIGHT; + for (row, line) in inks.iter().enumerate().take(kept) { + assert!( + line.iter().all(|ink| !ink.is_bird() && !ink.is_bough()), + "row {row} would be cropped but holds the subject" + ); + } + } + + /// A star behind the crow or the moon is a table entry that never shows. + #[test] + fn every_star_is_visible_at_its_brightest() { + for &(row, col, phase) in super::STARS { + let tick = (CYCLE - phase % CYCLE) % CYCLE; + let ink = inks(tick)[row][col]; + assert!( + matches!(ink, Ink::Star { bright: true }), + "the star at row {row}, column {col} is hidden by {ink:?}" + ); + } + } + + #[test] + fn a_wrapped_tick_still_renders() { + assert_eq!(frame(usize::MAX).len(), HEIGHT); + assert_eq!(frame(PERIOD), frame(0)); + } +} diff --git a/src/ui/splash/tests.rs b/src/ui/splash/tests.rs new file mode 100644 index 00000000..a3a15f50 --- /dev/null +++ b/src/ui/splash/tests.rs @@ -0,0 +1,148 @@ +use super::{build_id, draw, draw_idle, scene}; +use ratatui::{Terminal, backend::TestBackend, style::Color, text::Line}; + +fn rows(width: u16, height: u16, draw_into: impl FnOnce(&mut ratatui::Frame)) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(draw_into).unwrap(); + let buf = terminal.backend().buffer().clone(); + (0..height) + .map(|y| { + (0..width) + .map(|x| buf.cell((x, y)).unwrap().symbol().to_string()) + .collect() + }) + .collect() +} + +fn splash(tick: usize) -> Vec { + rows(64, 26, |frame| draw(frame, Color::Yellow, tick)) +} + +fn idle(width: u16, height: u16) -> Vec { + rows(width, height, |frame| { + let area = frame.area(); + draw_idle(frame, area, Color::Yellow, Line::from("hint")); + }) +} + +/// The bough's row. Its run of solid blocks is longer than any in the bird, so +/// this finds the bough rather than the body. +fn bough_row(rows: &[String]) -> usize { + rows.iter() + .position(|row| row.contains("████████████████████████")) + .expect("the bough is drawn") +} + +#[test] +fn the_splash_shows_the_crow_the_moon_and_the_stars() { + let text = splash(0).join("\n"); + assert!(text.contains('●'), "missing the crow's eye:\n{text}"); + assert!(text.contains('▟'), "missing the crescent moon:\n{text}"); + assert!( + text.contains('*') || text.contains('·'), + "missing stars:\n{text}" + ); +} + +#[test] +fn the_splash_names_the_build_and_the_way_out() { + let text = splash(0).join("\n"); + assert!(text.contains("nightcrow"), "missing product name:\n{text}"); + assert!( + text.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))), + "missing version:\n{text}" + ); + assert!( + text.contains(env!("NIGHTCROW_COMMIT")), + "missing commit {}:\n{text}", + env!("NIGHTCROW_COMMIT") + ); + assert!( + text.contains("Press any key to continue"), + "missing dismissal prompt:\n{text}" + ); +} + +#[test] +fn the_build_id_carries_both_the_version_and_the_commit() { + let id = build_id(); + assert!( + id.starts_with(&format!("v{}", env!("CARGO_PKG_VERSION"))), + "{id}" + ); + assert!(id.ends_with(env!("NIGHTCROW_COMMIT")), "{id}"); +} + +#[test] +fn only_the_sky_moves_while_the_splash_waits() { + let first = splash(0); + let later = splash(3); + assert_ne!(first, later, "the sky never changed"); + + let bough = bough_row(&first); + assert_eq!(bough_row(&later), bough, "the bough moved"); + assert_eq!( + first[bough..bough + 2], + later[bough..bough + 2], + "the bough changed between frames" + ); + + let eye = |rows: &[String]| { + rows.iter() + .enumerate() + .find_map(|(y, row)| row.find('●').map(|x| (y, x))) + }; + assert_eq!(eye(&first), eye(&later), "the crow moved"); +} + +#[test] +fn a_terminal_too_small_for_the_scene_still_draws() { + let text = rows(scene::WIDTH as u16 / 2, 8, |frame| { + draw(frame, Color::Yellow, 3) + }) + .join("\n"); + assert!(text.contains("nightcrow"), "the text must survive:\n{text}"); +} + +#[test] +fn an_empty_pane_puts_the_hint_and_the_build_id_under_the_scene() { + let rows = idle(72, 22); + let text = rows.join("\n"); + assert!(text.contains('●'), "the crow is missing:\n{text}"); + + let bough = bough_row(&rows); + let hint = rows.iter().position(|row| row.contains("hint")).unwrap(); + let build = rows + .iter() + .position(|row| row.contains(env!("NIGHTCROW_COMMIT"))) + .expect("the build id is drawn"); + assert!( + bough < hint && hint < build, + "expected scene, hint, build id; got rows {bough}, {hint}, {build}" + ); +} + +#[test] +fn a_short_empty_pane_keeps_the_hint_and_drops_the_scene() { + let rows = idle(72, 6); + let text = rows.join("\n"); + assert!(text.contains("hint"), "the hint must survive:\n{text}"); + assert!( + text.contains(env!("NIGHTCROW_COMMIT")), + "the build id must survive:\n{text}" + ); + assert!(!text.contains('●'), "no room for the crow here:\n{text}"); +} + +#[test] +fn an_empty_pane_narrower_than_the_scene_still_shows_the_hint() { + let text = idle(scene::WIDTH as u16 - 4, 22).join("\n"); + assert!(text.contains("hint"), "the hint must survive:\n{text}"); + assert!(!text.contains('●'), "no room for the crow here:\n{text}"); +} + +#[test] +fn a_one_row_pane_shows_the_hint_alone() { + let rows = idle(72, 1); + assert!(rows[0].contains("hint"), "{rows:?}"); +} diff --git a/src/ui/terminal_tab/mod.rs b/src/ui/terminal_tab/mod.rs index 0b8d29c6..6e746635 100644 --- a/src/ui/terminal_tab/mod.rs +++ b/src/ui/terminal_tab/mod.rs @@ -62,14 +62,14 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect, accent: Color) { let cells = visible_pane_cells(app, content_area); if cells.is_empty() { - let screen_lines = vec![Line::from(Span::styled( + let hint = Line::from(Span::styled( format!( - " No terminal — press {} t to open one ", + "No terminal — press {} t to open one", leader_label_of(app.interaction.leader) ), Style::default().fg(Color::DarkGray), - ))]; - frame.render_widget(Paragraph::new(screen_lines), content_area); + )); + crate::ui::splash::draw_idle(frame, content_area, accent, hint); return; }