diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f2ec79 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,237 @@ +# AGENTS.md - Timer CLI Codebase Guide + +## Project Overview + +**Timer CLI** is a Rust-based terminal countdown timer and stopwatch with ASCII art display, sound alerts, and hardware PC speaker support. It provides a simple, elegant way to set countdown timers or run a stopwatch with large, centered FIGlet text output. + +**Repository:** https://github.com/pando85/timer +**Binary name:** `timer` +**Package name:** `timer-cli` + +## Architecture + +``` +src/ +├── main.rs # Entry point, CLI parsing, signal handling, main loop +├── opts.rs # CLI options structure (clap derive) +├── timer.rs # Core countdown logic, time string parsing +├── stopwatch.rs # Core stopwatch logic, state machine, keyboard handling +├── time.rs # Time struct, formatting, FIGlet ASCII rendering +├── ui.rs # Terminal UI (crossterm): setup, draw, restore +├── beep.rs # Linux PC speaker hardware beep (ioctl) +├── constants.rs # Timing constants for beep/sound +├── utils.rs # Thread utilities with timeout +├── figlet/ +│ ├── mod.rs # FIGlet ASCII art text conversion +│ └── univers.flf # FIGlet font file (embedded) +└── sound/ + ├── mod.rs # Audio playback via rodio + └── beep.ogg # Embedded notification sound +``` + +## Module Responsibilities + +### `main.rs` - Application Entry Point +- Parses CLI arguments using `clap` +- Routes to countdown or stopwatch based on subcommand +- Converts input time to `OffsetDateTime` target (for countdown) +- Sets up terminal (alternate screen, hidden cursor) +- Spawns countdown thread +- Handles Unix signals (SIGWINCH for resize, SIGTERM/SIGINT/SIGQUIT for exit) +- Supports loop mode (`--loop` flag) + +**Key functions:** +- `parse_time(input_time: &str) -> Option` - Parses duration or target time +- `handle_countdown(w, end, opts) -> Result<()>` - Runs countdown with terminal restoration +- `run_stopwatch()` - Runs stopwatch mode +- `run_countdown(opts)` - Runs countdown mode +- `main()` - Entry point orchestrating everything + +### `opts.rs` - CLI Options +Defines the `Opts` struct with clap derive: +```rust +pub struct Opts { + pub command: Option, // Subcommand (stopwatch) + pub r#loop: bool, // -l, --loop: Repeat countdown + pub silence: bool, // -s, --silence: Disable sounds + pub terminal_bell: bool, // -t, --terminal-bell: Enable bell character + pub time: Vec, // Positional: time input +} + +pub enum Command { + Stopwatch, // Start a stopwatch (counts up from zero) +} +``` + +**Supported time formats (countdown):** +- Duration: `10s`, `5m`, `2h10m`, `1h30m45s`, `15min`, `10` (seconds) +- Target time: `12:00`, `08:25`, `13:45:43` + +### `timer.rs` - Core Countdown Logic +- `parse_counter_time(s: &str) -> Option` - Parses duration strings using regex +- `parse_end_time(s: &str) -> Option` - Parses target time strings +- `countdown(w, end, opts) -> Result<()>` - Main tail-recursive countdown loop +- `resize_term(w, end) -> Result<()>` - Redraws on terminal resize +- `play_beep() -> Result<()>` - Plays hardware PC speaker beep +- `play_sound() -> Result<()>` - Plays audio file with timeout + +**Countdown algorithm:** Uses tail-call optimization (`tailcall` crate) to recursively count down, drawing the UI each second until the end time is reached. + +### `stopwatch.rs` - Core Stopwatch Logic +- `run(w) -> Result<()>` - Main stopwatch loop with keyboard handling +- `State` enum - State machine (Running/Paused) tracking elapsed time +- `handle_key(key) -> Action` - Maps keyboard input to actions + +**Stopwatch controls:** +- `Space` / `p` - Toggle pause/resume +- `l` / `Enter` - Record lap time +- `r` - Reset stopwatch +- `q` / `Ctrl+C` - Quit + +**State machine:** +```rust +enum State { + Running { start: Instant, accumulated: Duration }, + Paused { accumulated: Duration }, +} +``` + +### `time.rs` - Time Representation & Rendering +Defines the `Time` struct: +```rust +pub struct Time { + hours: u64, + minutes: u8, + seconds: u8, +} +``` + +**Key functions:** +- `From<&Duration> for Time` - Converts `time::Duration` to `Time` +- `format() -> String` - Returns formatted string like `2h 10m 5s` +- `render(size: (u16, u16)) -> String` - Renders as centered FIGlet ASCII art + +**Rendering strategy (graceful degradation):** +1. Full format: `2h 10m 5s` +2. If too wide: `2h 10m` (omit seconds) +3. If still too wide: `2h` (omit minutes) +4. If still too wide: plain text (no FIGlet) + +### `ui.rs` - Terminal UI Management +Uses `crossterm` for terminal operations: +- `draw(w, counter: Duration) -> Result<()>` - Clears screen, sets title, renders time +- `draw_with_laps(w, elapsed, laps, is_running) -> Result<()>` - Draws stopwatch with lap times +- `set_up_terminal(w) -> Result<()>` - Enters alternate screen, hides cursor +- `restore_terminal(w) -> Result<()>` - Shows cursor, leaves alternate screen + +### `beep.rs` - Hardware PC Speaker +Linux-specific hardware beep via ioctl: +- Searches for beep devices: `/dev/input/by-path/platform-pcspkr-event-spkr`, `/dev/console`, TTYs +- Two driver strategies: Console driver (KIOCSOUND) and Evdev driver (input_event) +- `beep(freq: i32, time: Duration) -> Result<()>` - Plays beep at frequency + +### `sound/mod.rs` - Audio Playback +Uses `rodio` crate: +- Embeds `beep.ogg` at compile time +- `Sound::new() -> Result` - Opens audio stream +- `Sound::play(&self) -> Result<()>` - Plays the embedded sound + +### `figlet/mod.rs` - ASCII Art Text +FIGlet font rendering: +- Embeds `univers.flf` font at compile time +- `Figlet::convert(&self, s: &str) -> String` - Converts text to ASCII art + +### `constants.rs` - Timing Constants +```rust +pub const BEEP_DELAY: u64 = 100; // ms between beeps +pub const BEEP_DURATION: u64 = 400; // ms per beep +pub const BEEP_REPETITIONS: usize = 5; // number of beeps +pub const BEEP_FREQ: i32 = 440; // Hz (A4 note) +``` + +### `utils.rs` - Thread Utilities +- `JoinWithTimeout` - Thread handle with timeout support +- `spawn_thread(f) -> JoinWithTimeout` - Spawns thread with completion signal +- `join(timeout) -> Option` - Waits with timeout + +## Key Dependencies + +| Crate | Purpose | +|-------|---------| +| `clap` | CLI argument parsing with derive macros | +| `crossterm` | Cross-platform terminal manipulation | +| `time` | Date/time handling and formatting | +| `regex` | Time input string parsing | +| `rodio` | Audio playback (vorbis support) | +| `signal-hook` | Unix signal handling | +| `tailcall` | Tail-call optimization for recursion | +| `libc` / `nix` | Low-level Linux ioctl for PC speaker | +| `glob` | Finding TTY devices | + +## Application Flow + +### Countdown Flow +``` +1. main() + ├── Parse CLI args → Opts + ├── parse_time(input) → OffsetDateTime + │ ├── Try parse_counter_time() for "5m30s" style + │ └── Or parse_end_time() for "12:30" style + ├── ui::set_up_terminal() + ├── Spawn countdown thread + │ └── timer::countdown() [tail-recursive] + │ ├── Calculate remaining = end - now + │ ├── ui::draw(remaining) + │ ├── sleep(1 second) + │ └── Recurse until done, then play alerts + └── Main thread: Signal handler loop + ├── SIGWINCH → resize_term() + └── SIGTERM/SIGINT/SIGQUIT → cleanup & exit +``` + +### Stopwatch Flow +``` +1. main() + ├── Parse CLI args → Opts + ├── Match Command::Stopwatch + ├── ui::set_up_terminal() + └── stopwatch::run() + ├── Initialize State::Running + └── Loop: + ├── Calculate elapsed time + ├── ui::draw_with_laps(elapsed, laps) + ├── Poll keyboard events (100ms timeout) + └── Handle key → Action (Quit/Pause/Lap/Reset) +``` + +## Testing + +Run tests with: +```bash +cargo test +``` + +## Building + +```bash +cargo build --release +``` + +The binary is optimized with LTO and symbol stripping (see `Cargo.toml` profile). + +## Code Style + +- Uses Rust 2024 edition +- Tail-call optimization for recursive functions +- Embedded assets (font, sound) at compile time +- Error handling via `Box` +- Modular separation of concerns + +## Extension Points + +When adding new features: +- **New CLI options:** Add to `Opts` struct in `opts.rs` +- **New time formats:** Extend parsing in `timer.rs` +- **UI changes:** Modify `ui.rs` and `time.rs` rendering +- **New alerts:** Add to `beep.rs` or `sound/mod.rs` +- **Subcommands:** Consider using clap subcommands in `opts.rs` diff --git a/src/main.rs b/src/main.rs index a094952..2bc92f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod constants; mod figlet; mod opts; mod sound; +mod stopwatch; mod time; mod timer; mod ui; @@ -11,7 +12,7 @@ mod utils; extern crate time as time_crate; use crate::beep::beep; -use crate::opts::Opts; +use crate::opts::{Command, Opts}; use std::io; use std::process::exit; @@ -49,6 +50,33 @@ fn handle_countdown(w: &mut W, end: OffsetDateTime, opts: &Opts) { fn main() { let opts: Opts = Opts::parse(); + // Handle stopwatch subcommand + if let Some(Command::Stopwatch) = opts.command { + run_stopwatch(); + return; + } + + // Handle countdown (default behavior) + run_countdown(opts); +} + +fn run_stopwatch() { + let mut stdout = io::stdout(); + ui::set_up_terminal(&mut stdout).unwrap(); + + match stopwatch::run(&mut stdout) { + Ok(_) => { + ui::restore_terminal(&mut stdout).unwrap(); + } + Err(e) => { + ui::restore_terminal(&mut stdout).unwrap(); + eprintln!("Error: {e:?}"); + exit(1); + } + } +} + +fn run_countdown(opts: Opts) { let input_time = opts.time.join(" "); let end = match parse_time(input_time.as_str()) { Some(x) => x, diff --git a/src/opts.rs b/src/opts.rs index 6ec3e3a..8c934ef 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -1,4 +1,4 @@ -use clap::{ArgAction, Parser, crate_authors, crate_description, crate_version}; +use clap::{ArgAction, Parser, Subcommand, crate_authors, crate_description, crate_version}; #[derive(Parser)] #[command( @@ -8,6 +8,9 @@ use clap::{ArgAction, Parser, crate_authors, crate_description, crate_version}; author = crate_authors!("\n"), )] pub struct Opts { + #[command(subcommand)] + pub command: Option, + /// Repeat countdown infinitely #[arg(short, long)] pub r#loop: bool, @@ -24,6 +27,12 @@ pub struct Opts { pub time: Vec, } +#[derive(Subcommand)] +pub enum Command { + /// Start a stopwatch (counts up from zero) + Stopwatch, +} + #[test] fn verify_cli() { use clap::CommandFactory; diff --git a/src/stopwatch.rs b/src/stopwatch.rs new file mode 100644 index 0000000..f7401a8 --- /dev/null +++ b/src/stopwatch.rs @@ -0,0 +1,132 @@ +use crate::Result; +use crate::ui; + +use std::io; +use std::thread::sleep; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::terminal; +use time::Duration as TimeDuration; + +/// Stopwatch state machine +enum State { + Running { + start: Instant, + accumulated: Duration, + }, + Paused { + accumulated: Duration, + }, +} + +impl State { + fn elapsed(&self) -> Duration { + match self { + State::Running { start, accumulated } => *accumulated + start.elapsed(), + State::Paused { accumulated } => *accumulated, + } + } + + fn toggle_pause(self) -> Self { + match self { + State::Running { start, accumulated } => State::Paused { + accumulated: accumulated + start.elapsed(), + }, + State::Paused { accumulated } => State::Running { + start: Instant::now(), + accumulated, + }, + } + } + + fn reset() -> Self { + State::Running { + start: Instant::now(), + accumulated: Duration::ZERO, + } + } + + fn is_running(&self) -> bool { + matches!(self, State::Running { .. }) + } +} + +/// Run the stopwatch loop +pub fn run(w: &mut W) -> Result<()> { + terminal::enable_raw_mode()?; + + let mut state = State::Running { + start: Instant::now(), + accumulated: Duration::ZERO, + }; + let mut laps: Vec = Vec::new(); + let mut last_drawn_secs: u64 = u64::MAX; + + loop { + let elapsed = state.elapsed(); + let current_secs = elapsed.as_secs(); + + // Only redraw if seconds changed (reduces flickering) + if current_secs != last_drawn_secs { + let time_duration = TimeDuration::new(elapsed.as_secs() as i64, 0); + ui::draw_with_laps(w, time_duration, &laps, state.is_running())?; + last_drawn_secs = current_secs; + } + + // Poll for events with a short timeout + if event::poll(Duration::from_millis(50))? + && let Event::Key(key_event) = event::read()? + { + match handle_key(key_event) { + Action::Quit => break, + Action::TogglePause => { + state = state.toggle_pause(); + // Force redraw on state change + last_drawn_secs = u64::MAX; + } + Action::Lap => { + if state.is_running() { + laps.push(state.elapsed()); + // Force redraw on lap + last_drawn_secs = u64::MAX; + } + } + Action::Reset => { + laps.clear(); + state = State::reset(); + // Force redraw on reset + last_drawn_secs = u64::MAX; + } + Action::None => {} + } + } + + // Small sleep to prevent busy-waiting + if state.is_running() { + sleep(Duration::from_millis(10)); + } + } + + terminal::disable_raw_mode()?; + Ok(()) +} + +enum Action { + Quit, + TogglePause, + Lap, + Reset, + None, +} + +fn handle_key(key: KeyEvent) -> Action { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit, + KeyCode::Char(' ') | KeyCode::Char('p') => Action::TogglePause, + KeyCode::Char('l') | KeyCode::Enter => Action::Lap, + KeyCode::Char('r') => Action::Reset, + _ => Action::None, + } +} diff --git a/src/ui.rs b/src/ui.rs index 7cb11ca..8dc7021 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,6 +2,7 @@ use crate::Result; use crate::time::Time; use std::io; +use std::time::Duration as StdDuration; use crossterm::cursor; use crossterm::execute; @@ -27,6 +28,121 @@ where Ok(()) } +pub fn draw_with_laps( + w: &mut W, + elapsed: Duration, + laps: &[StdDuration], + is_running: bool, +) -> Result<()> +where + W: io::Write, +{ + let elapsed_time = Time::from(&elapsed); + let size = terminal::size()?; + + // Reserve space for laps and status at the bottom + let laps_lines = if laps.is_empty() { 0 } else { 2 }; + let status_lines = 2; + let reserved_lines = laps_lines + status_lines; + let adjusted_size = (size.0, size.1.saturating_sub(reserved_lines)); + + let status = if is_running { "RUNNING" } else { "PAUSED" }; + let title = format!("Stopwatch - {} - {}", elapsed_time.format(), status); + + execute!( + w, + terminal::SetTitle(&title), + cursor::MoveTo(0, 0), + terminal::Clear(terminal::ClearType::All), + )?; + + // Draw the main time display (dimmed when paused) + let s = elapsed_time.render(adjusted_size); + if !is_running { + execute!(w, style::SetForegroundColor(style::Color::DarkGrey))?; + } + // In raw mode, we need \r\n for proper line breaks + for line in s.split('\n') { + execute!( + w, + crossterm::style::Print(line), + crossterm::style::Print("\r\n") + )?; + } + if !is_running { + execute!(w, style::SetForegroundColor(style::Color::Reset))?; + } + + // Draw laps at the bottom + if !laps.is_empty() { + let laps_str = format_laps(laps); + let laps_y = size.1.saturating_sub(reserved_lines); + execute!( + w, + cursor::MoveTo(0, laps_y), + crossterm::style::Print(format!("Laps: {}\r\n", laps_str)) + )?; + } + + // Draw status/controls at the very bottom + let status_y = size.1.saturating_sub(1); + execute!(w, cursor::MoveTo(0, status_y))?; + + // Draw legend with bold key letters + // Show Play/Pause based on current state + let action = if is_running { "ause" } else { "lay" }; + + execute!( + w, + style::SetAttribute(style::Attribute::Bold), + style::Print("P"), + style::SetAttribute(style::Attribute::Reset), + style::Print(action), + style::SetForegroundColor(style::Color::DarkGrey), + style::Print("(space)"), + style::SetForegroundColor(style::Color::Reset), + style::Print(" "), + style::SetAttribute(style::Attribute::Bold), + style::Print("L"), + style::SetAttribute(style::Attribute::Reset), + style::Print("ap "), + style::SetAttribute(style::Attribute::Bold), + style::Print("R"), + style::SetAttribute(style::Attribute::Reset), + style::Print("eset "), + style::SetAttribute(style::Attribute::Bold), + style::Print("Q"), + style::SetAttribute(style::Attribute::Reset), + style::Print("uit"), + )?; + + w.flush()?; + Ok(()) +} + +fn format_laps(laps: &[StdDuration]) -> String { + laps.iter() + .enumerate() + .map(|(i, lap)| { + let secs = lap.as_secs(); + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + + let time_str = if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + }; + + format!("[{}] {}", i + 1, time_str) + }) + .collect::>() + .join(" ") +} + pub fn set_up_terminal(w: &mut W) -> std::io::Result<()> where W: io::Write,