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:
235
src/main.rs
Normal file
235
src/main.rs
Normal 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
66
src/window.rs
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user