Fix projects, remove output in interface, log output to file

This commit is contained in:
Ian Keane 2025-11-16 13:12:45 -05:00
parent 401ee37b32
commit ae045167bf
5 changed files with 274 additions and 162 deletions

View file

@ -1,8 +1,8 @@
use std::process::Command; use crate::config::Config;
use crate::state::{AppState, TimeItem};
use chrono::Utc; use chrono::Utc;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crate::state::{AppState, TimeItem}; use std::process::Command;
use crate::config::Config;
pub enum Screen { pub enum Screen {
Main, Main,
@ -19,7 +19,7 @@ pub struct App {
pub new_entry_buffer: String, pub new_entry_buffer: String,
pub new_entry_project: String, pub new_entry_project: String,
pub new_entry_cursor: usize, pub new_entry_cursor: usize,
pub new_entry_mode: NewEntryMode, // Task or Project pub new_entry_mode: NewEntryMode, // Task or Project
pub status_message: Option<(String, std::time::Instant)>, pub status_message: Option<(String, std::time::Instant)>,
} }
@ -46,7 +46,7 @@ impl App {
pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> { pub fn handle_event(&mut self, event: Event) -> anyhow::Result<bool> {
// Update status message // Update status message
self.update_status_message(); self.update_status_message();
match self.current_screen { match self.current_screen {
Screen::Main => self.handle_main_event(event), Screen::Main => self.handle_main_event(event),
Screen::Help => self.handle_help_event(event), Screen::Help => self.handle_help_event(event),
@ -58,9 +58,7 @@ impl App {
fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> { fn handle_main_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event { match event {
Event::Key(KeyEvent { Event::Key(KeyEvent {
code, code, modifiers, ..
modifiers,
..
}) => match (code, modifiers) { }) => match (code, modifiers) {
(KeyCode::Char('q'), _) => return Ok(true), (KeyCode::Char('q'), _) => return Ok(true),
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1), (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.move_selection(1),
@ -85,9 +83,7 @@ impl App {
fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> { fn handle_new_entry_event(&mut self, event: Event) -> anyhow::Result<bool> {
match event { match event {
Event::Key(KeyEvent { Event::Key(KeyEvent {
code, code, modifiers, ..
modifiers,
..
}) => { }) => {
match (code, modifiers) { match (code, modifiers) {
(KeyCode::Esc, _) => { (KeyCode::Esc, _) => {
@ -95,16 +91,20 @@ impl App {
self.new_entry_buffer.clear(); self.new_entry_buffer.clear();
self.new_entry_project.clear(); self.new_entry_project.clear();
self.new_entry_mode = NewEntryMode::Task; self.new_entry_mode = NewEntryMode::Task;
self.new_entry_cursor = 0;
} }
(KeyCode::Enter, _) => { (KeyCode::Enter, _) => {
match self.new_entry_mode { match self.new_entry_mode {
NewEntryMode::Task => { NewEntryMode::Task => {
if !self.new_entry_buffer.is_empty() { if !self.new_entry_buffer.is_empty() {
self.new_entry_mode = NewEntryMode::Project; self.new_entry_mode = NewEntryMode::Project;
self.new_entry_cursor = self.new_entry_project.len();
} }
} }
NewEntryMode::Project => { NewEntryMode::Project => {
if self.new_entry_project.is_empty() || self.config.is_valid_project(&self.new_entry_project) { if self.new_entry_project.is_empty()
|| self.config.is_valid_project(&self.new_entry_project)
{
// Create new time item // Create new time item
let item = TimeItem { let item = TimeItem {
name: self.new_entry_buffer.clone(), name: self.new_entry_buffer.clone(),
@ -117,13 +117,13 @@ impl App {
}; };
// Add to current pane (or recurring if in recent) // Add to current pane (or recurring if in recent)
match self.state.current_pane { match self.state.current_pane {
0 => self.state.permanent_items.push(item), // Permanent Items 0 => self.state.permanent_items.push(item), // Permanent Items
1 => self.state.recurring_items.push(item), // Recurring Items 1 => self.state.recurring_items.push(item), // Recurring Items
2 => self.state.recent_items.push(item), // Ad-Hoc Items 2 => self.state.recent_items.push(item), // Ad-Hoc Items
_ => unreachable!(), _ => unreachable!(),
} }
self.state.save()?; self.state.save()?;
// Clear and return to main screen // Clear and return to main screen
self.current_screen = Screen::Main; self.current_screen = Screen::Main;
self.new_entry_buffer.clear(); self.new_entry_buffer.clear();
@ -133,34 +133,35 @@ impl App {
} }
} }
} }
(KeyCode::Backspace, _) => { (KeyCode::Backspace, _) => match self.new_entry_mode {
match self.new_entry_mode { NewEntryMode::Task => {
NewEntryMode::Task => { if self.new_entry_cursor > 0 {
if self.new_entry_cursor > 0 { self.new_entry_buffer.remove(self.new_entry_cursor - 1);
self.new_entry_buffer.remove(self.new_entry_cursor - 1); self.new_entry_cursor -= 1;
self.new_entry_cursor -= 1;
}
} }
NewEntryMode::Project => { }
if self.new_entry_cursor > 0 { NewEntryMode::Project => {
self.new_entry_project.remove(self.new_entry_cursor - 1); if self.new_entry_cursor > 0 {
let idx = self.new_entry_cursor - 1;
if idx < self.new_entry_project.len() {
self.new_entry_project.remove(idx);
self.new_entry_cursor -= 1; self.new_entry_cursor -= 1;
} }
} }
} }
} },
(KeyCode::Char(c), m) if m.is_empty() => { (KeyCode::Char(c), m) if m.is_empty() => match self.new_entry_mode {
match self.new_entry_mode { NewEntryMode::Task => {
NewEntryMode::Task => { self.new_entry_buffer.insert(self.new_entry_cursor, c);
self.new_entry_buffer.insert(self.new_entry_cursor, c); self.new_entry_cursor += 1;
self.new_entry_cursor += 1; }
} NewEntryMode::Project => {
NewEntryMode::Project => { if self.new_entry_cursor <= self.new_entry_project.len() {
self.new_entry_project.insert(self.new_entry_cursor, c); self.new_entry_project.insert(self.new_entry_cursor, c);
self.new_entry_cursor += 1; self.new_entry_cursor += 1;
} }
} }
} },
_ => {} _ => {}
} }
} }
@ -227,7 +228,13 @@ impl App {
fn toggle_current_item(&mut self) -> anyhow::Result<()> { fn toggle_current_item(&mut self) -> anyhow::Result<()> {
if let Some(item) = self.get_current_item() { if let Some(item) = self.get_current_item() {
if self.state.active_timer.as_ref().map(|(active, _)| active.name == item.name).unwrap_or(false) { if self
.state
.active_timer
.as_ref()
.map(|(active, _)| active.name == item.name)
.unwrap_or(false)
{
self.state.stop_timer()?; self.state.stop_timer()?;
} else { } else {
self.state.start_timer(item)?; self.state.start_timer(item)?;
@ -248,7 +255,7 @@ impl App {
}; };
let index = self.state.selected_indices[self.state.current_pane]; let index = self.state.selected_indices[self.state.current_pane];
if !items.is_empty() && index < items.len() { if !items.is_empty() && index < items.len() {
if let Some((ref active, _)) = self.state.active_timer { if let Some((ref active, _)) = self.state.active_timer {
items[index].name == active.name items[index].name == active.name
@ -274,20 +281,20 @@ impl App {
}; };
let index = self.state.selected_indices[self.state.current_pane]; let index = self.state.selected_indices[self.state.current_pane];
if !items.is_empty() && index < items.len() { if !items.is_empty() && index < items.len() {
// Remove the item // Remove the item
items.remove(index); items.remove(index);
// Adjust index if we're at the end // Adjust index if we're at the end
if !items.is_empty() && index == items.len() { if !items.is_empty() && index == items.len() {
self.state.selected_indices[self.state.current_pane] = items.len() - 1; self.state.selected_indices[self.state.current_pane] = items.len() - 1;
} }
// Save changes // Save changes
self.state.save()?; self.state.save()?;
} }
Ok(()) Ok(())
} }
@ -314,23 +321,21 @@ impl App {
fn edit_app_config(&mut self) -> anyhow::Result<()> { fn edit_app_config(&mut self) -> anyhow::Result<()> {
use crossterm::{ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode},
execute, execute,
terminal::{LeaveAlternateScreen, EnterAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use std::io::stdout; 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 = Config::config_path()?; let config_path = Config::config_path()?;
// Leave TUI mode // Leave TUI mode
disable_raw_mode()?; disable_raw_mode()?;
execute!(stdout(), LeaveAlternateScreen)?; execute!(stdout(), LeaveAlternateScreen)?;
// Run editor // Run editor
let status = Command::new(editor) let status = Command::new(editor).arg(&config_path).status()?;
.arg(&config_path)
.status()?;
if status.success() { if status.success() {
// Reload entire application state // Reload entire application state
@ -343,21 +348,21 @@ impl App {
// Return to TUI mode // Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?; execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?; enable_raw_mode()?;
Ok(()) Ok(())
} }
fn edit_config(&mut self) -> anyhow::Result<()> { fn edit_config(&mut self) -> anyhow::Result<()> {
use crossterm::{ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode},
execute, execute,
terminal::{LeaveAlternateScreen, EnterAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use std::io::stdout; 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()?;
if !config_path.exists() { 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")?; 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")?;
} }
@ -367,9 +372,7 @@ impl App {
execute!(stdout(), LeaveAlternateScreen)?; execute!(stdout(), LeaveAlternateScreen)?;
// Run editor // Run editor
let status = Command::new(editor) let status = Command::new(editor).arg(&config_path).status()?;
.arg(&config_path)
.status()?;
if status.success() { if status.success() {
// Reload entire application state // Reload entire application state
@ -382,7 +385,7 @@ impl App {
// Return to TUI mode // Return to TUI mode
execute!(stdout(), EnterAlternateScreen)?; execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?; enable_raw_mode()?;
Ok(()) Ok(())
} }
} }

View file

@ -57,8 +57,8 @@ impl Config {
} }
pub fn config_path() -> anyhow::Result<PathBuf> { pub fn config_path() -> anyhow::Result<PathBuf> {
let mut path = dirs::config_dir() let mut path =
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
path.push("wat"); path.push("wat");
std::fs::create_dir_all(&path)?; std::fs::create_dir_all(&path)?;
path.push("config.yaml"); path.push("config.yaml");
@ -71,4 +71,4 @@ impl Config {
} }
self.projects.contains(&project.to_string()) self.projects.contains(&project.to_string())
} }
} }

View file

@ -1,7 +1,7 @@
mod app; mod app;
mod config;
mod state; mod state;
mod ui; mod ui;
mod config;
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
@ -48,7 +48,7 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result
terminal.draw(|f| ui::render(f, app))?; terminal.draw(|f| ui::render(f, app))?;
app.needs_redraw = false; 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(());
@ -57,4 +57,4 @@ fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut app::App) -> Result
terminal.draw(|f| ui::render(f, app))?; terminal.draw(|f| ui::render(f, app))?;
} }
} }
} }

View file

@ -1,6 +1,10 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::{
fs::{self, OpenOptions},
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeItem { pub struct TimeItem {
@ -34,8 +38,8 @@ impl Default for AppState {
impl AppState { impl AppState {
pub fn config_dir() -> anyhow::Result<PathBuf> { pub fn config_dir() -> anyhow::Result<PathBuf> {
let mut path = dirs::config_dir() let mut path =
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?; dirs::config_dir().ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?;
path.push("wat"); path.push("wat");
std::fs::create_dir_all(&path)?; std::fs::create_dir_all(&path)?;
Ok(path) Ok(path)
@ -53,6 +57,12 @@ impl AppState {
Ok(path) Ok(path)
} }
fn log_file() -> anyhow::Result<PathBuf> {
let mut path = Self::config_dir()?;
path.push("watson.log");
Ok(path)
}
pub fn load() -> anyhow::Result<Self> { pub fn load() -> anyhow::Result<Self> {
let path = Self::state_file()?; let path = Self::state_file()?;
if !path.exists() { if !path.exists() {
@ -76,18 +86,50 @@ impl AppState {
} }
} }
pub fn read_log_lines(limit: usize) -> anyhow::Result<Vec<String>> {
let path = Self::log_file()?;
if !path.exists() {
return Ok(Vec::new());
}
let contents = fs::read_to_string(path)?;
let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
if lines.len() > limit {
lines = lines.split_off(lines.len() - limit);
}
Ok(lines)
}
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
if let Some((_, _)) = self.active_timer { if let Some((_, _)) = self.active_timer {
self.stop_timer()?; self.stop_timer()?;
} }
// Start new timer let mut command = std::process::Command::new("watson");
std::process::Command::new("watson") command.arg("start").arg(&item.name);
.args(["start", &item.name]) for tag in &item.tags {
.args(item.tags.iter().map(|t| format!("-T {}", t))) let tag = tag.trim();
.spawn()? if tag.is_empty() {
.wait()?; continue;
}
if tag.starts_with('+') {
command.arg(tag);
} else {
command.arg(format!("+{}", tag));
}
}
let output = command.output()?;
Self::log_command_output(
&format!("watson start {} {}", item.name, item.tags.join(" ")),
&output,
)?;
if !output.status.success() {
anyhow::bail!(
"Watson start failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
self.active_timer = Some((item.clone(), Utc::now())); self.active_timer = Some((item.clone(), Utc::now()));
Ok(()) Ok(())
@ -95,12 +137,17 @@ impl AppState {
pub fn stop_timer(&mut self) -> anyhow::Result<()> { pub fn stop_timer(&mut self) -> anyhow::Result<()> {
if let Some((ref item, _)) = self.active_timer { if let Some((ref item, _)) = self.active_timer {
std::process::Command::new("watson") let output = std::process::Command::new("watson").arg("stop").output()?;
.arg("stop")
.spawn()? Self::log_command_output("watson stop", &output)?;
.wait()?;
if !output.status.success() {
// Update last_used timestamp anyhow::bail!(
"Watson stop failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let update_last_used = |items: &mut Vec<TimeItem>| { let update_last_used = |items: &mut Vec<TimeItem>| {
if let Some(target) = items.iter_mut().find(|i| i.name == item.name) { if let Some(target) = items.iter_mut().find(|i| i.name == item.name) {
target.last_used = Some(Utc::now()); target.last_used = Some(Utc::now());
@ -115,4 +162,51 @@ impl AppState {
} }
Ok(()) Ok(())
} }
}
fn log_command_output(description: &str, output: &std::process::Output) -> anyhow::Result<()> {
let mut lines = Vec::new();
lines.push(format!("[{}] {description}", Utc::now().to_rfc3339()));
lines.push(format!("status: {}", output.status));
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
lines.push("stdout:".into());
lines.push(stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
lines.push("stderr:".into());
lines.push(stderr);
}
lines.push(String::new());
Self::append_log_entry(&lines)?;
Ok(())
}
fn append_log_entry(lines: &[String]) -> anyhow::Result<()> {
let path = Self::log_file()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
for line in lines {
writeln!(file, "{line}")?;
}
Self::trim_log(&path)?;
Ok(())
}
fn trim_log(path: &Path) -> anyhow::Result<()> {
const MAX_LINES: usize = 800;
let contents = fs::read_to_string(path)?;
let mut lines: Vec<&str> = contents.lines().collect();
if lines.len() > MAX_LINES {
lines = lines.split_off(lines.len() - MAX_LINES);
fs::write(path, lines.join("\n") + "\n")?;
}
Ok(())
}
}

175
src/ui.rs
View file

@ -1,12 +1,15 @@
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect, Alignment}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Clear}, widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame, Frame,
}; };
use crate::{state::{AppState, TimeItem}, app::{App, Screen, NewEntryMode}}; use crate::{
app::{App, NewEntryMode, Screen},
state::{AppState, TimeItem},
};
const ACTIVE_COLOR: Color = Color::Green; const ACTIVE_COLOR: Color = Color::Green;
const INACTIVE_COLOR: Color = Color::Yellow; const INACTIVE_COLOR: Color = Color::Yellow;
@ -25,24 +28,28 @@ fn render_main(frame: &mut Frame, app: &App) {
let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints; let show_bottom_bar = app.config.show_help_hint || app.config.show_command_hints;
let has_status = app.status_message.is_some(); let has_status = app.status_message.is_some();
let bottom_height = if show_bottom_bar { let bottom_height = if show_bottom_bar {
if has_status { 2 } else { 1 } if has_status {
2
} else {
1
}
} else { } else {
if has_status { 1 } else { 0 } if has_status {
1
} else {
0
}
}; };
let constraints = if bottom_height > 0 { let constraints = if bottom_height > 0 {
vec![ vec![
Constraint::Min(3), // At least 3 lines for each section Constraint::Min(3), // At least 3 lines for each section
Constraint::Min(3), Constraint::Min(3),
Constraint::Min(3), Constraint::Min(3),
Constraint::Length(bottom_height), // Command bar + optional status Constraint::Length(bottom_height), // Command bar + optional status
] ]
} else { } else {
vec![ vec![Constraint::Min(3), Constraint::Min(3), Constraint::Min(3)]
Constraint::Min(3),
Constraint::Min(3),
Constraint::Min(3),
]
}; };
let chunks = Layout::default() let chunks = Layout::default()
@ -109,24 +116,22 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Length(3), Constraint::Length(3)])
Constraint::Length(3),
Constraint::Length(3),
])
.split(area); .split(area);
// Task name input // Task name input
let task_block = Block::default() let task_block = Block::default()
.title("Task Name") .title("Task Name")
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) { .style(
ACTIVE_COLOR Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Task) {
} else { ACTIVE_COLOR
Color::White } else {
})); Color::White
}),
);
let task_text = Paragraph::new(app.new_entry_buffer.as_str()) let task_text = Paragraph::new(app.new_entry_buffer.as_str()).block(task_block);
.block(task_block);
frame.render_widget(task_text, chunks[0]); frame.render_widget(task_text, chunks[0]);
@ -134,14 +139,17 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
let project_block = Block::default() let project_block = Block::default()
.title("Project (optional)") .title("Project (optional)")
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) { .style(
ACTIVE_COLOR Style::default().fg(if matches!(app.new_entry_mode, NewEntryMode::Project) {
} else { ACTIVE_COLOR
Color::White } else {
})); Color::White
}),
);
let project_text = if !app.config.projects.is_empty() { let project_text = if !app.config.projects.is_empty() {
format!("{} (available: {})", format!(
"{} (available: {})",
app.new_entry_project, app.new_entry_project,
app.config.projects.join(", ") app.config.projects.join(", ")
) )
@ -149,18 +157,12 @@ fn render_new_entry(frame: &mut Frame, app: &App) {
app.new_entry_project.clone() app.new_entry_project.clone()
}; };
let project_text = Paragraph::new(project_text) let project_text = Paragraph::new(project_text).block(project_block);
.block(project_block);
frame.render_widget(project_text, chunks[1]); frame.render_widget(project_text, chunks[1]);
// Render command bar // Render command bar
let bar_area = Rect::new( let bar_area = Rect::new(0, frame.size().height - 1, frame.size().width, 1);
0,
frame.size().height - 1,
frame.size().width,
1,
);
let command_text = match app.new_entry_mode { let command_text = match app.new_entry_mode {
NewEntryMode::Task => "Enter task name, press Enter to continue", NewEntryMode::Task => "Enter task name, press Enter to continue",
@ -180,8 +182,8 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
Layout::default() Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(1), // Status message Constraint::Length(1), // Status message
Constraint::Length(1), // Command bar Constraint::Length(1), // Command bar
]) ])
.split(area) .split(area)
} else { } else {
@ -210,19 +212,23 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
("d", "delete"), ("d", "delete"),
("q", "quit"), ("q", "quit"),
]; ];
let command_text = format!(" {}", commands.iter() let command_text = format!(
.map(|(key, desc)| format!("{} ({})", key, desc)) " {}",
.collect::<Vec<_>>() commands
.join(" · ")); .iter()
.map(|(key, desc)| format!("{} ({})", key, desc))
.collect::<Vec<_>>()
.join(" · ")
);
let command_area = if app.config.show_help_hint { let command_area = if app.config.show_help_hint {
// Leave space for help hint // Leave space for help hint
Rect::new( Rect::new(
chunks[command_line_idx].x, chunks[command_line_idx].x,
chunks[command_line_idx].y, chunks[command_line_idx].y,
chunks[command_line_idx].width.saturating_sub(12), chunks[command_line_idx].width.saturating_sub(12),
1 1,
) )
} else { } else {
chunks[command_line_idx] chunks[command_line_idx]
@ -231,7 +237,7 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
let command_bar = Paragraph::new(command_text) let command_bar = Paragraph::new(command_text)
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
.alignment(Alignment::Left); .alignment(Alignment::Left);
frame.render_widget(command_bar, command_area); frame.render_widget(command_bar, command_area);
} }
@ -239,49 +245,50 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, app: &App) {
let help_hint = Paragraph::new("(?) for help") let help_hint = Paragraph::new("(?) for help")
.alignment(Alignment::Right) .alignment(Alignment::Right)
.style(Style::default().fg(Color::DarkGray)); .style(Style::default().fg(Color::DarkGray));
let help_area = Rect::new( let help_area = Rect::new(
chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12), chunks[command_line_idx].x + chunks[command_line_idx].width.saturating_sub(12),
chunks[command_line_idx].y, chunks[command_line_idx].y,
12, 12,
1, 1,
); );
frame.render_widget(help_hint, help_area); frame.render_widget(help_hint, help_area);
} }
} }
fn render_help_command_bar(frame: &mut Frame) { fn render_help_command_bar(frame: &mut Frame) {
let commands = vec![ let commands = vec![("c", "configuration help"), ("q/ESC", "back")];
("c", "configuration help"),
("q/ESC", "back"), let command_text = format!(
]; " {}",
commands
let command_text = format!(" {}", commands.iter() .iter()
.map(|(key, desc)| format!("{} ({})", key, desc)) .map(|(key, desc)| format!("{} ({})", key, desc))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" · ")); .join(" · ")
);
let bar_area = Rect::new( let bar_area = Rect::new(
0, 0,
frame.size().height.saturating_sub(1), frame.size().height.saturating_sub(1),
frame.size().width, frame.size().width,
1, 1,
); );
let command_bar = Paragraph::new(command_text) let command_bar = Paragraph::new(command_text)
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
.alignment(Alignment::Left); .alignment(Alignment::Left);
frame.render_widget(command_bar, bar_area); frame.render_widget(command_bar, bar_area);
} }
fn render_help(frame: &mut Frame, _app: &App) { fn render_help(frame: &mut Frame, _app: &App) {
let width = frame.size().width.saturating_sub(4).min(60); let width = frame.size().width.saturating_sub(4).min(60);
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let help_text = vec![ let help_text = vec![
"WAT - Watson Time Tracker Interface", "WAT - Watson Time Tracker Interface",
"", "",
@ -312,17 +319,17 @@ fn render_help(frame: &mut Frame, _app: &App) {
]; ];
let text = help_text.join("\n"); let text = help_text.join("\n");
let block = Block::default() let block = Block::default()
.title("Help") .title("Help")
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(Color::White)); .style(Style::default().fg(Color::White));
let paragraph = Paragraph::new(text) let paragraph = Paragraph::new(text)
.block(block) .block(block)
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: true }); .wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
render_help_command_bar(frame); render_help_command_bar(frame);
} }
@ -330,9 +337,9 @@ fn render_help(frame: &mut Frame, _app: &App) {
fn render_config_help(frame: &mut Frame, _app: &App) { fn render_config_help(frame: &mut Frame, _app: &App) {
let width = frame.size().width.saturating_sub(4).min(60); let width = frame.size().width.saturating_sub(4).min(60);
let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size()); let area = centered_rect(width, frame.size().height.saturating_sub(2), frame.size());
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
let help_text = vec![ let help_text = vec![
"WAT Configuration", "WAT Configuration",
"", "",
@ -360,17 +367,17 @@ fn render_config_help(frame: &mut Frame, _app: &App) {
]; ];
let text = help_text.join("\n"); let text = help_text.join("\n");
let block = Block::default() let block = Block::default()
.title("Configuration Help") .title("Configuration Help")
.borders(Borders::ALL) .borders(Borders::ALL)
.style(Style::default().fg(Color::White)); .style(Style::default().fg(Color::White));
let paragraph = Paragraph::new(text) let paragraph = Paragraph::new(text)
.block(block) .block(block)
.style(Style::default().fg(Color::White)) .style(Style::default().fg(Color::White))
.wrap(ratatui::widgets::Wrap { trim: true }); .wrap(ratatui::widgets::Wrap { trim: true });
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
render_help_command_bar(frame); render_help_command_bar(frame);
} }
@ -384,8 +391,12 @@ fn render_section(
selected: usize, selected: usize,
state: &AppState, state: &AppState,
) { ) {
let border_color = if is_active { ACTIVE_COLOR } else { INACTIVE_COLOR }; let border_color = if is_active {
ACTIVE_COLOR
} else {
INACTIVE_COLOR
};
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(title) .title(title)
@ -402,9 +413,13 @@ fn render_section(
.unwrap_or(false); .unwrap_or(false);
let style = if is_running { let style = if is_running {
Style::default().fg(ACTIVE_COLOR).add_modifier(Modifier::BOLD) Style::default()
.fg(ACTIVE_COLOR)
.add_modifier(Modifier::BOLD)
} else if i == selected && is_active { } else if i == selected && is_active {
Style::default().fg(border_color).add_modifier(Modifier::REVERSED) Style::default()
.fg(border_color)
.add_modifier(Modifier::REVERSED)
} else { } else {
Style::default() Style::default()
}; };
@ -427,11 +442,11 @@ fn render_section(
fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { fn centered_rect(width: u16, height: u16, r: Rect) -> Rect {
let x = (r.width.saturating_sub(width)) / 2; let x = (r.width.saturating_sub(width)) / 2;
let y = (r.height.saturating_sub(height)) / 2; let y = (r.height.saturating_sub(height)) / 2;
Rect { Rect {
x: r.x + x, x: r.x + x,
y: r.y + y, y: r.y + y,
width: width.min(r.width), width: width.min(r.width),
height: height.min(r.height), height: height.min(r.height),
} }
} }