Basic page layout, help, and configuration

This commit is contained in:
Ian Keane 2025-11-16 10:19:14 -05:00
parent 2f0cc79631
commit ead09c4a80
5 changed files with 301 additions and 53 deletions

View file

@ -1,19 +1,40 @@
use std::process::Command; use std::process::Command;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::state::{AppState, TimeItem}; use crate::state::{AppState, TimeItem};
use crate::config::Config;
pub enum Screen {
Main,
Help,
ConfigHelp,
}
pub struct App { pub struct App {
pub state: AppState, pub state: AppState,
pub config: Config,
pub current_screen: Screen,
pub needs_redraw: bool,
} }
impl App { impl App {
pub fn new() -> anyhow::Result<Self> { pub fn new() -> anyhow::Result<Self> {
Ok(Self { Ok(Self {
state: AppState::load()?, state: AppState::load()?,
config: Config::load()?,
current_screen: Screen::Main,
needs_redraw: true,
}) })
} }
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> { pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
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<bool> {
match event { match event {
Event::Key(KeyEvent { Event::Key(KeyEvent {
code, code,
@ -27,6 +48,31 @@ impl App {
(KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1), (KeyCode::Char('l'), _) | (KeyCode::Right, _) => self.change_pane(1),
(KeyCode::Enter, _) => self.toggle_current_item()?, (KeyCode::Enter, _) => self.toggle_current_item()?,
(KeyCode::Char('e'), KeyModifiers::CONTROL) => self.edit_config()?, (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<bool> {
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<bool> {
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(()) 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 editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let config_path = AppState::config_file()?; 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")?; 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) // Leave TUI mode
.arg(&config_path) disable_raw_mode()?;
.spawn()? execute!(stdout(), LeaveAlternateScreen)?;
.wait()?;
// Reload configuration // Run editor
if config_path.exists() { let status = Command::new(editor)
let contents = std::fs::read_to_string(config_path)?; .arg(&config_path)
let config: serde_yaml::Value = serde_yaml::from_str(&contents)?; .status()?;
if let Some(items) = config["permanent_items"].as_sequence() { if status.success() {
// Clear existing permanent items // Reload entire application state
self.state.permanent_items.clear(); self.state = AppState::load()?;
// Signal for complete reload
// Add new items from config self.current_screen = Screen::Main;
for item in items { self.needs_redraw = true;
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,
});
}
}
}
} }
// Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
Ok(()) Ok(())
} }
} }

50
src/config.rs Normal file
View file

@ -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<Self> {
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<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)?;
path.push("config.yaml");
Ok(path)
}
}

View file

@ -1,6 +1,7 @@
mod app; mod app;
mod state; mod state;
mod ui; mod ui;
mod config;
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
@ -41,12 +42,19 @@ fn main() -> Result<()> {
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result<()> { fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result<()> {
loop { 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 event::poll(std::time::Duration::from_millis(50))? {
if app.handle_event(event::read()?)? { if app.handle_event(event::read()?)? {
return Ok(()); return Ok(());
} }
// Always redraw after any event
terminal.draw(|f| ui::render(f, app))?;
} }
} }
} }

View file

@ -78,7 +78,7 @@ impl AppState {
pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> { pub fn start_timer(&mut self, item: TimeItem) -> anyhow::Result<()> {
// Stop current timer if any // 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()?; self.stop_timer()?;
} }

149
src/ui.rs
View file

@ -1,17 +1,25 @@
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect, Alignment},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph}, widgets::{Block, Borders, List, ListItem, Paragraph, Clear},
Frame, Frame,
}; };
use crate::state::{AppState, TimeItem}; use crate::{state::{AppState, TimeItem}, app::{App, Screen}};
const ACTIVE_COLOR: Color = Color::Green; const ACTIVE_COLOR: Color = Color::Green;
const INACTIVE_COLOR: Color = Color::Yellow; 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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@ -25,31 +33,46 @@ pub fn render(frame: &mut Frame, state: &AppState) {
frame, frame,
chunks[0], chunks[0],
"Permanent Items", "Permanent Items",
&state.permanent_items, &app.state.permanent_items,
state.current_pane == 0, app.state.current_pane == 0,
state.selected_indices[0], app.state.selected_indices[0],
state, &app.state,
); );
render_section( render_section(
frame, frame,
chunks[1], chunks[1],
"Recurring Items", "Recurring Items",
&state.recurring_items, &app.state.recurring_items,
state.current_pane == 1, app.state.current_pane == 1,
state.selected_indices[1], app.state.selected_indices[1],
state, &app.state,
); );
render_section( render_section(
frame, frame,
chunks[2], chunks[2],
"Recent Items", "Recent Items",
&state.recent_items, &app.state.recent_items,
state.current_pane == 2, app.state.current_pane == 2,
state.selected_indices[2], app.state.selected_indices[2],
state, &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( fn render_section(
@ -99,4 +122,98 @@ fn render_section(
let list = List::new(items).block(block); let list = List::new(items).block(block);
frame.render_widget(list, area); 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),
}
} }