diff --git a/src/interval.rs b/src/interval.rs new file mode 100644 index 0000000..833d54e --- /dev/null +++ b/src/interval.rs @@ -0,0 +1,51 @@ +use std::time::Duration; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Interval { + Work(Duration), + ShortBreak(Duration), + LongBreak(Duration), +} + +impl Interval { + const FIVE_MINUTES: Duration = Duration::from_secs(5 * 60); + const FIFTEEN_MINUTES: Duration = Duration::from_secs(15 * 60); + const TWENTY_FIVE_MINUTES: Duration = Duration::from_secs(25 * 60); + + pub(crate) fn default_sequence() -> [Self; 8] { + [ + Self::Work(Self::TWENTY_FIVE_MINUTES), + Self::ShortBreak(Self::FIVE_MINUTES), + Self::Work(Self::TWENTY_FIVE_MINUTES), + Self::ShortBreak(Self::FIVE_MINUTES), + Self::Work(Self::TWENTY_FIVE_MINUTES), + Self::ShortBreak(Self::FIVE_MINUTES), + Self::Work(Self::TWENTY_FIVE_MINUTES), + Self::LongBreak(Self::FIFTEEN_MINUTES), + ] + } + + pub(crate) fn get_duration(&self) -> Duration { + match self { + Interval::Work(d) => *d, + Interval::ShortBreak(d) => *d, + Interval::LongBreak(d) => *d, + } + } + + pub(crate) fn get_description(&self) -> &str { + match self { + Interval::Work(_) => "work", + Interval::ShortBreak(_) => "short break", + Interval::LongBreak(_) => "long break", + } + } + + pub(crate) fn get_message(&self) -> &str { + match self { + Interval::Work(_) => "back to work", + Interval::ShortBreak(_) => "take a break", + Interval::LongBreak(_) => "big break, you earned it!", + } + } +} diff --git a/src/main.rs b/src/main.rs index d807242..fa7a947 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,60 +1,46 @@ -use std::ffi::OsString; -use std::fs::{self, read_to_string}; -use std::io::{stdout, Stdout, Write}; -use std::os::unix::prelude::OsStringExt; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; -use std::time::Duration; +mod interval; +mod message; +mod pid; +mod pomodoro; +mod ui; -use anyhow::{anyhow, bail, Result}; +use std::process::ExitCode; -use crossterm::{cursor, QueueableCommand}; -use crossterm::{ - cursor::MoveTo, - event::{Event, EventStream, KeyCode, KeyModifiers}, - terminal::{self, disable_raw_mode, enable_raw_mode}, - ExecutableCommand, -}; -use futures::StreamExt; - -use tokio::sync::broadcast::{channel, Receiver, Sender}; +use anyhow::{bail, Result}; +use tokio::sync::broadcast::channel; use tokio::task::JoinSet; -use tokio::time::sleep; - -const ONE_SECOND: Duration = Duration::from_secs(1); -const PID_FILE: &str = "/tmp/pomodoro.pid"; #[tokio::main(flavor = "current_thread")] async fn main() -> ExitCode { match main2().await { Ok(()) => ExitCode::SUCCESS, Err(e) => { - eprintln!("{}", e.root_cause().to_string()); + eprintln!("{}", e.root_cause()); ExitCode::FAILURE } } } async fn main2() -> Result<()> { - let pid = if let Some(pid) = Pid::check_already_running() { + let pid = if let Some(pid) = pid::Pid::check_already_running() { bail!("already running (pid {})", pid.pid) } else { - Pid::create()? + pid::Pid::create()? }; let result = run_all_tasks().await; - Ui::clear_terminal()?; + ui::clear_terminal()?; pid.remove()?; result } async fn run_all_tasks() -> Result<()> { - let (tx, rx) = channel::(10); + let (tx, rx) = channel::(10); - let timer = Pomodoro::new(rx); - let ui = Ui::new(tx); + let timer = pomodoro::Pomodoro::new(rx); + let ui = ui::Ui::new(tx); let mut tasks = JoinSet::new(); @@ -67,291 +53,3 @@ async fn run_all_tasks() -> Result<()> { Ok(()) } - -struct Pid { - pid: u32, - path: PathBuf, -} - -impl Pid { - fn check_already_running() -> Option { - let path = PathBuf::from(PID_FILE); - Self::get_pid_from_file(&path) - .and_then(Self::check_process_exists) - .map(|pid| Self { pid, path }) - } - - fn check_process_exists(pid: u32) -> Option { - let mut path = PathBuf::from("/proc"); - path.push(pid.to_string()); - path.push("cmdline"); - let content = fs::read(path).ok()?; - let cmdline = OsString::from_vec(content).to_string_lossy().to_string(); - if cmdline.contains("pomodoro") { - Some(pid) - } else { - None - } - } - - fn get_pid_from_file(file: &Path) -> Option { - let content = read_to_string(file).ok()?; - let line = content.lines().nth(0)?; - let result: u32 = line.parse().ok()?; - Some(result) - } - - fn create() -> Result { - let pid = std::process::id(); - let path = PathBuf::from(PID_FILE); - let mut fd = fs::File::create(&path)?; - write!(fd, "{pid}")?; - let result = Self { pid, path }; - Ok(result) - } - - fn remove(self) -> Result<()> { - fs::remove_file(self.path).map_err(|e| anyhow!(e)) - } -} - -struct Ui { - sender: Sender, -} - -impl Ui { - fn new(sender: Sender) -> Self { - Self { sender } - } - - async fn main_loop(self) -> Result<()> { - let mut reader = EventStream::new(); - - loop { - enable_raw_mode()?; - stdout().execute(cursor::Hide)?; - - if let Some(Ok(Event::Key(key_event))) = reader.next().await { - let should_exit = self.handle_key_event(key_event)?; - if should_exit { - return Ok(()); - }; - } - } - } - - fn handle_key_event(&self, key_event: crossterm::event::KeyEvent) -> Result { - if Self::is_char_q(key_event) { - self.prepare_for_program_quit()?; - return Ok(true); - }; - if Self::is_ctrl_c(key_event) { - self.prepare_for_program_quit()?; - self.sender.send(Message::CtrlC)?; - bail!(""); - } - if Self::is_space(key_event) { - self.sender.send(Message::TogglePause)?; - } - Ok(false) - } - - fn prepare_for_program_quit(&self) -> Result<()> { - self.sender.send(Message::Quit)?; - disable_raw_mode()?; - stdout().execute(cursor::Show)?; - Ok(()) - } - - fn is_char_q(key_event: crossterm::event::KeyEvent) -> bool { - key_event.code == KeyCode::Char('q') - } - - fn is_space(key_event: crossterm::event::KeyEvent) -> bool { - key_event.code == KeyCode::Char(' ') - } - - fn is_ctrl_c(key_event: crossterm::event::KeyEvent) -> bool { - key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL) - } - - fn clear_terminal() -> Result<()> { - let mut stdout = stdout(); - stdout.queue(terminal::Clear(terminal::ClearType::All))?; - stdout.queue(terminal::Clear(terminal::ClearType::Purge))?; - stdout.queue(MoveTo(0, 0))?; - stdout.flush()?; - Ok(()) - } -} - -struct Pomodoro { - interval_idx: usize, - intervals: [Interval; 8], - remaining: Duration, - stdout: Stdout, - receiver: Receiver, - paused: bool, -} - -impl Pomodoro { - fn new(receiver: Receiver) -> Self { - let intervals = Interval::default_sequence(); - let interval_idx = 0; - let remaining = intervals[interval_idx].get_duration(); - - Pomodoro { - interval_idx, - intervals, - remaining, - stdout: stdout(), - receiver, - paused: false, - } - } - - fn show_status(&mut self) -> Result<()> { - Ui::clear_terminal()?; - write!( - self.stdout, - "currently: {} ({}/{})", - self.intervals[self.interval_idx].get_description(), - self.interval_idx + 1, - self.intervals.len() - )?; - self.stdout.queue(MoveTo(0, 1))?; - write!(self.stdout, "remaining: {}", format(self.remaining))?; - if self.paused { - write!(self.stdout, " (paused)")?; - } - self.stdout.flush()?; - Ok(()) - } - - fn toggle_pause(&mut self) { - self.paused ^= true; - } - - async fn main_loop(mut self) -> Result<()> { - loop { - 'inner: loop { - self.show_status()?; - - tokio::select! ( - _ = sleep(ONE_SECOND) => { - if !self.paused { - self.remaining -= ONE_SECOND; - } - }, - message = self.receiver.recv() => { - if let Ok(m) = message{ - match m { - Message::Quit => { - return Ok(()); - }, - Message::CtrlC => { - bail!(""); - }, - Message::TogglePause => { - self.toggle_pause(); - continue; - } - } - } else { - bail!(""); - } - } - ); - - if self.remaining.is_zero() { - self.show_status()?; - break 'inner; - } - } - - self.next_interval(); - self.remaining = self.current_interval().get_duration(); - self.send_notification(self.current_interval().get_message())?; - } - } - - fn next_interval(&mut self) { - self.interval_idx += 1; - self.interval_idx %= self.intervals.len(); - } - - fn current_interval(&self) -> Interval { - self.intervals[self.interval_idx] - } - - fn send_notification(&self, message: &str) -> Result<()> { - let mut command = Command::new("notify-send"); - command.args(["-t", "0", message]); - command.status()?; - Ok(()) - } -} - -#[derive(Debug, Clone, Copy)] -enum Interval { - Work(Duration), - ShortBreak(Duration), - LongBreak(Duration), -} - -impl Interval { - const FIVE_MINUTES: Duration = Duration::from_secs(5 * 60); - const FIFTEEN_MINUTES: Duration = Duration::from_secs(15 * 60); - const TWENTY_FIVE_MINUTES: Duration = Duration::from_secs(25 * 60); - - fn default_sequence() -> [Self; 8] { - [ - Self::Work(Self::TWENTY_FIVE_MINUTES), - Self::ShortBreak(Self::FIVE_MINUTES), - Self::Work(Self::TWENTY_FIVE_MINUTES), - Self::ShortBreak(Self::FIVE_MINUTES), - Self::Work(Self::TWENTY_FIVE_MINUTES), - Self::ShortBreak(Self::FIVE_MINUTES), - Self::Work(Self::TWENTY_FIVE_MINUTES), - Self::LongBreak(Self::FIFTEEN_MINUTES), - ] - } - - fn get_duration(&self) -> Duration { - match self { - Interval::Work(d) => *d, - Interval::ShortBreak(d) => *d, - Interval::LongBreak(d) => *d, - } - } - - fn get_description(&self) -> &str { - match self { - Interval::Work(_) => "work", - Interval::ShortBreak(_) => "short break", - Interval::LongBreak(_) => "long break", - } - } - - fn get_message(&self) -> &str { - match self { - Interval::Work(_) => "back to work", - Interval::ShortBreak(_) => "take a break", - Interval::LongBreak(_) => "big break, you earned it!", - } - } -} - -fn format(dur: Duration) -> String { - let min = dur.as_secs() / 60; - let sec = dur.as_secs() % 60; - - format!("{min}:{sec:0>2}") -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum Message { - CtrlC, - Quit, - TogglePause, -} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..2a95720 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Message { + CtrlC, + Quit, + TogglePause, +} diff --git a/src/pid.rs b/src/pid.rs new file mode 100644 index 0000000..930524f --- /dev/null +++ b/src/pid.rs @@ -0,0 +1,56 @@ +use std::ffi::OsString; +use std::fs; +use std::io::Write; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +const PID_FILE: &str = "/tmp/pomodoro.pid"; + +pub(crate) struct Pid { + pub(crate) pid: u32, + path: PathBuf, +} + +impl Pid { + pub(crate) fn check_already_running() -> Option { + let path = PathBuf::from(PID_FILE); + get_pid_from_file(&path) + .and_then(check_process_exists) + .map(|pid| Self { pid, path }) + } + + pub(crate) fn create() -> Result { + let pid = std::process::id(); + let path = PathBuf::from(PID_FILE); + let mut fd = fs::File::create(&path)?; + write!(fd, "{pid}")?; + let result = Self { pid, path }; + Ok(result) + } + + pub(crate) fn remove(self) -> Result<()> { + fs::remove_file(self.path).map_err(|e| anyhow!(e)) + } +} + +fn get_pid_from_file(file: &Path) -> Option { + let content = fs::read_to_string(file).ok()?; + let line = content.lines().next()?; + let result: u32 = line.parse().ok()?; + Some(result) +} + +fn check_process_exists(pid: u32) -> Option { + let mut path = PathBuf::from("/proc"); + path.push(pid.to_string()); + path.push("cmdline"); + let content = fs::read(path).ok()?; + let cmdline = OsString::from_vec(content).to_string_lossy().to_string(); + if cmdline.contains("pomodoro") { + Some(pid) + } else { + None + } +} diff --git a/src/pomodoro.rs b/src/pomodoro.rs new file mode 100644 index 0000000..984b0fe --- /dev/null +++ b/src/pomodoro.rs @@ -0,0 +1,128 @@ +use std::io::{stdout, Stdout, Write}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{bail, Result}; +use crossterm::{cursor::MoveTo, QueueableCommand}; +use tokio::sync::broadcast::Receiver; +use tokio::time::sleep; + +use crate::interval::Interval; +use crate::message::Message; +use crate::ui; + +const ONE_SECOND: Duration = Duration::from_secs(1); + +pub(crate) struct Pomodoro { + interval_idx: usize, + intervals: [Interval; 8], + remaining: Duration, + stdout: Stdout, + receiver: Receiver, + paused: bool, +} + +impl Pomodoro { + pub(crate) fn new(receiver: Receiver) -> Self { + let intervals = Interval::default_sequence(); + let interval_idx = 0; + let remaining = intervals[interval_idx].get_duration(); + + Pomodoro { + interval_idx, + intervals, + remaining, + stdout: stdout(), + receiver, + paused: false, + } + } + + fn show_status(&mut self) -> Result<()> { + ui::clear_terminal()?; + write!( + self.stdout, + "currently: {} ({}/{})", + self.intervals[self.interval_idx].get_description(), + self.interval_idx + 1, + self.intervals.len() + )?; + self.stdout.queue(MoveTo(0, 1))?; + write!(self.stdout, "remaining: {}", format(self.remaining))?; + if self.paused { + write!(self.stdout, " (paused)")?; + } + self.stdout.flush()?; + Ok(()) + } + + fn toggle_pause(&mut self) { + self.paused ^= true; + } + + pub(crate) async fn main_loop(mut self) -> Result<()> { + loop { + 'inner: loop { + self.show_status()?; + + tokio::select! ( + _ = sleep(ONE_SECOND) => { + if !self.paused { + self.remaining -= ONE_SECOND; + } + }, + message = self.receiver.recv() => { + if let Ok(m) = message{ + match m { + Message::Quit => { + return Ok(()); + }, + Message::CtrlC => { + bail!(""); + }, + Message::TogglePause => { + self.toggle_pause(); + continue; + } + } + } else { + bail!(""); + } + } + ); + + if self.remaining.is_zero() { + self.show_status()?; + break 'inner; + } + } + + self.next_interval(); + self.remaining = self.current_interval().get_duration(); + self.send_notification(self.current_interval().get_message())?; + } + } + + fn next_interval(&mut self) { + self.interval_idx += 1; + self.interval_idx %= self.intervals.len(); + } + + fn current_interval(&self) -> Interval { + self.intervals[self.interval_idx] + } + + fn send_notification(&self, message: &str) -> Result<()> { + let mut command = Command::new("notify-send"); + command.args(["-t", "0", message]); + command.status()?; + Ok(()) + } +} + +fn format(dur: Duration) -> String { + let min = dur.as_secs() / 60; + let sec = dur.as_secs() % 60; + + format!("{min}:{sec:0>2}") +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..a07fa46 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,84 @@ +use std::io::stdout; +use std::io::Write; + +use anyhow::{bail, Result}; +use crossterm::{ + cursor::{self, MoveTo}, + event::{Event, EventStream, KeyCode, KeyModifiers}, + terminal::{self, disable_raw_mode, enable_raw_mode}, + ExecutableCommand, QueueableCommand, +}; +use futures::StreamExt; +use tokio::sync::broadcast::Sender; + +use crate::message::Message; + +pub(crate) struct Ui { + sender: Sender, +} + +impl Ui { + pub(crate) fn new(sender: Sender) -> Self { + Self { sender } + } + + pub(crate) async fn main_loop(self) -> Result<()> { + let mut reader = EventStream::new(); + + loop { + enable_raw_mode()?; + stdout().execute(cursor::Hide)?; + + if let Some(Ok(Event::Key(key_event))) = reader.next().await { + let should_exit = self.handle_key_event(key_event)?; + if should_exit { + return Ok(()); + }; + } + } + } + + fn handle_key_event(&self, key_event: crossterm::event::KeyEvent) -> Result { + if Self::is_char_q(key_event) { + self.prepare_for_program_quit()?; + return Ok(true); + }; + if Self::is_ctrl_c(key_event) { + self.prepare_for_program_quit()?; + self.sender.send(Message::CtrlC)?; + bail!(""); + } + if Self::is_space(key_event) { + self.sender.send(Message::TogglePause)?; + } + Ok(false) + } + + fn prepare_for_program_quit(&self) -> Result<()> { + self.sender.send(Message::Quit)?; + disable_raw_mode()?; + stdout().execute(cursor::Show)?; + Ok(()) + } + + fn is_char_q(key_event: crossterm::event::KeyEvent) -> bool { + key_event.code == KeyCode::Char('q') + } + + fn is_space(key_event: crossterm::event::KeyEvent) -> bool { + key_event.code == KeyCode::Char(' ') + } + + fn is_ctrl_c(key_event: crossterm::event::KeyEvent) -> bool { + key_event.code == KeyCode::Char('c') && key_event.modifiers.contains(KeyModifiers::CONTROL) + } +} + +pub(crate) fn clear_terminal() -> Result<()> { + let mut stdout = stdout(); + stdout.queue(terminal::Clear(terminal::ClearType::All))?; + stdout.queue(terminal::Clear(terminal::ClearType::Purge))?; + stdout.queue(MoveTo(0, 0))?; + stdout.flush()?; + Ok(()) +}