Compare commits

..

10 Commits

7 changed files with 333 additions and 58 deletions

View File

@@ -7,3 +7,9 @@ edition = "2021"
anyhow = "1.0.86" anyhow = "1.0.86"
ratatui = "0.27.0" ratatui = "0.27.0"
regex = "1.10.5" regex = "1.10.5"
shellexpand = "3.1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "processthreadsapi"] }

13
README.md Normal file
View File

@@ -0,0 +1,13 @@
# AntiDrift
Just my personal productivity tool. It asks me about my intention for the next
work session. Until I have provided an intention, duration, and start a
session, it forcefully minimizes all windows. It then records all active
windows during the session. At the end, it allows me to rate how relevant each
window was and calculates a session score from that.
To use AntiDrift, run `cargo run --release` directly, or `cargo build --release`
and copy the binary into your `PATH`.
Under Linux, we use `xdotool` to get window titles and minimize windows. Under
Windows, we use the package `winapi` for the same functionality.

20
src/constants.rs Normal file
View File

@@ -0,0 +1,20 @@
pub const APP_TITLE: &str = "AntiDrift";
pub const DEFAULT_DURATION: &str = "25";
pub const DURATION_TITLE: &str = "Duration";
pub const INTENTION_TITLE: &str = "Intention";
pub const PAUSED: &str = "paused";
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
pub const READY_TO_START: &str = "Ready to start next session.";
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
pub const SESSION_STATS_TITLE: &str = "Session Stats";
pub const STATUS_FILE: &str = "~/.antidrift_status";
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
pub const STATUS_TITLE: &str = "Status";
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
pub const STATUS_QUIT: &str = "antidrift shutting down";

View File

@@ -1,8 +1,11 @@
use anyhow::Result; use anyhow::Result;
use serde::{Serialize, Serializer};
use shellexpand;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::stdout; use std::io::{stdout, Write};
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant}; // <--- Add this
mod constants;
mod window; mod window;
use ratatui::{ use ratatui::{
@@ -24,10 +27,57 @@ enum State {
InputIntention, InputIntention,
InputDuration, InputDuration,
InProgress, InProgress,
Paused,
End, End,
ShouldQuit, ShouldQuit,
} }
fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u64(duration.as_secs())
}
#[derive(Serialize)]
struct SessionResult {
intention: String,
#[serde(serialize_with = "serialize_duration")]
duration: Duration,
session_ratings: Vec<SessionRating>,
rating: u8,
rating_f64: f64,
}
impl SessionResult {
fn rate(&mut self) {
let mut rating = 0_f64;
let total_duration = self
.session_ratings
.iter()
.map(|r| r.duration)
.fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur));
for session_rating in &self.session_ratings {
let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64();
rating += (session_rating.rating as f64) * ratio;
}
self.rating_f64 = rating;
if rating > 2.5 {
self.rating = 3;
} else if rating > 2.0 {
self.rating = 2;
} else if rating > 1.0 {
self.rating = 1;
} else {
self.rating = 0;
}
}
}
#[derive(Clone, Serialize)]
struct SessionRating { struct SessionRating {
window_title: Rc<String>, window_title: Rc<String>,
duration: Duration, duration: Duration,
@@ -46,10 +96,29 @@ struct App {
session_remaining: Duration, session_remaining: Duration,
session_ratings: Vec<SessionRating>, session_ratings: Vec<SessionRating>,
session_ratings_index: usize, session_ratings_index: usize,
session_results: Vec<SessionResult>,
last_tick_50ms: Instant, last_tick_50ms: Instant,
last_tick_1s: Instant, last_tick_1s: Instant,
} }
fn save_session(session: &SessionResult) {
let path = shellexpand::tilde(constants::HISTORY_FILE).to_string();
// 1. Open file in append mode, create if missing
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path);
if let Ok(mut f) = file {
// 2. Serialize to JSON
if let Ok(json) = serde_json::to_string(session) {
// 3. Write newline appended
let _ = writeln!(f, "{}", json);
}
}
}
impl App { impl App {
fn new() -> Self { fn new() -> Self {
let window_title = window::get_title_clean(); let window_title = window::get_title_clean();
@@ -57,15 +126,16 @@ impl App {
App { App {
state: State::InputIntention, state: State::InputIntention,
user_intention: String::new(), user_intention: String::new(),
user_duration_str: "25".to_string(), user_duration_str: constants::DEFAULT_DURATION.to_string(),
user_duration: Duration::new(0, 0), user_duration: Duration::ZERO,
current_window_title: window_title.into(), current_window_title: window_title.into(),
current_window_time: Instant::now(), current_window_time: Instant::now(),
session_start: Instant::now(), session_start: Instant::now(),
session_stats: HashMap::new(), session_stats: HashMap::new(),
session_remaining: Duration::new(0, 0), session_remaining: Duration::ZERO,
session_ratings: Vec::new(), session_ratings: Vec::new(),
session_ratings_index: 0, session_ratings_index: 0,
session_results: Vec::new(),
last_tick_50ms: Instant::now(), last_tick_50ms: Instant::now(),
last_tick_1s: Instant::now(), last_tick_1s: Instant::now(),
} }
@@ -94,7 +164,39 @@ impl App {
fn to_end(&mut self) { fn to_end(&mut self) {
self.state = State::End; self.state = State::End;
self.session_ratings = session_stats_as_vec(&self.session_stats); self.session_ratings = session_stats_as_vec(&self.session_stats);
self.user_intention = "Press 1, 2, 3 to rate titles".to_string(); }
fn cleanup(&self) {
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
let _ = std::fs::remove_file(path);
}
fn write_status(&self) {
let status = match self.state {
State::InputIntention | State::InputDuration => constants::STATUS_CONFIGURE.to_string(),
State::InProgress => format!(
"🎯 {} - {}",
self.user_intention,
duration_as_str(&self.session_remaining)
),
State::Paused => format!(
"{} - {}",
constants::STATUS_PAUSED,
duration_as_str(&self.session_remaining)
),
State::End => constants::STATUS_RATE_SESSION.to_string(),
State::ShouldQuit => constants::STATUS_QUIT.to_string(),
};
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
if let Ok(mut file) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&path)
{
let _ = file.write_all(status.as_bytes());
}
} }
fn tick_50ms(&mut self) { fn tick_50ms(&mut self) {
@@ -106,20 +208,24 @@ impl App {
self.user_duration = Duration::ZERO; self.user_duration = Duration::ZERO;
} }
window::minimize_other("AntiDrift"); window::minimize_other(&constants::APP_TITLE);
} }
State::InProgress => { State::InProgress => {
let elapsed = self.session_start.elapsed(); let elapsed = self.session_start.elapsed();
self.session_remaining = self.user_duration.saturating_sub(elapsed); self.session_remaining = self.user_duration.saturating_sub(elapsed);
update_session_stats(self); update_session_stats(self);
if self.session_remaining == Duration::new(0, 0) { if self.session_remaining.is_zero() {
self.to_end(); self.to_end();
} }
} }
State::ShouldQuit => {} State::ShouldQuit => {}
State::Paused => {
window::minimize_other(&constants::APP_TITLE);
update_session_stats(self);
}
State::End => { State::End => {
window::minimize_other("AntiDrift"); window::minimize_other(&constants::APP_TITLE);
} }
} }
@@ -128,13 +234,34 @@ impl App {
fn tick_1s(&mut self) { fn tick_1s(&mut self) {
self.last_tick_1s = Instant::now(); self.last_tick_1s = Instant::now();
self.write_status();
if self.state == State::Paused {
self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1));
}
} }
fn timeout(&self) -> Duration { fn timeout(&self) -> Duration {
Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed())
} }
fn get_session_stats(&self) -> Vec<Line> { fn get_session_results(&self) -> Vec<Line<'_>> {
self.session_results
.iter()
.map(|r| {
Line::from(Span::styled(
format!("{} {}", duration_as_str(&r.duration), r.intention,),
match r.rating {
2 => Style::new().fg(Color::LightYellow),
3 => Style::new().fg(Color::LightGreen),
_ => Style::new().fg(Color::LightRed),
},
))
})
.collect()
}
fn get_session_stats(&self) -> Vec<Line<'_>> {
let mut zero_encountered = if self.state != State::End { let mut zero_encountered = if self.state != State::End {
true true
} else { } else {
@@ -174,6 +301,7 @@ fn duration_as_str(duration: &Duration) -> String {
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> { fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
let mut stats: Vec<_> = session_stats let mut stats: Vec<_> = session_stats
.iter() .iter()
.filter(|(_, duration)| duration.as_secs() > 30)
.map(|(title, duration)| SessionRating { .map(|(title, duration)| SessionRating {
window_title: title.clone(), window_title: title.clone(),
duration: duration.clone(), duration: duration.clone(),
@@ -187,7 +315,7 @@ fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<Se
fn main() -> Result<()> { fn main() -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?; stdout().execute(EnterAlternateScreen)?;
execute!(stdout(), SetTitle("AntiDrift"))?; execute!(stdout(), SetTitle(constants::APP_TITLE))?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut app = App::new(); let mut app = App::new();
@@ -197,6 +325,7 @@ fn main() -> Result<()> {
app.handle_ticks(); app.handle_ticks();
} }
app.cleanup();
disable_raw_mode()?; disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?; stdout().execute(LeaveAlternateScreen)?;
Ok(()) Ok(())
@@ -219,8 +348,8 @@ fn handle_events(app: &mut App) -> Result<()> {
app.state = State::ShouldQuit; app.state = State::ShouldQuit;
} }
if app.state == State::InputIntention { match app.state {
match key.code { State::InputIntention => match key.code {
KeyCode::Enter => app.state = State::InputDuration, KeyCode::Enter => app.state = State::InputDuration,
KeyCode::Tab => app.state = State::InputDuration, KeyCode::Tab => app.state = State::InputDuration,
KeyCode::Backspace => { KeyCode::Backspace => {
@@ -230,9 +359,8 @@ fn handle_events(app: &mut App) -> Result<()> {
app.user_intention.push(c); app.user_intention.push(c);
} }
_ => {} _ => {}
} },
} else if app.state == State::InputDuration { State::InputDuration => match key.code {
match key.code {
KeyCode::Enter => app.to_in_progress(), KeyCode::Enter => app.to_in_progress(),
KeyCode::Tab => app.state = State::InputIntention, KeyCode::Tab => app.state = State::InputIntention,
KeyCode::Backspace => { KeyCode::Backspace => {
@@ -242,12 +370,14 @@ fn handle_events(app: &mut App) -> Result<()> {
app.user_duration_str.push(c); app.user_duration_str.push(c);
} }
_ => {} _ => {}
} },
} else if app.state == State::InProgress { State::InProgress => match key.code {
match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.to_end(); app.to_end();
} }
KeyCode::Char('p') => {
app.state = State::Paused;
}
KeyCode::Char('a') => { KeyCode::Char('a') => {
app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60)); app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60));
} }
@@ -255,8 +385,14 @@ fn handle_events(app: &mut App) -> Result<()> {
app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60)); app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60));
} }
_ => {} _ => {}
},
State::Paused => {
if key.code == KeyCode::Char('p') {
app.state = State::InProgress;
} }
} else if app.state == State::End { }
State::ShouldQuit => (),
State::End => {
let code = match key.code { let code = match key.code {
KeyCode::Char('1') => 1, KeyCode::Char('1') => 1,
KeyCode::Char('2') => 2, KeyCode::Char('2') => 2,
@@ -271,7 +407,17 @@ fn handle_events(app: &mut App) -> Result<()> {
if app.session_ratings_index >= app.session_ratings.len() { if app.session_ratings_index >= app.session_ratings.len() {
app.state = State::InputIntention; app.state = State::InputIntention;
app.user_intention = "Next session!".to_string(); let mut session_result = SessionResult {
intention: app.user_intention.clone(),
duration: app.session_start.elapsed(),
session_ratings: std::mem::take(&mut app.session_ratings),
rating: 0,
rating_f64: 0.0,
};
session_result.rate();
save_session(&session_result);
app.session_results.push(session_result);
}
} }
} }
@@ -279,7 +425,12 @@ fn handle_events(app: &mut App) -> Result<()> {
} }
fn update_session_stats(app: &mut App) { fn update_session_stats(app: &mut App) {
let window_title = window::get_title_clean().into(); let window_title = if app.state == State::Paused {
constants::PAUSED.to_string().into()
} else {
window::get_title_clean().into()
};
let delta = app.current_window_time.elapsed(); let delta = app.current_window_time.elapsed();
if app.current_window_title != window_title || (delta > Duration::from_secs(1)) { if app.current_window_title != window_title || (delta > Duration::from_secs(1)) {
let entry = app let entry = app
@@ -300,7 +451,8 @@ fn ui(frame: &mut Frame, app: &App) {
Constraint::Percentage(100), Constraint::Percentage(100),
Constraint::Min(3), Constraint::Min(3),
]); ]);
let [layout_intention, layout_duration, layout_titles, layout_status] = layout.areas(frame.size()); let [layout_intention, layout_duration, layout_titles, layout_status] =
layout.areas(frame.size());
let border_type_intention = if app.state == State::InputIntention { let border_type_intention = if app.state == State::InputIntention {
BorderType::Thick BorderType::Thick
@@ -312,16 +464,19 @@ fn ui(frame: &mut Frame, app: &App) {
Paragraph::new(input_intention).block( Paragraph::new(input_intention).block(
Block::bordered() Block::bordered()
.border_type(border_type_intention) .border_type(border_type_intention)
.title("Intention"), .title(constants::INTENTION_TITLE),
), ),
layout_intention, layout_intention,
); );
let input_duration: Vec<Line> = if app.state == State::InProgress { let input_duration: Vec<Line> = match app.state {
State::InProgress | State::Paused => {
let s = duration_as_str(&app.session_remaining); let s = duration_as_str(&app.session_remaining);
vec![Line::from(Span::raw(s))] vec![Line::from(Span::raw(s))]
} else { }
_ => {
vec![Line::from(Span::raw(&app.user_duration_str))] vec![Line::from(Span::raw(&app.user_duration_str))]
}
}; };
let border_type_duration = if app.state == State::InputDuration { let border_type_duration = if app.state == State::InputDuration {
BorderType::Thick BorderType::Thick
@@ -332,38 +487,74 @@ fn ui(frame: &mut Frame, app: &App) {
Paragraph::new(input_duration).block( Paragraph::new(input_duration).block(
Block::bordered() Block::bordered()
.border_type(border_type_duration) .border_type(border_type_duration)
.title("Duration"), .title(constants::DURATION_TITLE),
), ),
layout_duration, layout_duration,
); );
let stats = app.get_session_stats(); if app.state == State::InputIntention || app.state == State::InputDuration {
let results = app.get_session_results();
frame.render_widget( frame.render_widget(
Paragraph::new(stats).block(Block::bordered().title("Session")), Paragraph::new(results)
.block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)),
layout_titles, layout_titles,
); );
} else {
let stats = app.get_session_stats();
frame.render_widget(
Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)),
layout_titles,
);
}
let mut spans: Vec<Span> = Vec::new(); let mut spans: Vec<Span> = Vec::new();
if app.user_intention.len() == 0 { if app.user_intention.len() == 0 {
let span = Span::styled("Provide intention! ", Style::new().fg(Color::LightRed)); let span = Span::styled(
constants::PROVIDE_INTENTION,
Style::new().fg(Color::LightRed),
);
spans.push(span); spans.push(span);
} }
if app.user_duration == Duration::ZERO { if app.user_duration.is_zero() {
let span = Span::styled("Provide valid duration in minutes! ", Style::new().fg(Color::LightRed)); let span = Span::styled(
constants::PROVIDE_VALID_DURATION,
Style::new().fg(Color::LightRed),
);
spans.push(span); spans.push(span);
} }
if app.state == State::InProgress { match app.state {
let span = Span::styled("Session In-Progress", Style::new().fg(Color::LightGreen)); State::InputIntention | State::InputDuration => {
if spans.len() == 0 {
let span = Span::styled(
constants::READY_TO_START,
Style::new().fg(Color::LightGreen),
);
spans.push(span); spans.push(span);
} }
}
State::InProgress => {
let span = Span::styled(
constants::SESSION_IN_PROGRESS,
Style::new().fg(Color::LightGreen),
);
spans.push(span);
}
State::Paused => {
let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue));
spans.push(span);
}
State::ShouldQuit => {}
State::End => {
let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue));
spans.push(span);
}
}
let input_status: Vec<Line> = vec![Line::from(spans)]; let input_status: Vec<Line> = vec![Line::from(spans)];
frame.render_widget( frame.render_widget(
Paragraph::new(input_status).block( Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)),
Block::bordered().title("Status"),
),
layout_status, layout_status,
); );
} }

9
src/window/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;

36
src/window/windows.rs Normal file
View File

@@ -0,0 +1,36 @@
use regex::Regex;
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use winapi::shared::windef::HWND;
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
pub fn get_title_clean() -> String {
let title = get_window_info().title;
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
re.replace_all(&title, "").to_string()
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.title != title {
unsafe {
ShowWindow(window_info.hwnd, SW_MINIMIZE);
}
}
}
struct WindowInfo {
title: String,
hwnd: HWND,
}
fn get_window_info() -> WindowInfo {
unsafe {
let hwnd = GetForegroundWindow();
let mut text: [u16; 512] = [0; 512];
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
let title = OsString::from_wide(&text[..len])
.to_string_lossy()
.into_owned();
WindowInfo { title, hwnd }
}
}