From df8a888dbbed8147cf4b823889067f32781fe700 Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 21:54:28 +0900 Subject: [PATCH 1/7] refactor(splash): replace timer-based splash with key-press-driven crow logo - Remove SplashState, SPLASH_DURATION, and progress bar - Add crow silhouette ASCII art, version, and branch perch - Change splash_loop to block on key press instead of a 1600ms timer - Drop state parameter from draw() signature - Redraw splash on terminal resize --- src/application/splash.rs | 23 ++++--- src/ui/splash.rs | 135 ++++++++++++++++++++------------------ 2 files changed, 85 insertions(+), 73 deletions(-) diff --git a/src/application/splash.rs b/src/application/splash.rs index 7cdcfbed..638cb5b6 100644 --- a/src/application/splash.rs +++ b/src/application/splash.rs @@ -6,7 +6,7 @@ pub(crate) enum SplashOutcome { Quit, } -/// Run the splash until it times out or a key dismisses it. +/// Draw the splash once, then block until the user presses a key. /// /// `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 +16,13 @@ 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(); + terminal.draw(|frame| { + crate::ui::splash::draw(frame, accent); + })?; + loop { - terminal.draw(|frame| { - crate::ui::splash::draw(frame, &splash, accent); - })?; - if splash.is_done() { - break; - } - if event::poll(std::time::Duration::from_millis(16))? { + if event::poll(std::time::Duration::from_millis(100))? { 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 +35,17 @@ pub(crate) fn splash_loop( } break; } - Event::Resize(_, _) => terminal.clear()?, + Event::Resize(_, _) => { + terminal.clear()?; + terminal.draw(|frame| { + crate::ui::splash::draw(frame, accent); + })?; + } _ => {} } } } + terminal.clear()?; Ok(SplashOutcome::Enter) } diff --git a/src/ui/splash.rs b/src/ui/splash.rs index 6ef9249c..1de72c05 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash.rs @@ -5,47 +5,37 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Paragraph}, }; -use std::time::{Duration, Instant}; -const LOGO: &[&str] = &[ - "███╗ ██╗██╗ ██████╗ ██╗ ██╗████████╗ ██████╗██████╗ ██████╗ ██╗ ██╗", - "████╗ ██║██║██╔════╝ ██║ ██║╚══██╔══╝██╔════╝██╔══██╗██╔═══██╗██║ ██║", - "██╔██╗ ██║██║██║ ███╗███████║ ██║ ██║ ██████╔╝██║ ██║██║ █╗ ██║", - "██║╚██╗██║██║██║ ██║██╔══██║ ██║ ██║ ██╔══██╗██║ ██║██║███╗██║", - "██║ ╚████║██║╚██████╔╝██║ ██║ ██║ ╚██████╗██║ ██║╚██████╔╝╚███╔███╔╝", - "╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══╝╚══╝", +/// A perched crow silhouette — head with a beak and eye on the upper-left, +/// body sweeping down to a pointed tail on the lower-right, sitting on a branch. +const CROW: &[&str] = &[ + " ▄▄▄", + " ▄█▀ ▀█▄", + " ▄█ ● █▄", + " █▀▀ ▀▀█▄▄▄", + " ██████████████▄", + " ███████████████▄", + " ████████████████▄", + " █████████████████▄", + " ██████████████████▄", + " ███████████████████", + " ████████████████▀", + " ██████████████▀", + " ████████████▀", + " ██████████▀", + " ████████▀", + " ██████▀", + " ████▀", + " ██▀", ]; -const SPLASH_DURATION: Duration = Duration::from_millis(1600); -const BAR_WIDTH: usize = 44; +const BRANCH: &str = " ───────────────────────────"; -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) { +/// Draw the splash screen: crow logo, version, and a key-press prompt. +/// +/// There is no timer — the splash stays until the user presses a key +/// (handled by [`crate::application::splash::splash_loop`]). +pub fn draw(frame: &mut Frame, accent: Color) { let area = frame.area(); frame.render_widget( @@ -53,8 +43,10 @@ pub fn draw(frame: &mut Frame, state: &SplashState, accent: Color) { area, ); - let logo_h = LOGO.len() as u16; - let content_h = logo_h + 1 + 1 + 1 + 1; + let version = env!("CARGO_PKG_VERSION"); + let logo_h = CROW.len() as u16; + // crow + branch + gap + version + gap + prompt + let content_h = logo_h + 1 + 1 + 1 + 1 + 1; let outer = Layout::default() .direction(Direction::Vertical) @@ -68,52 +60,69 @@ pub fn draw(frame: &mut Frame, state: &SplashState, accent: Color) { let inner = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(logo_h), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), + Constraint::Length(logo_h), // crow + Constraint::Length(1), // branch + Constraint::Length(1), // gap + Constraint::Length(1), // version + subtitle + Constraint::Length(1), // gap + Constraint::Length(1), // prompt ]) .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 + // Crow logo + let logo_lines: Vec = CROW .iter() - .map(|&row| Line::from(Span::styled(row, logo_style))) + .map(|&row| Line::from(Span::styled(row, Style::default().fg(accent)))) .collect(); frame.render_widget( Paragraph::new(logo_lines).alignment(Alignment::Center), inner[0], ); - let version = env!("CARGO_PKG_VERSION"); + // Branch under the crow + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + BRANCH, + Style::default().fg(accent).add_modifier(Modifier::DIM), + ))) + .alignment(Alignment::Center), + inner[1], + ); + + // Version + tagline frame.render_widget( Paragraph::new(Line::from(vec![ - Span::styled("Agent-adjacent TUI", Style::default().fg(Color::DarkGray)), + Span::styled( + "nightcrow", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), Span::styled( format!(" v{version}"), + Style::default().fg(Color::DarkGray), + ), + Span::styled(" · ", Style::default().fg(Color::DarkGray)), + Span::styled( + "Agent-adjacent TUI", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::DIM), ), ])) .alignment(Alignment::Center), - inner[2], + inner[3], ); - let filled = ((progress * BAR_WIDTH as f64) as usize).min(BAR_WIDTH); - let empty = BAR_WIDTH - filled; - let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - + // Key-press prompt frame.render_widget( - Paragraph::new(Line::from(Span::styled(bar, Style::default().fg(accent)))) - .alignment(Alignment::Center), - inner[4], + 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], ); } From 7edd39bae6e3b761aec7c68aec16d284aa93b0b0 Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 22:42:17 +0900 Subject: [PATCH 2/7] feat(splash): flap the crow's wing over a ten-frame cycle The logo now animates while the splash waits for a key: one wing sweeps from outstretched to raised and back, composited over a body silhouette that never moves. Frame rows are padded to a fixed canvas width so the centred block keeps its column instead of drifting with the wing. --- docs/architecture.md | 8 +- src/application/splash.rs | 28 +++-- src/ui/splash/crow.rs | 182 ++++++++++++++++++++++++++++ src/ui/{splash.rs => splash/mod.rs} | 55 +++------ src/ui/splash/tests.rs | 69 +++++++++++ 5 files changed, 296 insertions(+), 46 deletions(-) create mode 100644 src/ui/splash/crow.rs rename src/ui/{splash.rs => splash/mod.rs} (60%) create mode 100644 src/ui/splash/tests.rs diff --git a/docs/architecture.md b/docs/architecture.md index c0c5b227..d4168a58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,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 flap loop +│ │ # (timed 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,9 +130,10 @@ 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 +│ ├── path_tree.rs, file_view.rs, search.rs, splash/, wall_clock.rs # repo-dialog │ │ # browser, file preview state, SearchQuery newtype, first-run -│ │ # overlay, unix epoch → HH:MM without a date crate +│ │ # overlay (crow.rs: body silhouette + wing flap frames), +│ │ # 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; diff --git a/src/application/splash.rs b/src/application/splash.rs index 638cb5b6..aba3e3fb 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::FLAP_FRAME; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use std::time::Instant; pub(crate) enum SplashOutcome { Enter, Quit, } -/// Draw the splash once, then block until the user presses a key. +/// Flap the crow until the user presses a key. +/// +/// There is no dismissal timer — only the animation 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 @@ -17,12 +22,21 @@ pub(crate) fn splash_loop( accent_idx: usize, ) -> anyhow::Result { let accent = crate::config::Accent::from_index(accent_idx).color(); - terminal.draw(|frame| { - crate::ui::splash::draw(frame, accent); - })?; + let mut tick = 0usize; + let mut next_frame = Instant::now(); loop { - if event::poll(std::time::Duration::from_millis(100))? { + if Instant::now() >= next_frame { + terminal.draw(|frame| { + crate::ui::splash::draw(frame, accent, tick); + })?; + tick = tick.wrapping_add(1); + next_frame = Instant::now() + FLAP_FRAME; + } + + // 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 @@ -37,9 +51,7 @@ pub(crate) fn splash_loop( } Event::Resize(_, _) => { terminal.clear()?; - terminal.draw(|frame| { - crate::ui::splash::draw(frame, accent); - })?; + next_frame = Instant::now(); } _ => {} } diff --git a/src/ui/splash/crow.rs b/src/ui/splash/crow.rs new file mode 100644 index 00000000..02d087c2 --- /dev/null +++ b/src/ui/splash/crow.rs @@ -0,0 +1,182 @@ +//! The splash crow: one fixed body silhouette with a wing flapping over it. + +/// A perched crow silhouette — head with a beak and eye on the upper-left, +/// body sweeping down to a pointed tail on the lower-right. +const BODY: &[&str] = &[ + " ▄▄▄", + " ▄█▀ ▀█▄", + " ▄█ ● █▄", + " █▀▀ ▀▀█▄▄▄", + " ██████████████▄", + " ███████████████▄", + " ████████████████▄", + " █████████████████▄", + " ██████████████████▄", + " ███████████████████", + " ████████████████▀", + " ██████████████▀", + " ████████████▀", + " ██████████▀", + " ████████▀", + " ██████▀", + " ████▀", + " ██▀", +]; + +/// Blank canvas rows above the body, leaving the raised wing somewhere to go. +const BODY_TOP: usize = 1; + +/// Canvas row where the wing joins the back — every wing's last row lands here. +const WING_ROOT: usize = 4; + +/// Wing positions from outstretched to raised. Rows run top-down; a wing may be +/// at most `WING_ROOT + 1` rows tall so its root stays on the back. +const WINGS: &[&[&str]] = &[ + &[" ▄██████████▀▀"], + &[" ▄██████▀", " ▄███████▀"], + &[ + " ▄████▀", + " ▄█████▀", + " ▄██████▀", + ], + &[ + " ▄███▀", + " ▄████▀", + " ▄█████▀", + " ▄██████▀", + ], + &[ + " ▄██▀", + " ▄███▀", + " ▄████▀", + " ▄█████▀", + " ▄██████▀", + ], +]; + +/// Which wing each animation frame shows: one flap, held at both extremes. +const FLAP: &[usize] = &[0, 1, 2, 3, 4, 4, 3, 2, 1, 0]; + +/// Every row is padded to this width so the centred logo keeps its column +/// instead of drifting as the wing sweeps out. +pub(super) const WIDTH: usize = 28; + +pub(super) const HEIGHT: usize = BODY_TOP + BODY.len(); + +/// The crow for animation frame `tick`, one padded row per canvas line. +pub(super) fn frame(tick: usize) -> Vec { + let wing = WINGS[FLAP[tick % FLAP.len()]]; + let wing_top = WING_ROOT + 1 - wing.len(); + + (0..HEIGHT) + .map(|row| { + let mut cells = vec![' '; WIDTH]; + if let Some(art) = row.checked_sub(BODY_TOP).and_then(|i| BODY.get(i)) { + paint(&mut cells, art); + } + if let Some(art) = row.checked_sub(wing_top).and_then(|i| wing.get(i)) { + paint(&mut cells, art); + } + cells.into_iter().collect() + }) + .collect() +} + +/// Overlay `art` onto `cells`, its spaces left transparent. +fn paint(cells: &mut Vec, art: &str) { + for (col, ch) in art.chars().enumerate() { + if ch == ' ' { + continue; + } + if col >= cells.len() { + cells.resize(col + 1, ' '); + } + cells[col] = ch; + } +} + +#[cfg(test)] +mod tests { + use super::{BODY, BODY_TOP, FLAP, HEIGHT, WIDTH, WING_ROOT, WINGS, frame}; + + fn padded_body_row(row: usize) -> String { + let art = BODY[row - BODY_TOP]; + format!("{art: = FLAP.to_vec(); + used.sort_unstable(); + used.dedup(); + assert_eq!(used, (0..WINGS.len()).collect::>()); + assert_eq!(frame(FLAP.len()), frame(0)); + } + + #[test] + fn each_wing_is_rooted_on_the_back_without_a_gap() { + let back = BODY[WING_ROOT - BODY_TOP]; + let back_end = back.trim_end().chars().count() - 1; + + for (stage, wing) in WINGS.iter().enumerate() { + assert!( + wing.len() <= WING_ROOT + 1, + "wing {stage} is too tall to keep its root on the back" + ); + let root = wing.last().expect("a wing has at least one row"); + let start = root + .chars() + .position(|ch| ch != ' ') + .expect("a wing row is not blank"); + assert!( + start <= back_end + 1, + "wing {stage} starts at column {start}, detached from the back \ + (painted through column {back_end})" + ); + } + } + + #[test] + fn no_wing_paints_over_the_body_below_the_back() { + for tick in 0..FLAP.len() { + for (row, painted) in frame(tick).iter().enumerate().skip(WING_ROOT + 1) { + assert_eq!( + *painted, + padded_body_row(row), + "frame {tick} altered body row {row}" + ); + } + } + } + + #[test] + fn no_wing_covers_the_eye() { + for tick in 0..FLAP.len() { + assert!( + frame(tick).iter().any(|row| row.contains('●')), + "frame {tick} lost the crow's eye" + ); + } + } + + #[test] + fn a_wrapped_tick_still_renders_a_frame() { + assert_eq!(frame(usize::MAX).len(), HEIGHT); + assert_eq!(frame(usize::MAX), frame(usize::MAX % FLAP.len())); + } +} diff --git a/src/ui/splash.rs b/src/ui/splash/mod.rs similarity index 60% rename from src/ui/splash.rs rename to src/ui/splash/mod.rs index 1de72c05..57c26062 100644 --- a/src/ui/splash.rs +++ b/src/ui/splash/mod.rs @@ -1,3 +1,7 @@ +mod crow; +#[cfg(test)] +mod tests; + use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout}, @@ -5,37 +9,17 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Paragraph}, }; +use std::time::Duration; -/// A perched crow silhouette — head with a beak and eye on the upper-left, -/// body sweeping down to a pointed tail on the lower-right, sitting on a branch. -const CROW: &[&str] = &[ - " ▄▄▄", - " ▄█▀ ▀█▄", - " ▄█ ● █▄", - " █▀▀ ▀▀█▄▄▄", - " ██████████████▄", - " ███████████████▄", - " ████████████████▄", - " █████████████████▄", - " ██████████████████▄", - " ███████████████████", - " ████████████████▀", - " ██████████████▀", - " ████████████▀", - " ██████████▀", - " ████████▀", - " ██████▀", - " ████▀", - " ██▀", -]; - -const BRANCH: &str = " ───────────────────────────"; +/// How long one wing position is held. +pub const FLAP_FRAME: Duration = Duration::from_millis(110); -/// Draw the splash screen: crow logo, version, and a key-press prompt. +/// Draw the splash screen: the flapping crow, version, and a key-press prompt. /// -/// There is no timer — the splash stays until the user presses a key -/// (handled by [`crate::application::splash::splash_loop`]). -pub fn draw(frame: &mut Frame, accent: Color) { +/// `tick` advances once per animation frame and drives the flap; 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( @@ -44,7 +28,7 @@ pub fn draw(frame: &mut Frame, accent: Color) { ); let version = env!("CARGO_PKG_VERSION"); - let logo_h = CROW.len() as u16; + let logo_h = crow::HEIGHT as u16; // crow + branch + gap + version + gap + prompt let content_h = logo_h + 1 + 1 + 1 + 1 + 1; @@ -69,20 +53,21 @@ pub fn draw(frame: &mut Frame, accent: Color) { ]) .split(outer[1]); - // Crow logo - let logo_lines: Vec = CROW - .iter() - .map(|&row| Line::from(Span::styled(row, Style::default().fg(accent)))) + // Crow logo. Every row is padded to the same width by `crow::frame`, so + // Paragraph's per-line centring lands them all on the same column. + let logo_lines: Vec = crow::frame(tick) + .into_iter() + .map(|row| Line::from(Span::styled(row, Style::default().fg(accent)))) .collect(); frame.render_widget( Paragraph::new(logo_lines).alignment(Alignment::Center), inner[0], ); - // Branch under the crow + // Branch under the crow, spanning the logo's width frame.render_widget( Paragraph::new(Line::from(Span::styled( - BRANCH, + "─".repeat(crow::WIDTH), Style::default().fg(accent).add_modifier(Modifier::DIM), ))) .alignment(Alignment::Center), diff --git a/src/ui/splash/tests.rs b/src/ui/splash/tests.rs new file mode 100644 index 00000000..2695c794 --- /dev/null +++ b/src/ui/splash/tests.rs @@ -0,0 +1,69 @@ +use super::{crow, draw}; +use ratatui::{Terminal, backend::TestBackend, style::Color}; + +const W: u16 = 60; +const H: u16 = 30; + +fn rows_at(tick: usize) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap(); + terminal + .draw(|frame| draw(frame, Color::Yellow, tick)) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + (0..H) + .map(|y| { + (0..W) + .map(|x| buf.cell((x, y)).unwrap().symbol().to_string()) + .collect() + }) + .collect() +} + +/// Columns of the branch and of the tail tip just above it — parts of the logo +/// no wing touches, so they pin down where the whole block was drawn. +fn anchor_columns(rows: &[String]) -> (usize, usize) { + let branch = rows + .iter() + .position(|row| row.contains('─')) + .expect("the branch row is drawn"); + let column = |row: &String| row.find(|ch: char| ch != ' ').expect("row is not blank"); + (column(&rows[branch]), column(&rows[branch - 1])) +} + +#[test] +fn the_logo_keeps_its_column_while_the_wing_sweeps() { + let first = anchor_columns(&rows_at(0)); + for tick in 1..10 { + assert_eq!( + anchor_columns(&rows_at(tick)), + first, + "frame {tick} shifted the splash horizontally" + ); + } +} + +#[test] +fn the_wing_moves_between_frames() { + let raised = rows_at(4); + let lowered = rows_at(0); + assert_ne!(raised, lowered, "the flap did not change the drawn crow"); +} + +#[test] +fn the_splash_names_the_crow_and_the_way_out() { + let text = rows_at(0).join("\n"); + assert!(text.contains("nightcrow"), "missing product name:\n{text}"); + assert!( + text.contains("Press any key to continue"), + "missing dismissal prompt:\n{text}" + ); + assert!(text.contains('●'), "missing crow eye:\n{text}"); +} + +#[test] +fn a_terminal_narrower_than_the_logo_still_draws() { + let mut terminal = Terminal::new(TestBackend::new(crow::WIDTH as u16 / 2, 8)).unwrap(); + terminal + .draw(|frame| draw(frame, Color::Yellow, 3)) + .unwrap(); +} From 816b8c3f362f89866327731092467b7d3587a1ca Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 22:43:39 +0900 Subject: [PATCH 3/7] feat(terminal): perch the flapping crow in an empty pane With every pane closed the panel showed a lone hint line. It now shows the same crow the splash does, above that hint. A short pane crops the tail and lets the branch stand in for it, which reads as the bird behind the branch rather than as a cut-off drawing; a pane too small for even head and shoulders keeps the hint alone. The flap runs off a shared origin instead of a caller's counter, so the event loop's existing redraw is all it needs to animate. --- docs/architecture.md | 10 +-- src/ui/splash/mod.rs | 49 ++++--------- src/ui/splash/perch.rs | 138 +++++++++++++++++++++++++++++++++++++ src/ui/splash/tests.rs | 64 ++++++++++++++++- src/ui/terminal_tab/mod.rs | 8 +-- 5 files changed, 225 insertions(+), 44 deletions(-) create mode 100644 src/ui/splash/perch.rs diff --git a/docs/architecture.md b/docs/architecture.md index d4168a58..eed9c1ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -131,12 +131,14 @@ src/ │ │ # 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/, wall_clock.rs # repo-dialog -│ │ # browser, file preview state, SearchQuery newtype, first-run -│ │ # overlay (crow.rs: body silhouette + wing flap frames), -│ │ # unix epoch → HH:MM without a date crate +│ │ # browser, file preview state, SearchQuery newtype, the crow +│ │ # (crow.rs: silhouette + flap frames, perch.rs: crow on its +│ │ # branch — 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/ui/splash/mod.rs b/src/ui/splash/mod.rs index 57c26062..febb8be0 100644 --- a/src/ui/splash/mod.rs +++ b/src/ui/splash/mod.rs @@ -1,7 +1,11 @@ mod crow; +mod perch; #[cfg(test)] mod tests; +pub use perch::{FLAP_FRAME, draw_idle}; + +use perch::{PERCH_HEIGHT, draw_perch}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout}, @@ -9,10 +13,6 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Paragraph}, }; -use std::time::Duration; - -/// How long one wing position is held. -pub const FLAP_FRAME: Duration = Duration::from_millis(110); /// Draw the splash screen: the flapping crow, version, and a key-press prompt. /// @@ -28,9 +28,8 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { ); let version = env!("CARGO_PKG_VERSION"); - let logo_h = crow::HEIGHT as u16; - // crow + branch + gap + version + gap + prompt - let content_h = logo_h + 1 + 1 + 1 + 1 + 1; + // crow + branch, gap, version, gap, prompt + let content_h = PERCH_HEIGHT + 1 + 1 + 1 + 1; let outer = Layout::default() .direction(Direction::Vertical) @@ -44,35 +43,15 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { let inner = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(logo_h), // crow - Constraint::Length(1), // branch - Constraint::Length(1), // gap - Constraint::Length(1), // version + subtitle - Constraint::Length(1), // gap - Constraint::Length(1), // prompt + Constraint::Length(PERCH_HEIGHT), // crow + branch + Constraint::Length(1), // gap + Constraint::Length(1), // version + subtitle + Constraint::Length(1), // gap + Constraint::Length(1), // prompt ]) .split(outer[1]); - // Crow logo. Every row is padded to the same width by `crow::frame`, so - // Paragraph's per-line centring lands them all on the same column. - let logo_lines: Vec = crow::frame(tick) - .into_iter() - .map(|row| Line::from(Span::styled(row, Style::default().fg(accent)))) - .collect(); - frame.render_widget( - Paragraph::new(logo_lines).alignment(Alignment::Center), - inner[0], - ); - - // Branch under the crow, spanning the logo's width - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - "─".repeat(crow::WIDTH), - Style::default().fg(accent).add_modifier(Modifier::DIM), - ))) - .alignment(Alignment::Center), - inner[1], - ); + draw_perch(frame, inner[0], accent, tick); // Version + tagline frame.render_widget( @@ -96,7 +75,7 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { ), ])) .alignment(Alignment::Center), - inner[3], + inner[2], ); // Key-press prompt @@ -108,6 +87,6 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { .add_modifier(Modifier::DIM), ))) .alignment(Alignment::Center), - inner[5], + inner[4], ); } diff --git a/src/ui/splash/perch.rs b/src/ui/splash/perch.rs new file mode 100644 index 00000000..f9d90bdc --- /dev/null +++ b/src/ui/splash/perch.rs @@ -0,0 +1,138 @@ +use super::crow; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// How long one wing position is held. +pub const FLAP_FRAME: Duration = Duration::from_millis(110); + +/// The crow plus the branch it perches on. +pub(super) const PERCH_HEIGHT: u16 = crow::HEIGHT as u16 + 1; + +/// Below this the crow is cropped past recognition, so it is left out entirely. +const MIN_PERCH_HEIGHT: u16 = 10; + +/// Rows between the crow and whatever a caller draws under it. +const GAP: u16 = 1; + +/// Which animation frame `elapsed` since the flap's origin falls in. +fn flap_frame(elapsed: Duration) -> usize { + (elapsed.as_millis() / FLAP_FRAME.as_millis()) as usize +} + +/// One origin for every flap, so crows on screen together beat in step. Callers +/// that keep their own frame counter (the startup splash) don't need this. +fn flap_phase() -> Duration { + static ORIGIN: OnceLock = OnceLock::new(); + ORIGIN.get_or_init(Instant::now).elapsed() +} + +/// Draw the flapping crow on its branch, filling `area` from the top. +/// +/// A short `area` crops the tail and the branch takes its place, which reads as +/// the bird standing behind the branch rather than as a cut-off drawing. Too +/// short for even the head and shoulders ([`MIN_PERCH_HEIGHT`]) and nothing is +/// drawn — the caller's `area` is left blank. +pub(super) fn draw_perch(frame: &mut Frame, area: Rect, accent: Color, tick: usize) { + if area.height < MIN_PERCH_HEIGHT || area.width < crow::WIDTH as u16 { + return; + } + + let body_rows = (area.height - 1).min(crow::HEIGHT as u16) as usize; + let mut lines: Vec = crow::frame(tick) + .into_iter() + .take(body_rows) + .map(|row| Line::from(Span::styled(row, Style::default().fg(accent)))) + .collect(); + lines.push(Line::from(Span::styled( + "─".repeat(crow::WIDTH), + Style::default().fg(accent).add_modifier(Modifier::DIM), + ))); + + frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); +} + +/// Fill an empty terminal pane: the flapping crow above `hint`. +/// +/// The flap runs off the shared clock, so this needs no frame counter from the +/// caller — it animates 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 perch = perch_height(area); + let block_h = if perch == 0 { 1 } else { perch + GAP + 1 }; + let top = area.y + (area.height.saturating_sub(block_h)) / 2; + + if perch > 0 { + draw_perch( + frame, + Rect::new(area.x, top, area.width, perch), + accent, + flap_frame(flap_phase()), + ); + } + + frame.render_widget( + Paragraph::new(hint).alignment(Alignment::Center), + Rect::new(area.x, top + block_h - 1, area.width, 1), + ); +} + +/// Rows to give the perch in `area`, leaving room for the hint; 0 when the pane +/// cannot hold a crow at all. +fn perch_height(area: Rect) -> u16 { + let spare = area.height.saturating_sub(GAP + 1); + if area.width < crow::WIDTH as u16 || spare < MIN_PERCH_HEIGHT { + return 0; + } + spare.min(PERCH_HEIGHT) +} + +#[cfg(test)] +mod tests { + use super::{FLAP_FRAME, MIN_PERCH_HEIGHT, PERCH_HEIGHT, flap_frame, perch_height}; + use crate::ui::splash::crow; + use ratatui::layout::Rect; + use std::time::Duration; + + #[test] + fn the_flap_advances_one_frame_per_interval() { + assert_eq!(flap_frame(Duration::ZERO), 0); + assert_eq!(flap_frame(FLAP_FRAME - Duration::from_millis(1)), 0); + assert_eq!(flap_frame(FLAP_FRAME), 1); + assert_eq!(flap_frame(FLAP_FRAME * 7), 7); + } + + #[test] + fn a_roomy_pane_gets_the_whole_crow() { + let area = Rect::new(0, 0, 80, 40); + assert_eq!(perch_height(area), PERCH_HEIGHT); + } + + #[test] + fn a_short_pane_crops_the_crow_instead_of_overflowing() { + let height = MIN_PERCH_HEIGHT + 3; + let area = Rect::new(0, 0, 80, height); + let perch = perch_height(area); + assert!(perch < PERCH_HEIGHT, "expected a cropped crow, got {perch}"); + assert!( + perch + 2 <= height, + "the crow and hint must fit in {height}" + ); + } + + #[test] + fn a_pane_too_small_for_a_recognisable_crow_gets_none() { + assert_eq!(perch_height(Rect::new(0, 0, 80, MIN_PERCH_HEIGHT)), 0); + assert_eq!(perch_height(Rect::new(0, 0, 80, 1)), 0); + assert_eq!(perch_height(Rect::new(0, 0, 0, 0)), 0); + assert_eq!(perch_height(Rect::new(0, 0, crow::WIDTH as u16 - 1, 40)), 0); + } +} diff --git a/src/ui/splash/tests.rs b/src/ui/splash/tests.rs index 2695c794..6d9833dc 100644 --- a/src/ui/splash/tests.rs +++ b/src/ui/splash/tests.rs @@ -1,5 +1,5 @@ use super::{crow, draw}; -use ratatui::{Terminal, backend::TestBackend, style::Color}; +use ratatui::{Terminal, backend::TestBackend, style::Color, text::Line}; const W: u16 = 60; const H: u16 = 30; @@ -67,3 +67,65 @@ fn a_terminal_narrower_than_the_logo_still_draws() { .draw(|frame| draw(frame, Color::Yellow, 3)) .unwrap(); } + +/// The empty-terminal pane, drawn into an `area` of the given size. +fn idle_rows(width: u16, height: u16) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + super::draw_idle(frame, area, Color::Yellow, Line::from("hint")); + }) + .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() +} + +#[test] +fn an_empty_pane_perches_the_crow_above_the_hint() { + let rows = idle_rows(80, 24); + let text = rows.join("\n"); + assert!(text.contains('●'), "the crow is missing:\n{text}"); + + let eye = rows.iter().position(|row| row.contains('●')).unwrap(); + let hint = rows.iter().position(|row| row.contains("hint")).unwrap(); + let branch = rows.iter().position(|row| row.contains('─')).unwrap(); + assert!( + eye < branch && branch < hint, + "expected crow, branch, then hint; got rows {eye}, {branch}, {hint}" + ); +} + +#[test] +fn a_short_empty_pane_keeps_the_hint_and_drops_the_crow() { + let rows = idle_rows(80, 6); + let text = rows.join("\n"); + assert!(text.contains("hint"), "the hint must survive:\n{text}"); + assert!(!text.contains('●'), "no room for a crow here:\n{text}"); +} + +#[test] +fn an_empty_pane_narrower_than_the_crow_still_shows_the_hint() { + let rows = idle_rows(crow::WIDTH as u16 - 4, 24); + let text = rows.join("\n"); + assert!(text.contains("hint"), "the hint must survive:\n{text}"); + assert!(!text.contains('●'), "no room for a crow here:\n{text}"); +} + +#[test] +fn a_cropped_crow_still_perches_on_its_branch() { + let rows = idle_rows(80, 14); + let branch = rows + .iter() + .position(|row| row.contains('─')) + .expect("a cropped crow keeps its branch"); + let hint = rows.iter().position(|row| row.contains("hint")).unwrap(); + assert!(rows[..branch].iter().any(|row| row.contains('●'))); + assert!(branch < hint, "the branch must stay above the hint"); +} 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; } From d677ab81d815e4dcfaa2c04b89b77b1211e480a4 Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 23:33:37 +0900 Subject: [PATCH 4/7] feat(splash): draw a moonlit night scene instead of a flapping crow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The logo becomes a scene: a crow perched on a bough, facing a crescent moon, under a sky of stars. The bird, the bough and the moon are fixed art — only the stars change, each on its own phase, so the screen has motion without an animation to sit through. Cells carry what they are rather than how they look, which lets the renderer paint the bird in the session accent, the bough dim behind it, and the sky in its own palette. A short pane drops sky rows from the top, so the bird and its bough are the last thing to go. --- docs/architecture.md | 11 +- src/application/splash.rs | 8 +- src/ui/splash/crow.rs | 182 --------------------------- src/ui/splash/mod.rs | 64 +++++----- src/ui/splash/night.rs | 186 ++++++++++++++++++++++++++++ src/ui/splash/perch.rs | 138 --------------------- src/ui/splash/scene.rs | 250 ++++++++++++++++++++++++++++++++++++++ src/ui/splash/tests.rs | 158 +++++++++++------------- 8 files changed, 555 insertions(+), 442 deletions(-) delete mode 100644 src/ui/splash/crow.rs create mode 100644 src/ui/splash/night.rs delete mode 100644 src/ui/splash/perch.rs create mode 100644 src/ui/splash/scene.rs diff --git a/docs/architecture.md b/docs/architecture.md index eed9c1ce..e3b5b716 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,8 +92,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), splash flap loop -│ │ # (timed frames, dismissed only by a key press) +│ │ # 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: @@ -131,9 +131,10 @@ src/ │ │ # 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/, wall_clock.rs # repo-dialog -│ │ # browser, file preview state, SearchQuery newtype, the crow -│ │ # (crow.rs: silhouette + flap frames, perch.rs: crow on its -│ │ # branch — startup splash and the empty terminal pane both +│ │ # browser, file preview state, SearchQuery newtype, the night +│ │ # scene (scene.rs: fixed crow/bough/moon art + a twinkling +│ │ # star table, night.rs: ink → palette, bottom-anchored +│ │ # crop; 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 diff --git a/src/application/splash.rs b/src/application/splash.rs index aba3e3fb..10453107 100644 --- a/src/application/splash.rs +++ b/src/application/splash.rs @@ -1,5 +1,5 @@ use crate::application::terminal_guard::TuiTerminal; -use crate::ui::splash::FLAP_FRAME; +use crate::ui::splash::TWINKLE_FRAME; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use std::time::Instant; @@ -8,9 +8,9 @@ pub(crate) enum SplashOutcome { Quit, } -/// Flap the crow until the user presses a key. +/// Show the night scene until the user presses a key. /// -/// There is no dismissal timer — only the animation is timed, so the splash +/// 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 @@ -31,7 +31,7 @@ pub(crate) fn splash_loop( crate::ui::splash::draw(frame, accent, tick); })?; tick = tick.wrapping_add(1); - next_frame = Instant::now() + FLAP_FRAME; + next_frame = Instant::now() + TWINKLE_FRAME; } // Wait out the rest of the frame rather than a fixed slice, so input diff --git a/src/ui/splash/crow.rs b/src/ui/splash/crow.rs deleted file mode 100644 index 02d087c2..00000000 --- a/src/ui/splash/crow.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! The splash crow: one fixed body silhouette with a wing flapping over it. - -/// A perched crow silhouette — head with a beak and eye on the upper-left, -/// body sweeping down to a pointed tail on the lower-right. -const BODY: &[&str] = &[ - " ▄▄▄", - " ▄█▀ ▀█▄", - " ▄█ ● █▄", - " █▀▀ ▀▀█▄▄▄", - " ██████████████▄", - " ███████████████▄", - " ████████████████▄", - " █████████████████▄", - " ██████████████████▄", - " ███████████████████", - " ████████████████▀", - " ██████████████▀", - " ████████████▀", - " ██████████▀", - " ████████▀", - " ██████▀", - " ████▀", - " ██▀", -]; - -/// Blank canvas rows above the body, leaving the raised wing somewhere to go. -const BODY_TOP: usize = 1; - -/// Canvas row where the wing joins the back — every wing's last row lands here. -const WING_ROOT: usize = 4; - -/// Wing positions from outstretched to raised. Rows run top-down; a wing may be -/// at most `WING_ROOT + 1` rows tall so its root stays on the back. -const WINGS: &[&[&str]] = &[ - &[" ▄██████████▀▀"], - &[" ▄██████▀", " ▄███████▀"], - &[ - " ▄████▀", - " ▄█████▀", - " ▄██████▀", - ], - &[ - " ▄███▀", - " ▄████▀", - " ▄█████▀", - " ▄██████▀", - ], - &[ - " ▄██▀", - " ▄███▀", - " ▄████▀", - " ▄█████▀", - " ▄██████▀", - ], -]; - -/// Which wing each animation frame shows: one flap, held at both extremes. -const FLAP: &[usize] = &[0, 1, 2, 3, 4, 4, 3, 2, 1, 0]; - -/// Every row is padded to this width so the centred logo keeps its column -/// instead of drifting as the wing sweeps out. -pub(super) const WIDTH: usize = 28; - -pub(super) const HEIGHT: usize = BODY_TOP + BODY.len(); - -/// The crow for animation frame `tick`, one padded row per canvas line. -pub(super) fn frame(tick: usize) -> Vec { - let wing = WINGS[FLAP[tick % FLAP.len()]]; - let wing_top = WING_ROOT + 1 - wing.len(); - - (0..HEIGHT) - .map(|row| { - let mut cells = vec![' '; WIDTH]; - if let Some(art) = row.checked_sub(BODY_TOP).and_then(|i| BODY.get(i)) { - paint(&mut cells, art); - } - if let Some(art) = row.checked_sub(wing_top).and_then(|i| wing.get(i)) { - paint(&mut cells, art); - } - cells.into_iter().collect() - }) - .collect() -} - -/// Overlay `art` onto `cells`, its spaces left transparent. -fn paint(cells: &mut Vec, art: &str) { - for (col, ch) in art.chars().enumerate() { - if ch == ' ' { - continue; - } - if col >= cells.len() { - cells.resize(col + 1, ' '); - } - cells[col] = ch; - } -} - -#[cfg(test)] -mod tests { - use super::{BODY, BODY_TOP, FLAP, HEIGHT, WIDTH, WING_ROOT, WINGS, frame}; - - fn padded_body_row(row: usize) -> String { - let art = BODY[row - BODY_TOP]; - format!("{art: = FLAP.to_vec(); - used.sort_unstable(); - used.dedup(); - assert_eq!(used, (0..WINGS.len()).collect::>()); - assert_eq!(frame(FLAP.len()), frame(0)); - } - - #[test] - fn each_wing_is_rooted_on_the_back_without_a_gap() { - let back = BODY[WING_ROOT - BODY_TOP]; - let back_end = back.trim_end().chars().count() - 1; - - for (stage, wing) in WINGS.iter().enumerate() { - assert!( - wing.len() <= WING_ROOT + 1, - "wing {stage} is too tall to keep its root on the back" - ); - let root = wing.last().expect("a wing has at least one row"); - let start = root - .chars() - .position(|ch| ch != ' ') - .expect("a wing row is not blank"); - assert!( - start <= back_end + 1, - "wing {stage} starts at column {start}, detached from the back \ - (painted through column {back_end})" - ); - } - } - - #[test] - fn no_wing_paints_over_the_body_below_the_back() { - for tick in 0..FLAP.len() { - for (row, painted) in frame(tick).iter().enumerate().skip(WING_ROOT + 1) { - assert_eq!( - *painted, - padded_body_row(row), - "frame {tick} altered body row {row}" - ); - } - } - } - - #[test] - fn no_wing_covers_the_eye() { - for tick in 0..FLAP.len() { - assert!( - frame(tick).iter().any(|row| row.contains('●')), - "frame {tick} lost the crow's eye" - ); - } - } - - #[test] - fn a_wrapped_tick_still_renders_a_frame() { - assert_eq!(frame(usize::MAX).len(), HEIGHT); - assert_eq!(frame(usize::MAX), frame(usize::MAX % FLAP.len())); - } -} diff --git a/src/ui/splash/mod.rs b/src/ui/splash/mod.rs index febb8be0..4f08cdb3 100644 --- a/src/ui/splash/mod.rs +++ b/src/ui/splash/mod.rs @@ -1,11 +1,11 @@ -mod crow; -mod perch; +mod night; +mod scene; #[cfg(test)] mod tests; -pub use perch::{FLAP_FRAME, draw_idle}; +pub use night::{TWINKLE_FRAME, draw_idle}; -use perch::{PERCH_HEIGHT, draw_perch}; +use night::{SCENE_HEIGHT, draw_scene}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout}, @@ -14,11 +14,14 @@ use ratatui::{ widgets::{Block, Paragraph}, }; -/// Draw the splash screen: the flapping crow, version, and a key-press prompt. +/// Rows the splash needs under the scene: gap, name, tagline, gap, prompt. +const FOOTER_HEIGHT: u16 = 5; + +/// Draw the splash screen: the night scene, the version, and how to leave. /// -/// `tick` advances once per animation frame and drives the flap; 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`]). +/// `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(); @@ -27,15 +30,15 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { area, ); - let version = env!("CARGO_PKG_VERSION"); - // crow + branch, gap, version, gap, prompt - let content_h = PERCH_HEIGHT + 1 + 1 + 1 + 1; + // 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(content_h), + Constraint::Length(scene_h + FOOTER_HEIGHT), Constraint::Min(0), ]) .split(area); @@ -43,17 +46,17 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { let inner = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(PERCH_HEIGHT), // crow + branch - Constraint::Length(1), // gap - Constraint::Length(1), // version + subtitle - Constraint::Length(1), // gap - Constraint::Length(1), // prompt + 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_perch(frame, inner[0], accent, tick); + draw_scene(frame, inner[0], accent, tick); - // Version + tagline frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled( @@ -63,22 +66,25 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" v{version}"), + format!(" v{}", env!("CARGO_PKG_VERSION")), Style::default().fg(Color::DarkGray), ), - Span::styled(" · ", Style::default().fg(Color::DarkGray)), - Span::styled( - "Agent-adjacent TUI", - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::DIM), - ), ])) .alignment(Alignment::Center), inner[2], ); - // Key-press prompt + 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", @@ -87,6 +93,6 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { .add_modifier(Modifier::DIM), ))) .alignment(Alignment::Center), - inner[4], + inner[5], ); } diff --git a/src/ui/splash/night.rs b/src/ui/splash/night.rs new file mode 100644 index 00000000..252af0ff --- /dev/null +++ b/src/ui/splash/night.rs @@ -0,0 +1,186 @@ +use super::scene::{self, Cell, Ink}; +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Modifier, 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) +} + +fn style(ink: Ink, accent: Color) -> Style { + match ink { + Ink::Sky => Style::default(), + Ink::Bird => Style::default().fg(accent), + Ink::Bough => Style::default().fg(accent).add_modifier(Modifier::DIM), + Ink::Moon => Style::default().fg(Color::LightYellow), + Ink::Star { bright: true } => Style::default().fg(Color::White), + Ink::Star { bright: false } => Style::default().fg(Color::DarkGray), + } +} + +/// Fill an empty terminal pane: the night scene over `hint`. +/// +/// 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 }; + let block_h = scene_h + gap + 1; + 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()), + ); + } + + frame.render_widget( + Paragraph::new(hint).alignment(Alignment::Center), + Rect::new(area.x, top + scene_h + gap, area.width, 1), + ); +} + +/// Rows to give the scene in `area`, leaving room for the hint; 0 when the pane +/// cannot hold the bird at all. +fn scene_height(area: Rect) -> u16 { + let spare = area.height.saturating_sub(GAP + 1); + 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 + 2; + let shown = scene_height(Rect::new(0, 0, 80, height)); + assert!(shown < SCENE_HEIGHT, "expected a cropped sky, got {shown}"); + assert!( + shown + 2 <= height, + "the scene and its hint 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/perch.rs b/src/ui/splash/perch.rs deleted file mode 100644 index f9d90bdc..00000000 --- a/src/ui/splash/perch.rs +++ /dev/null @@ -1,138 +0,0 @@ -use super::crow; -use ratatui::{ - Frame, - layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::Paragraph, -}; -use std::sync::OnceLock; -use std::time::{Duration, Instant}; - -/// How long one wing position is held. -pub const FLAP_FRAME: Duration = Duration::from_millis(110); - -/// The crow plus the branch it perches on. -pub(super) const PERCH_HEIGHT: u16 = crow::HEIGHT as u16 + 1; - -/// Below this the crow is cropped past recognition, so it is left out entirely. -const MIN_PERCH_HEIGHT: u16 = 10; - -/// Rows between the crow and whatever a caller draws under it. -const GAP: u16 = 1; - -/// Which animation frame `elapsed` since the flap's origin falls in. -fn flap_frame(elapsed: Duration) -> usize { - (elapsed.as_millis() / FLAP_FRAME.as_millis()) as usize -} - -/// One origin for every flap, so crows on screen together beat in step. Callers -/// that keep their own frame counter (the startup splash) don't need this. -fn flap_phase() -> Duration { - static ORIGIN: OnceLock = OnceLock::new(); - ORIGIN.get_or_init(Instant::now).elapsed() -} - -/// Draw the flapping crow on its branch, filling `area` from the top. -/// -/// A short `area` crops the tail and the branch takes its place, which reads as -/// the bird standing behind the branch rather than as a cut-off drawing. Too -/// short for even the head and shoulders ([`MIN_PERCH_HEIGHT`]) and nothing is -/// drawn — the caller's `area` is left blank. -pub(super) fn draw_perch(frame: &mut Frame, area: Rect, accent: Color, tick: usize) { - if area.height < MIN_PERCH_HEIGHT || area.width < crow::WIDTH as u16 { - return; - } - - let body_rows = (area.height - 1).min(crow::HEIGHT as u16) as usize; - let mut lines: Vec = crow::frame(tick) - .into_iter() - .take(body_rows) - .map(|row| Line::from(Span::styled(row, Style::default().fg(accent)))) - .collect(); - lines.push(Line::from(Span::styled( - "─".repeat(crow::WIDTH), - Style::default().fg(accent).add_modifier(Modifier::DIM), - ))); - - frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); -} - -/// Fill an empty terminal pane: the flapping crow above `hint`. -/// -/// The flap runs off the shared clock, so this needs no frame counter from the -/// caller — it animates 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 perch = perch_height(area); - let block_h = if perch == 0 { 1 } else { perch + GAP + 1 }; - let top = area.y + (area.height.saturating_sub(block_h)) / 2; - - if perch > 0 { - draw_perch( - frame, - Rect::new(area.x, top, area.width, perch), - accent, - flap_frame(flap_phase()), - ); - } - - frame.render_widget( - Paragraph::new(hint).alignment(Alignment::Center), - Rect::new(area.x, top + block_h - 1, area.width, 1), - ); -} - -/// Rows to give the perch in `area`, leaving room for the hint; 0 when the pane -/// cannot hold a crow at all. -fn perch_height(area: Rect) -> u16 { - let spare = area.height.saturating_sub(GAP + 1); - if area.width < crow::WIDTH as u16 || spare < MIN_PERCH_HEIGHT { - return 0; - } - spare.min(PERCH_HEIGHT) -} - -#[cfg(test)] -mod tests { - use super::{FLAP_FRAME, MIN_PERCH_HEIGHT, PERCH_HEIGHT, flap_frame, perch_height}; - use crate::ui::splash::crow; - use ratatui::layout::Rect; - use std::time::Duration; - - #[test] - fn the_flap_advances_one_frame_per_interval() { - assert_eq!(flap_frame(Duration::ZERO), 0); - assert_eq!(flap_frame(FLAP_FRAME - Duration::from_millis(1)), 0); - assert_eq!(flap_frame(FLAP_FRAME), 1); - assert_eq!(flap_frame(FLAP_FRAME * 7), 7); - } - - #[test] - fn a_roomy_pane_gets_the_whole_crow() { - let area = Rect::new(0, 0, 80, 40); - assert_eq!(perch_height(area), PERCH_HEIGHT); - } - - #[test] - fn a_short_pane_crops_the_crow_instead_of_overflowing() { - let height = MIN_PERCH_HEIGHT + 3; - let area = Rect::new(0, 0, 80, height); - let perch = perch_height(area); - assert!(perch < PERCH_HEIGHT, "expected a cropped crow, got {perch}"); - assert!( - perch + 2 <= height, - "the crow and hint must fit in {height}" - ); - } - - #[test] - fn a_pane_too_small_for_a_recognisable_crow_gets_none() { - assert_eq!(perch_height(Rect::new(0, 0, 80, MIN_PERCH_HEIGHT)), 0); - assert_eq!(perch_height(Rect::new(0, 0, 80, 1)), 0); - assert_eq!(perch_height(Rect::new(0, 0, 0, 0)), 0); - assert_eq!(perch_height(Rect::new(0, 0, crow::WIDTH as u16 - 1, 40)), 0); - } -} diff --git a/src/ui/splash/scene.rs b/src/ui/splash/scene.rs new file mode 100644 index 00000000..c0b16f32 --- /dev/null +++ b/src/ui/splash/scene.rs @@ -0,0 +1,250 @@ +//! The night scene: a crow perched on a bough under a crescent moon. +//! +//! Everything but the sky is fixed art — the bird never moves. Only the stars +//! change from frame to frame, which is what keeps the screen alive without +//! turning it into an animation. + +/// Crescent moon, horns opening right. +const MOON: &[&str] = &[ + " ▗▄▄▄▖", + " ▟███▀▘", + "▟███▘", + "▐███", + "▐███", + "▝███▖", + " ▜███▄▖", + " ▝▀▀▀▘", +]; +const MOON_AT: (usize, usize) = (0, 37); + +/// The crow in profile, facing the moon: beak out past the eye, tail swept back +/// and down, two legs under the body. Its last row are the legs, and they land +/// on [`BOUGH`]'s top edge — see `crow_stands_on_the_bough`. +const CROW: &[&str] = &[ + " ▄▄▄▄", + " ▄███████▄", + " ▐████●████▄▄▄", + " ▀███████▀", + " ▄▄▄███████████▄", + " ▄█████████████████", + " ▄▄▄███████████████▀", + " ▀▀▀▀ ▀▀████████▀", + " ██ ██", +]; +const CROW_AT: (usize, usize) = (2, 4); + +/// The bough, with a twig rising away from the bird. +const BOUGH: &[&str] = &[ + " ▄▄▄▀▀", + " ▄▄▀▀", + "▄▄▄▄▄██████████████████████████████▄▄▄▄▄▄▄▄▄", + " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", +]; +const BOUGH_AT: (usize, usize) = (9, 2); + +/// `(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; + +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; + +/// What a cell is, rather than how it looks — the palette lives in the renderer. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Ink { + Sky, + Bird, + Bough, + Moon, + Star { bright: bool }, +} + +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 = vec![vec![(' ', Ink::Sky); WIDTH]; HEIGHT]; + + for &(row, col, phase) in STARS { + if let Some(cell) = star(tick, phase) { + grid[row][col] = cell; + } + } + // Painted after the stars: the sky is behind everything else. + paint(&mut grid, MOON, MOON_AT, Ink::Moon); + paint(&mut grid, BOUGH, BOUGH_AT, Ink::Bough); + paint(&mut grid, CROW, CROW_AT, Ink::Bird); + + 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, + } +} + +/// Overlay `art` at `at`, its spaces left transparent. Art that would fall off +/// the canvas is clipped rather than wrapped; `art_fits_the_canvas` guards that +/// the shipped art never needs it. +fn paint(grid: &mut [Vec], art: &[&str], at: (usize, usize), ink: Ink) { + let (row0, col0) = at; + for (i, line) in art.iter().enumerate() { + for (j, ch) in line.chars().enumerate() { + if ch == ' ' { + continue; + } + if let Some(cell) = grid.get_mut(row0 + i).and_then(|r| r.get_mut(col0 + j)) { + *cell = (ch, ink); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + BOUGH, BOUGH_AT, CROW, CROW_AT, CYCLE, HEIGHT, Ink, MOON, MOON_AT, 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 (name, art, at) in [ + ("moon", MOON, MOON_AT), + ("crow", CROW, CROW_AT), + ("bough", BOUGH, BOUGH_AT), + ] { + assert!(at.0 + art.len() <= HEIGHT, "{name} runs past the last row"); + for (i, line) in art.iter().enumerate() { + let end = at.1 + line.chars().count(); + assert!(end <= WIDTH, "{name} row {i} runs past the canvas: {end}"); + } + } + } + + #[test] + fn only_the_sky_changes_between_frames() { + let first = frame(0); + for tick in 1..CYCLE { + 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() { + let sky = |ink| matches!(ink, Ink::Sky | Ink::Star { .. }); + if sky(x.1) && sky(y.1) { + continue; + } + assert_eq!( + x, y, + "frame {tick} moved a fixed cell at row {row}, column {col}" + ); + } + } + } + } + + #[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.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] == Ink::Bird) + .collect(); + assert!(!feet.is_empty(), "no legs found on row {legs}"); + for col in feet { + assert_eq!( + inks[bough_top][col], + Ink::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| !matches!(ink, Ink::Bird | Ink::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(CYCLE), frame(0)); + } +} diff --git a/src/ui/splash/tests.rs b/src/ui/splash/tests.rs index 6d9833dc..a141b143 100644 --- a/src/ui/splash/tests.rs +++ b/src/ui/splash/tests.rs @@ -1,131 +1,121 @@ -use super::{crow, draw}; +use super::{draw, draw_idle, scene}; use ratatui::{Terminal, backend::TestBackend, style::Color, text::Line}; -const W: u16 = 60; -const H: u16 = 30; - -fn rows_at(tick: usize) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(W, H)).unwrap(); - terminal - .draw(|frame| draw(frame, Color::Yellow, tick)) - .unwrap(); +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..H) + (0..height) .map(|y| { - (0..W) + (0..width) .map(|x| buf.cell((x, y)).unwrap().symbol().to_string()) .collect() }) .collect() } -/// Columns of the branch and of the tail tip just above it — parts of the logo -/// no wing touches, so they pin down where the whole block was drawn. -fn anchor_columns(rows: &[String]) -> (usize, usize) { - let branch = rows - .iter() - .position(|row| row.contains('─')) - .expect("the branch row is drawn"); - let column = |row: &String| row.find(|ch: char| ch != ' ').expect("row is not blank"); - (column(&rows[branch]), column(&rows[branch - 1])) +fn splash(tick: usize) -> Vec { + rows(64, 26, |frame| draw(frame, Color::Yellow, tick)) } -#[test] -fn the_logo_keeps_its_column_while_the_wing_sweeps() { - let first = anchor_columns(&rows_at(0)); - for tick in 1..10 { - assert_eq!( - anchor_columns(&rows_at(tick)), - first, - "frame {tick} shifted the splash horizontally" - ); - } +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_wing_moves_between_frames() { - let raised = rows_at(4); - let lowered = rows_at(0); - assert_ne!(raised, lowered, "the flap did not change the drawn crow"); +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_crow_and_the_way_out() { - let text = rows_at(0).join("\n"); +fn the_splash_names_the_version_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("Press any key to continue"), "missing dismissal prompt:\n{text}" ); - assert!(text.contains('●'), "missing crow eye:\n{text}"); } #[test] -fn a_terminal_narrower_than_the_logo_still_draws() { - let mut terminal = Terminal::new(TestBackend::new(crow::WIDTH as u16 / 2, 8)).unwrap(); - terminal - .draw(|frame| draw(frame, Color::Yellow, 3)) - .unwrap(); +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"); } -/// The empty-terminal pane, drawn into an `area` of the given size. -fn idle_rows(width: u16, height: u16) -> Vec { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - let area = frame.area(); - super::draw_idle(frame, area, Color::Yellow, Line::from("hint")); - }) - .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() +#[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_perches_the_crow_above_the_hint() { - let rows = idle_rows(80, 24); +fn an_empty_pane_puts_the_hint_under_the_scene() { + let rows = idle(72, 22); let text = rows.join("\n"); assert!(text.contains('●'), "the crow is missing:\n{text}"); - let eye = rows.iter().position(|row| row.contains('●')).unwrap(); + let bough = bough_row(&rows); let hint = rows.iter().position(|row| row.contains("hint")).unwrap(); - let branch = rows.iter().position(|row| row.contains('─')).unwrap(); - assert!( - eye < branch && branch < hint, - "expected crow, branch, then hint; got rows {eye}, {branch}, {hint}" - ); + assert!(bough < hint, "expected the scene above the hint"); } #[test] -fn a_short_empty_pane_keeps_the_hint_and_drops_the_crow() { - let rows = idle_rows(80, 6); - let text = rows.join("\n"); +fn a_short_empty_pane_keeps_the_hint_and_drops_the_scene() { + let text = idle(72, 6).join("\n"); assert!(text.contains("hint"), "the hint must survive:\n{text}"); - assert!(!text.contains('●'), "no room for a crow here:\n{text}"); + assert!(!text.contains('●'), "no room for the crow here:\n{text}"); } #[test] -fn an_empty_pane_narrower_than_the_crow_still_shows_the_hint() { - let rows = idle_rows(crow::WIDTH as u16 - 4, 24); - let text = rows.join("\n"); +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 a crow here:\n{text}"); + assert!(!text.contains('●'), "no room for the crow here:\n{text}"); } #[test] -fn a_cropped_crow_still_perches_on_its_branch() { - let rows = idle_rows(80, 14); - let branch = rows - .iter() - .position(|row| row.contains('─')) - .expect("a cropped crow keeps its branch"); - let hint = rows.iter().position(|row| row.contains("hint")).unwrap(); - assert!(rows[..branch].iter().any(|row| row.contains('●'))); - assert!(branch < hint, "the branch must stay above the hint"); +fn a_one_row_pane_shows_the_hint_alone() { + let rows = idle(72, 1); + assert!(rows[0].contains("hint"), "{rows:?}"); } From 48adc016919d834c39870a8a785454cf518cf173 Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 23:34:16 +0900 Subject: [PATCH 5/7] feat(splash): show the commit a binary was built from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A build.rs stamps the checked-out commit into NIGHTCROW_COMMIT, and the splash and the empty pane print it beside the version. Which build is running was until now invisible from the screen: a client left over from an earlier install looks exactly like a current one, which is how a stale binary went unnoticed against a freshly restarted daemon. A trailing `+` marks a dirty work tree. Builds with no git metadata — a crates.io package — report `unknown` rather than failing. --- build.rs | 63 ++++++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 10 +++++-- src/ui/splash/mod.rs | 26 +++++++++++++++-- src/ui/splash/night.rs | 28 +++++++++++++------ src/ui/splash/tests.rs | 37 +++++++++++++++++++++---- 5 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 build.rs 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 e3b5b716..bd86ad57 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 @@ -133,9 +137,9 @@ src/ │ ├── 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: fixed crow/bough/moon art + a twinkling -│ │ # star table, night.rs: ink → palette, bottom-anchored -│ │ # crop; the startup splash and the empty terminal pane both -│ │ # draw it), unix epoch → HH:MM without a date crate +│ │ # star table, night.rs: ink → 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 (with no diff --git a/src/ui/splash/mod.rs b/src/ui/splash/mod.rs index 4f08cdb3..afaaccf5 100644 --- a/src/ui/splash/mod.rs +++ b/src/ui/splash/mod.rs @@ -17,7 +17,8 @@ use ratatui::{ /// Rows the splash needs under the scene: gap, name, tagline, gap, prompt. const FOOTER_HEIGHT: u16 = 5; -/// Draw the splash screen: the night scene, the version, and how to leave. +/// 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 @@ -66,7 +67,7 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" v{}", env!("CARGO_PKG_VERSION")), + format!(" {}", build_id()), Style::default().fg(Color::DarkGray), ), ])) @@ -96,3 +97,24 @@ pub fn draw(frame: &mut Frame, accent: Color, tick: usize) { 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 index 252af0ff..ddee64f2 100644 --- a/src/ui/splash/night.rs +++ b/src/ui/splash/night.rs @@ -84,7 +84,7 @@ fn style(ink: Ink, accent: Color) -> Style { } } -/// Fill an empty terminal pane: the night scene over `hint`. +/// 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. @@ -95,7 +95,10 @@ pub fn draw_idle(frame: &mut Frame, area: Rect, accent: Color, hint: Line<'_>) { let scene_h = scene_height(area); let gap = if scene_h > 0 { GAP } else { 0 }; - let block_h = scene_h + gap + 1; + // 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 { @@ -107,16 +110,23 @@ pub fn draw_idle(frame: &mut Frame, area: Rect, accent: Color, hint: Line<'_>) { ); } + let hint_y = top + scene_h + gap; frame.render_widget( Paragraph::new(hint).alignment(Alignment::Center), - Rect::new(area.x, top + scene_h + gap, area.width, 1), + 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 hint; 0 when the pane -/// cannot hold the bird at all. +/// 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 + 1); + let spare = area.height.saturating_sub(GAP + 2); if area.width < scene::WIDTH as u16 || spare < MIN_SCENE_HEIGHT { return 0; } @@ -148,12 +158,12 @@ mod tests { #[test] fn a_short_pane_crops_the_sky_instead_of_overflowing() { - let height = MIN_SCENE_HEIGHT + 2; + 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 + 2 <= height, - "the scene and its hint must fit in {height}" + shown + 3 <= height, + "the scene and its footer must fit in {height}" ); } diff --git a/src/ui/splash/tests.rs b/src/ui/splash/tests.rs index a141b143..a3a15f50 100644 --- a/src/ui/splash/tests.rs +++ b/src/ui/splash/tests.rs @@ -1,4 +1,4 @@ -use super::{draw, draw_idle, scene}; +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 { @@ -45,19 +45,34 @@ fn the_splash_shows_the_crow_the_moon_and_the_stars() { } #[test] -fn the_splash_names_the_version_and_the_way_out() { +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); @@ -90,20 +105,32 @@ fn a_terminal_too_small_for_the_scene_still_draws() { } #[test] -fn an_empty_pane_puts_the_hint_under_the_scene() { +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(); - assert!(bough < hint, "expected the scene above the hint"); + 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 text = idle(72, 6).join("\n"); + 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}"); } From a752b3713c3a26573594f9fa7dcdb477249990fc Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 4 Aug 2026 23:56:37 +0900 Subject: [PATCH 6/7] feat(splash): shade the crow and give the bough its own colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bird was one flat accent-coloured mass, so the folded wing did not read at all. Sprites now carry two aligned tables: glyphs for shape and density (█ ▓ ▒ against ▄ ▀ contours) and an ink map naming the surface each cell belongs to. The renderer turns ink into colour, so shading works on both axes at once — the wing sits a step lighter than the body and the underside a step darker, lit from the moon's side. The accent moves to the eye. Painting the whole bird in the session colour made it a red or magenta crow; one bright cell keeps the session's colour on screen and lets the bird be grey. The bough takes browns of its own, which the sixteen named colours cannot supply — hence 256-colour indices throughout the scene. --- docs/architecture.md | 9 +- src/ui/splash/night.rs | 31 ++-- src/ui/splash/scene.rs | 331 ++++++++++++++++++++++++++++++----------- 3 files changed, 275 insertions(+), 96 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bd86ad57..c0551b68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,10 +136,11 @@ src/ │ ├── file_list.rs, commit_list/, tree_list.rs # the three upper-left row renderers │ ├── 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: fixed crow/bough/moon art + a twinkling -│ │ # star table, night.rs: ink → 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 +│ │ # scene (scene.rs: sprites as glyph art + an aligned ink map +│ │ # — shading in ░▒▓█, surface in the map — plus a twinkling +│ │ # star table; 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, no 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 (with no diff --git a/src/ui/splash/night.rs b/src/ui/splash/night.rs index ddee64f2..47ab1ed2 100644 --- a/src/ui/splash/night.rs +++ b/src/ui/splash/night.rs @@ -2,7 +2,7 @@ use super::scene::{self, Cell, Ink}; use ratatui::{ Frame, layout::{Alignment, Rect}, - style::{Color, Modifier, Style}, + style::{Color, Style}, text::{Line, Span}, widgets::Paragraph, }; @@ -73,15 +73,28 @@ fn paint_row(row: &[Cell], accent: Color) -> Line<'static> { 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 { - match ink { - Ink::Sky => Style::default(), - Ink::Bird => Style::default().fg(accent), - Ink::Bough => Style::default().fg(accent).add_modifier(Modifier::DIM), - Ink::Moon => Style::default().fg(Color::LightYellow), - Ink::Star { bright: true } => Style::default().fg(Color::White), - Ink::Star { bright: false } => Style::default().fg(Color::DarkGray), - } + 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. diff --git a/src/ui/splash/scene.rs b/src/ui/splash/scene.rs index c0b16f32..8cbd574c 100644 --- a/src/ui/splash/scene.rs +++ b/src/ui/splash/scene.rs @@ -3,44 +3,151 @@ //! Everything but the sky is fixed art — the bird never moves. Only the stars //! change from frame to frame, which 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: &[&str] = &[ - " ▗▄▄▄▖", - " ▟███▀▘", - "▟███▘", - "▐███", - "▐███", - "▝███▖", - " ▜███▄▖", - " ▝▀▀▀▘", -]; -const MOON_AT: (usize, usize) = (0, 37); - -/// The crow in profile, facing the moon: beak out past the eye, tail swept back -/// and down, two legs under the body. Its last row are the legs, and they land -/// on [`BOUGH`]'s top edge — see `crow_stands_on_the_bough`. -const CROW: &[&str] = &[ - " ▄▄▄▄", - " ▄███████▄", - " ▐████●████▄▄▄", - " ▀███████▀", - " ▄▄▄███████████▄", - " ▄█████████████████", - " ▄▄▄███████████████▀", - " ▀▀▀▀ ▀▀████████▀", - " ██ ██", -]; -const CROW_AT: (usize, usize) = (2, 4); - -/// The bough, with a twig rising away from the bird. -const BOUGH: &[&str] = &[ - " ▄▄▄▀▀", - " ▄▄▀▀", - "▄▄▄▄▄██████████████████████████████▄▄▄▄▄▄▄▄▄", - " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀", -]; -const BOUGH_AT: (usize, usize) = (9, 2); +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. @@ -70,37 +177,39 @@ 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; - -/// What a cell is, rather than how it looks — the palette lives in the renderer. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum Ink { - Sky, - Bird, - Bough, - Moon, - Star { bright: bool }, -} +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 = vec![vec![(' ', Ink::Sky); WIDTH]; HEIGHT]; + let mut grid = fixed().clone(); for &(row, col, phase) in STARS { - if let Some(cell) = star(tick, phase) { + // 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; } } - // Painted after the stars: the sky is behind everything else. - paint(&mut grid, MOON, MOON_AT, Ink::Moon); - paint(&mut grid, BOUGH, BOUGH_AT, Ink::Bough); - paint(&mut grid, CROW, CROW_AT, Ink::Bird); grid } +/// 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 { @@ -110,29 +219,38 @@ fn star(tick: usize, phase: usize) -> Option { } } -/// Overlay `art` at `at`, its spaces left transparent. Art that would fall off -/// the canvas is clipped rather than wrapped; `art_fits_the_canvas` guards that -/// the shipped art never needs it. -fn paint(grid: &mut [Vec], art: &[&str], at: (usize, usize), ink: Ink) { - let (row0, col0) = at; - for (i, line) in art.iter().enumerate() { +/// 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; } - if let Some(cell) = grid.get_mut(row0 + i).and_then(|r| r.get_mut(col0 + j)) { + 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, BOUGH_AT, CROW, CROW_AT, CYCLE, HEIGHT, Ink, MOON, MOON_AT, SUBJECT_HEIGHT, WIDTH, - frame, - }; + use super::{BOUGH, CROW, CYCLE, HEIGHT, Ink, Paint, SPRITES, SUBJECT_HEIGHT, WIDTH, frame}; fn inks(tick: usize) -> Vec> { frame(tick) @@ -154,19 +272,69 @@ mod tests { #[test] fn art_fits_the_canvas() { - for (name, art, at) in [ - ("moon", MOON, MOON_AT), - ("crow", CROW, CROW_AT), - ("bough", BOUGH, BOUGH_AT), - ] { - assert!(at.0 + art.len() <= HEIGHT, "{name} runs past the last row"); - for (i, line) in art.iter().enumerate() { - let end = at.1 + line.chars().count(); - assert!(end <= WIDTH, "{name} row {i} runs past the canvas: {end}"); + 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" + ); + } + } + #[test] fn only_the_sky_changes_between_frames() { let first = frame(0); @@ -174,8 +342,7 @@ mod tests { 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() { - let sky = |ink| matches!(ink, Ink::Sky | Ink::Star { .. }); - if sky(x.1) && sky(y.1) { + if !x.1.is_fixed() && !y.1.is_fixed() { continue; } assert_eq!( @@ -195,8 +362,8 @@ mod tests { #[test] fn the_crow_stands_on_the_bough() { let inks = inks(0); - let legs = CROW_AT.0 + CROW.len() - 1; - let bough_top = BOUGH_AT.0 + 2; + let legs = CROW.at.0 + CROW.art.len() - 1; + let bough_top = BOUGH.at.0 + 2; assert_eq!( legs + 1, bough_top, @@ -204,13 +371,12 @@ mod tests { ); let feet: Vec = (0..WIDTH) - .filter(|&col| inks[legs][col] == Ink::Bird) + .filter(|&col| inks[legs][col].is_bird()) .collect(); assert!(!feet.is_empty(), "no legs found on row {legs}"); for col in feet { - assert_eq!( - inks[bough_top][col], - Ink::Bough, + assert!( + inks[bough_top][col].is_bough(), "the leg at column {col} does not land on the bough" ); } @@ -222,8 +388,7 @@ mod tests { let kept = HEIGHT - SUBJECT_HEIGHT; for (row, line) in inks.iter().enumerate().take(kept) { assert!( - line.iter() - .all(|ink| !matches!(ink, Ink::Bird | Ink::Bough)), + line.iter().all(|ink| !ink.is_bird() && !ink.is_bough()), "row {row} would be cropped but holds the subject" ); } From d97fd572ec52b2a783413e833740ce75a0892c4d Mon Sep 17 00:00:00 2001 From: whackur Date: Wed, 5 Aug 2026 00:08:11 +0900 Subject: [PATCH 7/7] feat(splash): let the crow blink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One frame in fifteen the eye becomes a closed lid, which reads as a blink without the bird moving. It is the second and last thing on the screen allowed to change between frames, so the invariant that pins the drawing down now names it: sky and eye may differ, nothing else may. The blink deliberately misses frame 0 — the first thing anyone sees on the splash should be the bird looking back. --- docs/architecture.md | 9 ++--- src/ui/splash/scene.rs | 75 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index c0551b68..2bafae21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -137,10 +137,11 @@ src/ │ ├── 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 a twinkling -│ │ # star table; 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, no date crate +│ │ # — 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 (with no diff --git a/src/ui/splash/scene.rs b/src/ui/splash/scene.rs index 8cbd574c..33e1504b 100644 --- a/src/ui/splash/scene.rs +++ b/src/ui/splash/scene.rs @@ -1,7 +1,7 @@ //! The night scene: a crow perched on a bough under a crescent moon. //! -//! Everything but the sky is fixed art — the bird never moves. Only the stars -//! change from frame to frame, which is what keeps the screen alive without +//! 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 @@ -172,6 +172,17 @@ const STARS: &[(usize, usize, usize)] = &[ /// 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; @@ -195,9 +206,29 @@ pub(super) fn frame(tick: usize) -> Vec> { } } + 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(); @@ -250,7 +281,9 @@ impl Sprite { #[cfg(test)] mod tests { - use super::{BOUGH, CROW, CYCLE, HEIGHT, Ink, Paint, SPRITES, SUBJECT_HEIGHT, WIDTH, frame}; + use super::{ + BOUGH, CROW, CYCLE, HEIGHT, Ink, PERIOD, Paint, SPRITES, SUBJECT_HEIGHT, WIDTH, frame, + }; fn inks(tick: usize) -> Vec> { frame(tick) @@ -335,16 +368,21 @@ mod tests { } } + /// 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_changes_between_frames() { + fn only_the_sky_and_the_blink_change_between_frames() { let first = frame(0); - for tick in 1..CYCLE { + 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}" @@ -354,6 +392,31 @@ mod tests { } } + #[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"); @@ -410,6 +473,6 @@ mod tests { #[test] fn a_wrapped_tick_still_renders() { assert_eq!(frame(usize::MAX).len(), HEIGHT); - assert_eq!(frame(CYCLE), frame(0)); + assert_eq!(frame(PERIOD), frame(0)); } }