From ead09c4a807b1c0278bea66b9d4435dac3d5771f Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Sun, 16 Nov 2025 10:19:14 -0500 Subject: [PATCH] Basic page layout, help, and configuration --- src/app.rs | 141 +++++++++++++++++++++++++++++++++++------------ src/config.rs | 50 +++++++++++++++++ src/main.rs | 12 +++- src/state.rs | 2 +- src/ui.rs | 149 ++++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 301 insertions(+), 53 deletions(-) create mode 100644 src/config.rs diff --git a/src/app.rs b/src/app.rs index 1d2a8f5..cf812f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,19 +1,40 @@ use std::process::Command; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use crate::state::{AppState, TimeItem}; +use crate::config::Config; + +pub enum Screen { + Main, + Help, + ConfigHelp, +} pub struct App { pub state: AppState, + pub config: Config, + pub current_screen: Screen, + pub needs_redraw: bool, } impl App { pub fn new() -> anyhow::Result { Ok(Self { state: AppState::load()?, + config: Config::load()?, + current_screen: Screen::Main, + needs_redraw: true, }) } pub fn handle_event(&mut self, event: Event) -> anyhow::Result { + match self.current_screen { + Screen::Main => self.handle_main_event(event), + Screen::Help => self.handle_help_event(event), + Screen::ConfigHelp => self.handle_config_help_event(event), + } + } + + fn handle_main_event(&mut self, event: Event) -> anyhow::Result { match event { Event::Key(KeyEvent { code, @@ -27,6 +48,31 @@ impl App { (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1), (KeyCode::Enter, _) => self.toggle_current_item()?, (KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?, + (KeyCode::Char('?'), _) => self.current_screen = Screen::Help, + (KeyCode::Char('c'), _) => self.edit_app_config()?, + _ => {} + }, + _ => {} + } + Ok(false) + } + + fn handle_help_event(&mut self, event: Event) -> anyhow::Result { + match event { + Event::Key(KeyEvent { code, .. }) => match code { + KeyCode::Char('c') => self.current_screen = Screen::ConfigHelp, + KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Main, + _ => {} + }, + _ => {} + } + Ok(false) + } + + fn handle_config_help_event(&mut self, event: Event) -> anyhow::Result { + match event { + Event::Key(KeyEvent { code, .. }) => match code { + KeyCode::Esc | KeyCode::Char('q') => self.current_screen = Screen::Help, _ => {} }, _ => {} @@ -79,7 +125,49 @@ impl App { Ok(()) } - fn edit_config(&self) -> anyhow::Result<()> { + fn edit_app_config(&mut self) -> anyhow::Result<()> { + use crossterm::{ + terminal::{disable_raw_mode, enable_raw_mode}, + execute, + terminal::{LeaveAlternateScreen, EnterAlternateScreen}, + }; + use std::io::stdout; + + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); + let config_path = Config::config_path()?; + + // Leave TUI mode + disable_raw_mode()?; + execute!(stdout(), LeaveAlternateScreen)?; + + // Run editor + let status = Command::new(editor) + .arg(&config_path) + .status()?; + + if status.success() { + // Reload entire application state + self.config = Config::load()?; + // Signal for complete reload + self.current_screen = Screen::Main; + self.needs_redraw = true; + } + + // Return to TUI mode + execute!(stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + + Ok(()) + } + + fn edit_config(&mut self) -> anyhow::Result<()> { + use crossterm::{ + terminal::{disable_raw_mode, enable_raw_mode}, + execute, + terminal::{LeaveAlternateScreen, EnterAlternateScreen}, + }; + use std::io::stdout; + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string()); let config_path = AppState::config_file()?; @@ -87,42 +175,27 @@ impl App { 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()?; + // Leave TUI mode + disable_raw_mode()?; + execute!(stdout(), LeaveAlternateScreen)?; - // 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, - }); - } - } - } + // Run editor + let status = Command::new(editor) + .arg(&config_path) + .status()?; + + if status.success() { + // Reload entire application state + self.state = AppState::load()?; + // Signal for complete reload + self.current_screen = Screen::Main; + self.needs_redraw = true; } + // Return to TUI mode + execute!(stdout(), EnterAlternateScreen)?; + enable_raw_mode()?; + Ok(()) } } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9db7397 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + #[serde(default = "default_show_help_hint")] + pub show_help_hint: bool, +} + +fn default_show_help_hint() -> bool { + true +} + +impl Default for Config { + fn default() -> Self { + Self { + show_help_hint: true, + } + } +} + +impl Config { + pub fn load() -> anyhow::Result { + let path = Self::config_path()?; + if !path.exists() { + let config = Config::default(); + let yaml = serde_yaml::to_string(&config)?; + std::fs::write(&path, yaml)?; + return Ok(config); + } + let contents = std::fs::read_to_string(path)?; + Ok(serde_yaml::from_str(&contents)?) + } + + pub fn save(&self) -> anyhow::Result<()> { + let path = Self::config_path()?; + let yaml = serde_yaml::to_string(&self)?; + std::fs::write(path, yaml)?; + Ok(()) + } + + pub fn config_path() -> anyhow::Result { + 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)?; + path.push("config.yaml"); + Ok(path) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index e38e2fe..46cc7c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod state; mod ui; +mod config; use anyhow::Result; use crossterm::{ @@ -41,12 +42,19 @@ fn main() -> Result<()> { fn run_app(terminal: &mut Terminal, app: &mut app::App) -> Result<()> { loop { - terminal.draw(|f| ui::render(f, &app.state))?; - + // Force a redraw if needed + if app.needs_redraw { + terminal.clear()?; // Clear the entire screen + terminal.draw(|f| ui::render(f, app))?; + app.needs_redraw = false; + } + if event::poll(std::time::Duration::from_millis(50))? { if app.handle_event(event::read()?)? { return Ok(()); } + // Always redraw after any event + terminal.draw(|f| ui::render(f, app))?; } } } \ No newline at end of file diff --git a/src/state.rs b/src/state.rs index c50996e..1a8a2fe 100644 --- a/src/state.rs +++ b/src/state.rs @@ -78,7 +78,7 @@ impl AppState { 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 { + if let Some((_, _)) = self.active_timer { self.stop_timer()?; } diff --git a/src/ui.rs b/src/ui.rs index 3ded134..33553fe 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,17 +1,25 @@ use ratatui::{ - layout::{Constraint, Direction, Layout, Rect}, + layout::{Constraint, Direction, Layout, Rect, Alignment}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, Paragraph, Clear}, Frame, }; -use crate::state::{AppState, TimeItem}; +use crate::{state::{AppState, TimeItem}, app::{App, Screen}}; const ACTIVE_COLOR: Color = Color::Green; const INACTIVE_COLOR: Color = Color::Yellow; -pub fn render(frame: &mut Frame, state: &AppState) { +pub fn render(frame: &mut Frame, app: &App) { + match app.current_screen { + Screen::Main => render_main(frame, app), + Screen::Help => render_help(frame), + Screen::ConfigHelp => render_config_help(frame), + } +} + +fn render_main(frame: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -25,31 +33,46 @@ pub fn render(frame: &mut Frame, state: &AppState) { frame, chunks[0], "Permanent Items", - &state.permanent_items, - state.current_pane == 0, - state.selected_indices[0], - state, + &app.state.permanent_items, + app.state.current_pane == 0, + app.state.selected_indices[0], + &app.state, ); render_section( frame, chunks[1], "Recurring Items", - &state.recurring_items, - state.current_pane == 1, - state.selected_indices[1], - state, + &app.state.recurring_items, + app.state.current_pane == 1, + app.state.selected_indices[1], + &app.state, ); render_section( frame, chunks[2], "Recent Items", - &state.recent_items, - state.current_pane == 2, - state.selected_indices[2], - state, + &app.state.recent_items, + app.state.current_pane == 2, + app.state.selected_indices[2], + &app.state, ); + + if app.config.show_help_hint { + let help_hint = Paragraph::new("(?) for help") + .alignment(Alignment::Right) + .style(Style::default().fg(Color::DarkGray)); + + let help_area = Rect { + x: frame.size().width.saturating_sub(12), + y: frame.size().height.saturating_sub(1), + width: 12, + height: 1, + }; + + frame.render_widget(help_hint, help_area); + } } fn render_section( @@ -99,4 +122,98 @@ fn render_section( let list = List::new(items).block(block); frame.render_widget(list, area); +} + +fn render_help(frame: &mut Frame) { + let area = centered_rect(60, 20, frame.size()); + + frame.render_widget(Clear, area); + + let help_text = vec![ + "WAT - Watson Time Tracker Interface", + "", + "This tool helps you track time using Watson with a convenient interface.", + "", + "The interface is divided into three sections:", + "1. Permanent Items: Configured tasks that are always available", + "2. Recurring Items: Frequently used tasks that you might return to", + "3. Recent Items: One-off tasks, showing the last 20 used", + "", + "Navigation:", + "- Use j/k or ↑/↓ to move selection up/down", + "- Use h/l or ←/→ to switch between panes", + "- Press Enter to start/stop time tracking", + "", + "Help Pages:", + "c - Configuration help", + "", + "Main Commands:", + "j/k, arrows - Navigate", + "h/l, arrows - Switch panes", + "Enter - Start/stop timer", + "Ctrl+e - Edit tasks config", + "c - Edit app config", + "q - Quit", + "? (or ESC) - Exit help", + ]; + + let text = help_text.join("\n"); + + let block = Block::default() + .title("Help") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + + let paragraph = Paragraph::new(text) + .block(block) + .style(Style::default().fg(Color::White)); + + frame.render_widget(paragraph, area); +} + +fn render_config_help(frame: &mut Frame) { + let area = centered_rect(60, 15, frame.size()); + + frame.render_widget(Clear, area); + + let help_text = vec![ + "WAT Configuration", + "", + "The configuration file is in YAML format and supports these options:", + "", + "show_help_hint: true/false", + " Controls visibility of the help hint in the main interface", + "", + "Example configuration:", + "show_help_hint: true", + "", + "", + "Commands:", + "q (or ESC) - Return to main help", + ]; + + let text = help_text.join("\n"); + + let block = Block::default() + .title("Configuration Help") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + + let paragraph = Paragraph::new(text) + .block(block) + .style(Style::default().fg(Color::White)); + + frame.render_widget(paragraph, area); +} + +fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { + let x = (r.width.saturating_sub(width)) / 2; + let y = (r.height.saturating_sub(height)) / 2; + + Rect { + x: r.x + x, + y: r.y + y, + width: width.min(r.width), + height: height.min(r.height), + } } \ No newline at end of file