Files
ghostty-rs/crates/ghostty-cli/src/boo.rs
T
ddidderr 6286e02f07 feat(cli): port ghostty +action CLI commands
Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with
per-action --help, and the actions help, version, list-actions,
list-colors, list-keybinds, list-themes (plain listing plus an
interactive ratatui browser mirroring Ghostty's libvaxis UI),
show-config, validate-config, edit-config, crash-report (listing),
ssh-cache and boo (235 embedded frames).

Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout,
stderr and exit codes of most actions, incl. tty output in a pty at
widths 20-200. Known differences: +list-colors --plain prints all 782
colors (Ghostty truncates at 617 because it never flushes), +version
has placeholder build info until build_info::set is wired up.

Deferred: list-fonts, show-face (need font discovery integration) and
new-window (needs GTK IPC); they return NotYetAvailable for now.

Test Plan:
- cargo test -p ghostty-cli (86 tests)
- cargo run -p ghostty-cli --example cli -- +list-keybinds --plain
  and diff against `ghostty +list-keybinds --plain`
2026-10-11 22:22:01 +02:00

275 lines
9.0 KiB
Rust

//! `+boo` (ZIG/src/cli/boo.zig): the ghost animation from the Ghostty
//! website, played in the terminal.
//!
//! The frames are `src/build/framegen/frames/frame_*.txt` from the Zig tree,
//! joined with `\x01` and raw-deflate compressed into `boo_frames.deflate`
//! (the same format `framegen` produces for Zig's `framedata`).
use std::io::Read;
use std::time::{Duration, Instant};
use flate2::read::DeflateDecoder;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use crate::error::Error;
use crate::options::NoOptions as Options;
static FRAME_DATA: &[u8] = include_bytes!("boo_frames.deflate");
/// Width of a single frame
pub const FRAME_WIDTH: u16 = 100;
/// Height of a single frame
pub const FRAME_HEIGHT: u16 = 41;
/// One cell of an animation frame: the character and whether it is part of
/// the outline (drawn with the outline style).
pub type FrameCell = (char, bool);
/// Decompress the frames into a list of individual frames
/// (Zig: `decompressFrames`).
pub fn decompress_frames() -> Result<Vec<String>, Error> {
let mut data = Vec::new();
DeflateDecoder::new(FRAME_DATA).read_to_end(&mut data)?;
let text = String::from_utf8(data).map_err(|e| Error::Other(e.to_string()))?;
Ok(text.split('\u{1}').map(str::to_string).collect())
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Normal,
Span,
InTag,
InClosingTag,
}
/// Convert a frame to its cells (Zig: the body of `updateFrame`).
///
/// A frame is characters with html spans. When we encounter a span, we use
/// the outline style until the span ends. That is, when we find a '<', we
/// parse until '>'. Then we use the outline style until the next '<', and
/// skip until the next '>'.
#[must_use]
pub fn parse_frame(frame: &str) -> Vec<FrameCell> {
let mut cells = Vec::with_capacity(usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT));
for line in frame.split('\n') {
let mut state = State::Normal;
let mut outline = false;
for ch in line.chars() {
match state {
State::Normal => {
if ch == '<' {
state = State::InTag;
// We will be entering a span
outline = true;
continue;
}
}
State::Span => {
if ch == '<' {
state = State::InTag;
outline = false;
continue;
}
}
State::InTag => {
// If we encounter a '/', we are a closing tag
// If we parse all the way to a '>' we are an opening tag: we are now in a span
if ch == '/' {
state = State::InClosingTag;
} else if ch == '>' {
state = State::Span;
}
continue;
}
State::InClosingTag => {
// If we are closing a tag, we will enter the normal state
if ch == '>' {
state = State::Normal;
}
continue;
}
}
cells.push((ch, outline));
}
}
cells
}
/// The animation state (Zig: `Boo`).
pub struct Boo {
frames: Vec<String>,
frame: usize,
/// 30 fps
pub framerate: Duration,
buffer: Vec<FrameCell>,
}
impl Boo {
pub fn new() -> Result<Boo, Error> {
Ok(Boo {
frames: decompress_frames()?,
frame: 0,
framerate: Duration::from_millis(1000 / 30),
buffer: vec![(' ', false); usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)],
})
}
/// Updates our internal buffer with the current frame, then advances the
/// frame index.
pub fn update_frame(&mut self) {
self.buffer = parse_frame(&self.frames[self.frame]);
debug_assert_eq!(
self.buffer.len(),
usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT)
);
// Lastly, update the frame index
self.frame += 1;
if self.frame == self.frames.len() {
self.frame = 0;
}
}
#[must_use]
pub fn frame_count(&self) -> usize {
self.frames.len()
}
/// Draw the current frame centered in `buf`.
pub fn draw(&self, buf: &mut Buffer) {
let area = buf.area;
// Warn for screen size
if area.width < FRAME_WIDTH || area.height < FRAME_HEIGHT {
let text = "Screen must be at least 100w x 41h";
let w = u16::try_from(text.len()).unwrap_or(0);
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height / 2;
buf.set_stringn(x, y, text, usize::from(area.width), Style::default());
return;
}
// Calculate x and y offsets to center the animation frame
let offset_y = area.y + (area.height - FRAME_HEIGHT) / 2;
let offset_x = area.x + (area.width - FRAME_WIDTH) / 2;
let ghostty_style = Style::default();
let outline_style = Style::default().fg(Color::Indexed(4));
let frame_area = Rect::new(offset_x, offset_y, FRAME_WIDTH, FRAME_HEIGHT);
for (i, (ch, outline)) in self.buffer.iter().enumerate() {
let i = u16::try_from(i).unwrap_or(0);
let x = frame_area.x + i % FRAME_WIDTH;
let y = frame_area.y + i / FRAME_WIDTH;
if let Some(cell) = buf.cell_mut((x, y)) {
let mut tmp = [0u8; 4];
cell.set_symbol(ch.encode_utf8(&mut tmp));
cell.set_style(if *outline {
outline_style
} else {
ghostty_style
});
}
}
}
}
/// The `boo` command is used to display the animation from the Ghostty website in the terminal
pub fn run(args: &[String]) -> Result<u8, Error> {
let mut opts = Options;
crate::parse_options(&mut opts, args)?;
let mut boo = Boo::new()?;
let mut terminal = ratatui::try_init()?;
let result = run_loop(&mut terminal, &mut boo);
ratatui::restore();
result?;
Ok(0)
}
fn run_loop(terminal: &mut ratatui::DefaultTerminal, boo: &mut Boo) -> Result<(), Error> {
let mut next_tick = Instant::now();
loop {
if Instant::now() >= next_tick {
boo.update_frame();
next_tick = Instant::now() + boo.framerate;
}
terminal.draw(|f| boo.draw(f.buffer_mut()))?;
let timeout = next_tick.saturating_duration_since(Instant::now());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Release
&& ((key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL))
|| key.code == KeyCode::Esc)
{
return Ok(());
}
}
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn all_frames_decode_to_full_screen() {
let frames = decompress_frames().unwrap();
assert_eq!(235, frames.len());
for (i, f) in frames.iter().enumerate() {
assert_eq!(
usize::from(FRAME_WIDTH) * usize::from(FRAME_HEIGHT),
parse_frame(f).len(),
"frame {i}"
);
}
}
#[test]
fn spans_use_outline_style() {
// normal, then a span (outline), then normal again.
let cells = parse_frame("a<b>bc</b>d");
assert_eq!(
vec![('a', false), ('b', true), ('c', true), ('d', false)],
cells
);
// Style resets on each line (a closing tag seen in the normal state keeps the outline, like Zig).
let cells = parse_frame("<b>x\ny</b>z");
assert_eq!(vec![('x', true), ('y', false), ('z', true)], cells);
}
#[test]
fn frame_advances_and_wraps() {
let mut boo = Boo::new().unwrap();
for _ in 0..boo.frame_count() {
boo.update_frame();
}
assert_eq!(0, boo.frame);
}
#[test]
fn small_screen_warning() {
let boo = Boo::new().unwrap();
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 10));
boo.draw(&mut buf);
let line: String = (0..60).map(|x| buf[(x, 5)].symbol().to_string()).collect();
assert!(line.contains("Screen must be at least 100w x 41h"));
}
#[test]
fn draw_centers_frame() {
let mut boo = Boo::new().unwrap();
boo.update_frame();
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 50));
boo.draw(&mut buf);
// Anything outside the frame area stays blank.
assert_eq!(" ", buf[(0, 0)].symbol());
assert!((10..110).any(|x| (4..45).any(|y| buf[(x, y)].symbol() != " ")));
}
}