Implment rough TUI version of new workflow

Merge in Python stuff and then we will probably get
rid of it and continue in Rust only.
This commit is contained in:
2024-07-05 10:44:28 -04:00
parent d97a4885cb
commit 3b7d88f279
5 changed files with 949 additions and 145 deletions

235
src/main.rs Normal file
View File

@@ -0,0 +1,235 @@
use anyhow::Result;
use std::collections::HashMap;
use std::io::stdout;
use std::rc::Rc;
use std::time::{Duration, Instant};
mod window;
use ratatui::{
crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
},
prelude::*,
widgets::*,
};
#[derive(PartialEq)]
enum State {
InputIntention,
InputDuration,
InProgress,
ShouldQuit,
}
struct App {
state: State,
user_intention: String,
user_duration_str: String,
user_duration: Duration,
current_window_title: Rc<String>,
current_window_time: Instant,
session_start: Instant,
session_stats: HashMap<Rc<String>, Duration>,
last_tick_50ms: Instant,
last_tick_1s: Instant,
}
impl App {
fn new() -> Self {
let window_title = window::get_title_clean();
App {
state: State::InputIntention,
user_intention: String::new(),
user_duration_str: String::new(),
user_duration: Duration::new(0, 0),
current_window_title: window_title.into(),
current_window_time: Instant::now(),
session_start: Instant::now(),
session_stats: HashMap::new(),
last_tick_50ms: Instant::now(),
last_tick_1s: Instant::now(),
}
}
fn handle_ticks(&mut self) {
if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) {
self.tick_50ms();
}
if self.last_tick_1s.elapsed() >= Duration::from_secs(1) {
self.tick_1s();
}
}
fn to_in_progress(&mut self) {
let Ok(user_duration) = self.user_duration_str.parse::<u64>() else {
// TODO: Print error to the user
return;
};
self.user_duration = Duration::from_secs(user_duration * 60);
self.state = State::InProgress;
self.current_window_time = Instant::now();
self.session_start = self.current_window_time;
self.session_stats = HashMap::new();
}
fn tick_50ms(&mut self) {
match self.state {
State::InputIntention | State::InputDuration => {
window::minimize_other("kitty");
}
State::InProgress => {
update_session_stats(self);
}
State::ShouldQuit => {},
}
self.last_tick_50ms = Instant::now();
}
fn tick_1s(&mut self) {
self.last_tick_1s = Instant::now();
}
fn timeout(&self) -> Duration {
Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed())
}
}
fn duration_as_str(duration: &Duration) -> String {
format!(
"{:3}:{:02}",
duration.as_secs() / 60,
duration.as_secs() % 60
)
}
fn session_stats_to_lines(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<Line> {
let mut stats: Vec<_> = session_stats
.iter()
.map(|(title, duration)| (title.clone(), *duration))
.collect();
stats.sort_by(|a, b| b.1.cmp(&a.1));
stats
.iter()
.map(|(title, duration)| {
Line::from(Span::raw(format!(
"{}: {}",
duration_as_str(&duration),
title
)))
})
.collect()
}
fn main() -> Result<()> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut app = App::new();
while app.state != State::ShouldQuit {
terminal.draw(|frame| ui(frame, &app))?;
handle_events(&mut app)?;
app.handle_ticks();
}
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
fn handle_events(app: &mut App) -> Result<()> {
if !event::poll(app.timeout())? {
return Ok(());
}
let Event::Key(key) = event::read()? else {
return Ok(());
};
if key.kind != event::KeyEventKind::Press {
return Ok(());
}
if key.code == KeyCode::Esc {
app.state = State::ShouldQuit;
}
if app.state == State::InputIntention {
match key.code {
KeyCode::Enter => app.state = State::InputDuration,
KeyCode::Backspace => {
let _ = app.user_intention.pop();
}
KeyCode::Char(c) => {
app.user_intention.push(c);
}
_ => {}
}
} else if app.state == State::InputDuration {
match key.code {
KeyCode::Enter => app.to_in_progress(),
KeyCode::Backspace => {
let _ = app.user_duration_str.pop();
}
KeyCode::Char(c) if c.is_ascii_digit() => {
app.user_duration_str.push(c);
}
_ => {}
}
}
Ok(())
}
fn update_session_stats(app: &mut App) {
let window_title = window::get_title_clean().into();
let delta = app.current_window_time.elapsed();
if app.current_window_title != window_title || (delta > Duration::from_secs(1)) {
let entry = app
.session_stats
.entry(app.current_window_title.clone())
.or_insert_with(|| Duration::default());
*entry += app.current_window_time.elapsed();
app.current_window_time = Instant::now();
app.current_window_title = window_title;
}
}
fn ui(frame: &mut Frame, app: &App) {
let layout = Layout::vertical([
Constraint::Min(3),
Constraint::Min(3),
Constraint::Percentage(100),
]);
let [layout_intention, layout_countdown, layout_titles] = layout.areas(frame.size());
let input: Vec<Line> = vec![Line::from(Span::raw(&app.user_intention))];
frame.render_widget(
Paragraph::new(input).block(Block::bordered().title("Intention")),
layout_intention,
);
let input: Vec<Line> = if app.state == State::InProgress {
let remaining = app.user_duration.saturating_sub(app.session_start.elapsed());
let s = duration_as_str(&remaining);
vec![Line::from(Span::raw(s))]
} else {
vec![Line::from(Span::raw(&app.user_duration_str))]
};
frame.render_widget(
Paragraph::new(input).block(Block::bordered().title("Duration")),
layout_countdown,
);
let stats = session_stats_to_lines(&app.session_stats);
frame.render_widget(
Paragraph::new(stats).block(Block::bordered().title("Session")),
layout_titles,
);
}

66
src/window.rs Normal file
View File

@@ -0,0 +1,66 @@
use regex::Regex;
use std::{process::Command, process::Output, str};
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(class: &str) {
let window_info = get_window_info();
if &window_info.class != class {
run(&format!("xdotool windowminimize {}", window_info.wid));
}
}
struct WindowInfo {
title: String,
class: String,
wid: String,
}
fn run(cmd: &str) -> Option<String> {
let output = Command::new("sh").arg("-c").arg(cmd).output();
let Ok(Output {
status,
stdout,
stderr: _,
}) = output
else {
return None;
};
if status.code() != Some(0) {
return None;
}
let Ok(output_str) = str::from_utf8(&stdout) else {
return None;
};
Some(output_str.trim().to_string())
}
fn get_window_info() -> WindowInfo {
let none = WindowInfo {
title: "none".to_string(),
class: "none".to_string(),
wid: "".to_string(),
};
let Some(wid) = run("xdotool getactivewindow") else {
return none;
};
let Some(class) = run(&format!("xdotool getwindowclassname {wid}")) else {
return none;
};
let Some(title) = run(&format!("xdotool getwindowname {wid}")) else {
return none;
};
WindowInfo { title, class, wid }
}