diff --git a/src/tui.rs b/src/tui.rs index e71e901..a762e2b 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -3,6 +3,7 @@ use crate::db::Database; use crate::filtering::{filter_by_status, FilterByStatus}; use crate::sorting::{SortByDirection, SortByField}; use crate::streak::{Frequency, Streak}; +use catppuccin::{self, Flavor}; use ratatui::widgets::{ Block, BorderType, Borders, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Tabs, @@ -22,6 +23,69 @@ use ratatui::{ use std::io; use term_size::dimensions; +static PALETTE: Flavor = catppuccin::PALETTE.mocha; + +#[derive(Clone, Debug)] +struct AppStyles { + background: Color, + foreground: Color, + danger: Color, + row_bg: Color, + alt_row_bg: Color, + row_fg: Color, + alt_row_fg: Color, + highlight_bg: Color, + highlight_fg: Color, + tab_fg: Color, + selected_tab_fg: Color, +} + +impl AppStyles { + fn new() -> Self { + let peach = Color::Rgb( + PALETTE.colors.peach.rgb.r, + PALETTE.colors.peach.rgb.g, + PALETTE.colors.peach.rgb.b, + ); + let text = Color::Rgb( + PALETTE.colors.text.rgb.r, + PALETTE.colors.text.rgb.g, + PALETTE.colors.text.rgb.b, + ); + let base = Color::Rgb( + PALETTE.colors.base.rgb.r, + PALETTE.colors.base.rgb.g, + PALETTE.colors.base.rgb.b, + ); + + AppStyles { + background: base, + foreground: text, + danger: Color::Rgb( + PALETTE.colors.red.rgb.r, + PALETTE.colors.red.rgb.g, + PALETTE.colors.red.rgb.b, + ), + row_bg: Color::Rgb( + PALETTE.colors.surface0.rgb.r, + PALETTE.colors.surface0.rgb.g, + PALETTE.colors.surface0.rgb.b, + ), + alt_row_bg: Color::Rgb( + PALETTE.colors.surface1.rgb.r, + PALETTE.colors.surface1.rgb.g, + PALETTE.colors.surface1.rgb.b, + ), + row_fg: text, + alt_row_fg: text, + highlight_bg: peach, + highlight_fg: base, + tab_fg: text, + selected_tab_fg: peach, + } + } +} + #[derive(Clone, Debug)] struct NewStreak { task: String, @@ -42,6 +106,7 @@ enum AppState { Normal, Insert, Search, + Delete, } #[derive(Clone, Debug)] @@ -56,6 +121,7 @@ struct App { tab_state: u8, search_phrase: String, new_streak: NewStreak, + styles: AppStyles, } impl App { @@ -72,6 +138,7 @@ impl App { tab_state: 0, search_phrase: String::default(), new_streak: NewStreak::default(), + styles: AppStyles::new(), } } @@ -131,6 +198,22 @@ impl App { self.db.save()?; Ok(()) } + + pub fn delete_selected(&mut self) -> io::Result<()> { + let i = self.table_state.selected().unwrap(); + let streak = self + .db + .get_by_index( + i, + self.sort_by_field.clone(), + self.sort_by_direction.clone(), + self.filter_by_status.clone(), + ) + .unwrap(); + self.db.delete(streak.id)?; + self.db.save()?; + Ok(()) + } } pub fn main() -> io::Result<()> { @@ -222,6 +305,9 @@ fn run_app(terminal: &mut Terminal, mut app: &mut App) -> io::Res app.new_streak = NewStreak::default(); app.app_state = AppState::Insert; } + KeyCode::Char('d') => { + app.app_state = AppState::Delete; + } _ => {} }, AppState::Insert => match key.code { @@ -243,7 +329,10 @@ fn run_app(terminal: &mut Terminal, mut app: &mut App) -> io::Res _ => {} }, AppState::Search => match key.code { - KeyCode::Esc => app.app_state = AppState::Normal, + KeyCode::Esc => { + app.search_phrase = "".to_string(); + app.app_state = AppState::Normal + } KeyCode::Enter => app.app_state = AppState::Normal, KeyCode::Backspace => { app.search_phrase.pop(); @@ -253,6 +342,16 @@ fn run_app(terminal: &mut Terminal, mut app: &mut App) -> io::Res } _ => {} }, + AppState::Delete => match key.code { + KeyCode::Char('y') => { + app.delete_selected()?; + app.app_state = AppState::Normal; + } + KeyCode::Char('n') => { + app.app_state = AppState::Normal; + } + _ => {} + }, } } } @@ -272,11 +371,12 @@ fn layout_app(app: &mut App, frame: &mut Frame) -> io::Result<()> { ]) .split(frame.area()); - draw_header(frame, chunks[0])?; + draw_header(app, frame, chunks[0])?; match app.app_state { AppState::Search => layout_search(app, frame, chunks[1])?, AppState::Insert => layout_add(app, frame, chunks[1])?, + AppState::Delete => layout_delete(app, frame, chunks[1])?, _ => layout_main(app, frame, chunks[1])?, } @@ -285,10 +385,11 @@ fn layout_app(app: &mut App, frame: &mut Frame) -> io::Result<()> { Ok(()) } -fn draw_header(frame: &mut Frame, area: Rect) -> io::Result<()> { +fn draw_header(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let block = Block::new() .borders(Borders::BOTTOM) - .border_type(BorderType::Thick); + .border_type(BorderType::Thick) + .bg(app.styles.background); let text = "Skidmarks"; let paragraph = Paragraph::new(text) .alignment(Alignment::Center) @@ -299,12 +400,14 @@ fn draw_header(frame: &mut Frame, area: Rect) -> io::Result<()> { fn draw_footer(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let block = Block::new() + .bg(app.styles.background) .borders(Borders::TOP) .border_type(BorderType::Thick); let text = match app.app_state { - AppState::Normal => "[j/k] move, [c] check in, [o] change order, [z] reverse order,\n[f] filter, [s] search, [a] add, [q] quit", + AppState::Normal => "[f] filter, [o] change sort order, [z] reverse order, [s] search\n[j/k] select, [c] check in, [a] add, [d] delete, [q] quit", AppState::Insert => "[Esc] cancel, [Enter] save, [Tab] toggle frequency", AppState::Search => "[Esc] cancel, [Enter] search, [Backspace] delete", + AppState::Delete => "[y] yes, [n] no", }; let help_text = Paragraph::new(text) .alignment(Alignment::Center) @@ -330,40 +433,19 @@ fn draw_tabs(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let tabs = Tabs::new(vec!["All", "Waiting", "Missed", "Completed"]) .block( Block::default() + .bg(app.styles.background) .borders(Borders::BOTTOM) .title_alignment(Alignment::Left) .title("Filter"), ) - .style(Style::default().fg(Color::White)) - .highlight_style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(app.styles.tab_fg)) + .highlight_style(Style::default().fg(app.styles.selected_tab_fg)) .select(app.tab_state.into()) .divider(symbols::DOT); frame.render_widget(tabs, area); Ok(()) } -#[allow(dead_code)] -fn draw_form(frame: &mut Frame, area: Rect) -> io::Result<()> { - let form_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(33)]) - .split(area); - - let task_block = Block::default().borders(Borders::ALL).title("Task"); - let task = Paragraph::new("Task goes here") - .block(task_block) - .alignment(Alignment::Left); - frame.render_widget(task, form_layout[0]); - - let freq_block = Block::default().borders(Borders::ALL).title("Frequency"); - let freq = Paragraph::new("Daily") - .block(freq_block) - .alignment(Alignment::Left); - frame.render_widget(freq, form_layout[1]); - - Ok(()) -} - fn layout_content(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let chunks = Layout::default() .direction(Direction::Horizontal) @@ -393,7 +475,7 @@ fn draw_table(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let rows = get_rows(app); let header_style = Style::default().add_modifier(Modifier::BOLD); - let sorted_by_style = Style::default().fg(Color::Yellow); + let sorted_by_style = Style::default().fg(app.styles.selected_tab_fg); let sorted_icon = match app.sort_by_direction { SortByDirection::Ascending => "⬆", SortByDirection::Descending => "⬇", @@ -430,7 +512,11 @@ fn draw_table(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { .column_spacing(1) .header(header_row.style(header_style).height(2)) .footer(Row::new(vec![ - format!("Search: {}", app.search_phrase), + if app.search_phrase.is_empty() { + "".to_string() + } else { + format!("Search: {}", app.search_phrase) + }, "".to_string(), "".to_string(), "".to_string(), @@ -438,10 +524,15 @@ fn draw_table(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { "".to_string(), format!("{}/{}", rows.clone().len(), app.db.num_tasks()), ])) - .bg(Color::Black) + .bg(app.styles.row_bg) + .style(Style::default().fg(app.styles.row_fg)) .highlight_spacing(HighlightSpacing::WhenSelected) - .style(Style::default().fg(Color::White)) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + .highlight_style( + Style::default() + .bg(app.styles.highlight_bg) + .fg(app.styles.highlight_fg), + ) + .block(Block::default().bg(app.styles.background)); frame.render_stateful_widget(table, area, &mut app.table_state); @@ -467,11 +558,13 @@ fn get_rows(app: &mut App) -> Vec> { .collect(); } + let styles = AppStyles::new(); + let mut rows = vec![]; let (w, _) = dimensions().unwrap(); let w = w.saturating_sub(50); - for streak in streaks { + for (i, streak) in streaks.iter().enumerate() { let task_lines = textwrap::wrap(&streak.task, w); let h = task_lines.len(); let task = task_lines.join("\n"); @@ -490,6 +583,12 @@ fn get_rows(app: &mut App) -> Vec> { let total_checkins = Text::from(streak.total_checkins.to_string()).alignment(Alignment::Center); + let row_style = if i % 2 == 0 { + Style::default().fg(styles.row_fg).bg(styles.row_bg) + } else { + Style::default().fg(styles.alt_row_fg).bg(styles.alt_row_bg) + }; + let row = Row::new(vec![ Cell::from(task.clone()), Cell::from(freq), @@ -499,6 +598,7 @@ fn get_rows(app: &mut App) -> Vec> { Cell::from(longest_streak), Cell::from(total_checkins), ]) + .style(row_style) .height(h as u16); rows.push(row.clone()); } @@ -506,14 +606,7 @@ fn get_rows(app: &mut App) -> Vec> { } fn layout_search(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Fill(1), - Constraint::Length(3), - Constraint::Fill(1), - ]) - .split(area); + let layout = get_centered_layout(3).split(area); draw_search(app, frame, layout[1])?; Ok(()) @@ -522,10 +615,11 @@ fn layout_search(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> fn draw_search(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { let block = Block::default() .borders(Borders::ALL) + .border_style(Style::default().fg(app.styles.highlight_bg)) .title("Search") .title_alignment(Alignment::Center); let paragraph = Paragraph::new(app.search_phrase.clone()) - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(app.styles.foreground)) .block(block) .alignment(Alignment::Left); frame.render_widget(paragraph, area); @@ -534,14 +628,7 @@ fn draw_search(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { } fn layout_add(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Fill(1), - Constraint::Length(6), - Constraint::Fill(1), - ]) - .split(area); + let layout = get_centered_layout(6).split(area); draw_add(app, frame, layout[1])?; Ok(()) @@ -554,10 +641,11 @@ fn draw_add(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { .split(area); let block = Block::default() .borders(Borders::ALL) + .border_style(Style::default().fg(app.styles.highlight_bg)) .title("New Streak") .title_alignment(Alignment::Center); let task = Paragraph::new(app.new_streak.task.clone()) - .style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(app.styles.foreground)) .block(block) .alignment(Alignment::Left); frame.render_widget(task, layout[0]); @@ -581,9 +669,41 @@ fn draw_add_tabs(app: &mut App) -> Tabs { .title_alignment(Alignment::Center) .title("Frequency"), ) - .style(Style::default().fg(Color::White)) - .highlight_style(Style::default().fg(Color::Yellow)) + .style(Style::default().fg(app.styles.tab_fg)) + .highlight_style(Style::default().fg(app.styles.selected_tab_fg)) .select(select) .divider(symbols::DOT); tabs } + +fn get_centered_layout(height: u16) -> Layout { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) +} +fn layout_delete(app: &mut App, frame: &mut Frame, area: Rect) -> io::Result<()> { + let layout = get_centered_layout(3).split(area); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style( + Style::default() + .fg(app.styles.background) + .bg(app.styles.danger), + ) + .title("Delete") + .title_alignment(Alignment::Center) + .fg(app.styles.background) + .bg(app.styles.danger); + let text = "Are you sure you want to delete this streak?"; + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + frame.render_widget(paragraph, layout[1]); + + Ok(()) +}