27 Commits
Author SHA1 Message Date
Stevan Freeborn 9583e9b7f6 chore: bump version 2026-07-28 10:24:32 -05:00
Stevan 7411bdc4a7 Merge pull request 'feat: refactor architecture' (#4) from stevanfreeborn/feat/refactor-architecture into main 2026-07-28 15:22:12 +00:00
Stevan Freeborn 15e0fd9d08 refactor: extract constants 2026-07-28 10:20:37 -05:00
Stevan Freeborn a62f54e85b refactor: extract argument parsing to separate module 2026-07-28 09:55:17 -05:00
Stevan Freeborn a92bfe79cc refactor: update main.rs to use new architecture 2026-07-28 09:33:50 -05:00
Stevan Freeborn c8448c364e refactor: isolate rendering 2026-07-28 09:33:31 -05:00
Stevan Freeborn a2b3b23cc1 refactor: extract termato struct and implementations 2026-07-28 09:33:23 -05:00
Stevan Freeborn ec61470256 refactor: implement event handling 2026-07-28 09:33:03 -05:00
Stevan Freeborn 06d8a6fa8a refactor: extract terminal guard 2026-07-28 09:32:43 -05:00
Stevan Freeborn dc1b08095c refactor: extract notification sending 2026-07-28 09:32:31 -05:00
Stevan Freeborn 19a3d0927e refactor: extract audio visualizer 2026-07-28 09:32:16 -05:00
Stevan Freeborn 6acc79a5d1 refactor: extract message enum 2026-07-28 09:31:58 -05:00
Stevan Freeborn ddd3fee887 fix: use description instead of name 2026-07-27 22:04:14 -05:00
Stevan Freeborn b6a59f6563 chore: declare apt dependency for linux builds 2026-07-27 21:54:37 -05:00
Stevan Freeborn 575b70d4ba chore: update cargo dist 2026-07-27 21:43:16 -05:00
Stevan b0a46f0710 Merge pull request 'feat: add audio visualizer' (#3) from stevanfreeborn/feat/add-audio-visualizer into main 2026-07-28 02:36:03 +00:00
Stevan Freeborn 3b0cb7e82b chore: bump version 2026-07-27 21:35:12 -05:00
Stevan Freeborn 4d4c157f3a feat: apply my theme colors to app 2026-07-27 17:43:56 -05:00
Stevan Freeborn b047bd4324 fix: add semi colon and clean up whitespace 2026-07-27 17:38:41 -05:00
Stevan Freeborn 366c481827 fix: prever copied over cloned 2026-07-27 17:37:04 -05:00
Stevan Freeborn 01765bf115 tests: add test for with_visualizer 2026-07-27 17:36:33 -05:00
Stevan Freeborn c440f75a4a fix: account for all options when collecting positional arguments 2026-07-27 17:36:02 -05:00
Stevan Freeborn 714aebc2ed fix: clamp bar values to width that is available 2026-07-27 17:34:38 -05:00
Stevan Freeborn e3edf57745 fix: use proper arc method 2026-07-27 17:32:53 -05:00
Stevan Freeborn 627969a23b fix: pass flag to clone 2026-07-27 17:32:24 -05:00
Stevan Freeborn b7f34f795e fix: address thread shutdown
- replace unwrap() calls with checks
- add stop flag to allow thread shutdown
- implement drop trait to automatically set the stop flag
2026-07-27 17:31:47 -05:00
Stevan Freeborn 8c559d605e chore: remove pull request trigger from workflow 2026-07-27 17:17:23 -05:00
14 changed files with 1096 additions and 771 deletions
+12 -12
View File
@@ -64,9 +64,9 @@ jobs:
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.32.0/cargo-dist-installer.sh | sh"
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
@@ -82,7 +82,7 @@ jobs:
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
@@ -131,7 +131,7 @@ jobs:
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -158,7 +158,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
@@ -180,14 +180,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -205,7 +205,7 @@ jobs:
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
name: artifacts-build-global
path: |
@@ -230,14 +230,14 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: target/distrib/
@@ -250,14 +250,14 @@ jobs:
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v6
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v8
uses: actions/download-artifact@v7
with:
pattern: artifacts-*
path: artifacts
Generated
+1 -1
View File
@@ -2120,7 +2120,7 @@ dependencies = [
[[package]]
name = "termato"
version = "0.0.0"
version = "0.1.0"
dependencies = [
"cpal",
"crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "termato"
version = "0.0.0"
version = "0.1.1"
edition = "2024"
repository = "https://github.com/StevanFreeborn/termato"
+8 -1
View File
@@ -4,10 +4,17 @@ members = ["cargo:."]
# Config for 'dist'
[dist]
# The preferred dist version to use in CI (Cargo.toml SemVer syntax)
cargo-dist-version = "0.32.0"
cargo-dist-version = "0.31.0"
# CI backends to support
ci = "github"
# The installers to generate for each app
installers = ["shell", "powershell"]
# Target platforms to build apps for (Rust target-triple syntax)
targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
# Path that installers should place binaries in
install-path = "CARGO_HOME"
# Whether to install an updater program
install-updater = true
[dist.dependencies.apt]
libasound2-dev = "*"
+271
View File
@@ -0,0 +1,271 @@
use crate::constants::SECONDS_PER_MIN;
use crate::msg::Message;
use crate::notification::send_desktop_notification;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TimerMode {
Work,
Break,
}
pub struct Termato {
pub work_mins: u32,
pub break_mins: u32,
pub mode: TimerMode,
pub is_running: bool,
pub duration_in_secs: u32,
pub time_remaining_in_sec: u32,
pub show_help: bool,
pub enable_notifications: bool,
should_quit: bool,
}
impl Termato {
pub fn new(work_mins: u32, break_mins: u32) -> Self {
Termato {
work_mins,
break_mins,
mode: TimerMode::Work,
is_running: false,
duration_in_secs: work_mins * SECONDS_PER_MIN,
time_remaining_in_sec: work_mins * SECONDS_PER_MIN,
show_help: false,
enable_notifications: false,
should_quit: false,
}
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn with_notifications(mut self, enable: bool) -> Self {
self.enable_notifications = enable;
self
}
pub fn update(&mut self, msg: Message) {
match msg {
Message::Tick => self.tick(),
Message::ToggleRunning => self.toggle_running(),
Message::ToggleMode => self.toggle_mode(),
Message::Reset => self.reset(),
Message::ToggleHelp => self.toggle_help(),
Message::Quit => self.should_quit = true,
}
}
pub fn toggle_running(&mut self) {
self.is_running = !self.is_running;
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
TimerMode::Work => TimerMode::Break,
TimerMode::Break => TimerMode::Work,
};
self.duration_in_secs = match self.mode {
TimerMode::Work => self.work_mins * SECONDS_PER_MIN,
TimerMode::Break => self.break_mins * SECONDS_PER_MIN,
};
self.time_remaining_in_sec = self.duration_in_secs;
}
pub fn tick(&mut self) {
if self.is_running && self.time_remaining_in_sec > 0 {
self.time_remaining_in_sec -= 1;
if self.time_remaining_in_sec == 0 {
if self.enable_notifications {
match self.mode {
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
}
}
self.toggle_mode();
}
}
}
pub fn reset(&mut self) {
self.is_running = false;
self.mode = TimerMode::Work;
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN;
self.time_remaining_in_sec = self.duration_in_secs;
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn termato_new_when_called_it_should_return_expected_starting_state() {
let result = Termato::new(25, 5);
assert_eq!(result.work_mins, 25);
assert_eq!(result.break_mins, 5);
assert_eq!(result.mode, TimerMode::Work);
assert!(!result.is_running);
assert_eq!(result.duration_in_secs, 1500);
assert_eq!(result.time_remaining_in_sec, 1500);
assert!(!result.show_help);
assert!(!result.enable_notifications);
assert!(!result.should_quit);
}
#[test]
fn termato_update_when_called_with_messages_it_should_update_state() {
let mut termato = Termato::new(25, 5);
termato.update(Message::ToggleRunning);
assert!(termato.is_running);
termato.update(Message::ToggleHelp);
assert!(termato.show_help);
termato.update(Message::ToggleMode);
assert_eq!(termato.mode, TimerMode::Break);
termato.update(Message::Reset);
assert!(!termato.is_running);
assert_eq!(termato.mode, TimerMode::Work);
termato.update(Message::Quit);
assert!(termato.should_quit);
}
#[test]
fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.update(Message::Tick);
assert_eq!(termato.time_remaining_in_sec, 60);
}
#[test]
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.time_remaining_in_sec = 0;
termato.update(Message::Tick);
assert_eq!(termato.time_remaining_in_sec, 0);
}
#[test]
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.update(Message::Tick);
assert_eq!(termato.time_remaining_in_sec, 59);
}
#[test]
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
let mut termato = Termato::new(1, 2);
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.update(Message::Tick);
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
let mut termato = Termato::new(2, 1);
termato.mode = TimerMode::Break;
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.update(Message::Tick);
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
let mut termato = Termato::new(2, 1);
termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.toggle_running();
assert!(!termato.is_running)
}
#[test]
fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
let mut termato = Termato::new(1, 1);
termato.toggle_running();
assert!(termato.is_running)
}
#[test]
fn termato_rest_when_called_it_should_reset_the_apps_state() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.mode = TimerMode::Break;
termato.duration_in_secs = 1;
termato.time_remaining_in_sec = 1;
termato.reset();
assert!(!termato.is_running);
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
}
#[test]
fn termato_toggle_help_when_called_it_should_toggle_help() {
let mut termato = Termato::new(1, 1);
termato.toggle_help();
assert!(termato.show_help);
termato.toggle_help();
assert!(!termato.show_help);
}
#[test]
fn termato_notifications_when_called_it_should_verify_opt_in_behavior() {
let result = Termato::new(25, 5);
assert!(!result.enable_notifications);
let result = result.with_notifications(true);
assert!(result.enable_notifications);
}
}
+201
View File
@@ -0,0 +1,201 @@
use std::env;
use crate::constants::{DEFAULT_BREAK_MINS, DEFAULT_WORK_MINS};
pub const FLAG_HELP: &str = "-h";
pub const FLAG_HELP_LONG: &str = "--help";
pub const FLAG_VERSION: &str = "-v";
pub const FLAG_VERSION_LONG: &str = "--version";
pub const FLAG_NOTIFY: &str = "-n";
pub const FLAG_NOTIFY_LONG: &str = "--notify";
pub const FLAG_VISUALIZER: &str = "-z";
pub const FLAG_VISUALIZER_LONG: &str = "--visualizer";
#[derive(Debug, PartialEq, Eq)]
pub struct CliConfig {
pub work_mins: u32,
pub break_mins: u32,
pub enable_notifications: bool,
pub enable_visualizer: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub enum CliAction {
Run(CliConfig),
PrintHelp,
PrintVersion,
Error(String),
}
pub struct Cli;
impl Cli {
pub fn parse() -> CliAction {
Self::parse_args(env::args().collect())
}
pub fn parse_args(args: Vec<String>) -> CliAction {
let mut enable_notifications = false;
let mut enable_visualizer = false;
let mut positional = Vec::new();
for arg in args.into_iter().skip(1) {
match arg.as_str() {
FLAG_HELP | FLAG_HELP_LONG => return CliAction::PrintHelp,
FLAG_VERSION | FLAG_VERSION_LONG => return CliAction::PrintVersion,
FLAG_NOTIFY | FLAG_NOTIFY_LONG => enable_notifications = true,
FLAG_VISUALIZER | FLAG_VISUALIZER_LONG => enable_visualizer = true,
"--" => {}
s if s.starts_with('-') => {
return CliAction::Error(format!("Unknown option: '{}'", s));
}
s => positional.push(s.to_string()),
}
}
let numbers: Vec<u32> = positional
.iter()
.filter_map(|s| s.parse::<u32>().ok())
.collect();
let work_mins = numbers.first().copied().unwrap_or(DEFAULT_WORK_MINS);
let break_mins = numbers.get(1).copied().unwrap_or(DEFAULT_BREAK_MINS);
if numbers.len() > 2 {
return CliAction::Error(format!("Unexpected argument: '{}'", positional[2]));
}
CliAction::Run(CliConfig {
work_mins,
break_mins,
enable_notifications,
enable_visualizer,
})
}
pub fn print_help() {
println!("Usage: termato [options] [work_minutes] [break_minutes]");
println!();
println!("Options:");
println!(
" {:<19} Enable desktop notifications",
format_args!("{}, {}", FLAG_NOTIFY, FLAG_NOTIFY_LONG)
);
println!(
" {:<19} Display visualizer of playing audio",
format_args!("{}, {}", FLAG_VISUALIZER, FLAG_VISUALIZER_LONG)
);
println!(
" {:<19} Print this help message",
format_args!("{}, {}", FLAG_HELP, FLAG_HELP_LONG)
);
println!(
" {:<19} Print the version number",
format_args!("{}, {}", FLAG_VERSION, FLAG_VERSION_LONG)
);
println!();
println!(
"Defaults: work_minutes = {}, break_minutes = {}",
DEFAULT_WORK_MINS, DEFAULT_BREAK_MINS
);
}
pub fn print_version() {
println!("termato version {}", env!("CARGO_PKG_VERSION"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults() {
let args = vec!["termato".to_string()];
assert_eq!(
Cli::parse_args(args),
CliAction::Run(CliConfig {
work_mins: 25,
break_mins: 5,
enable_notifications: false,
enable_visualizer: false,
})
);
}
#[test]
fn test_cargo_run_syntax_with_dot_and_dash_dash() {
let args = vec![
"target/debug/termato.exe".to_string(),
".".to_string(),
"--".to_string(),
"-z".to_string(),
];
assert_eq!(
Cli::parse_args(args),
CliAction::Run(CliConfig {
work_mins: 25,
break_mins: 5,
enable_notifications: false,
enable_visualizer: true,
})
);
}
#[test]
fn test_custom_time_and_flags() {
let args = vec![
"termato".to_string(),
"-n".to_string(),
"-z".to_string(),
"50".to_string(),
"10".to_string(),
];
assert_eq!(
Cli::parse_args(args),
CliAction::Run(CliConfig {
work_mins: 50,
break_mins: 10,
enable_notifications: true,
enable_visualizer: true,
})
);
}
#[test]
fn test_help_flag() {
let args = vec!["termato".to_string(), "--help".to_string()];
assert_eq!(Cli::parse_args(args), CliAction::PrintHelp);
}
#[test]
fn test_unknown_flag() {
let args = vec!["termato".to_string(), "--foo".to_string()];
assert_eq!(
Cli::parse_args(args),
CliAction::Error("Unknown option: '--foo'".to_string())
);
}
#[test]
fn test_non_numeric_positional_defaults() {
let args = vec!["termato".to_string(), "abc".to_string()];
assert_eq!(
Cli::parse_args(args),
CliAction::Run(CliConfig {
work_mins: 25,
break_mins: 5,
enable_notifications: false,
enable_visualizer: false,
})
);
}
}
+298
View File
@@ -0,0 +1,298 @@
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use realfft::{RealFftPlanner, num_complex};
pub struct AudioVisualizer {
pub bar_data: Arc<Mutex<Vec<u64>>>,
pub stop_flag: Arc<AtomicBool>,
}
// This is a implementation largely lifted from these
// open source implementation. I just wrote it in Rust here:
// 1. CAVA (C): https://github.com/karlstav/cava
// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer
impl AudioVisualizer {
pub fn new(num_bars: usize) -> Self {
let bar_data = Arc::new(Mutex::new(vec![0; num_bars]));
let bar_data_clone = Arc::clone(&bar_data);
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_clone = Arc::clone(&stop_flag);
thread::spawn(move || {
if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars, stop_clone) {
eprintln!("Audio capture error: {:?}", e);
}
});
Self {
bar_data,
stop_flag,
}
}
// Doing best effort to support cross-platform functionality
fn get_audio_device(
host: &cpal::Host,
) -> Result<(cpal::Device, cpal::StreamConfig), Box<dyn std::error::Error>> {
// Try windows WASAPI loopback on default output device
#[cfg(target_os = "windows")]
{
if let Some(device) = host.default_output_device()
&& let Ok(config) = device.default_output_config()
{
return Ok((device, config.into()));
}
}
// Try Linux PipeWire/PulseAudio output monitor device
#[cfg(target_os = "linux")]
{
if let Ok(devices) = host.devices() {
for dev in devices {
if let Ok(desc) = dev.description() {
let name = desc.to_string();
// Monitor devices mirror system output under PulseAudio/PipeWire
if name.contains("monitor") {
if let Ok(config) = dev.default_input_config() {
return Ok((dev, config.into()));
}
}
}
}
}
}
// Fallback to default input device
let device = host
.default_input_device()
.or_else(|| host.default_output_device())
.ok_or("No audio input or output device found")?;
let config = device
.default_input_config()
.or_else(|_| device.default_output_config())?
.into();
Ok((device, config))
}
// we here in doubling octaves but fft outputs linearly spaced bins.
// so we bundle bins on a log scale from 20 hz to 12 kHz
// this allows bass, mids, and trembls to have equal visual
// proportions during display
//
// i.e.
// bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz]
// vs
// bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz]
fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> {
let nyquist = sample_rate / 2.0;
let max_hz = 12000.0f32;
(0..num_bars)
.map(|i| {
let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32);
let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32);
let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
(low.max(1), high.max(low + 1))
})
.collect()
}
// without we get sharp edges which gives noise in FFT processing
fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) {
let chunk_size = samples.len();
for (i, sample) in samples.iter().enumerate() {
let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos());
input_buffer[i] = sample * window;
}
}
fn process_fft_magnitudes(
spectrum: &[num_complex::Complex32],
log_bins: &[(usize, usize)],
freq_boost: &[f32],
prev_heights: &[f32],
autosens: f32,
smoothing: f32,
falloff: f32,
) -> Vec<f32> {
let num_bars = log_bins.len();
let mut current_bars = vec![0.0f32; num_bars];
// determine magnitude
for i in 0..num_bars {
let (start, stop) = log_bins[i];
let bin_slice = &spectrum[start..stop.min(spectrum.len())];
let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum();
let avg_mag = if !bin_slice.is_empty() {
magnitude_sum / bin_slice.len() as f32
} else {
0.0
};
// ignore quiet static noise below specific amplitude
let raw_val = if avg_mag < 0.02 {
0.0
} else {
(avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5
};
// rise smoothly from previous height
let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing);
// prevent sudden drops
if target < prev_heights[i] {
current_bars[i] = (prev_heights[i] - falloff).max(0.0);
} else {
current_bars[i] = target;
}
}
current_bars
}
// blend the heights between neighboring freq bins
// so instead of sharp spikes we get more of waves
fn apply_monstercat_smoothing(bars: &[f32]) -> Vec<f32> {
let num_bars = bars.len();
let mut smoothed = bars.to_vec();
for i in 1..(num_bars - 1) {
smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25);
}
smoothed
}
// songs can be quiet and loud so we try to adjust
// sensitivity to avoid bars becoming flattened or
// clipped
fn adjust_autosens(autosens: &mut f32, bars: &[f32]) {
let max_val = bars.iter().copied().fold(0.0f32, f32::max);
if max_val > 8.0 {
*autosens *= 0.98;
} else if max_val < 3.0 && *autosens < 3.0 {
*autosens *= 1.01;
}
}
// captures output and runs through pipeline
// system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing
fn run_audio_loop(
bar_data: Arc<Mutex<Vec<u64>>>,
num_bars: usize,
stop: Arc<AtomicBool>,
) -> Result<(), Box<dyn std::error::Error>> {
let host = cpal::default_host();
let (device, config) = Self::get_audio_device(&host)?;
let sample_rate = config.sample_rate as f32;
let channels = config.channels as usize;
let chunk_size = 2048;
let mut planner = RealFftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(chunk_size);
let mut windowed_buffer = fft.make_input_vec();
let mut spectrum = fft.make_output_vec();
let mut prev_heights = vec![0.0f32; num_bars];
let smoothing = 0.70f32;
let falloff = 0.08f32;
let mut autosens = 1.0f32;
let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size);
let freq_boost: Vec<f32> = (0..num_bars)
.map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2)))
.collect();
let audio_buffer = Arc::new(Mutex::new(Vec::<f32>::with_capacity(chunk_size * 2)));
let buffer_clone = Arc::clone(&audio_buffer);
let stream = device.build_input_stream(
config,
move |data: &[f32], _| {
if let Ok(mut buf) = buffer_clone.lock() {
for chunk in data.chunks(channels) {
let mono: f32 = chunk.iter().sum::<f32>() / channels as f32;
buf.push(mono);
}
if buf.len() > chunk_size * 2 {
let drain_amt = buf.len() - chunk_size;
buf.drain(0..drain_amt);
}
}
},
|err| eprintln!("Stream error: {}", err),
None,
)?;
stream.play()?;
while !stop.load(Ordering::Relaxed) {
thread::sleep(std::time::Duration::from_millis(16));
let samples = {
let buf = match audio_buffer.lock() {
Ok(b) => b,
Err(_) => continue,
};
if buf.len() < chunk_size {
continue;
}
buf[buf.len() - chunk_size..].to_vec()
};
Self::apply_hann_window(&samples, &mut windowed_buffer);
if let Err(e) = fft.process(&mut windowed_buffer, &mut spectrum) {
eprintln!("FFT error: {:?}", e);
continue;
}
let raw_bars = Self::process_fft_magnitudes(
&spectrum,
&log_bins,
&freq_boost,
&prev_heights,
autosens,
smoothing,
falloff,
);
let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars);
prev_heights = smoothed_bars.clone();
Self::adjust_autosens(&mut autosens, &smoothed_bars);
if let Ok(mut bars) = bar_data.lock() {
for (i, val) in smoothed_bars.iter().enumerate() {
bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64;
}
}
}
Ok(())
}
}
impl Drop for AudioVisualizer {
fn drop(&mut self) {
self.stop_flag.store(true, Ordering::Relaxed);
}
}
+4
View File
@@ -0,0 +1,4 @@
pub const SECONDS_PER_MIN: u32 = 60;
pub const DEFAULT_WORK_MINS: u32 = 25;
pub const DEFAULT_BREAK_MINS: u32 = 5;
pub const VIZ_NUM_BARS: u16 = 64;
+38
View File
@@ -0,0 +1,38 @@
use crossterm::event::{self, Event, KeyCode, KeyModifiers};
use crate::msg::Message;
pub fn handle_event(event: Event) -> Option<Message> {
if let Event::Key(key) = event
&& key.kind == event::KeyEventKind::Press
{
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => Some(Message::Quit),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Some(Message::Quit),
KeyCode::Char(' ') => Some(Message::ToggleRunning),
KeyCode::Char('s') | KeyCode::Char('S') => Some(Message::ToggleMode),
KeyCode::Char('r') | KeyCode::Char('R') => Some(Message::Reset),
KeyCode::Char('?') => Some(Message::ToggleHelp),
_ => None,
}
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
#[test]
fn handle_event_when_called_maps_key_presses_to_messages() {
let key_event = Event::Key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(handle_event(key_event), Some(Message::ToggleRunning));
let key_event_q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert_eq!(handle_event(key_event_q), Some(Message::Quit));
let key_event_unknown = Event::Key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
assert_eq!(handle_event(key_event_unknown), None);
}
}
+44 -752
View File
@@ -1,469 +1,52 @@
mod app;
mod args;
mod audio;
mod constants;
mod events;
mod msg;
mod notification;
mod terminal;
mod ui;
use std::{
io::{self, stdout},
sync::{Arc, Mutex, mpsc},
sync::mpsc,
thread,
time::Duration,
};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use crossterm::{
ExecutableCommand,
event::{self, Event, KeyCode, KeyModifiers},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use crossterm::event;
use figlet_rs::Toilet;
use notify_rust::Notification;
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Clear, Paragraph},
};
use realfft::{RealFftPlanner, num_complex};
use ratatui::{Terminal, backend::CrosstermBackend};
const SECONDS_PER_MIN: u32 = 60;
use app::Termato;
use audio::AudioVisualizer;
use events::handle_event;
use msg::Message;
use terminal::TerminalGuard;
use ui::render_app;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum TimerMode {
Work,
Break,
}
use args::{Cli, CliAction};
fn send_desktop_notification(title: &str, body: &str) {
let title = title.to_string();
let body = body.to_string();
use crate::constants::VIZ_NUM_BARS;
std::thread::spawn(move || {
let _ = Notification::new()
.summary(&title)
.body(&body)
.appname("termato")
.show();
});
}
struct Termato {
work_mins: u32,
break_mins: u32,
mode: TimerMode,
is_running: bool,
duration_in_secs: u32,
time_remaining_in_sec: u32,
show_help: bool,
enable_notifications: bool,
enable_visualizer: bool,
}
impl Termato {
fn new(work_mins: u32, break_mins: u32) -> Self {
Termato {
work_mins,
break_mins,
mode: TimerMode::Work,
is_running: false,
duration_in_secs: work_mins * SECONDS_PER_MIN,
time_remaining_in_sec: work_mins * SECONDS_PER_MIN,
show_help: false,
enable_notifications: false,
enable_visualizer: false,
}
}
fn with_notifications(mut self, enable: bool) -> Self {
self.enable_notifications = enable;
self
}
fn with_visualizer(mut self, enable: bool) -> Self {
self.enable_visualizer = enable;
self
}
fn toggle_running(&mut self) {
self.is_running = !self.is_running;
}
fn toggle_mode(&mut self) {
self.mode = match self.mode {
TimerMode::Work => TimerMode::Break,
TimerMode::Break => TimerMode::Work,
};
self.duration_in_secs = match self.mode {
TimerMode::Work => self.work_mins * SECONDS_PER_MIN,
TimerMode::Break => self.break_mins * SECONDS_PER_MIN,
};
self.time_remaining_in_sec = self.duration_in_secs;
}
fn tick(&mut self) {
if self.is_running && self.time_remaining_in_sec > 0 {
self.time_remaining_in_sec -= 1;
if self.time_remaining_in_sec == 0 {
if self.enable_notifications {
match self.mode {
TimerMode::Work => send_desktop_notification("Work Done", "Time to take a break."),
TimerMode::Break => send_desktop_notification("Break Over", "Time to focus."),
}
}
self.toggle_mode();
}
}
}
fn reset(&mut self) {
self.is_running = false;
self.mode = TimerMode::Work;
self.duration_in_secs = self.work_mins * SECONDS_PER_MIN;
self.time_remaining_in_sec = self.duration_in_secs;
}
fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
}
struct TerminalGuard;
impl TerminalGuard {
fn new() -> Result<Self, io::Error> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
Ok(TerminalGuard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
}
}
pub struct AudioVisualizer {
pub bar_data: Arc<Mutex<Vec<u64>>>,
}
// This is a implementation largely lifted from these
// open source implementation. I just wrote it in Rust here:
// 1. CAVA (C): https://github.com/karlstav/cava
// 2. cli-visualizer (C++): https://github.com/dpayne/cli-visualizer
impl AudioVisualizer {
pub fn new(num_bars: usize) -> Self {
//
let bar_data = Arc::new(Mutex::new(vec![0; num_bars]));
let bar_data_clone = Arc::clone(&bar_data);
thread::spawn(move || {
if let Err(e) = Self::run_audio_loop(bar_data_clone, num_bars) {
eprintln!("Audio capture error: {:?}", e);
}
});
Self { bar_data }
}
// Doing best effort to support cross-platform functionality
fn get_audio_device(
host: &cpal::Host,
) -> Result<(cpal::Device, cpal::StreamConfig), Box<dyn std::error::Error>> {
// Try windows WASAPI loopback on default output device
#[cfg(target_os = "windows")]
{
if let Some(device) = host.default_output_device()
&& let Ok(config) = device.default_output_config()
{
return Ok((device, config.into()));
}
}
// Try Linux PipeWire/PulseAudio output monitor device
#[cfg(target_os = "linux")]
{
if let Ok(devices) = host.devices() {
for dev in devices {
if let Ok(name) = dev.name() {
// Monitor devices mirror system output under PulseAudio/PipeWire
if name.contains("monitor") {
if let Ok(config) = dev.default_input_config() {
return Ok((dev, config.into()));
}
}
}
}
}
}
// Fallback to default input device
let device = host
.default_input_device()
.or_else(|| host.default_output_device())
.ok_or("No audio input or output device found")?;
let config = device
.default_input_config()
.or_else(|_| device.default_output_config())?
.into();
Ok((device, config))
}
// we here in doubling octaves but fft outputs linearly spaced bins.
// so we bundle bins on a log scale from 20 hz to 12 kHz
// this allows bass, mids, and trembls to have equal visual
// proportions during display
//
// i.e.
// bins like this [0-500Hz] [500-1k] [1k-1.5k] [1.5k-2k] [2k-2.5k] [2.5k-3k] [3k-12kHz]
// vs
// bins like this [20-60Hz] [60-250Hz] [250-500Hz] [500-2kHz] [2k-4kHz] [4k-8kHz] [8k-12kHz]
fn build_log_bins(num_bars: usize, sample_rate: f32, chunk_size: usize) -> Vec<(usize, usize)> {
let nyquist = sample_rate / 2.0;
let max_hz = 12000.0f32;
(0..num_bars)
.map(|i| {
let low_hz = 20.0 * (max_hz / 20.0).powf(i as f32 / num_bars as f32);
let high_hz = 20.0 * (max_hz / 20.0).powf((i + 1) as f32 / num_bars as f32);
let low = ((low_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
let high = ((high_hz / nyquist) * (chunk_size as f32 / 2.0)) as usize;
(low.max(1), high.max(low + 1))
})
.collect()
}
// without we get sharp edges which gives noise in FFT processing
fn apply_hann_window(samples: &[f32], input_buffer: &mut [f32]) {
let chunk_size = samples.len();
for (i, sample) in samples.iter().enumerate() {
let window = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / chunk_size as f32).cos());
input_buffer[i] = sample * window;
}
}
fn process_fft_magnitudes(
spectrum: &[num_complex::Complex32],
log_bins: &[(usize, usize)],
freq_boost: &[f32],
prev_heights: &[f32],
autosens: f32,
smoothing: f32,
falloff: f32,
) -> Vec<f32> {
let num_bars = log_bins.len();
let mut current_bars = vec![0.0f32; num_bars];
// determine magnitude
for i in 0..num_bars {
let (start, stop) = log_bins[i];
let bin_slice = &spectrum[start..stop.min(spectrum.len())];
let magnitude_sum: f32 = bin_slice.iter().map(|c| c.norm()).sum();
let avg_mag = if !bin_slice.is_empty() {
magnitude_sum / bin_slice.len() as f32
} else {
0.0
};
// ignore quiet static noise below specific amplitude
let raw_val = if avg_mag < 0.02 {
0.0
} else {
(avg_mag * freq_boost[i] * autosens + 1.0).log10() * 3.5
};
// rise smoothly from previous height
let target = (raw_val * (1.0 - smoothing)) + (prev_heights[i] * smoothing);
// prevent sudden drops
if target < prev_heights[i] {
current_bars[i] = (prev_heights[i] - falloff).max(0.0);
} else {
current_bars[i] = target;
}
}
current_bars
}
// blend the heights between neighboring freq bins
// so instead of sharp spikes we get more of waves
fn apply_monstercat_smoothing(bars: &[f32]) -> Vec<f32> {
let num_bars = bars.len();
let mut smoothed = bars.to_vec();
for i in 1..(num_bars - 1) {
smoothed[i] = (bars[i - 1] * 0.25) + (bars[i] * 0.50) + (bars[i + 1] * 0.25);
}
smoothed
}
// songs can be quiet and load so we try to adjust
// sensitivity to avoid bars becoming flattened or
// clipped
fn adjust_autosens(autosens: &mut f32, bars: &[f32]) {
let max_val = bars.iter().cloned().fold(0.0f32, f32::max);
if max_val > 8.0 {
*autosens *= 0.98;
} else if max_val < 3.0 && *autosens < 3.0 {
*autosens *= 1.01;
}
}
// captures output and runs through pipeline
// system audio -> audio buffer -> windowing -> fft process -> binning -> floor/autosens -> smoothing
fn run_audio_loop(
bar_data: Arc<Mutex<Vec<u64>>>,
num_bars: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let host = cpal::default_host();
let (device, config) = Self::get_audio_device(&host)?;
let sample_rate = config.sample_rate as f32;
let channels = config.channels as usize;
let chunk_size = 2048;
let mut planner = RealFftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(chunk_size);
let mut windowed_buffer = fft.make_input_vec();
let mut spectrum = fft.make_output_vec();
let mut prev_heights = vec![0.0f32; num_bars];
let smoothing = 0.70f32;
let falloff = 0.08f32;
let mut autosens = 1.0f32;
let log_bins = Self::build_log_bins(num_bars, sample_rate, chunk_size);
// high freq have less amplitude so we boost
// more and more as we go right
let freq_boost: Vec<f32> = (0..num_bars)
.map(|i| 1.0 + (3.5 * (i as f32 / num_bars as f32).powf(1.2)))
.collect();
let audio_buffer = Arc::new(Mutex::new(Vec::<f32>::with_capacity(chunk_size * 2)));
let buffer_clone = Arc::clone(&audio_buffer);
let stream = device.build_input_stream(
config,
move |data: &[f32], _| {
let mut buf = buffer_clone.lock().unwrap();
for chunk in data.chunks(channels) {
let mono: f32 = chunk.iter().sum::<f32>() / channels as f32;
buf.push(mono);
}
if buf.len() > chunk_size * 2 {
let drain_amt = buf.len() - chunk_size;
buf.drain(0..drain_amt);
}
},
|err| eprintln!("Stream error: {}", err),
None,
)?;
stream.play()?;
loop {
thread::sleep(std::time::Duration::from_millis(16));
let samples = {
let buf = audio_buffer.lock().unwrap();
if buf.len() < chunk_size {
continue;
}
buf[buf.len() - chunk_size..].to_vec()
};
Self::apply_hann_window(&samples, &mut windowed_buffer);
fft.process(&mut windowed_buffer, &mut spectrum).unwrap();
let raw_bars = Self::process_fft_magnitudes(
&spectrum,
&log_bins,
&freq_boost,
&prev_heights,
autosens,
smoothing,
falloff,
);
let smoothed_bars = Self::apply_monstercat_smoothing(&raw_bars);
prev_heights = smoothed_bars.clone();
Self::adjust_autosens(&mut autosens, &smoothed_bars);
if let Ok(mut bars) = bar_data.lock() {
for (i, val) in smoothed_bars.iter().enumerate() {
bars[i] = ((*val * 10.0).clamp(0.0, 100.0)) as u64;
}
}
}
}
}
// use unicode blocks for rendering
// Empty -> " "
// 1/8th height -> "▂"
// 2/8th height -> "▃"
// 3/8th height -> "▄"
// 4/8th height -> "▅"
// 5/8th height -> "▆"
// 6/8th height -> "▇"
// Full Height -> "█"
fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) {
const BLOCKS: [&str; 8] = ["", "", "", "", "", "", "", ""];
let line: String = bar_values
.iter()
.map(|&val| {
if val == 0 {
" "
} else {
let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32)
.clamp(0.0, (BLOCKS.len() - 1) as f32) as usize;
BLOCKS[idx]
}
})
.collect();
let viz_paragraph = Paragraph::new(line)
.style(Style::default().fg(Color::Cyan))
.alignment(Alignment::Center);
f.render_widget(viz_paragraph, area);
}
// TODO: Refactor to an Elm like architecture
fn main() -> Result<(), io::Error> {
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
println!("Usage: termato [options] [work_minutes] [break_minutes]");
println!();
println!("Options:");
println!(" -n, --notify Enable desktop notifications");
println!(" -h, --help Print this help message");
println!(" -v, --version Print the version number");
println!(" -z, --visualizer Display visualizer of playing audio");
println!();
println!("Defaults: work_minutes = 25, break_minutes = 5");
let config = match Cli::parse() {
CliAction::PrintHelp => {
Cli::print_help();
return Ok(());
}
if args.iter().any(|arg| arg == "-v" || arg == "--version") {
println!("termato version {}", env!("CARGO_PKG_VERSION"));
CliAction::PrintVersion => {
Cli::print_version();
return Ok(());
}
CliAction::Error(err) => {
eprintln!("Error: {}", err);
eprintln!("Run 'termato --help' for usage instructions.");
std::process::exit(1);
}
CliAction::Run(cfg) => cfg,
};
let _guard = TerminalGuard::new()?;
@@ -471,34 +54,14 @@ fn main() -> Result<(), io::Error> {
let mut terminal = Terminal::new(backend)?;
let font = Toilet::smblock().unwrap();
let enable_visualizer = args.iter().any(|arg| arg == "-z" || arg == "--visualizer");
let enable_notifications = args.iter().any(|arg| arg == "-n" || arg == "--notify");
let visualizer = if enable_visualizer {
Some(AudioVisualizer::new(64))
let visualizer = if config.enable_visualizer {
Some(AudioVisualizer::new(VIZ_NUM_BARS as usize))
} else {
None
};
let positional_args: Vec<&String> = args
.iter()
.skip(1)
.filter(|arg| *arg != "-n" && *arg != "--notify" && *arg != "-z" && *arg != "--visualizer")
.collect();
let work_mins = positional_args
.first()
.and_then(|s| s.parse().ok())
.unwrap_or(25);
let break_mins = positional_args
.get(1)
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let mut termato = Termato::new(work_mins, break_mins)
.with_notifications(enable_notifications)
.with_visualizer(enable_visualizer);
let mut termato = Termato::new(config.work_mins, config.break_mins)
.with_notifications(config.enable_notifications);
let (tx, rx) = mpsc::channel();
@@ -511,291 +74,20 @@ fn main() -> Result<(), io::Error> {
}
});
loop {
terminal.draw(|f| {
let size = f.area();
while !termato.should_quit() {
terminal.draw(|f| render_app(&termato, f, &font, visualizer.as_ref()))?;
let minutes = termato.time_remaining_in_sec / SECONDS_PER_MIN;
let seconds = termato.time_remaining_in_sec % SECONDS_PER_MIN;
let time_str = format!("{:02}:{:02}", minutes, seconds);
let time_color = if !termato.is_running {
Color::Yellow
} else {
match termato.mode {
TimerMode::Work => Color::Red,
TimerMode::Break => Color::Green,
}
};
let raw_time_text = if let Some(fig) = font.convert(&time_str) {
fig.to_string()
} else {
time_str
};
let trimmed_time_text = raw_time_text.trim_matches('\n');
let text_lines: Vec<&str> = trimmed_time_text.lines().collect();
let text_height = text_lines.len() as u16;
let viz_height = if visualizer.is_some() { 1 } else { 0 };
let viz_width = 64;
let content_height = text_height + viz_height;
let outer_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(content_height),
Constraint::Min(0),
])
.split(size);
let inner_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(text_height),
Constraint::Length(viz_height),
])
.split(outer_vertical[1]);
let viz_horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(viz_width.min(size.width)),
Constraint::Min(0),
])
.split(inner_vertical[1]);
let timer_paragraph = Paragraph::new(trimmed_time_text)
.style(Style::default().fg(time_color))
.alignment(Alignment::Center);
f.render_widget(timer_paragraph, inner_vertical[0]);
if let Some(ref viz) = visualizer
&& let Ok(bar_data) = viz.bar_data.lock()
{
render_visualizer(f, viz_horizontal[1], &bar_data);
}
if termato.show_help {
let popup_width = 44;
let popup_height = 7;
if size.width >= popup_width && size.height >= popup_height {
let popup_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((size.height.saturating_sub(popup_height)) / 2),
Constraint::Length(popup_height),
Constraint::Min(1),
])
.split(size);
let popup_horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((size.width.saturating_sub(popup_width)) / 2),
Constraint::Length(popup_width),
Constraint::Min(1),
])
.split(popup_vertical[1]);
let popup_area = popup_horizontal[1];
let help_block = Block::default()
.title(" Controls ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let help_content = Paragraph::new(
"[Space] Pause / Resume\n\
[S] Switch Mode\n\
[R] Reset Timer\n\
[?] Toggle Help\n\
[Q] Quit",
)
.block(help_block)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Left);
f.render_widget(Clear, popup_area);
f.render_widget(help_content, popup_area);
}
}
})?;
if event::poll(Duration::from_millis(16))?
&& let Event::Key(key) = event::read()?
&& key.kind == event::KeyEventKind::Press
{
match key.code {
KeyCode::Char('q') | KeyCode::Char('Q') => break,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
KeyCode::Char(' ') => termato.toggle_running(),
KeyCode::Char('s') | KeyCode::Char('S') => termato.toggle_mode(),
KeyCode::Char('r') | KeyCode::Char('R') => termato.reset(),
KeyCode::Char('?') => termato.toggle_help(),
_ => {}
if event::poll(Duration::from_millis(16))? {
let raw_event = event::read()?;
if let Some(msg) = handle_event(raw_event) {
termato.update(msg);
}
}
if rx.try_recv().is_ok() {
termato.tick();
termato.update(Message::Tick);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn termato_new_when_called_it_should_return_expected_starting_state() {
let result = Termato::new(25, 5);
assert_eq!(result.work_mins, 25);
assert_eq!(result.break_mins, 5);
assert_eq!(result.mode, TimerMode::Work);
assert!(!result.is_running);
assert_eq!(result.duration_in_secs, 1500);
assert_eq!(result.time_remaining_in_sec, 1500);
assert!(!result.show_help);
assert!(!result.enable_notifications);
assert!(!result.enable_visualizer);
}
#[test]
fn termato_tick_when_called_and_not_running_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 60);
}
#[test]
fn termato_tick_when_called_and_no_time_remaining_it_should_do_nothing() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.time_remaining_in_sec = 0;
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 0);
}
#[test]
fn termato_tick_when_running_and_time_reamining_it_should_reduce_remaining_by_one() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.tick();
assert_eq!(termato.time_remaining_in_sec, 59);
}
#[test]
fn termato_tick_when_running_on_work_and_time_remaining_reaches_zero_it_should_toggle_to_break() {
let mut termato = Termato::new(1, 2);
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.tick();
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_tick_when_running_on_break_and_time_remaining_reaches_zero_it_should_toggle_to_work() {
let mut termato = Termato::new(2, 1);
termato.mode = TimerMode::Break;
termato.is_running = true;
termato.time_remaining_in_sec = 1;
termato.tick();
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_toggle_mode_when_called_it_should_toggle_the_mode() {
let mut termato = Termato::new(2, 1);
termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Break);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
termato.toggle_mode();
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 120);
assert_eq!(termato.time_remaining_in_sec, 120);
}
#[test]
fn termato_toggle_running_when_called_and_already_running_it_should_pause() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.toggle_running();
assert!(!termato.is_running)
}
#[test]
fn termato_toggle_running_when_called_and_already_paused_it_should_run() {
let mut termato = Termato::new(1, 1);
termato.toggle_running();
assert!(termato.is_running)
}
#[test]
fn termato_rest_when_called_it_should_reset_the_apps_state() {
let mut termato = Termato::new(1, 1);
termato.is_running = true;
termato.mode = TimerMode::Break;
termato.duration_in_secs = 1;
termato.time_remaining_in_sec = 1;
termato.reset();
assert!(!termato.is_running);
assert_eq!(termato.mode, TimerMode::Work);
assert_eq!(termato.duration_in_secs, 60);
assert_eq!(termato.time_remaining_in_sec, 60);
}
#[test]
fn termato_toggle_help_when_called_it_should_toggle_help() {
let mut termato = Termato::new(1, 1);
termato.toggle_help();
assert!(termato.show_help);
termato.toggle_help();
assert!(!termato.show_help);
}
#[test]
fn termato_notifications_when_called_it_should_verify_opt_in_behavior() {
let result = Termato::new(25, 5);
assert!(!result.enable_notifications);
let result = result.with_notifications(true);
assert!(result.enable_notifications);
}
}
+9
View File
@@ -0,0 +1,9 @@
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum Message {
Tick,
ToggleRunning,
ToggleMode,
Reset,
ToggleHelp,
Quit,
}
+14
View File
@@ -0,0 +1,14 @@
use notify_rust::Notification;
pub fn send_desktop_notification(title: &str, body: &str) {
let title = title.to_string();
let body = body.to_string();
std::thread::spawn(move || {
let _ = Notification::new()
.summary(&title)
.body(&body)
.appname("termato")
.show();
});
}
+22
View File
@@ -0,0 +1,22 @@
use crossterm::{
ExecutableCommand,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use std::io::{self, stdout};
pub struct TerminalGuard;
impl TerminalGuard {
pub fn new() -> Result<Self, io::Error> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
Ok(TerminalGuard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = stdout().execute(LeaveAlternateScreen);
}
}
+169
View File
@@ -0,0 +1,169 @@
use figlet_rs::Toilet;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::{audio::AudioVisualizer, constants::VIZ_NUM_BARS};
use crate::{
app::{Termato, TimerMode},
constants::SECONDS_PER_MIN,
};
// use unicode blocks for rendering
// Empty -> " "
// 1/8th height -> "▂"
// 2/8th height -> "▃"
// 3/8th height -> "▄"
// 4/8th height -> "▅"
// 5/8th height -> "▆"
// 6/8th height -> "▇"
// Full Height -> "█"
pub fn render_visualizer(f: &mut Frame, area: Rect, bar_values: &[u64]) {
const BLOCKS: [&str; 8] = ["", "", "", "", "", "", "", ""];
let max_bars = area.width as usize;
let line: String = bar_values
.iter()
.take(max_bars)
.map(|&val| {
if val == 0 {
" "
} else {
let idx = ((val as f32 / 100.0) * (BLOCKS.len() - 1) as f32)
.clamp(0.0, (BLOCKS.len() - 1) as f32) as usize;
BLOCKS[idx]
}
})
.collect();
let viz_paragraph = Paragraph::new(line)
.style(Style::default().fg(Color::Rgb(200, 184, 224)))
.alignment(Alignment::Center);
f.render_widget(viz_paragraph, area);
}
pub fn render_app(
app: &Termato,
f: &mut Frame,
font: &Toilet,
visualizer: Option<&AudioVisualizer>,
) {
let size = f.area();
let minutes = app.time_remaining_in_sec / SECONDS_PER_MIN;
let seconds = app.time_remaining_in_sec % SECONDS_PER_MIN;
let time_str = format!("{:02}:{:02}", minutes, seconds);
let time_color = if !app.is_running {
Color::Rgb(212, 200, 122)
} else {
match app.mode {
TimerMode::Work => Color::Rgb(212, 115, 115),
TimerMode::Break => Color::Rgb(126, 200, 192),
}
};
let raw_time_text = if let Some(fig) = font.convert(&time_str) {
fig.to_string()
} else {
time_str
};
let trimmed_time_text = raw_time_text.trim_matches('\n');
let text_lines: Vec<&str> = trimmed_time_text.lines().collect();
let text_height = text_lines.len() as u16;
let viz_height = if visualizer.is_some() { 1 } else { 0 };
let viz_width = VIZ_NUM_BARS;
let content_height = text_height + viz_height;
let outer_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(content_height),
Constraint::Min(0),
])
.split(size);
let inner_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(text_height),
Constraint::Length(viz_height),
])
.split(outer_vertical[1]);
let viz_horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(viz_width.min(size.width)),
Constraint::Min(0),
])
.split(inner_vertical[1]);
let timer_paragraph = Paragraph::new(trimmed_time_text)
.style(Style::default().fg(time_color))
.alignment(Alignment::Center);
f.render_widget(timer_paragraph, inner_vertical[0]);
if let Some(viz) = visualizer
&& let Ok(bar_data) = viz.bar_data.lock()
{
render_visualizer(f, viz_horizontal[1], &bar_data);
}
if app.show_help {
let popup_width = 44;
let popup_height = 7;
if size.width >= popup_width && size.height >= popup_height {
let popup_vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((size.height.saturating_sub(popup_height)) / 2),
Constraint::Length(popup_height),
Constraint::Min(1),
])
.split(size);
let popup_horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((size.width.saturating_sub(popup_width)) / 2),
Constraint::Length(popup_width),
Constraint::Min(1),
])
.split(popup_vertical[1]);
let popup_area = popup_horizontal[1];
let help_block = Block::default()
.title(" Controls ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(58, 62, 70)));
let help_content = Paragraph::new(
"[Space] Pause / Resume\n\
[S] Switch Mode\n\
[R] Reset Timer\n\
[?] Toggle Help\n\
[Q] Quit",
)
.block(help_block)
.style(Style::default().fg(Color::Rgb(224, 228, 232)))
.alignment(Alignment::Left);
f.render_widget(Clear, popup_area);
f.render_widget(help_content, popup_area);
}
}
}