Basic page layout, help, and configuration
This commit is contained in:
parent
2f0cc79631
commit
ead09c4a80
5 changed files with 301 additions and 53 deletions
141
src/app.rs
141
src/app.rs
|
|
@ -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
50
src/config.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main.rs
12
src/main.rs
|
|
@ -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))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
149
src/ui.rs
|
|
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue