clck 0.1.11

A responsive cross-platform countdown alarm for the terminal.
Documentation
use crate::fonts::{FontCatalog, Size};
use anyhow::Result;
use crossterm::{
    cursor::{Hide, MoveTo, Show},
    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
    execute, queue,
    style::Print,
    terminal::{
        self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
        LeaveAlternateScreen,
    },
};
use std::{
    io::{self, Write},
    time::Duration,
};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayEvent {
    None,
    Cancel,
    Dismiss,
    Resize,
    TogglePause,
}

pub fn event_from_key(key: KeyEvent, ringing: bool) -> DisplayEvent {
    if ringing {
        return DisplayEvent::Dismiss;
    }
    match key.code {
        KeyCode::Char('q') | KeyCode::Esc => DisplayEvent::Cancel,
        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => DisplayEvent::Cancel,
        KeyCode::Char(' ') => DisplayEvent::TogglePause,
        _ => DisplayEvent::None,
    }
}

pub struct TerminalSession;

impl TerminalSession {
    pub fn enter() -> Result<Self> {
        enable_raw_mode()?;
        execute!(io::stdout(), EnterAlternateScreen, Hide)?;
        Ok(Self)
    }

    pub fn next_event(timeout: Duration, ringing: bool) -> Result<DisplayEvent> {
        if !event::poll(timeout)? {
            return Ok(DisplayEvent::None);
        }
        Ok(match event::read()? {
            Event::Key(key) => event_from_key(key, ringing),
            Event::Resize(_, _) => DisplayEvent::Resize,
            _ => DisplayEvent::None,
        })
    }

    pub fn render_countdown(
        &self,
        remaining: Duration,
        preferred_font: &str,
        sound: &str,
        target: Option<&str>,
        title: Option<&str>,
        paused: bool,
    ) -> Result<()> {
        let (width, height) = terminal::size()?;
        let text = format_duration(remaining);
        let catalog = FontCatalog::default();
        let available = Size::new(width.saturating_sub(2), height.saturating_sub(4));
        let lines = catalog
            .largest_fit_preferring(preferred_font, &text, available)
            .map(|font| font.render(&text))
            .unwrap_or_else(|| vec![text]);

        let full_status = countdown_status(sound, target, title, paused);
        let title_status = countdown_status(sound, None, title, paused);
        let target_status = countdown_status(sound, target, None, paused);
        let compact_status = countdown_status(sound, None, None, paused);

        let status = if full_status.chars().count() <= usize::from(width) {
            Some(full_status)
        } else if title_status.chars().count() <= usize::from(width) {
            Some(title_status)
        } else if target_status.chars().count() <= usize::from(width) {
            Some(target_status)
        } else if compact_status.chars().count() <= usize::from(width) {
            Some(compact_status)
        } else {
            None
        };
        render_lines(&lines, status.as_deref())
    }

    pub fn render_stopwatch(
        &self,
        elapsed: Duration,
        preferred_font: &str,
        title: Option<&str>,
        paused: bool,
    ) -> Result<()> {
        let (width, height) = terminal::size()?;
        let text = format_elapsed(elapsed);
        let catalog = FontCatalog::default();
        let available = Size::new(width.saturating_sub(2), height.saturating_sub(4));
        let lines = catalog
            .largest_fit_preferring(preferred_font, &text, available)
            .map(|font| font.render(&text))
            .unwrap_or_else(|| vec![text]);

        let status = stopwatch_status(title, paused);
        let status = if status.chars().count() <= usize::from(width) {
            Some(status)
        } else {
            None
        };
        render_lines(&lines, status.as_deref())
    }

    pub fn render_ringing(&self, target: Option<&str>, title: Option<&str>) -> Result<()> {
        let status = match (title, target) {
            (Some(title), Some(target)) => {
                format!("Title: {title} | Target: {target} | Press any key to dismiss")
            }
            (Some(title), None) => format!("Title: {title} | Press any key to dismiss"),
            (None, Some(target)) => format!("Target: {target} | Press any key to dismiss"),
            (None, None) => "Press any key to dismiss".to_owned(),
        };
        render_lines(&["TIME IS UP!".to_owned()], Some(&status))
    }
}

impl Drop for TerminalSession {
    fn drop(&mut self) {
        let _ = execute!(io::stdout(), Show, LeaveAlternateScreen);
        let _ = disable_raw_mode();
    }
}

fn render_lines(lines: &[String], status: Option<&str>) -> Result<()> {
    let mut stdout = io::stdout();
    queue!(stdout, Clear(ClearType::All), MoveTo(0, 0))?;
    for line in lines {
        queue!(stdout, Print(line), Print("\r\n"))?;
    }
    if let Some(status) = status {
        queue!(stdout, Print("\r\n"), Print(status))?;
    }
    stdout.flush()?;
    Ok(())
}

pub fn format_duration(duration: Duration) -> String {
    let seconds = duration.as_secs() + u64::from(duration.subsec_nanos() > 0);
    let hours = seconds / 3_600;
    let minutes = (seconds % 3_600) / 60;
    let seconds = seconds % 60;
    if hours > 0 {
        format!("{hours:02}:{minutes:02}:{seconds:02}")
    } else {
        format!("{minutes:02}:{seconds:02}")
    }
}

pub fn format_elapsed(duration: Duration) -> String {
    let seconds = duration.as_secs();
    let hours = seconds / 3_600;
    let minutes = (seconds % 3_600) / 60;
    let secs = seconds % 60;
    if hours > 0 {
        format!("{hours:02}:{minutes:02}:{secs:02}")
    } else {
        format!("{minutes:02}:{secs:02}")
    }
}

pub fn stopwatch_status(title: Option<&str>, paused: bool) -> String {
    let mut parts = Vec::new();
    if let Some(title) = title {
        parts.push(format!("Title: {title}"));
    }
    if paused {
        parts.push("PAUSED | Space to resume | q/Esc/Ctrl+C to stop".to_owned());
    } else {
        parts.push("Space to pause | q/Esc/Ctrl+C to stop".to_owned());
    }
    parts.join(" | ")
}

pub fn countdown_status(
    sound: &str,
    target: Option<&str>,
    title: Option<&str>,
    paused: bool,
) -> String {
    let mut parts = Vec::new();
    if let Some(title) = title {
        parts.push(format!("Title: {title}"));
    }
    if let Some(target) = target {
        parts.push(format!("Target: {target}"));
    }
    parts.push(format!("Sound: {sound}"));
    if paused {
        parts.push("PAUSED | Space to resume | q/Esc/Ctrl+C to cancel".to_owned());
    } else {
        parts.push("Space to pause | q/Esc/Ctrl+C to cancel".to_owned());
    }
    parts.join(" | ")
}

#[cfg(test)]
mod tests {
    use super::{countdown_status, event_from_key, format_elapsed, stopwatch_status, DisplayEvent};
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use std::time::Duration;

    #[test]
    fn maps_countdown_cancel_keys() {
        assert_eq!(
            event_from_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), false),
            DisplayEvent::Cancel
        );
        assert_eq!(
            event_from_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), false),
            DisplayEvent::Cancel
        );
        assert_eq!(
            event_from_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), true),
            DisplayEvent::Dismiss
        );
        assert_eq!(
            event_from_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE), false),
            DisplayEvent::TogglePause
        );
    }

    #[test]
    fn format_elapsed_floors_to_whole_seconds() {
        assert_eq!(format_elapsed(Duration::from_secs(0)), "00:00");
        assert_eq!(format_elapsed(Duration::from_millis(999)), "00:00");
        assert_eq!(format_elapsed(Duration::from_secs(65)), "01:05");
        assert_eq!(format_elapsed(Duration::from_secs(3661)), "01:01:01");
    }

    #[test]
    fn stopwatch_status_reflects_pause_state() {
        assert_eq!(
            stopwatch_status(None, false),
            "Space to pause | q/Esc/Ctrl+C to stop"
        );
        assert_eq!(
            stopwatch_status(None, true),
            "PAUSED | Space to resume | q/Esc/Ctrl+C to stop"
        );
        assert_eq!(
            stopwatch_status(Some("Build"), false),
            "Title: Build | Space to pause | q/Esc/Ctrl+C to stop"
        );
    }

    #[test]
    fn countdown_status_includes_optional_target_and_title() {
        assert_eq!(
            countdown_status("Glass", Some("2026-06-11 09:00 EDT"), Some("Lunch"), false),
            "Title: Lunch | Target: 2026-06-11 09:00 EDT | Sound: Glass | Space to pause | q/Esc/Ctrl+C to cancel"
        );
        assert_eq!(
            countdown_status("Glass", Some("2026-06-11 09:00 EDT"), None, false),
            "Target: 2026-06-11 09:00 EDT | Sound: Glass | Space to pause | q/Esc/Ctrl+C to cancel"
        );
        assert_eq!(
            countdown_status("Glass", None, Some("Lunch"), false),
            "Title: Lunch | Sound: Glass | Space to pause | q/Esc/Ctrl+C to cancel"
        );
        assert_eq!(
            countdown_status("Glass", None, None, false),
            "Sound: Glass | Space to pause | q/Esc/Ctrl+C to cancel"
        );
        assert_eq!(
            countdown_status("Glass", None, None, true),
            "Sound: Glass | PAUSED | Space to resume | q/Esc/Ctrl+C to cancel"
        );
    }
}