feat: round up basic functionality

This commit is contained in:
itsscb 2025-07-25 14:17:16 +02:00
parent 9f7953740f
commit a7dc703a91
2 changed files with 282 additions and 107 deletions

View File

@ -3,7 +3,7 @@ use std::{fs, path::PathBuf};
use color_eyre::{Result, eyre::eyre}; use color_eyre::{Result, eyre::eyre};
use ratatui::crossterm::event::KeyModifiers; use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
use ratatui::layout::Flex; use ratatui::layout::Flex;
use ratatui::prelude::*; use ratatui::prelude::*;
@ -24,14 +24,19 @@ pub mod log;
pub static APP_NAME: &str = "lw"; pub static APP_NAME: &str = "lw";
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new(); static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
static COLOR_PRIMARY: Color = Color::Rgb(51, 217, 178);
static COLOR_SECONDARY: Color = Color::Rgb(52, 172, 224);
static COLOR_TERTIARY: Color = Color::Rgb(247, 241, 227);
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct App { pub struct App {
logs: Vec<Item>, logs: Vec<Item>,
#[serde(skip)] #[serde(skip)]
delete: Option<usize>, exit: bool,
#[serde(skip)] #[serde(skip)]
input: String, edit: Option<Item>,
#[serde(skip)]
delete: Option<usize>,
#[serde(skip)] #[serde(skip)]
state: TableState, state: TableState,
} }
@ -53,8 +58,10 @@ impl App {
pub fn new(config: PathBuf) -> Result<Self> { pub fn new(config: PathBuf) -> Result<Self> {
if config.exists() if config.exists()
&& let Ok(v) = fs::read_to_string(config) && let Ok(v) = fs::read_to_string(config)
&& let Ok(app) = serde_json::from_str(&v)
{ {
let mut app: Self = serde_json::from_str(&v)?;
app.logs.sort_by(|a, b| b.created().cmp(&a.created()));
return Ok(app); return Ok(app);
} }
Err(eyre!("failed to read config")) Err(eyre!("failed to read config"))
@ -82,95 +89,210 @@ impl App {
} }
fn draw(&mut self, frame: &mut Frame) { 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()); self.render(frame.area(), frame.buffer_mut());
if let Some(ref item) = self.edit {
let block = Block::bordered().title("Details");
let area = popup_area(frame.area(), 80, 80);
frame.render_widget(Clear, area);
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let inner = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(50), Constraint::Percentage(50)])
.split(outer[1]);
frame.render_widget(
Paragraph::new(
Text::from(format!(
"modified at {}",
item.modified().format("%Y-%m-%d %H:%M:%S").to_string()
))
.right_aligned(),
)
.right_aligned(),
inner[1],
);
frame.render_widget(
Paragraph::new(Text::from(format!(
"created at {}",
item.created().format("%Y-%m-%d %H:%M:%S").to_string()
))),
inner[0],
);
let content: String = item.content().to_owned();
let v: Vec<Line> = content
.split("\n")
.enumerate()
.map(|(i, c)| {
let mut t = vec![Span::from(c)];
if i >= content.split("\n").count() - 1 {
t.push(
Span::from("_")
.patch_style(Style::new().add_modifier(Modifier::RAPID_BLINK)),
);
}
Line::from(t)
})
.collect();
frame.render_widget(Paragraph::new(v).block(block), outer[0]);
}
}
pub fn handle_edit_keys(&mut self, key_event: KeyEvent, item: Item) -> Result<()> {
match key_event.code {
KeyCode::Char('o') | KeyCode::Enter
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
{
if !item
.content()
.replace("\n", "")
.replace("\t", "")
.is_empty()
{
if self.logs.iter().find(|l| l.id() == item.id()).is_some() {
self.update(item.id(), item.content());
} else {
self.logs.push(item.clone());
}
self.edit = None;
self.save()?;
}
}
KeyCode::Enter => {
let mut tmp = item.clone();
let mut s = tmp.content();
s.push('\n');
tmp.update(s);
self.edit = Some(tmp);
}
KeyCode::Char(key) => {
if key == 'c' && key_event.modifiers.contains(KeyModifiers::CONTROL) {
self.edit = None;
} else {
let mut tmp = item.clone();
let mut s = tmp.content();
s.push(key);
tmp.update(s);
self.edit = Some(tmp);
}
}
KeyCode::Backspace => {
let mut tmp = item.clone();
let mut s: String = tmp.content();
if s.len() > 0 {
tmp.update(if key_event.modifiers.contains(KeyModifiers::CONTROL) {
ctrl_backspace_remaining(s)
} else {
s.truncate(s.len() - 1);
s
});
self.edit = Some(tmp);
}
}
KeyCode::Esc => {
self.edit = None;
}
_ => {}
}
Ok(())
}
pub fn handle_main_keys(&mut self, key_event: KeyEvent) -> Result<()> {
match key_event.code {
KeyCode::Char('q') | KeyCode::Esc => self.exit = true,
KeyCode::Char('e') | KeyCode::Char(' ') | KeyCode::Enter => match self.state.selected()
{
Some(selected) => {
if let Some(v) = self.logs.get(selected) {
self.edit = Some(v.clone());
} else {
self.edit = Some(Item::new());
}
}
None => self.edit = Some(Item::new()),
},
KeyCode::Char('j') | KeyCode::Down => {
self.state.select_next();
self.delete = None;
}
KeyCode::Char('k') | KeyCode::Up => {
self.state.select_previous();
self.delete = None;
}
KeyCode::Char('g') | KeyCode::Home => {
self.state.select_first();
self.delete = None;
}
KeyCode::Char('G') | KeyCode::End => {
self.state.select_last();
self.delete = None;
}
KeyCode::Char('o') => {
self.edit = Some(Item::new());
self.delete = None;
}
KeyCode::Char('d') => {
let curr = self.state.selected();
match curr {
None => {
self.delete = None;
}
Some(c) => {
if self.delete == curr {
let id = self.logs[c].id();
self.delete = None;
self.remove(id);
self.save()?;
} else {
self.delete = curr;
}
}
}
}
_ => {}
}
Ok(())
} }
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> { pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
self.state.select_next();
loop { loop {
if self.exit {
break Ok(());
}
terminal.draw(|frame| self.draw(frame))?; terminal.draw(|frame| self.draw(frame))?;
if let Ok(event) = event::read() if let Ok(event) = event::read()
&& let Event::Key(key_event) = event && let Event::Key(key_event) = event
&& key_event.kind == event::KeyEventKind::Press && key_event.kind == event::KeyEventKind::Press
{ {
if let Some(item) = self.logs.last() if self.edit.is_some() {
&& item.content().is_empty() let item = self.edit.clone().unwrap();
{ self.handle_edit_keys(key_event, item)?;
match key_event.code { continue;
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;
}
}
}
}
_ => {}
} }
self.handle_main_keys(key_event)?;
} }
} }
} }
pub fn add(&mut self, item: Item) { pub fn add(&mut self, item: Item) {
self.logs.push(item); self.logs.push(item);
self.logs.sort_by(|a, b| b.created().cmp(&a.created()));
} }
pub fn update<T: AsRef<str>>(&mut self, id: T, content: T) { pub fn update<T: AsRef<str>>(&mut self, id: T, content: T) {
if let Some(item) = self.logs.iter_mut().find(|i| i.id() == id.as_ref()) { if let Some(item) = self.logs.iter_mut().find(|i| i.id() == id.as_ref()) {
item.update(content.as_ref().to_owned()); item.update(content.as_ref().to_owned());
} }
self.logs.sort_by(|a, b| b.created().cmp(&a.created()));
} }
pub fn remove<T: AsRef<str>>(&mut self, id: T) { pub fn remove<T: AsRef<str>>(&mut self, id: T) {
@ -189,42 +311,45 @@ impl Widget for &mut App {
where where
Self: Sized, Self: Sized,
{ {
let title = Line::from(" Log Your Work ".bold()); let title = Line::from(Span::styled(
" Log Your Work ",
Style::default().fg(COLOR_PRIMARY).bold(),
));
let instructions = Line::from(vec![ let instructions = Line::from(vec![
Span::raw(" New "), Span::raw(" New "),
Span::styled( Span::styled(
"<O>", "<O>",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" Select "), Span::raw(" Select "),
Span::styled( Span::styled(
"<Space>", "<Space>",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" Down "), Span::raw(" Down "),
Span::styled( Span::styled(
"<J>", "<J>",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" Up "), Span::raw(" Up "),
Span::styled( Span::styled(
"<K>", "<K>",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" Quit "), Span::raw(" Quit "),
Span::styled( Span::styled(
"<Q> | <ESC>", "<Q> | <ESC>",
Style::default() Style::default()
.fg(Color::DarkGray) .fg(COLOR_PRIMARY)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
]); ]);
@ -232,53 +357,92 @@ impl Widget for &mut App {
let block = Block::bordered() let block = Block::bordered()
.title(title.centered()) .title(title.centered())
.title_bottom(instructions.centered()) .title_bottom(instructions.centered())
.title_style(Style::new().blue()) .title_style(Color::White)
.border_set(border::THICK) .border_set(border::THICK)
.border_style(Style::new().dark_gray()); .border_style(Color::White);
let header = ["Log", "Modified", "Created"] let header = [
.into_iter() "Log", // "Modified",
.map(Cell::from) "Created",
.collect::<Row>() ]
.style(Style::default().fg(Color::DarkGray).bold()) .into_iter()
.height(1); .map(Cell::from)
.collect::<Row>()
.style(Style::default().fg(COLOR_TERTIARY).bold())
.height(1);
let mut highlight_style = Style::new().fg(COLOR_PRIMARY).bold();
let items: Vec<Row> = self let items: Vec<Row> = self
.logs .logs
.iter() .iter()
.map(|item| { .enumerate()
.map(|(i, item)| {
[ [
item.content(), item.content().replace("\n", " "),
item.modified().format("%Y-%m-%d %H:%M:%S").to_string(), // item.modified().format("%Y-%m-%d %H:%M:%S").to_string(),
item.created().format("%Y-%m-%d %H:%M:%S").to_string(), item.created().format("%Y-%m-%d %H:%M:%S").to_string(),
] ]
.into_iter() .into_iter()
.map(|c| Cell::from(Text::from(c))) .map(|c| {
Cell::from(Text::from(c).style({
let s = Style::new();
if let Some(index) = self.delete
&& i == index
{
highlight_style = Style::new().fg(Color::LightRed).bold();
s.fg(Color::LightRed).bold()
} else {
s
}
}))
})
.collect::<Row>() .collect::<Row>()
.style(Style::new().fg(Color::White)) .style(Style::new().fg(Color::White))
.height(4) .height(4)
}) })
.collect(); .collect();
let table = Table::new( let table = Table::new(items, [Constraint::Min(200), Constraint::Min(20)])
items, .block(block)
[ .header(header)
Constraint::Min(200), .highlight_symbol("> ")
Constraint::Min(10), .row_highlight_style(highlight_style)
Constraint::Min(10), .highlight_spacing(HighlightSpacing::Always);
],
)
.block(block)
.header(header)
.highlight_symbol(">")
.row_highlight_style(Style::new().bold().fg(Color::Green))
.highlight_spacing(HighlightSpacing::Always);
// let list = List::new(items)
// .block(block)
// .highlight_symbol(">")
// .highlight_spacing(HighlightSpacing::Always);
//
StatefulWidget::render(table, area, buf, &mut self.state); StatefulWidget::render(table, area, buf, &mut self.state);
} }
} }
fn ctrl_backspace_remaining<T: AsRef<str>>(s: T) -> String {
let s = String::from(s.as_ref());
let cut_pos = {
let mut chars = s.char_indices().rev();
let mut pos_after_trailing_ws = None;
for (idx, ch) in &mut chars {
if !ch.is_whitespace() {
pos_after_trailing_ws = Some(idx + ch.len_utf8());
break;
}
}
match pos_after_trailing_ws {
None => 0,
Some(_) => {
let mut o = 0;
for (idx, ch) in chars {
if ch.is_whitespace() {
o = idx + ch.len_utf8();
o = o.saturating_sub(1);
break;
}
}
o
}
}
};
String::from(&s[..cut_pos])
}

View File

@ -1,12 +1,23 @@
use std::env;
use color_eyre::Result; use color_eyre::Result;
use lw::App; use lw::App;
fn main() -> Result<()> { fn main() -> Result<()> {
color_eyre::install()?; color_eyre::install()?;
let terminal = ratatui::init();
let mut app = App::default(); let mut app = App::default();
let args = env::args();
if args.len() > 1 {
args.into_iter().skip(1).for_each(|a| app.add(a.into()));
app.save()?;
return Ok(());
}
let terminal = ratatui::init();
let result = app.run(terminal); let result = app.run(terminal);
ratatui::restore(); ratatui::restore();
result result