From 9f7953740fd78dd903744afb22621e6ebdd7d846 Mon Sep 17 00:00:00 2001 From: itsscb Date: Thu, 24 Jul 2025 20:26:56 +0200 Subject: [PATCH] feat: add cr*d via table > update will be implemented tomorrow --- src/lib.rs | 244 +++++++++++++++++++++++++++++++++++----------------- src/log.rs | 7 ++ src/main.rs | 2 +- 3 files changed, 175 insertions(+), 78 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 05f03ae..7571ac3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,20 @@ +use std::sync::OnceLock; use std::{fs, path::PathBuf}; -use color_eyre::Result; +use color_eyre::{Result, eyre::eyre}; +use ratatui::crossterm::event::KeyModifiers; +use ratatui::layout::Flex; use ratatui::prelude::*; +use ratatui::widgets::{Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState}; use ratatui::{ DefaultTerminal, Frame, crossterm::event::{self, Event, KeyCode}, style::Color, symbols::border, text::Line, - widgets::{Block, HighlightSpacing, List, ListItem, ListState, StatefulWidget, Widget}, + widgets::{Block, Widget}, }; use serde::{Deserialize, Serialize}; @@ -19,106 +23,167 @@ use crate::log::Item; pub mod log; pub static APP_NAME: &str = "lw"; +static CONFIG_PATH: OnceLock = OnceLock::new(); #[derive(Debug, Serialize, Deserialize)] pub struct App { logs: Vec, - config: PathBuf, #[serde(skip)] - state: ListState, + delete: Option, + #[serde(skip)] + input: String, + #[serde(skip)] + state: TableState, } - -impl App { - pub fn new() -> Self { - Self::default() +impl Default for App { + #[allow(clippy::expect_used)] + fn default() -> Self { + let config = Self::config_path(); + Self::new(config.to_owned()).expect("could not create app struct with default config") } +} +fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); + let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); + let [area] = vertical.areas(area); + let [area] = horizontal.areas(area); + area +} +impl App { + pub fn new(config: PathBuf) -> Result { + if config.exists() + && let Ok(v) = fs::read_to_string(config) + && let Ok(app) = serde_json::from_str(&v) + { + return Ok(app); + } + Err(eyre!("failed to read config")) + } + #[allow(clippy::expect_used)] + pub fn config_path() -> &'static PathBuf { + CONFIG_PATH.get_or_init(|| { + if cfg!(windows) { + let base = std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()); + let dir = PathBuf::from(base).join(APP_NAME); + + if !dir.exists() { + fs::create_dir_all(&dir).expect("failed to create app directory"); + } + dir.join("config.json") + } else { + let base = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + let dir = PathBuf::from(base).join(".config").join(APP_NAME); + if !dir.exists() { + fs::create_dir_all(&dir).expect("failed to create app directory"); + } + dir.join("config.json") + } + }) + } + fn draw(&mut self, frame: &mut Frame) { + if let Some(item) = self.logs.last() + && item.content().is_empty() + { + let block = Block::bordered().title("New"); + let area = popup_area(frame.area(), 60, 20); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new(Text::from(self.input.clone())).block(block), + area, + ); + } self.render(frame.area(), frame.buffer_mut()); } + pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { loop { - // terminal.draw(render)?; terminal.draw(|frame| self.draw(frame))?; if let Ok(event) = event::read() && let Event::Key(key_event) = event && key_event.kind == event::KeyEventKind::Press { + if let Some(item) = self.logs.last() + && item.content().is_empty() + { + match key_event.code { + KeyCode::Char('o') + if key_event.modifiers.contains(KeyModifiers::CONTROL) => + { + self.update(item.id(), self.input.clone()); + self.input = String::new(); + self.save()?; + continue; + } + KeyCode::Char(key) => { + self.input.push(key); + continue; + } + KeyCode::Backspace => { + self.input.truncate(self.input.len() - 1); + continue; + } + KeyCode::Esc => { + self.remove(item.id()); + self.input = String::new(); + self.save()?; + continue; + } + _ => {} + } + } match key_event.code { KeyCode::Char('q') | KeyCode::Esc => break Ok(()), + KeyCode::Char('j') | KeyCode::Down => self.state.select_next(), + KeyCode::Char('k') | KeyCode::Up => self.state.select_previous(), + KeyCode::Char('g') | KeyCode::Home => self.state.select_first(), + KeyCode::Char('G') | KeyCode::End => self.state.select_last(), + KeyCode::Char('o') => self.add(Item::new()), + KeyCode::Char('d') => { + let curr = self.state.selected(); + match curr { + None => { + self.delete = None; + continue; + } + Some(c) => { + if self.delete == curr { + let id = self.logs[c].id(); + self.remove(id); + self.save()?; + } else { + self.delete = curr; + } + } + } + } _ => {} } } - // match event::read() { - // Ok(event) => match event { - // Event::Key(key_event) => match key_event.kind { - // event::KeyEventKind::Press => { - // if key_event.code == KeyCode::Char('q') { - // break Ok(()); - // } - // } - // _ => {} - // }, - // _ => {} - // }, - // _ => {} - // } } } - pub fn add(&mut self, item: Item) -> Result<()> { + pub fn add(&mut self, item: Item) { self.logs.push(item); - self.save() } - pub fn update>(&mut self, id: T, content: T) -> Result<()> { + pub fn update>(&mut self, id: T, content: T) { if let Some(item) = self.logs.iter_mut().find(|i| i.id() == id.as_ref()) { item.update(content.as_ref().to_owned()); } - self.save() } - pub fn remove>(&mut self, id: T) -> Result<()> { + pub fn remove>(&mut self, id: T) { self.logs.retain(|i| i.id() != id.as_ref()); - self.save() } pub fn save(&self) -> Result<()> { let output = serde_json::to_string_pretty(&self)?; - fs::write(self.config.clone(), output)?; + fs::write(Self::config_path(), output)?; Ok(()) } } -impl Default for App { - fn default() -> Self { - #[cfg(unix)] - let mut dir = PathBuf::from(std::env::var("HOME").expect("No HOME directory")); - #[cfg(windows)] - let mut dir = PathBuf::from(std::env::var("APPDATA").expect("No APPDATA directory")); - - dir.push(APP_NAME); - - if !dir.exists() { - fs::create_dir_all(dir.clone()).expect("Failed to create App directory"); - } - - dir.push("config.json"); - - if dir.exists() - && let Ok(v) = fs::read_to_string(&dir) - && let Ok(app) = serde_json::from_str(&v) - { - return app; - } - - Self { - logs: vec![], - config: dir, - ..Default::default() - } - } -} - impl Widget for &mut App { fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) where @@ -127,7 +192,7 @@ impl Widget for &mut App { let title = Line::from(" Log Your Work ".bold()); let instructions = Line::from(vec![ - Span::raw(" Add "), + Span::raw(" New "), Span::styled( "", Style::default() @@ -167,28 +232,53 @@ impl Widget for &mut App { let block = Block::bordered() .title(title.centered()) .title_bottom(instructions.centered()) - .border_set(border::THICK); + .title_style(Style::new().blue()) + .border_set(border::THICK) + .border_style(Style::new().dark_gray()); - let items: Vec = self + let header = ["Log", "Modified", "Created"] + .into_iter() + .map(Cell::from) + .collect::() + .style(Style::default().fg(Color::DarkGray).bold()) + .height(1); + + let items: Vec = self .logs .iter() - .enumerate() - .map(|(i, item)| { - ListItem::new(Line::styled(format!("{}", item.content()), Color::White)).bg( - if i % 2 == 0 { - Color::Black - } else { - Color::DarkGray - }, - ) + .map(|item| { + [ + item.content(), + item.modified().format("%Y-%m-%d %H:%M:%S").to_string(), + item.created().format("%Y-%m-%d %H:%M:%S").to_string(), + ] + .into_iter() + .map(|c| Cell::from(Text::from(c))) + .collect::() + .style(Style::new().fg(Color::White)) + .height(4) }) .collect(); - let list = List::new(items) - .block(block) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always); + let table = Table::new( + items, + [ + Constraint::Min(200), + Constraint::Min(10), + Constraint::Min(10), + ], + ) + .block(block) + .header(header) + .highlight_symbol(">") + .row_highlight_style(Style::new().bold().fg(Color::Green)) + .highlight_spacing(HighlightSpacing::Always); - StatefulWidget::render(list, area, buf, &mut self.state); + // let list = List::new(items) + // .block(block) + // .highlight_symbol(">") + // .highlight_spacing(HighlightSpacing::Always); + // + StatefulWidget::render(table, area, buf, &mut self.state); } } diff --git a/src/log.rs b/src/log.rs index 0bba5c6..373d351 100644 --- a/src/log.rs +++ b/src/log.rs @@ -15,6 +15,13 @@ impl Item { Self::default() } + pub fn modified(&self) -> DateTime { + self.modified + } + pub fn created(&self) -> DateTime { + self.created + } + pub fn content(&self) -> String { self.content.clone() } diff --git a/src/main.rs b/src/main.rs index 6651445..6b13f68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use color_eyre::Result; -use lw::{App, log::Item}; +use lw::App; fn main() -> Result<()> { color_eyre::install()?;