Initial commit: TUI Watson time tracker implementation
This commit is contained in:
commit
2f0cc79631
7 changed files with 1389 additions and 0 deletions
128
src/app.rs
Normal file
128
src/app.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
use std::process::Command;
|
||||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use crate::state::{AppState, TimeItem};
|
||||
|
||||
pub struct App {
|
||||
pub state: AppState,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
state: AppState::load()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
|
||||
match event {
|
||||
Event::Key(KeyEvent {
|
||||
code,
|
||||
modifiers,
|
||||
..
|
||||
}) => match (code, modifiers) {
|
||||
(KeyCode::Char('q'), _) => return Ok(true),
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.move_selection(-1),
|
||||
(KeyCode::Char('h'), _) | (KeyCode::Left, _) => self.change_pane(-1),
|
||||
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1),
|
||||
(KeyCode::Enter, _) => self.toggle_current_item()?,
|
||||
(KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?,
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn move_selection(&mut self, delta: i32) {
|
||||
let items_len = match self.state.current_pane {
|
||||
0 => self.state.permanent_items.len(),
|
||||
1 => self.state.recurring_items.len(),
|
||||
2 => self.state.recent_items.len(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
if items_len == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let current = self.state.selected_indices[self.state.current_pane] as i32;
|
||||
let new_index = (current + delta).rem_euclid(items_len as i32) as usize;
|
||||
self.state.selected_indices[self.state.current_pane] = new_index;
|
||||
}
|
||||
|
||||
fn change_pane(&mut self, delta: i32) {
|
||||
self.state.current_pane = ((self.state.current_pane as i32 + delta).rem_euclid(3)) as usize;
|
||||
}
|
||||
|
||||
fn get_current_item(&self) -> Option<TimeItem> {
|
||||
let items = match self.state.current_pane {
|
||||
0 => &self.state.permanent_items,
|
||||
1 => &self.state.recurring_items,
|
||||
2 => &self.state.recent_items,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let index = self.state.selected_indices[self.state.current_pane];
|
||||
items.get(index).cloned()
|
||||
}
|
||||
|
||||
fn toggle_current_item(&mut self) -> anyhow::Result<()> {
|
||||
if let Some(item) = self.get_current_item() {
|
||||
if self.state.active_timer.as_ref().map(|(active, _)| active.name == item.name).unwrap_or(false) {
|
||||
self.state.stop_timer()?;
|
||||
} else {
|
||||
self.state.start_timer(item)?;
|
||||
}
|
||||
self.state.save()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn edit_config(&self) -> anyhow::Result<()> {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
||||
let config_path = AppState::config_file()?;
|
||||
|
||||
if !config_path.exists() {
|
||||
std::fs::write(&config_path, "# WAT Configuration\n# Add your permanent items here\n\npermanent_items:\n - name: Daily Standup\n tags: [daily, meeting]\n")?;
|
||||
}
|
||||
|
||||
Command::new(editor)
|
||||
.arg(&config_path)
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
|
||||
// Reload configuration
|
||||
if config_path.exists() {
|
||||
let contents = std::fs::read_to_string(config_path)?;
|
||||
let config: serde_yaml::Value = serde_yaml::from_str(&contents)?;
|
||||
|
||||
if let Some(items) = config["permanent_items"].as_sequence() {
|
||||
// Clear existing permanent items
|
||||
self.state.permanent_items.clear();
|
||||
|
||||
// Add new items from config
|
||||
for item in items {
|
||||
if let (Some(name), Some(tags)) = (
|
||||
item["name"].as_str(),
|
||||
item["tags"].as_sequence()
|
||||
) {
|
||||
let tags = tags
|
||||
.iter()
|
||||
.filter_map(|t| t.as_str())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
self.state.permanent_items.push(TimeItem {
|
||||
name: name.to_string(),
|
||||
tags,
|
||||
last_used: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
52
src/main.rs
Normal file
52
src/main.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
mod app;
|
||||
mod state;
|
||||
mod ui;
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::prelude::*;
|
||||
use std::io;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Create app and run it
|
||||
let mut app = app::App::new()?;
|
||||
let res = run_app(&mut terminal, &mut app);
|
||||
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
if let Err(err) = res {
|
||||
println!("{:?}", err)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|f| ui::render(f, &app.state))?;
|
||||
|
||||
if event::poll(std::time::Duration::from_millis(50))? {
|
||||
if app.handle_event(event::read()?)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/state.rs
Normal file
118
src/state.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimeItem {
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppState {
|
||||
pub permanent_items: Vec<TimeItem>,
|
||||
pub recurring_items: Vec<TimeItem>,
|
||||
pub recent_items: Vec<TimeItem>,
|
||||
pub current_pane: usize,
|
||||
pub selected_indices: [usize; 3],
|
||||
pub active_timer: Option<(TimeItem, DateTime<Utc>)>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
permanent_items: Vec::new(),
|
||||
recurring_items: Vec::new(),
|
||||
recent_items: Vec::new(),
|
||||
current_pane: 0,
|
||||
selected_indices: [0; 3],
|
||||
active_timer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn config_dir() -> anyhow::Result<PathBuf> {
|
||||
let mut path = dirs::config_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
|
||||
path.push("wat");
|
||||
std::fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn state_file() -> anyhow::Result<PathBuf> {
|
||||
let mut path = Self::config_dir()?;
|
||||
path.push("state.yaml");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn config_file() -> anyhow::Result<PathBuf> {
|
||||
let mut path = Self::config_dir()?;
|
||||
path.push("config.yaml");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let path = Self::state_file()?;
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let contents = std::fs::read_to_string(path)?;
|
||||
Ok(serde_yaml::from_str(&contents)?)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> anyhow::Result<()> {
|
||||
let path = Self::state_file()?;
|
||||
let contents = serde_yaml::to_string(&self)?;
|
||||
std::fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_recent_item(&mut self, item: TimeItem) {
|
||||
self.recent_items.insert(0, item);
|
||||
if self.recent_items.len() > 20 {
|
||||
self.recent_items.pop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> {
|
||||
// Stop current timer if any
|
||||
if let Some((ref current_item, start_time)) = self.active_timer {
|
||||
self.stop_timer()?;
|
||||
}
|
||||
|
||||
// Start new timer
|
||||
std::process::Command::new("watson")
|
||||
.args(["start", &item.name])
|
||||
.args(item.tags.iter().map(|t| format!("-T {}", t)))
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
|
||||
self.active_timer = Some((item.clone(), Utc::now()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop_timer(&mut self) -> anyhow::Result<()> {
|
||||
if let Some((ref item, _)) = self.active_timer {
|
||||
std::process::Command::new("watson")
|
||||
.arg("stop")
|
||||
.spawn()?
|
||||
.wait()?;
|
||||
|
||||
// Update last_used timestamp
|
||||
let update_last_used = |items: &mut Vec<TimeItem>| {
|
||||
if let Some(target) = items.iter_mut().find(|i| i.name == item.name) {
|
||||
target.last_used = Some(Utc::now());
|
||||
}
|
||||
};
|
||||
|
||||
update_last_used(&mut self.permanent_items);
|
||||
update_last_used(&mut self.recurring_items);
|
||||
update_last_used(&mut self.recent_items);
|
||||
|
||||
self.active_timer = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
102
src/ui.rs
Normal file
102
src/ui.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use crate::state::{AppState, TimeItem};
|
||||
|
||||
const ACTIVE_COLOR: Color = Color::Green;
|
||||
const INACTIVE_COLOR: Color = Color::Yellow;
|
||||
|
||||
pub fn render(frame: &mut Frame, state: &AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
Constraint::Ratio(1, 3),
|
||||
])
|
||||
.split(frame.size());
|
||||
|
||||
render_section(
|
||||
frame,
|
||||
chunks[0],
|
||||
"Permanent Items",
|
||||
&state.permanent_items,
|
||||
state.current_pane == 0,
|
||||
state.selected_indices[0],
|
||||
state,
|
||||
);
|
||||
|
||||
render_section(
|
||||
frame,
|
||||
chunks[1],
|
||||
"Recurring Items",
|
||||
&state.recurring_items,
|
||||
state.current_pane == 1,
|
||||
state.selected_indices[1],
|
||||
state,
|
||||
);
|
||||
|
||||
render_section(
|
||||
frame,
|
||||
chunks[2],
|
||||
"Recent Items",
|
||||
&state.recent_items,
|
||||
state.current_pane == 2,
|
||||
state.selected_indices[2],
|
||||
state,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_section(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
title: &str,
|
||||
items: &[TimeItem],
|
||||
is_active: bool,
|
||||
selected: usize,
|
||||
state: &AppState,
|
||||
) {
|
||||
let border_color = if is_active { ACTIVE_COLOR } else { INACTIVE_COLOR };
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(title)
|
||||
.style(Style::default().fg(border_color));
|
||||
|
||||
let items: Vec<ListItem> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let is_running = state
|
||||
.active_timer
|
||||
.as_ref()
|
||||
.map(|(active, _)| active.name == item.name)
|
||||
.unwrap_or(false);
|
||||
|
||||
let style = if is_running {
|
||||
Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD)
|
||||
} else if i == selected && is_active {
|
||||
Style::default().fg(border_color).add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut line = item.name.clone();
|
||||
if !item.tags.is_empty() {
|
||||
line.push_str(" [");
|
||||
line.push_str(&item.tags.join(", "));
|
||||
line.push(']');
|
||||
}
|
||||
|
||||
ListItem::new(Line::from(vec![Span::styled(line, style)]))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(block);
|
||||
frame.render_widget(list, area);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue