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`
This commit is contained in:
1 parent
5462bd8e3e
commit
f2c4b40062
46 files changed
+7135
-6
No files matched your search
@@ -0,0 +1,274 @@
|
||||
//! `+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() != " ")));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user