├── .envrc ├── .rustfmt.toml ├── demo ├── menu.gif ├── blink.gif ├── decis.gif ├── event.gif ├── style.gif ├── timer.gif ├── countdown.gif ├── pomodoro.gif ├── timer-max.png ├── local-time.gif ├── countdown-max.png ├── local-time-footer.gif ├── countdown-max.tape ├── local-time.tape ├── local-time-footer.tape ├── blink.tape ├── decis.tape ├── style.tape ├── menu.tape ├── timer.tape ├── countdown.tape ├── event.tape ├── timer-max.tape └── pomodoro.tape ├── src ├── constants.rs ├── widgets.rs ├── widgets │ ├── header.rs │ ├── progressbar.rs │ ├── clock_elements_test.rs │ ├── timer.rs │ ├── clock_elements.rs │ ├── edit_time.rs │ ├── local_time.rs │ ├── pomodoro.rs │ ├── footer.rs │ ├── countdown.rs │ └── event.rs ├── config.rs ├── terminal.rs ├── logging.rs ├── main.rs ├── sound.rs ├── utils.rs ├── events.rs ├── args.rs ├── storage.rs ├── event.rs ├── common.rs └── app.rs ├── rust-toolchain.toml ├── .gitignore ├── AGENTS.md ├── CONTRIBUTING.md ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── LICENSE ├── Cargo.toml ├── flake.nix ├── flake.lock ├── justfile ├── CHANGELOG.md └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | style_edition = "2024" 2 | reorder_imports = true 3 | -------------------------------------------------------------------------------- /demo/menu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/menu.gif -------------------------------------------------------------------------------- /demo/blink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/blink.gif -------------------------------------------------------------------------------- /demo/decis.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/decis.gif -------------------------------------------------------------------------------- /demo/event.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/event.gif -------------------------------------------------------------------------------- /demo/style.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/style.gif -------------------------------------------------------------------------------- /demo/timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/timer.gif -------------------------------------------------------------------------------- /demo/countdown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/countdown.gif -------------------------------------------------------------------------------- /demo/pomodoro.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/pomodoro.gif -------------------------------------------------------------------------------- /demo/timer-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/timer-max.png -------------------------------------------------------------------------------- /demo/local-time.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/local-time.gif -------------------------------------------------------------------------------- /demo/countdown-max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/countdown-max.png -------------------------------------------------------------------------------- /demo/local-time-footer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sectore/timr-tui/HEAD/demo/local-time-footer.gif -------------------------------------------------------------------------------- /src/constants.rs: -------------------------------------------------------------------------------- 1 | pub static APP_NAME: &str = env!("CARGO_PKG_NAME"); 2 | 3 | pub static TICK_VALUE_MS: u64 = 1000 / 10; // 0.1 sec in milliseconds 4 | pub static FPS_VALUE_MS: u64 = 1000 / 60; // 60 FPS in milliseconds 5 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | # Reminder: Always keep `rust-version` in `Cargo.toml` in sync with `channel`. 3 | channel = "1.92.0" 4 | components = ["clippy", "rustfmt", "rust-src", "rust-analyzer"] 5 | targets = ["x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl"] 6 | profile = "minimal" 7 | -------------------------------------------------------------------------------- /src/widgets.rs: -------------------------------------------------------------------------------- 1 | pub mod clock; 2 | pub mod clock_elements; 3 | #[cfg(test)] 4 | pub mod clock_elements_test; 5 | #[cfg(test)] 6 | pub mod clock_test; 7 | pub mod countdown; 8 | pub mod edit_time; 9 | pub mod event; 10 | pub mod footer; 11 | pub mod header; 12 | pub mod local_time; 13 | pub mod pomodoro; 14 | pub mod progressbar; 15 | pub mod timer; 16 | -------------------------------------------------------------------------------- /demo/countdown-max.tape: -------------------------------------------------------------------------------- 1 | 2 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 3 | Set Theme "Retro" 4 | 5 | Set FontSize 14 6 | Set Width 1000 7 | Set Height 500 8 | Set Padding 0 9 | Set Margin 1 10 | 11 | # --- START --- 12 | Type 'cargo run -- -r -d -c "10000y"' 13 | Enter 14 | Sleep .2 15 | Type "m" 16 | # --- SCREENSHOT --- 17 | Sleep 1s 18 | Screenshot demo/countdown-max.png 19 | Sleep 1s 20 | -------------------------------------------------------------------------------- /demo/local-time.tape: -------------------------------------------------------------------------------- 1 | Output demo/local-time.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "Atom" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -m l" 16 | Enter 17 | Sleep .2 18 | Type "m" # hide menu 19 | Show 20 | # --- toggle local time --- 21 | Type@1s ":::" 22 | -------------------------------------------------------------------------------- /demo/local-time-footer.tape: -------------------------------------------------------------------------------- 1 | Output demo/local-time-footer.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "AtomOneLight" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -m c" 16 | Enter 17 | Sleep 0.2 18 | Type "m" # hide menu 19 | Show 20 | # --- toggle local time --- 21 | Type@1s ":::" 22 | -------------------------------------------------------------------------------- /demo/blink.tape: -------------------------------------------------------------------------------- 1 | Output demo/blink.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "nord-light" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | # countdown 1.0s 16 | Type "cargo run -- -r -m countdown -d -c 1 --blink=on" 17 | Enter 18 | Sleep 0.2 19 | Type "m" 20 | Type ":::" 21 | Show 22 | Type "s" 23 | Sleep 4 24 | -------------------------------------------------------------------------------- /demo/decis.tape: -------------------------------------------------------------------------------- 1 | Output demo/decis.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "nord-light" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -m t" 16 | Enter 17 | Sleep .2 18 | Type "m" # hide menu 19 | Show 20 | # --- STYLES --- 21 | Type "s" 22 | Sleep 0.2 23 | Type@0.4s "......." 24 | Sleep 0.2 25 | -------------------------------------------------------------------------------- /demo/style.tape: -------------------------------------------------------------------------------- 1 | Output demo/style.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "OneDark" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -d -m c" 16 | Enter 17 | Sleep 0.2 18 | Type "m" # hide menu 19 | Show 20 | # --- STYLES --- 21 | Sleep 0.5 22 | Type "s" 23 | Sleep 0.5 24 | Type@0.7s ",,,,,," 25 | Sleep 1 26 | -------------------------------------------------------------------------------- /demo/menu.tape: -------------------------------------------------------------------------------- 1 | Output demo/menu.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "Apple Classic" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -m c" 16 | Enter 17 | Type@200ms "m" # hide menu 18 | Show 19 | # --- STYLES --- 20 | Sleep 0.3s 21 | Type@0.3s "m" # show menu 22 | Type@0.3s "2" 23 | Type@0.3s "3" 24 | Type@0.3s "e" 25 | Escape@0.3s 26 | Type@0.3s "4" 27 | Type@0.3s "0" 28 | -------------------------------------------------------------------------------- /demo/timer.tape: -------------------------------------------------------------------------------- 1 | Output demo/timer.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "Belafonte Day" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -d -m t" 16 | Enter 17 | Sleep 0.2 18 | Type "m" # hide menu 19 | Show 20 | # --- TIMER --- 21 | Type "s" 22 | Sleep 1.4 23 | Type "s" 24 | Sleep 0.3 25 | Type "s" 26 | Sleep 0.3 27 | Type "e" 28 | Sleep 0.2 29 | Up@30ms 57 30 | Sleep 0.7 31 | Type "s" 32 | Sleep 4 33 | -------------------------------------------------------------------------------- /demo/countdown.tape: -------------------------------------------------------------------------------- 1 | Output demo/countdown.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "iceberg-light" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -d -c 10:00" 16 | Enter 17 | Sleep .2 18 | Type "m" # hide menu 19 | Show 20 | # --- COUNTDOWN --- 21 | Sleep .5 22 | Type "s" 23 | Sleep 1.4 24 | Type "s" 25 | Sleep 0.3 26 | Type "s" 27 | Sleep 0.3 28 | Type "e" 29 | Sleep 0.1 30 | Down@10ms 65 31 | Sleep 0.1 32 | Type "s" 33 | Sleep 3 34 | -------------------------------------------------------------------------------- /src/widgets/header.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::Rect, 4 | widgets::{Block, Borders, Widget}, 5 | }; 6 | 7 | use crate::widgets::progressbar::Progressbar; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Header { 11 | pub percentage: Option, 12 | } 13 | 14 | impl Widget for Header { 15 | fn render(self, area: Rect, buf: &mut Buffer) { 16 | if let Some(percentage) = self.percentage { 17 | Progressbar::new(percentage).render(area, buf); 18 | } else { 19 | Block::new().borders(Borders::TOP).render(area, buf); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/event.tape: -------------------------------------------------------------------------------- 1 | Output demo/event.gif 2 | 3 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 4 | Set Theme "Builtin Solarized Dark" 5 | 6 | Set FontSize 14 7 | Set Width 1000 8 | Set Height 500 9 | Set Padding 0 10 | Set Margin 1 11 | 12 | # --- START --- 13 | Set LoopOffset 4 14 | Hide 15 | Type "cargo run -- -r -e 'time=2010-01-10 10:00:00,title=hello world'" 16 | Enter 17 | Type "m" 18 | Sleep 0.2 19 | Show 20 | # --- EVENT --- 21 | Sleep 1 22 | Type "e" 23 | Backspace@1ms 17 24 | Type@20ms "50-01-01 01:00:01" 25 | Enter 26 | Type "e" 27 | Tab 28 | Backspace@10ms 11 29 | Type@20ms "hello future" 30 | Enter 31 | Sleep 1 32 | -------------------------------------------------------------------------------- /demo/timer-max.tape: -------------------------------------------------------------------------------- 1 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 2 | Set Theme "SeaShells" 3 | 4 | Set FontSize 14 5 | Set Width 1000 6 | Set Height 500 7 | Set Padding 0 8 | Set Margin 1 9 | 10 | # --- START --- 11 | Type 'cargo run -- -r -m t' 12 | Enter 13 | Type "m" 14 | Type "e" 15 | Up@1ms 60 # ss 16 | Left 17 | Up@1ms 60 # mm 18 | Left 19 | Up@1ms 23 # hh 20 | Left 21 | Up@1ms 363 # ddd 22 | Left 23 | Up@1ms 9999 # yyyy 24 | Right 4 25 | Down # ss 26 | Left 27 | Down ## mm 28 | Left 2 29 | Down ## ddd 30 | Up 2 31 | Type "." 32 | Type "s" # save 33 | Type "s" # start to reach DONE 34 | Sleep 2s 35 | # --- SCREENSHOT --- 36 | Screenshot demo/timer-max.png 37 | Sleep 1s 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | result/**/* 6 | 7 | # These are backup files generated by rustfmt 8 | **/*.rs.bk 9 | 10 | # MSVC Windows builds of rustc generate these, which store debugging information 11 | *.pdb 12 | 13 | # RustRover 14 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 15 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 16 | # and can be added to the global gitignore or merged into this file. For a more nuclear 17 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 18 | #.idea/ 19 | # 20 | .direnv 21 | 22 | # ignore (possible) sound files 23 | **/*.{mp3,wav} 24 | 25 | 26 | CLAUDE.md 27 | .claude 28 | -------------------------------------------------------------------------------- /demo/pomodoro.tape: -------------------------------------------------------------------------------- 1 | # Note: PR "support ctrl + arrow keys" https://github.com/charmbracelet/vhs/pull/673 needs to be merged to run this `tape`. 2 | 3 | Output demo/pomodoro.gif 4 | 5 | # https://github.com/charmbracelet/vhs/blob/main/THEMES.md 6 | Set Theme "Catppuccin Frappe" 7 | 8 | Set FontSize 14 9 | Set Width 1000 10 | Set Height 500 11 | Set Padding 0 12 | Set Margin 1 13 | 14 | # --- START --- 15 | Hide 16 | Type "cargo run -- -r -d -m p --blink on" 17 | Enter 18 | Sleep .2 19 | Type "m" # hide menu 20 | Show 21 | # --- POMODORO WORK --- 22 | Sleep .5 23 | Type "s" # start 24 | Sleep 2.3 25 | Type "e" 26 | Sleep 0.2 27 | Down@30ms 80 28 | Sleep 100ms 29 | Type "s" # save 30 | Sleep 4 31 | # --- POMODORO PAUSE --- 32 | Ctrl+Right 33 | Sleep 0.5 34 | Type "s" 35 | Sleep 2.3 36 | Type "e" 37 | Sleep 0.2 38 | Down@30ms 60 39 | Sleep 100ms 40 | Type "s" # save 41 | Sleep 4 42 | -------------------------------------------------------------------------------- /src/widgets/progressbar.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | buffer::Buffer, 3 | layout::{Constraint, Layout, Rect}, 4 | symbols::line, 5 | text::Span, 6 | widgets::Widget, 7 | }; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Progressbar { 11 | pub percentage: u16, 12 | } 13 | 14 | impl Progressbar { 15 | pub fn new(percentage: u16) -> Self { 16 | Self { percentage } 17 | } 18 | } 19 | 20 | impl Widget for Progressbar { 21 | fn render(self, area: Rect, buf: &mut Buffer) { 22 | let [h1, h2] = 23 | Layout::horizontal([Constraint::Percentage(self.percentage), Constraint::Fill(0)]) 24 | .areas(area); 25 | // done 26 | Span::from(line::THICK_HORIZONTAL.repeat(h1.width as usize)).render(h1, buf); 27 | // rest 28 | Span::from(line::HORIZONTAL.repeat(h2.width as usize)).render(h2, buf); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | `timr-tui` is a TUI to maintain productivity and focus by providing different timers: Pomodoro, Countdown, Timer, Events. 4 | 5 | Built with Rust using `Ratatui` as the main library. 6 | 7 | # Development, Build, Tests 8 | 9 | Check [README](./README.md) chapter `Development` to get all information about how to run, build, test the app. 10 | 11 | # Code Guidelines 12 | 13 | - Idiomatic Rust everywhere 14 | - DRY whenever it makes sense 15 | - Rare or no comments are preferred instead of commenting everything which the code already describes 16 | - Keep tests compact and simple 17 | 18 | # Agent Guidelines 19 | 20 | - Keep your answers compact, but explicit. An user will ask if something is missing. 21 | - For complex tasks provide a plan. 22 | - Structure plans as small as possible. 23 | - Solve complex problems step by step, never all at once. 24 | - Act as a pair programmer, not as a vibe-coding provider. That's an user should guide you, not the opposite. Always ask if something is not clear to you. 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Any feedback / contribution are welcome. Just open an `issue`, a `PR` or start a `discussion`. 4 | 5 | ## Code style / conventions 6 | 7 | - Try to write [clean, idiomatic Rust code](https://github.com/mre/idiomatic-rust). 8 | - Keep code [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) whenever it makes sense. 9 | - Before pushing any code make sure to run `clippy` and `fmt`. Check provided [`just`](./jusfile) file to run such commands, [CI](https://github.com/sectore/timr-tui/blob/main/.github/workflows/ci.yml) will do the same. 10 | - Have fun to write code. 11 | 12 | ## Design files 13 | 14 | Use [Figma Design file](https://www.figma.com/community/file/1553076532392275586/timr-tui) to suggest design changes 15 | 16 | ## AI 17 | 18 | Always understand what AI provides to you. Never push any code based on [`vibe coding`](https://en.wikipedia.org/wiki/Vibe_coding) you or anybody else can't follow. Make sure your agent still follows all code styles and conventions suggested above. Use AI for better, not for worse code. 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: lint, format, test, build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: DeterminateSystems/nix-installer-action@main 15 | - name: Check formatting 16 | run: nix develop --command cargo fmt --all -- --check 17 | - name: Run clippy 18 | run: nix develop --command cargo clippy -- -D warnings 19 | - name: Run alejandra 20 | run: nix develop --command alejandra --check flake.nix 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: DeterminateSystems/nix-installer-action@main 27 | - name: Run tests 28 | run: nix develop --command cargo test 29 | 30 | build: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: DeterminateSystems/nix-installer-action@main 35 | - name: Build project 36 | run: nix build .#timr 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 Jens Krause 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::constants::APP_NAME; 2 | use color_eyre::eyre::{Result, eyre}; 3 | use directories::ProjectDirs; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | 7 | pub struct Config { 8 | pub log_dir: PathBuf, 9 | pub data_dir: PathBuf, 10 | } 11 | 12 | impl Config { 13 | pub fn init() -> Result { 14 | // default logs dir 15 | let log_dir = get_default_state_dir()?.join("logs"); 16 | fs::create_dir_all(&log_dir)?; 17 | 18 | // default data dir 19 | let data_dir = get_default_state_dir()?.join("data"); 20 | fs::create_dir_all(&data_dir)?; 21 | 22 | Ok(Self { log_dir, data_dir }) 23 | } 24 | } 25 | 26 | pub fn get_project_dir() -> Result { 27 | let dirs = ProjectDirs::from("", "", APP_NAME) 28 | .ok_or_else(|| eyre!("Failed to get project directories"))?; 29 | 30 | Ok(dirs) 31 | } 32 | 33 | fn get_default_state_dir() -> Result { 34 | let dirs = get_project_dir()?; 35 | let directory: PathBuf = dirs 36 | .state_dir() 37 | .unwrap_or_else(|| dirs.data_local_dir()) 38 | .to_path_buf(); 39 | 40 | Ok(directory) 41 | } 42 | -------------------------------------------------------------------------------- /src/terminal.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use color_eyre::eyre::Result; 4 | use crossterm::{ 5 | cursor, execute, 6 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 7 | }; 8 | use ratatui::{Terminal as RatatuiTerminal, backend::CrosstermBackend}; 9 | 10 | pub type Terminal = RatatuiTerminal>; 11 | 12 | pub fn setup() -> Result { 13 | let mut stdout = std::io::stdout(); 14 | crossterm::terminal::enable_raw_mode()?; 15 | set_panic_hook(); 16 | execute!(stdout, EnterAlternateScreen, cursor::Hide)?; 17 | let mut terminal = RatatuiTerminal::new(CrosstermBackend::new(stdout))?; 18 | terminal.clear()?; 19 | terminal.hide_cursor()?; 20 | Ok(terminal) 21 | } 22 | 23 | pub fn teardown() -> Result<()> { 24 | execute!(io::stdout(), LeaveAlternateScreen, cursor::Show)?; 25 | crossterm::terminal::disable_raw_mode()?; 26 | Ok(()) 27 | } 28 | 29 | // Panic hook 30 | // see https://ratatui.rs/tutorials/counter-app/error-handling/#setup-hooks 31 | fn set_panic_hook() { 32 | let hook = std::panic::take_hook(); 33 | std::panic::set_hook(Box::new(move |panic_info| { 34 | let _ = teardown(); // ignore any errors as we are already failing 35 | hook(panic_info); 36 | })); 37 | } 38 | -------------------------------------------------------------------------------- /src/logging.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::{Result, eyre}; 2 | use std::fs; 3 | use std::path::PathBuf; 4 | use tracing::level_filters::LevelFilter; 5 | use tracing_subscriber::{ 6 | self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, 7 | }; 8 | 9 | pub struct Logger { 10 | log_dir: PathBuf, 11 | } 12 | 13 | impl Logger { 14 | pub fn new(log_dir: PathBuf) -> Self { 15 | Self { log_dir } 16 | } 17 | 18 | pub fn init(&self) -> Result<()> { 19 | let log_path = self.log_dir.join("app.log"); 20 | let log_file = fs::File::create(log_path).map_err(|err| { 21 | eyre!( 22 | "Could not create a log file in {:?} : {}", 23 | self.log_dir, 24 | err 25 | ) 26 | })?; 27 | let fmt_layer = tracing_subscriber::fmt::layer() 28 | .with_file(true) 29 | .with_line_number(true) 30 | .with_writer(log_file) 31 | .with_target(false) 32 | .with_ansi(false); 33 | let filter = tracing_subscriber::filter::EnvFilter::from_default_env() 34 | .add_directive(LevelFilter::DEBUG.into()); 35 | tracing_subscriber::registry() 36 | .with(fmt_layer) 37 | .with(filter) 38 | .init(); 39 | Ok(()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timr-tui" 3 | version = "1.6.1" 4 | description = "TUI to organize your time: Pomodoro, Countdown, Timer." 5 | edition = "2024" 6 | # Reminder: Always keep `channel` in `rust-toolchain.toml` in sync with `rust-version`. 7 | rust-version = "1.92.0" 8 | homepage = "https://github.com/sectore/timr-tui" 9 | repository = "https://github.com/sectore/timr-tui" 10 | readme = "README.md" 11 | license = "MIT" 12 | keywords = ["tui", "timer", "countdown", "pomodoro"] 13 | categories = ["command-line-utilities"] 14 | exclude = [ 15 | ".github/*", 16 | "demo/*.tape", 17 | "result/*", 18 | "*.mp3", 19 | ".claude", 20 | "CLAUDE.md", 21 | ] 22 | 23 | [dependencies] 24 | ratatui = "0.29.0" 25 | crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } 26 | color-eyre = "0.6.5" 27 | futures = "0.3" 28 | serde = { version = "1", features = ["derive"] } 29 | serde_json = "1.0" 30 | strum = { version = "0.26.3", features = ["derive"] } 31 | tokio = { version = "1.47.1", features = ["full"] } 32 | tokio-stream = "0.1.17" 33 | tokio-util = "0.7.16" 34 | tracing = "0.1.41" 35 | tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } 36 | directories = "5.0.1" 37 | clap = { version = "4.5.48", features = ["derive"] } 38 | time = { version = "0.3.44", features = ["formatting", "local-offset", "parsing", "macros", "serde"] } 39 | notify-rust = "4.11.7" 40 | rodio = { version = "0.20.1", features = [ 41 | "symphonia-mp3", 42 | "symphonia-wav", 43 | ], default-features = false, optional = true } 44 | thiserror = { version = "2.0.17", optional = true } 45 | tui-input = "0.14.0" 46 | 47 | 48 | [features] 49 | sound = ["dep:rodio", "dep:thiserror"] 50 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod common; 3 | mod config; 4 | mod constants; 5 | mod event; 6 | mod events; 7 | mod logging; 8 | 9 | mod args; 10 | mod duration; 11 | mod storage; 12 | mod terminal; 13 | mod utils; 14 | mod widgets; 15 | 16 | #[cfg(feature = "sound")] 17 | mod sound; 18 | 19 | use app::{App, FromAppArgs}; 20 | use args::{Args, LOG_DIRECTORY_DEFAULT_MISSING_VALUE}; 21 | use clap::Parser; 22 | use color_eyre::Result; 23 | use config::Config; 24 | use std::path::PathBuf; 25 | use storage::{AppStorage, Storage}; 26 | 27 | #[tokio::main] 28 | async fn main() -> Result<()> { 29 | // init `Config` 30 | let cfg = Config::init()?; 31 | 32 | color_eyre::install()?; 33 | 34 | // get args given by CLI 35 | let args = Args::parse(); 36 | // Note: 37 | // `log` arg can have three different values: 38 | // (1) not set => None 39 | // (2) set with path => Some(Some(path)) 40 | // (3) set without path => Some(None) 41 | let custom_log_dir: Option> = if let Some(path) = &args.log { 42 | if path.ne(PathBuf::from(LOG_DIRECTORY_DEFAULT_MISSING_VALUE).as_os_str()) { 43 | // (2) 44 | Some(Some(path)) 45 | } else { 46 | // (3) 47 | Some(None) 48 | } 49 | } else { 50 | // (1) 51 | None 52 | }; 53 | 54 | if let Some(log_dir) = custom_log_dir { 55 | let dir: PathBuf = log_dir.unwrap_or(&cfg.log_dir).to_path_buf(); 56 | logging::Logger::new(dir).init()?; 57 | } 58 | 59 | let mut terminal = terminal::setup()?; 60 | let events = events::Events::new(); 61 | 62 | // check persistant storage 63 | let storage = Storage::new(cfg.data_dir); 64 | // option to reset previous stored data to `default` 65 | let stg = if args.reset { 66 | AppStorage::default() 67 | } else { 68 | storage.load().unwrap_or_default() 69 | }; 70 | 71 | let app_storage = App::from(FromAppArgs { 72 | args, 73 | stg, 74 | app_tx: events.get_app_event_tx(), 75 | }) 76 | .run(&mut terminal, events) 77 | .await? 78 | .to_storage(); 79 | // store app state persistantly 80 | storage.save(app_storage)?; 81 | 82 | terminal::teardown()?; 83 | 84 | Ok(()) 85 | } 86 | -------------------------------------------------------------------------------- /src/widgets/clock_elements_test.rs: -------------------------------------------------------------------------------- 1 | use crate::widgets::clock_elements::*; 2 | use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; 3 | 4 | const D_RECT: Rect = Rect::new(0, 0, DIGIT_WIDTH, DIGIT_HEIGHT); 5 | 6 | #[test] 7 | fn test_d1() { 8 | let mut b = Buffer::empty(D_RECT); 9 | Digit::new(1, false, "█").render(D_RECT, &mut b); 10 | #[rustfmt::skip] 11 | let expected = Buffer::with_lines([ 12 | " ██", 13 | " ██", 14 | " ██", 15 | " ██", 16 | " ██", 17 | " ", 18 | ]); 19 | assert_eq!(b, expected, "w/o border"); 20 | 21 | Digit::new(1, true, "█").render(D_RECT, &mut b); 22 | #[rustfmt::skip] 23 | let expected = Buffer::with_lines([ 24 | " ██", 25 | " ██", 26 | " ██", 27 | " ██", 28 | " ██", 29 | "─────", 30 | ]); 31 | assert_eq!(b, expected, "w/ border"); 32 | } 33 | 34 | #[test] 35 | fn test_d2() { 36 | let mut b = Buffer::empty(D_RECT); 37 | Digit::new(2, false, "█").render(D_RECT, &mut b); 38 | #[rustfmt::skip] 39 | let expected = Buffer::with_lines([ 40 | "█████", 41 | " ██", 42 | "█████", 43 | "██ ", 44 | "█████", 45 | " ", 46 | ]); 47 | assert_eq!(b, expected, "w/o border"); 48 | 49 | Digit::new(2, true, "█").render(D_RECT, &mut b); 50 | #[rustfmt::skip] 51 | let expected = Buffer::with_lines([ 52 | "█████", 53 | " ██", 54 | "█████", 55 | "██ ", 56 | "█████", 57 | "─────", 58 | ]); 59 | assert_eq!(b, expected, "w/ border"); 60 | } 61 | 62 | #[test] 63 | fn test_dot() { 64 | let mut b = Buffer::empty(D_RECT); 65 | Dot::new("█").render(D_RECT, &mut b); 66 | #[rustfmt::skip] 67 | let expected = Buffer::with_lines([ 68 | " ", 69 | " ", 70 | " ", 71 | " ", 72 | " ██ ", 73 | " ", 74 | ]); 75 | assert_eq!(b, expected); 76 | } 77 | 78 | #[test] 79 | fn test_colon() { 80 | let mut b = Buffer::empty(D_RECT); 81 | Colon::new("█").render(D_RECT, &mut b); 82 | #[rustfmt::skip] 83 | let expected = Buffer::with_lines([ 84 | " ", 85 | " ██ ", 86 | " ", 87 | " ██ ", 88 | " ", 89 | " ", 90 | ]); 91 | assert_eq!(b, expected); 92 | } 93 | -------------------------------------------------------------------------------- /src/sound.rs: -------------------------------------------------------------------------------- 1 | use rodio::{Decoder, OutputStream, Sink}; 2 | use std::fs::File; 3 | use std::io::BufReader; 4 | use std::path::PathBuf; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug, Error)] 8 | pub enum SoundError { 9 | #[error("Sound output stream error: {0}")] 10 | OutputStream(String), 11 | #[error("Sound file error: {0}")] 12 | File(String), 13 | #[error("Sound sink error: {0}")] 14 | Sink(String), 15 | #[error("Sound decoder error: {0}")] 16 | Decoder(String), 17 | } 18 | 19 | pub fn validate_sound_file(path: &PathBuf) -> Result<&PathBuf, SoundError> { 20 | // validate path 21 | if !path.exists() { 22 | let err = SoundError::File(format!("File not found: {:?}", path)); 23 | return Err(err); 24 | }; 25 | 26 | // Validate file extension 27 | path.extension() 28 | .and_then(|ext| ext.to_str()) 29 | .filter(|ext| ["mp3", "wav"].contains(&ext.to_lowercase().as_str())) 30 | .ok_or_else(|| { 31 | SoundError::File( 32 | "Unsupported file extension. Only .mp3 and .wav are supported".to_owned(), 33 | ) 34 | })?; 35 | 36 | Ok(path) 37 | } 38 | 39 | // #[derive(Clone)] 40 | pub struct Sound { 41 | path: PathBuf, 42 | } 43 | 44 | impl Sound { 45 | pub fn new(path: PathBuf) -> Result { 46 | Ok(Self { path }) 47 | } 48 | 49 | pub fn play(&self) -> Result<(), SoundError> { 50 | // validate file again 51 | validate_sound_file(&self.path)?; 52 | // before playing the sound 53 | let path = self.path.clone(); 54 | 55 | std::thread::spawn(move || -> Result<(), SoundError> { 56 | // Important note: Never (ever) use a single `_` as a placeholder here. `_stream` or something is fine! 57 | // The value will dropped and the sound will fail without any errors 58 | // see https://github.com/RustAudio/rodio/issues/330 59 | let (_stream, handle) = 60 | OutputStream::try_default().map_err(|e| SoundError::OutputStream(e.to_string()))?; 61 | let file = File::open(&path).map_err(|e| SoundError::File(e.to_string()))?; 62 | let sink = Sink::try_new(&handle).map_err(|e| SoundError::Sink(e.to_string()))?; 63 | let decoder = Decoder::new(BufReader::new(file)) 64 | .map_err(|e| SoundError::Decoder(e.to_string()))?; 65 | sink.append(decoder); 66 | sink.sleep_until_end(); 67 | 68 | Ok(()) 69 | }); 70 | 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use ratatui::layout::{Constraint, Flex, Layout, Rect}; 2 | 3 | /// Helper to center an area horizontally by given `Constraint` 4 | /// based on [Center a Rect](https://ratatui.rs/recipes/layout/center-a-rect) 5 | pub fn center_horizontal(base_area: Rect, horizontal: Constraint) -> Rect { 6 | let [area] = Layout::horizontal([horizontal]) 7 | .flex(Flex::Center) 8 | .areas(base_area); 9 | area 10 | } 11 | 12 | /// Helper to center an area vertically by given `Constraint` 13 | /// based on [Center a Rect](https://ratatui.rs/recipes/layout/center-a-rect) 14 | pub fn center_vertical(base_area: Rect, vertical: Constraint) -> Rect { 15 | let [area] = Layout::vertical([vertical]) 16 | .flex(Flex::Center) 17 | .areas(base_area); 18 | area 19 | } 20 | /// Helper to center an area by given `Constraint`'s 21 | /// based on [Center a Rect](https://ratatui.rs/recipes/layout/center-a-rect) 22 | pub fn center(base_area: Rect, horizontal: Constraint, vertical: Constraint) -> Rect { 23 | let area = center_horizontal(base_area, horizontal); 24 | center_vertical(area, vertical) 25 | } 26 | 27 | #[cfg(test)] 28 | mod tests { 29 | 30 | use super::*; 31 | use ratatui::{buffer::Buffer, layout::Rect, text::Span, widgets::Widget}; 32 | 33 | #[test] 34 | fn test_center() { 35 | let l = Span::raw("hello!"); 36 | let mut b = Buffer::empty(Rect::new(0, 0, 10, 3)); 37 | let area = center( 38 | b.area, 39 | Constraint::Length(l.width() as u16), 40 | Constraint::Length(1), 41 | ); 42 | l.render(area, &mut b); 43 | #[rustfmt::skip] 44 | let expected = Buffer::with_lines([ 45 | " ", 46 | " hello! ", 47 | " ", 48 | ]); 49 | assert_eq!(b, expected); 50 | } 51 | 52 | #[test] 53 | fn test_center_horizontal() { 54 | let l = Span::raw("hello!"); 55 | let mut b = Buffer::empty(Rect::new(0, 0, 10, 1)); 56 | let area = center_horizontal(b.area, Constraint::Length(l.width() as u16)); 57 | l.render(area, &mut b); 58 | let expected = Buffer::with_lines([" hello! "]); 59 | assert_eq!(b, expected); 60 | } 61 | 62 | #[test] 63 | fn test_center_vertical() { 64 | let l = Span::raw("hello vertical"); 65 | let mut b = Buffer::empty(Rect::new(0, 0, 20, 3)); 66 | let area = center_vertical(b.area, Constraint::Length(1)); 67 | l.render(area, &mut b); 68 | #[rustfmt::skip] 69 | let expected = Buffer::with_lines([ 70 | " ", 71 | "hello vertical ", 72 | " ", 73 | ]); 74 | assert_eq!(b, expected); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | crane.url = "github:ipetkov/crane"; 6 | fenix = { 7 | url = "github:nix-community/fenix"; 8 | inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | }; 11 | 12 | outputs = { 13 | nixpkgs, 14 | flake-utils, 15 | fenix, 16 | crane, 17 | ... 18 | }: 19 | flake-utils.lib.eachDefaultSystem (system: let 20 | pkgs = nixpkgs.legacyPackages.${system}; 21 | 22 | toolchain = 23 | fenix.packages.${system}.fromToolchainFile 24 | { 25 | file = ./rust-toolchain.toml; 26 | # sha256 = nixpkgs.lib.fakeSha256; 27 | sha256 = "sha256-sqSWJDUxc+zaz1nBWMAJKTAGBuGWP25GCftIOlCEAtA="; 28 | }; 29 | 30 | craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; 31 | 32 | commonArgs = { 33 | src = craneLib.cleanCargoSource ./.; 34 | strictDeps = true; 35 | doCheck = false; # skip tests during nix build 36 | }; 37 | 38 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 39 | 40 | # Native build 41 | timr = craneLib.buildPackage commonArgs; 42 | 43 | # Linux build w/ statically linked binaries 44 | staticLinuxBuild = craneLib.buildPackage (commonArgs 45 | // { 46 | inherit cargoArtifacts; 47 | CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl"; 48 | CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static"; 49 | }); 50 | 51 | # Windows cross-compilation build 52 | # @see https://crane.dev/examples/cross-windows.html 53 | windowsBuild = craneLib.buildPackage { 54 | inherit (commonArgs) src strictDeps doCheck; 55 | 56 | CARGO_BUILD_TARGET = "x86_64-pc-windows-gnu"; 57 | 58 | # fixes issues related to libring 59 | TARGET_CC = "${pkgs.pkgsCross.mingwW64.stdenv.cc}/bin/${pkgs.pkgsCross.mingwW64.stdenv.cc.targetPrefix}cc"; 60 | 61 | #fixes issues related to openssl 62 | OPENSSL_DIR = "${pkgs.openssl.dev}"; 63 | OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; 64 | OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include/"; 65 | 66 | depsBuildBuild = with pkgs; [ 67 | pkgsCross.mingwW64.stdenv.cc 68 | pkgsCross.mingwW64.windows.pthreads 69 | ]; 70 | }; 71 | in { 72 | packages = { 73 | inherit timr; 74 | default = timr; 75 | linuxStatic = staticLinuxBuild; 76 | windows = windowsBuild; 77 | }; 78 | 79 | devShells.default = with nixpkgs.legacyPackages.${system}; 80 | craneLib.devShell { 81 | packages = 82 | [ 83 | toolchain 84 | pkgs.just 85 | pkgs.nixd 86 | pkgs.alejandra 87 | ] 88 | # pkgs needed to play sound on Linux 89 | ++ lib.optionals stdenv.isLinux [ 90 | pkgs.pkg-config 91 | pkgs.pipewire 92 | pkgs.alsa-lib 93 | ]; 94 | 95 | inherit (commonArgs) src; 96 | 97 | # Environment variables needed discover ALSA/PipeWire properly on Linux 98 | LD_LIBRARY_PATH = lib.optionalString stdenv.isLinux "${pkgs.alsa-lib}/lib:${pkgs.pipewire}/lib"; 99 | ALSA_PLUGIN_DIR = lib.optionalString stdenv.isLinux "${pkgs.pipewire}/lib/alsa-lib"; 100 | }; 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "locked": { 5 | "lastModified": 1765145449, 6 | "narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=", 7 | "owner": "ipetkov", 8 | "repo": "crane", 9 | "rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "ipetkov", 14 | "repo": "crane", 15 | "type": "github" 16 | } 17 | }, 18 | "fenix": { 19 | "inputs": { 20 | "nixpkgs": [ 21 | "nixpkgs" 22 | ], 23 | "rust-analyzer-src": "rust-analyzer-src" 24 | }, 25 | "locked": { 26 | "lastModified": 1765435813, 27 | "narHash": "sha256-C6tT7K1Lx6VsYw1BY5S3OavtapUvEnDQtmQB5DSgbCc=", 28 | "owner": "nix-community", 29 | "repo": "fenix", 30 | "rev": "6399553b7a300c77e7f07342904eb696a5b6bf9d", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "fenix", 36 | "type": "github" 37 | } 38 | }, 39 | "flake-utils": { 40 | "inputs": { 41 | "systems": "systems" 42 | }, 43 | "locked": { 44 | "lastModified": 1731533236, 45 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 46 | "owner": "numtide", 47 | "repo": "flake-utils", 48 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "numtide", 53 | "repo": "flake-utils", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1765186076, 60 | "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=", 61 | "owner": "NixOS", 62 | "repo": "nixpkgs", 63 | "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "NixOS", 68 | "ref": "nixos-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "root": { 74 | "inputs": { 75 | "crane": "crane", 76 | "fenix": "fenix", 77 | "flake-utils": "flake-utils", 78 | "nixpkgs": "nixpkgs" 79 | } 80 | }, 81 | "rust-analyzer-src": { 82 | "flake": false, 83 | "locked": { 84 | "lastModified": 1765400135, 85 | "narHash": "sha256-D3+4hfNwUhG0fdCpDhOASLwEQ1jKuHi4mV72up4kLQM=", 86 | "owner": "rust-lang", 87 | "repo": "rust-analyzer", 88 | "rev": "fface27171988b3d605ef45cf986c25533116f7e", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "rust-lang", 93 | "ref": "nightly", 94 | "repo": "rust-analyzer", 95 | "type": "github" 96 | } 97 | }, 98 | "systems": { 99 | "locked": { 100 | "lastModified": 1681028828, 101 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 102 | "owner": "nix-systems", 103 | "repo": "default", 104 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "nix-systems", 109 | "repo": "default", 110 | "type": "github" 111 | } 112 | } 113 | }, 114 | "root": "root", 115 | "version": 7 116 | } 117 | -------------------------------------------------------------------------------- /src/events.rs: -------------------------------------------------------------------------------- 1 | use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; 2 | use futures::{Stream, StreamExt}; 3 | use ratatui::layout::Position; 4 | use std::{pin::Pin, time::Duration}; 5 | use tokio::sync::mpsc; 6 | use tokio::time::interval; 7 | use tokio_stream::{StreamMap, wrappers::IntervalStream}; 8 | 9 | use crate::common::ClockTypeId; 10 | use crate::constants::{FPS_VALUE_MS, TICK_VALUE_MS}; 11 | 12 | #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] 13 | enum StreamKey { 14 | Ticks, 15 | Render, 16 | Crossterm, 17 | } 18 | 19 | #[derive(Clone, Debug)] 20 | pub enum TuiEvent { 21 | Error, 22 | Tick, 23 | Render, 24 | Crossterm(CrosstermEvent), 25 | } 26 | 27 | #[derive(Clone, Debug)] 28 | pub enum AppEvent { 29 | ClockDone(ClockTypeId, String), 30 | SetCursor(Option), 31 | } 32 | 33 | pub type AppEventTx = mpsc::UnboundedSender; 34 | pub type AppEventRx = mpsc::UnboundedReceiver; 35 | 36 | pub struct Events { 37 | streams: StreamMap>>>, 38 | app_channel: (AppEventTx, AppEventRx), 39 | } 40 | 41 | impl Default for Events { 42 | fn default() -> Self { 43 | Self { 44 | streams: StreamMap::from_iter([ 45 | (StreamKey::Ticks, tick_stream()), 46 | (StreamKey::Render, render_stream()), 47 | (StreamKey::Crossterm, crossterm_stream()), 48 | ]), 49 | app_channel: mpsc::unbounded_channel(), 50 | } 51 | } 52 | } 53 | 54 | pub enum Event { 55 | Terminal(TuiEvent), 56 | App(AppEvent), 57 | } 58 | 59 | impl Events { 60 | pub fn new() -> Self { 61 | Self::default() 62 | } 63 | 64 | pub async fn next(&mut self) -> Option { 65 | let streams = &mut self.streams; 66 | let app_rx = &mut self.app_channel.1; 67 | tokio::select! { 68 | Some((_, event)) = streams.next() => Some(Event::Terminal(event)), 69 | Some(app_event) = app_rx.recv() => Some(Event::App(app_event)), 70 | } 71 | } 72 | 73 | pub fn get_app_event_tx(&self) -> AppEventTx { 74 | self.app_channel.0.clone() 75 | } 76 | } 77 | 78 | fn tick_stream() -> Pin>> { 79 | let tick_interval = interval(Duration::from_millis(TICK_VALUE_MS)); 80 | Box::pin(IntervalStream::new(tick_interval).map(|_| TuiEvent::Tick)) 81 | } 82 | 83 | fn render_stream() -> Pin>> { 84 | let render_interval = interval(Duration::from_millis(FPS_VALUE_MS)); 85 | Box::pin(IntervalStream::new(render_interval).map(|_| TuiEvent::Render)) 86 | } 87 | 88 | fn crossterm_stream() -> Pin>> { 89 | Box::pin( 90 | EventStream::new() 91 | .fuse() 92 | // we are not interested in all events 93 | .filter_map(|result| async move { 94 | match result { 95 | // filter `KeyEventKind::Press` out to ignore all the other `CrosstermEvent::Key` events 96 | Ok(CrosstermEvent::Key(key)) => (key.kind == KeyEventKind::Press) 97 | .then_some(TuiEvent::Crossterm(CrosstermEvent::Key(key))), 98 | Ok(other) => Some(TuiEvent::Crossterm(other)), 99 | Err(_) => Some(TuiEvent::Error), 100 | } 101 | }), 102 | ) 103 | } 104 | 105 | pub trait TuiEventHandler { 106 | fn update(&mut self, _: TuiEvent) -> Option; 107 | } 108 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "release/**" 7 | 8 | jobs: 9 | get-version: 10 | name: Get version 11 | runs-on: ubuntu-latest 12 | outputs: 13 | version: ${{ steps.version.outputs.version }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Get version from Cargo.toml 17 | id: version 18 | run: | 19 | VERSION=$(grep '^version =' Cargo.toml | cut -d '"' -f2) 20 | echo "version=$VERSION" >> $GITHUB_OUTPUT 21 | 22 | build: 23 | name: Build ${{ matrix.os_target }} on ${{ matrix.os }} 24 | needs: get-version 25 | runs-on: ${{ matrix.os }} 26 | strategy: 27 | matrix: 28 | include: 29 | - os: ubuntu-latest 30 | os_target: linux 31 | binary_name: timr-tui 32 | arch: x86_64 # based on target 'x86_64-unknown-linux-musl' defined by `CARGO_BUILD_TARGET` in flake.nix 33 | - os: ubuntu-latest 34 | os_target: windows 35 | binary_name: timr-tui.exe 36 | arch: x86_64 # based on target 'x86_64-pc-windows-gnu' defined by `CARGO_BUILD_TARGET` in flake.nix 37 | - os: macOS-latest 38 | os_target: macos 39 | binary_name: timr-tui 40 | arch: x86_64 # `x86_64` by default 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: DeterminateSystems/nix-installer-action@main 44 | 45 | - name: Build (windows) 46 | if: matrix.os_target == 'windows' 47 | run: nix build .#windows 48 | 49 | - name: Build (linux) 50 | if: matrix.os_target == 'linux' 51 | run: nix build .#linuxStatic 52 | 53 | - name: Build (macos) 54 | if: matrix.os_target == 'macos' 55 | run: nix build 56 | 57 | - name: Copy artifact 58 | run: | 59 | mkdir artifacts 60 | cp result/bin/${{ matrix.binary_name }} artifacts/ 61 | 62 | - name: Install zip 63 | if: matrix.os_target == 'windows' 64 | run: sudo apt-get install -y zip 65 | 66 | - name: Archive (windows) 67 | if: matrix.os_target == 'windows' 68 | run: | 69 | cd artifacts 70 | zip ${{ matrix.binary_name }}-${{ needs.get-version.outputs.version }}-${{ matrix.os_target }}_${{ matrix.arch }}.zip ${{ matrix.binary_name }} 71 | 72 | - name: Archive (linux/macos) 73 | if: matrix.os_target != 'windows' 74 | run: | 75 | cd artifacts 76 | tar -czf ${{ matrix.binary_name }}-${{ needs.get-version.outputs.version }}-${{ matrix.os_target }}_${{ matrix.arch }}.tar.gz ${{ matrix.binary_name }} 77 | 78 | - name: Upload archive 79 | uses: actions/upload-artifact@v4 80 | with: 81 | name: ${{ matrix.os_target }}-build 82 | path: | 83 | artifacts/*.tar.gz 84 | artifacts/*.zip 85 | overwrite: true 86 | 87 | create-release: 88 | name: Create draft release 89 | needs: [get-version, build] 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Download all artifacts 93 | uses: actions/download-artifact@v4 94 | with: 95 | path: artifacts 96 | merge-multiple: true 97 | 98 | - name: Create draft release 99 | uses: softprops/action-gh-release@v2 100 | with: 101 | draft: true 102 | tag_name: "v${{ needs.get-version.outputs.version }}" 103 | name: "v${{ needs.get-version.outputs.version }}" 104 | body: Draft release generated by CI. 105 | files: artifacts/**/* 106 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | common::{Content, Style, Toggle}, 3 | duration, 4 | event::{Event, parse_event}, 5 | }; 6 | #[cfg(feature = "sound")] 7 | use crate::{sound, sound::SoundError}; 8 | use clap::Parser; 9 | use std::path::PathBuf; 10 | use std::time::Duration; 11 | 12 | pub const LOG_DIRECTORY_DEFAULT_MISSING_VALUE: &str = " "; // empty string 13 | 14 | #[derive(Parser)] 15 | #[command(version)] 16 | pub struct Args { 17 | #[arg(long, short, value_parser = duration::parse_long_duration, 18 | help = "Countdown time to start from. Formats: 'Yy Dd hh:mm:ss', 'Dd hh:mm:ss', 'Yy mm:ss', 'Dd mm:ss', 'Yy ss', 'Dd ss', 'hh:mm:ss', 'mm:ss', 'ss'. Examples: '1y 5d 10:30:00', '2d 4:00', '1d 10', '5:03'." 19 | )] 20 | pub countdown: Option, 21 | 22 | #[arg(long, short, value_parser = duration::parse_duration, 23 | help = "Work time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'" 24 | )] 25 | pub work: Option, 26 | 27 | #[arg(long, short, value_parser = duration::parse_duration, 28 | help = "Pause time to count down from. Formats: 'ss', 'mm:ss', 'hh:mm:ss'" 29 | )] 30 | pub pause: Option, 31 | 32 | #[arg( 33 | long, 34 | short = 'e', 35 | value_parser = parse_event, 36 | help = "Event date time and title (optional). Format: 'YYYY-MM-DD HH:MM:SS' or 'time=YYYY-MM-DD HH:MM:SS[,title=...]'. Examples: '2025-10-10 14:30:00' or 'time=2025-10-10 14:30:00,title=My Event'." 37 | )] 38 | pub event: Option, 39 | 40 | #[arg(long, short = 'd', help = "Show deciseconds.")] 41 | pub decis: bool, 42 | 43 | #[arg(long, short = 'm', value_enum, help = "Mode to start with.")] 44 | pub mode: Option, 45 | 46 | #[arg(long, short = 's', value_enum, help = "Style to display time with.")] 47 | pub style: Option