diff --git a/src/app.rs b/src/app.rs index 3c3450b..f56ceb2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,9 @@ use std::io; use chrono::{Datelike, Local}; -use crossterm::event::{self, Event, KeyEvent, KeyEventKind}; use ratatui::{ DefaultTerminal, Frame, + crossterm::event::{self, KeyEvent, KeyEventKind}, layout::{Constraint, Direction, Layout, Rect}, }; @@ -20,17 +20,17 @@ pub enum FocusedComponent { DayPopup, } -pub struct App { +pub struct App<'a> { year: Year, month: Month, days: Days, - day_popup: Popup, + day_popup: Popup<'a>, focused: FocusedComponent, exit: bool, } -impl App { +impl App<'_> { pub fn new() -> Self { let mut app = App { year: Year::default(), @@ -38,7 +38,7 @@ impl App { days: Days::default(), day_popup: Popup::default(), - focused: FocusedComponent::DayPopup, + focused: FocusedComponent::Days, exit: false, }; @@ -75,13 +75,13 @@ impl App { let mut db = DB::new().unwrap(); while !self.exit { - terminal.draw(|frame| self.draw(frame))?; + terminal.draw(|frame| self.draw(frame, &mut db))?; self.handle_events(&mut db)?; } Ok(()) } - fn draw(&mut self, frame: &mut Frame) { + fn draw(&mut self, frame: &mut Frame, db: &mut DB) { let main_area = Layout::default() .direction(Direction::Vertical) .constraints(vec![ @@ -100,7 +100,7 @@ impl App { self.year.ready_to_render(main_area[0]); self.month.ready_to_render(main_area[1]); - self.days.ready_to_render(main_area[2]); + self.days.ready_to_render(main_area[2], db); self.day_popup.ready_to_render(popup_area); frame.render_widget(&self.year, main_area[0]); @@ -136,7 +136,9 @@ impl App { fn handle_events(&mut self, db: &mut DB) -> io::Result<()> { match event::read()? { - Event::Key(key_event) if key_event.kind == KeyEventKind::Press => { + ratatui::crossterm::event::Event::Key(key_event) + if key_event.kind == KeyEventKind::Press => + { let app_event_option = self.handle_input(key_event, db); if let Some(app_event_vec) = app_event_option { @@ -160,13 +162,40 @@ impl App { self.month.minus_month(); } }, + AppEvent::Reload => { + self.days.reload_day_counts(db); + } AppEvent::MonthSet(month) => { self.days.reload(month, self.year.year, 1); } AppEvent::YearSet(year) => { self.days.reload(self.month.month, year, 1); } - _ => (), + + AppEvent::AddEvent(start, end) => { + self.switch_focus(FocusedComponent::DayPopup); + self.day_popup.add_event( + start, + end, + db.get_tags().expect("Database error"), + ); + } + + AppEvent::DaySelected(day) => { + self.switch_focus(FocusedComponent::DayPopup); + let events = + db.get_day(day).expect("Database error").unwrap_or_default(); + let tag_names = events + .iter() + .map(|e| match e.tag { + Some(id) => { + db.get_tag_name(id).expect("Database error").unwrap() + } + None => "Tag deleted".to_owned(), + }) + .collect(); + self.day_popup.show_day(day, events, tag_names); + } } } } diff --git a/src/database.rs b/src/database.rs index a73eeb1..2e18b39 100644 --- a/src/database.rs +++ b/src/database.rs @@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher}; use std::time::{SystemTime, UNIX_EPOCH}; use std::{env::home_dir, fs}; -use crate::day_info::{DayInfo, Event}; +use crate::day_info::Event; use chrono::NaiveDate; use redb::{Database, Error, ReadableDatabase, ReadableTable, TableDefinition}; @@ -36,16 +36,16 @@ impl DB { Ok(Self { db }) } - pub fn get_day(&self, day: NaiveDate) -> Result, Error> { + pub fn get_day(&self, day: NaiveDate) -> Result>, Error> { match self.db.begin_read()?.open_table(MAIN_TABLE)?.get(day)? { None => Ok(None), Some(data) => { let events = data.value(); - let mut output = DayInfo::default(); + let mut output = vec![]; for (tag_id, event_description) in events { let tag_name = self.db.begin_read()?.open_table(TAG_TABLE)?.get(tag_id)?; - output.events.push(match tag_name { + output.push(match tag_name { Some(_name) => Event::new(Some(tag_id), event_description), None => Event::new(None, event_description), diff --git a/src/day_info.rs b/src/day_info.rs index e27e866..44b334a 100644 --- a/src/day_info.rs +++ b/src/day_info.rs @@ -1,8 +1,3 @@ -#[derive(Default, Clone)] -pub struct DayInfo { - pub events: Vec, -} - #[derive(Default, Clone)] pub struct Event { pub tag: Option, @@ -14,9 +9,3 @@ impl Event { Self { tag, description } } } - -impl DayInfo { - pub fn new(events: Vec) -> Self { - Self { events } - } -} diff --git a/src/days.rs b/src/days.rs index f055f73..a7de20b 100644 --- a/src/days.rs +++ b/src/days.rs @@ -1,8 +1,7 @@ -use crate::{ - app::FocusedComponent, database::DB, day_info::DayInfo, events::AppEvent, focused::Focused, -}; -use chrono::{Datelike, Duration, NaiveDate}; -use crossterm::event::{KeyCode, KeyEvent}; +use crate::{app::FocusedComponent, database::DB, events::AppEvent, focused::Focused}; +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::text::Span; use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, @@ -23,8 +22,6 @@ pub struct Days { selected_day: NaiveDate, pivot: Option, - date_of_first_day: i32, - current_month: i32, current_year: i32, } @@ -42,7 +39,7 @@ enum CursorMovement { } impl Days { - pub fn ready_to_render(&mut self, area: Rect) { + pub fn ready_to_render(&mut self, area: Rect, db: &mut DB) { let view_rect = area.inner(Margin::new(1, 1)); let number_of_days_shown = view_rect.height; @@ -89,6 +86,28 @@ impl Days { } self.days.push_front(next_day); } + + for i in self.days.iter_mut() { + if i.info.is_none() { + i.info = Some( + db.get_day(i.day) + .expect("Database error") + .unwrap_or_default() + .len(), + ); + } + } + } + + pub fn reload_day_counts(&mut self, db: &mut DB) { + for i in self.days.iter_mut() { + i.info = Some( + db.get_day(i.day) + .expect("Database error") + .unwrap_or_default() + .len(), + ); + } } pub fn reload(&mut self, month: i32, year: i32, day: i32) { @@ -100,7 +119,6 @@ impl Days { NaiveDate::from_ymd_opt(year, month as u32, day as u32).unwrap(), )); self.selected_day = self.days.iter().next().unwrap().day; - self.date_of_first_day = self.days.iter().next().unwrap().index(); } fn handle_main_state_input(&mut self, key_code: KeyCode) -> Option> { @@ -186,7 +204,17 @@ impl Days { self.state = DaysState::Main; None } - KeyCode::Enter => None, + KeyCode::Enter => Some(vec![AppEvent::DaySelected(self.selected_day)]), + KeyCode::Char('a') => { + let mut start = self.pivot.unwrap_or(self.selected_day); + let mut end = self.selected_day; + + if start > end { + std::mem::swap(&mut start, &mut end); + } + + Some(vec![AppEvent::AddEvent(start, end)]) + } KeyCode::Down => self.handle_focused_arrows(CursorMovement::Down), KeyCode::Up => self.handle_focused_arrows(CursorMovement::Up), KeyCode::Char(' ') => { @@ -241,7 +269,7 @@ impl Widget for &Days { .split(inner_area); for (day, area) in self.days.iter().zip(day_areas.iter()) { - day.get_line(self.selected_day, self.pivot, self.date_of_first_day) + day.get_line(self.selected_day, self.pivot, inner_area.width as usize) .render(*area, buf); } } @@ -253,43 +281,71 @@ impl Widget for &Days { #[derive(Clone)] pub struct Day { pub day: NaiveDate, - pub info: DayInfo, + pub info: Option, } - impl Day { pub fn new(day: NaiveDate) -> Self { - Self { - day, - info: DayInfo::default(), + Self { day, info: None } + } + fn get_line(&self, current_day: NaiveDate, pivot: Option, width: usize) -> Line<'_> { + let day_of_week = match self.day.weekday() { + Weekday::Mon => "Mon", + Weekday::Tue => "Tue", + Weekday::Wed => "Wed", + Weekday::Thu => "Thu", + Weekday::Fri => "Fri", + Weekday::Sat => "Sat", + Weekday::Sun => "Sun", + }; + let is_weekend = matches!(self.day.weekday(), Weekday::Sat | Weekday::Sun); + let day_text_color = if is_weekend { + Color::DarkGray + } else { + Color::White + }; + let date_text = format!( + "{}, {}, {}", + self.day.day(), + self.day.month(), + self.day.year() + ); + let bg_color = if Days::is_date_between(current_day, pivot, self.day) { + Color::Red + } else { + Color::Reset + }; + + let l = date_text.len(); + + let mut spans = vec![ + Span::styled(day_of_week, Style::new().fg(day_text_color)), + Span::styled(" ", Style::new()), + Span::styled(date_text, Style::new().fg(day_text_color)), + ]; + + match self.info { + None | Some(0) => { + let current_length = day_of_week.len() + 1 + l; + if current_length < width { + spans.push(Span::styled( + " ".repeat(width - current_length), + Style::new(), + )); + } + } + Some(info_value) => { + let info_text = format!("({})", info_value); + let current_length = day_of_week.len() + 1 + l; + let available_space = width.saturating_sub(current_length + info_text.len()); + + if available_space > 0 { + spans.push(Span::styled(" ".repeat(available_space), Style::new())); + } + + spans.push(Span::styled(info_text, Style::new())); + } } - } - fn index(&self) -> i32 { - self.day - .signed_duration_since(NaiveDate::from_ymd_opt(0, 1, 1).unwrap()) - .num_days() as i32 - } - - fn get_line( - &self, - current_day: NaiveDate, - pivot: Option, - date_of_first_day: i32, - ) -> Line<'_> { - Line::styled( - format!( - "{}, {}, {}", - self.day.day(), - self.day.month(), - self.day.year() - ), - Style::new().bg(if Days::is_date_between(current_day, pivot, self.day) { - Color::Red - } else if (self.index() - date_of_first_day).abs() % 2 == 0 { - Color::DarkGray - } else { - Color::Reset - }), - ) + Line::from(spans).style(Style::new().bg(bg_color)) } } diff --git a/src/events.rs b/src/events.rs index be10199..9e9cb14 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,9 +1,9 @@ use crate::app::FocusedComponent; -use crate::day_info::DayInfo; +use chrono::NaiveDate; pub enum AppEvent { SwitchFocus(FocusedComponent), - DaySelected(DayInfo), + DaySelected(NaiveDate), YearSet(i32), MonthSet(i32), @@ -11,6 +11,10 @@ pub enum AppEvent { YearScrolled(Direction), MonthScrolled(Direction), + Reload, + + AddEvent(NaiveDate, NaiveDate), + Exit, } diff --git a/src/focused.rs b/src/focused.rs index 21cbd05..fbecff0 100644 --- a/src/focused.rs +++ b/src/focused.rs @@ -1,4 +1,4 @@ -use crossterm::event::KeyEvent; +use ratatui::crossterm::event::KeyEvent; use crate::database::DB; use crate::events::AppEvent; diff --git a/src/global_shortcuts.rs b/src/global_shortcuts.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/main.rs b/src/main.rs index 166b180..40a1815 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ mod day_info; mod days; mod events; mod focused; -mod global_shortcuts; mod joiner; mod month; mod popup; diff --git a/src/month.rs b/src/month.rs index 18c4780..3bff92e 100644 --- a/src/month.rs +++ b/src/month.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ buffer::Buffer, layout::Rect, diff --git a/src/popup.rs b/src/popup.rs deleted file mode 100644 index caea3ca..0000000 --- a/src/popup.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Color, Style}, - symbols::border, - widgets::{Block, Clear, Widget}, -}; - -use crate::{app::FocusedComponent, database::DB, database::*, events::AppEvent, focused::Focused}; - -pub enum PopupType { - TagManagement(Vec<(u64, String)>), - DayDetails, - EventAdding, -} - -impl Default for PopupType { - fn default() -> Self { - PopupType::TagManagement(vec![]) - } -} - -#[derive(Default)] -pub struct Popup { - self_state: PopupType, - focused: bool, -} - -impl Popup { - pub fn ready_to_render(&mut self, _area: Rect) {} -} - -impl Focused for Popup { - fn take_focus(&mut self) { - self.focused = true; - } - - fn lose_focus(&mut self) { - self.focused = false; - } - - fn handle_input(&mut self, key_event: KeyEvent, _db: &mut DB) -> Option> { - match key_event.code { - KeyCode::Esc => Some(vec![AppEvent::SwitchFocus(FocusedComponent::Days)]), - _ => None, - } - } -} - -impl Widget for &Popup { - fn render(self, area: Rect, buf: &mut Buffer) { - if !self.focused { - return; - } - - Clear.render(area, buf); - - Block::bordered() - .border_set(border::ROUNDED) - .border_style(Style::default().fg(match self.focused { - true => Color::Blue, - false => Color::White, - })) - .title("DayPopup") - .render(area, buf); - } -} diff --git a/src/popup/day_details.rs b/src/popup/day_details.rs new file mode 100644 index 0000000..7036411 --- /dev/null +++ b/src/popup/day_details.rs @@ -0,0 +1,225 @@ +use crate::day_info::Event; +use crate::{app::FocusedComponent, database::DB, events::AppEvent, focused::Focused}; +use chrono::NaiveDate; +use ratatui::crossterm::event::KeyCode; +use ratatui::crossterm::event::KeyEvent; +use ratatui::layout::{Alignment, Constraint, Layout, Margin}; +use ratatui::style::Stylize; +use ratatui::style::{Color, Style}; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Clear, Padding, Paragraph, Wrap}; +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + +#[derive(Default)] +pub struct DayDetails { + pub events: Vec, + tag_names: Vec, + day: NaiveDate, + + state: State, + selected_event: usize, +} + +#[derive(Default)] +enum State { + #[default] + EventSelect, + DeletionConfirm(bool), +} + +impl DayDetails { + pub fn ready_to_render(&mut self, _area: Rect) {} + + pub fn show_day(day: NaiveDate, events: Vec, tag_names: Vec) -> Self { + Self { + events, + day, + tag_names, + state: State::default(), + selected_event: 0, + } + } +} + +impl Focused for DayDetails { + fn take_focus(&mut self) {} + + fn lose_focus(&mut self) {} + + fn handle_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + match key_event.code { + KeyCode::Esc => Some(vec![AppEvent::SwitchFocus(FocusedComponent::Days)]), + + t => { + if self.events.is_empty() { + return None; + } + match &mut self.state { + State::EventSelect => match t { + KeyCode::Right => { + self.selected_event = (self.selected_event + 1) % self.events.len(); + None + } + + KeyCode::Left => { + if self.selected_event == 0 { + self.selected_event = self.events.len() - 1; + } else { + self.selected_event -= 1; + } + + None + } + + KeyCode::Enter => { + self.state = State::DeletionConfirm(false); + None + } + + _ => None, + }, + + State::DeletionConfirm(confirm) => match t { + KeyCode::Right => { + *confirm = false; + None + } + + KeyCode::Left => { + *confirm = true; + None + } + + KeyCode::Enter => { + if *confirm { + db.remove_event(self.day, self.selected_event) + .expect("Database error"); + Some(vec![ + AppEvent::SwitchFocus(FocusedComponent::Days), + AppEvent::Reload, + ]) + } else { + self.state = State::EventSelect; + None + } + } + + _ => None, + }, + } + } + } + } +} + +impl DayDetails { + pub fn render(&self, area: Rect, buf: &mut Buffer, _focused: bool) { + Clear.render(area, buf); + + if self.events.is_empty() { + let outline = Block::bordered() + .border_set(border::ROUNDED) + .border_style(Style::new().fg(Color::Blue)); + + let no_events_text = Paragraph::new("No events for this day") + .style(Style::default().fg(Color::Red)) + .alignment(Alignment::Center) + .block(outline); + + no_events_text.render(area, buf); + return; + } + match self.state { + State::EventSelect => { + let outline = Block::bordered() + .border_set(border::ROUNDED) + .border_style(Style::new().fg(Color::Blue)); + let layout = Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]) + .split(outline.inner(area)); + outline.render(area, buf); + let event_select = Block::bordered() + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)); + let event_description = Block::bordered() + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)); + + let select_area = event_select.inner(layout[0]); + let description_area = event_description.inner(layout[1]); + + event_select.render(layout[0], buf); + event_description.render(layout[1], buf); + + let event_select_span = Paragraph::new(format!( + "({}/{}) {}", + self.selected_event + 1, + self.events.len(), + self.tag_names[self.selected_event].clone() + )); + event_select_span.render(select_area, buf); + + let description_span = + Paragraph::new(self.events[self.selected_event].description.clone()) + .wrap(Wrap { trim: true }); + + description_span.render(description_area, buf); + } + + State::DeletionConfirm(confirm) => { + let block = Block::bordered() + .title(" Manage Tags ") + .title_alignment(Alignment::Center) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)) + .bg(Color::Reset); + + let inner_area = block.inner(area); + block.render(area, buf); + + let layout = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + let new_button = Paragraph::new("Delete") + .style(Style::default().fg(if confirm { Color::Green } else { Color::White })) + .alignment(Alignment::Center) + .block(Block::bordered().border_set(border::ROUNDED).border_style( + if confirm { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }, + )); + + let delete_button = Paragraph::new("Cancel deletion") + .style(Style::default().fg(if !confirm { Color::Red } else { Color::White })) + .alignment(Alignment::Center) + .block(Block::bordered().border_set(border::ROUNDED).border_style( + if !confirm { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::White) + }, + )); + + new_button.render(layout[0].inner(Margin::new(1, 2)), buf); + delete_button.render(layout[1].inner(Margin::new(1, 2)), buf); + let instructions = Paragraph::new(format!( + "Do you really want to delete tag: {}", + self.tag_names + .get(self.selected_event) + .expect("User selected invalid tag") + )); + + let instructions_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: 1, + }; + instructions.render(instructions_area, buf); + } + } + } +} diff --git a/src/popup/event_adding.rs b/src/popup/event_adding.rs new file mode 100644 index 0000000..f7346ea --- /dev/null +++ b/src/popup/event_adding.rs @@ -0,0 +1,301 @@ +use crate::day_info::Event; +use crate::{app::FocusedComponent, database::DB, events::AppEvent, focused::Focused}; +use chrono::NaiveDate; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Alignment, Constraint, Layout}; +use ratatui::prelude::Color; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Padding, Paragraph}; +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; +use std::boxed::Box; +use tui_textarea::TextArea; + +pub enum EventAddingState<'a> { + SelectingTag(ListState), + WritingDescription(u64, Box>), // selected_tag_id, text_area +} + +impl Default for EventAddingState<'_> { + fn default() -> Self { + let mut list_state = ListState::default(); + list_state.select(Some(0)); + EventAddingState::SelectingTag(list_state) + } +} + +#[derive(Default)] +pub struct EventAdding<'a> { + state: EventAddingState<'a>, + start: NaiveDate, + end: NaiveDate, + tags: Vec<(u64, String)>, +} + +impl Focused for EventAdding<'_> { + fn take_focus(&mut self) {} + + fn lose_focus(&mut self) {} + + fn handle_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + match key_event.code { + KeyCode::Esc => match &self.state { + EventAddingState::SelectingTag(_) => { + Some(vec![AppEvent::SwitchFocus(FocusedComponent::Days)]) + } + EventAddingState::WritingDescription(_, _) => { + // Go back to tag selection + let mut list_state = ListState::default(); + list_state.select(Some(0)); + self.state = EventAddingState::SelectingTag(list_state); + None + } + }, + _ => match &self.state { + EventAddingState::SelectingTag(_) => self.handle_tag_selection_input(key_event, db), + EventAddingState::WritingDescription(_, _) => { + self.handle_description_input(key_event, db) + } + }, + } + } +} + +impl EventAdding<'_> { + pub fn ready_to_render(&mut self, _area: Rect) {} + + pub fn add_event(start: NaiveDate, end: NaiveDate, tags: Vec<(u64, String)>) -> Self { + Self { + state: EventAddingState::default(), + start, + end, + tags, + } + } + + pub fn render(&self, area: Rect, buf: &mut Buffer, _focused: bool) { + match &self.state { + EventAddingState::SelectingTag(list_state) => { + self.render_tag_selection(area, buf, list_state) + } + EventAddingState::WritingDescription(tag_id, text_area) => { + self.render_description_input(area, buf, *tag_id, text_area) + } + } + } + + fn render_tag_selection(&self, area: Rect, buf: &mut Buffer, list_state: &ListState) { + Clear.render(area, buf); + + let block = Block::bordered() + .title(" Create Event - Select Tag ") + .title_alignment(Alignment::Center) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)) + .bg(Color::Reset); + + let inner_area = block.inner(area); + block.render(area, buf); + + if self.tags.is_empty() { + let no_tags_msg = Paragraph::new("No tags available. Create tags first!") + .style(Style::default().fg(Color::Red)) + .alignment(Alignment::Center); + + let msg_area = Rect { + x: inner_area.x, + y: inner_area.y + inner_area.height / 2, + width: inner_area.width, + height: 1, + }; + no_tags_msg.render(msg_area, buf); + } else { + let tag_items: Vec = self + .tags + .iter() + .map(|(_, name)| ListItem::new(name.as_str())) + .collect(); + + let tags_list = List::new(tag_items) + .block( + Block::bordered() + .title("Available Tags") + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::White)), + ) + .highlight_style( + Style::default() + .bg(Color::Blue) + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("→ "); + + let list_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: inner_area.height.saturating_sub(2), + }; + + ratatui::widgets::StatefulWidget::render( + tags_list, + list_area, + buf, + &mut list_state.clone(), + ); + } + + let instructions = if self.tags.is_empty() { + Paragraph::new("Esc to cancel") + } else { + Paragraph::new("↑↓ to navigate, Enter to select tag, Esc to cancel") + }; + + let instructions_area = Rect { + x: inner_area.x, + y: inner_area.y + inner_area.height.saturating_sub(1), + width: inner_area.width, + height: 1, + }; + instructions.render(instructions_area, buf); + } + + fn render_description_input( + &self, + area: Rect, + buf: &mut Buffer, + tag_id: u64, + text_area: &TextArea, + ) { + Clear.render(area, buf); + + let tag_name = self + .tags + .iter() + .find(|(id, _)| *id == tag_id) + .map(|(_, name)| name.as_str()) + .unwrap_or("Unknown Tag"); + + let block = Block::bordered() + .title(format!(" Create Event - Tag: {} ", tag_name)) + .title_alignment(Alignment::Center) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)) + .bg(Color::Reset); + + let inner_area = block.inner(area); + block.render(area, buf); + + let layout = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(inner_area); + + let label = Paragraph::new("Event Description:").style(Style::default().fg(Color::White)); + label.render(layout[0], buf); + + text_area.render(layout[1], buf); + + let instructions = + Paragraph::new("Enter description and press Ctrl+X to save, Esc to go back") + .style(Style::default().fg(Color::Gray)); + instructions.render(layout[2], buf); + } +} + +impl EventAdding<'_> { + fn handle_tag_selection_input( + &mut self, + key_event: KeyEvent, + _db: &mut DB, + ) -> Option> { + if let EventAddingState::SelectingTag(list_state) = &mut self.state { + match key_event.code { + KeyCode::Up => { + if !self.tags.is_empty() { + let selected = list_state.selected().unwrap_or(0); + let new_index = if selected == 0 { + self.tags.len() - 1 + } else { + selected - 1 + }; + list_state.select(Some(new_index)); + } + None + } + KeyCode::Down => { + if !self.tags.is_empty() { + let selected = list_state.selected().unwrap_or(0); + let new_index = (selected + 1) % self.tags.len(); + list_state.select(Some(new_index)); + } + None + } + KeyCode::Enter => { + if !self.tags.is_empty() + && let Some(selected) = list_state.selected() + && let Some((tag_id, _)) = self.tags.get(selected) + { + let mut text_area = TextArea::default(); + text_area.set_block( + Block::bordered() + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Green)), + ); + self.state = + EventAddingState::WritingDescription(*tag_id, Box::new(text_area)); + } + None + } + _ => None, + } + } else { + None + } + } + + fn handle_description_input( + &mut self, + key_event: KeyEvent, + db: &mut DB, + ) -> Option> { + if let EventAddingState::WritingDescription(tag_id, text_area) = &mut self.state { + match key_event.code { + KeyCode::Char('x') + if key_event + .modifiers + .contains(ratatui::crossterm::event::KeyModifiers::CONTROL) => + { + let description = text_area.lines().join("\n").trim().to_string(); + if !description.is_empty() { + let mut cur_day = self.start; + + while cur_day <= self.end { + db.add_event(cur_day, Event::new(Some(*tag_id), description.clone())) + .expect("Database error"); + cur_day = cur_day.checked_add_days(chrono::Days::new(1)).unwrap(); + } + + Some(vec![ + AppEvent::SwitchFocus(FocusedComponent::Days), + AppEvent::Reload, + ]) + } else { + None + } + } + _ => { + text_area.input(key_event); + None + } + } + } else { + None + } + } +} diff --git a/src/popup/mod.rs b/src/popup/mod.rs new file mode 100644 index 0000000..e3609d1 --- /dev/null +++ b/src/popup/mod.rs @@ -0,0 +1,91 @@ +mod day_details; +mod event_adding; +mod tag_management; + +use crate::day_info::Event; +use crate::{database::DB, events::AppEvent, focused::Focused}; +use chrono::NaiveDate; +use ratatui::crossterm::event::KeyEvent; +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; + +pub use day_details::DayDetails; +pub use event_adding::EventAdding; +pub use tag_management::TagManagement; + +pub enum PopupType<'a> { + TagManagement(TagManagement<'a>), + DayDetails(DayDetails), + EventAdding(EventAdding<'a>), +} + +impl Default for PopupType<'_> { + fn default() -> Self { + PopupType::TagManagement(TagManagement::default()) + } +} + +#[derive(Default)] +pub struct Popup<'a> { + pub self_state: PopupType<'a>, + pub focused: bool, +} + +impl Popup<'_> { + pub fn ready_to_render(&mut self, area: Rect) { + match &mut self.self_state { + PopupType::TagManagement(tm) => tm.ready_to_render(area), + PopupType::DayDetails(dd) => dd.ready_to_render(area), + PopupType::EventAdding(ea) => ea.ready_to_render(area), + } + } + + pub fn add_event(&mut self, start: NaiveDate, end: NaiveDate, tags: Vec<(u64, String)>) { + self.self_state = PopupType::EventAdding(EventAdding::add_event(start, end, tags)); + } + + pub fn show_day(&mut self, day: NaiveDate, events: Vec, tag_names: Vec) { + self.self_state = PopupType::DayDetails(DayDetails::show_day(day, events, tag_names)); + } +} + +impl Focused for Popup<'_> { + fn take_focus(&mut self) { + self.focused = true; + match &mut self.self_state { + PopupType::TagManagement(tm) => tm.take_focus(), + PopupType::DayDetails(dd) => dd.take_focus(), + PopupType::EventAdding(ea) => ea.take_focus(), + } + } + + fn lose_focus(&mut self) { + self.focused = false; + match &mut self.self_state { + PopupType::TagManagement(tm) => tm.lose_focus(), + PopupType::DayDetails(dd) => dd.lose_focus(), + PopupType::EventAdding(ea) => ea.lose_focus(), + } + } + + fn handle_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + match &mut self.self_state { + PopupType::TagManagement(tm) => tm.handle_input(key_event, db), + PopupType::DayDetails(dd) => dd.handle_input(key_event, db), + PopupType::EventAdding(ea) => ea.handle_input(key_event, db), + } + } +} + +impl Widget for &Popup<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + if !self.focused { + return; + } + + match &self.self_state { + PopupType::TagManagement(tm) => tm.render(area, buf, self.focused), + PopupType::DayDetails(dd) => dd.render(area, buf, self.focused), + PopupType::EventAdding(ea) => ea.render(area, buf, self.focused), + } + } +} diff --git a/src/popup/tag_management.rs b/src/popup/tag_management.rs new file mode 100644 index 0000000..a418192 --- /dev/null +++ b/src/popup/tag_management.rs @@ -0,0 +1,340 @@ +use crate::{app::FocusedComponent, database::DB, events::AppEvent, focused::Focused}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Alignment, Constraint, Layout, Margin}; +use ratatui::prelude::Color; +use ratatui::style::{Modifier, Style, Stylize}; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Clear, Padding, Paragraph}; +use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; +use std::boxed::Box; +use tui_textarea::TextArea; + +pub enum TagManagementState<'a> { + Undecided(bool), + New(Box>), + Delete(usize, bool, bool), //index_of_deleted_tag, needs_confirmation, choosing_yes +} + +impl Default for TagManagementState<'_> { + fn default() -> Self { + TagManagementState::Undecided(false) + } +} + +#[derive(Default)] +pub struct TagManagement<'a> { + state: TagManagementState<'a>, + tags: Vec<(u64, String)>, +} + +impl Focused for TagManagement<'_> { + fn take_focus(&mut self) {} + + fn lose_focus(&mut self) {} + + fn handle_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + match key_event.code { + KeyCode::Esc => Some(vec![AppEvent::SwitchFocus(FocusedComponent::Days)]), + _ => match &self.state { + TagManagementState::Undecided(_) => self.handle_choice_input(key_event, db), + TagManagementState::New(_) => self.handle_new_tag_input(key_event, db), + TagManagementState::Delete(_, _, _) => self.handle_delete_tag_input(key_event, db), + }, + } + } +} + +impl TagManagement<'_> { + pub fn ready_to_render(&mut self, _area: Rect) {} + + pub fn render(&self, area: Rect, buf: &mut Buffer, _focused: bool) { + match &self.state { + TagManagementState::Undecided(choice) => self.render_choice_dialog(area, buf, *choice), + TagManagementState::New(text_area) => self.render_new_tag_dialog(area, buf, text_area), + TagManagementState::Delete(index_of_selected_tag, confirm, choosing_yes) => { + if !*confirm { + self.render_delete_tag_dialog(area, buf, *index_of_selected_tag) + } else { + self.render_confirm(area, buf, *index_of_selected_tag, *choosing_yes) + } + } + } + } + + fn render_confirm(&self, area: Rect, buf: &mut Buffer, index: usize, choosing_yes: bool) { + Clear.render(area, buf); + + let block = Block::bordered() + .title(" Manage Tags ") + .title_alignment(Alignment::Center) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)) + .bg(Color::Reset); + + let inner_area = block.inner(area); + block.render(area, buf); + + let layout = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + let new_button = + Paragraph::new("Delete") + .style(Style::default().fg(if choosing_yes { + Color::Green + } else { + Color::White + })) + .alignment(Alignment::Center) + .block(Block::bordered().border_set(border::ROUNDED).border_style( + if choosing_yes { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }, + )); + + let delete_button = + Paragraph::new("Cancel deletion") + .style(Style::default().fg(if !choosing_yes { + Color::Red + } else { + Color::White + })) + .alignment(Alignment::Center) + .block(Block::bordered().border_set(border::ROUNDED).border_style( + if !choosing_yes { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::White) + }, + )); + + new_button.render(layout[0].inner(Margin::new(1, 2)), buf); + delete_button.render(layout[1].inner(Margin::new(1, 2)), buf); + let instructions = Paragraph::new(format!( + "Do you really want to delete tag: {}", + self.tags.get(index).expect("User selected invalid tag").1 + )); + + let instructions_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: inner_area.width, + height: 1, + }; + instructions.render(instructions_area, buf); + } + + fn render_choice_dialog(&self, area: Rect, buf: &mut Buffer, choice: bool) { + Clear.render(area, buf); + + let block = Block::bordered() + .title(" Manage Tags ") + .title_alignment(Alignment::Center) + .border_set(border::ROUNDED) + .border_style(Style::default().fg(Color::Blue)) + .padding(Padding::uniform(1)) + .bg(Color::Reset); + + let inner_area = block.inner(area); + block.render(area, buf); + + let layout = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + let new_button = Paragraph::new("New") + .style(Style::default().fg(if choice { Color::Green } else { Color::White })) + .alignment(Alignment::Center) + .block( + Block::bordered() + .border_set(border::ROUNDED) + .border_style(if choice { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }), + ); + + let delete_button = Paragraph::new("Delete") + .style(Style::default().fg(if !choice { Color::Red } else { Color::White })) + .alignment(Alignment::Center) + .block( + Block::bordered() + .border_set(border::ROUNDED) + .border_style(if !choice { + Style::default().fg(Color::Red) + } else { + Style::default().fg(Color::White) + }), + ); + + new_button.render(layout[0].inner(Margin::new(1, 2)), buf); + delete_button.render(layout[1].inner(Margin::new(1, 2)), buf); + } + + fn render_new_tag_dialog(&self, area: Rect, buf: &mut Buffer, text_area: &TextArea) { + Clear.render(area, buf); + + let block = Block::bordered() + .title("New Tag") + .border_set(border::ROUNDED) + .border_style(Style::new().fg(Color::Blue)); + + let inner_area = block.inner(area); + block.render(area, buf); + + text_area.render(inner_area, buf); + + let instructions = + Paragraph::new("Enter tag name and press Enter to confirm, Esc to cancel"); + let instructions_area = Rect { + x: inner_area.x, + y: inner_area.y + inner_area.height.saturating_sub(1), + width: inner_area.width, + height: 1, + }; + instructions.render(instructions_area, buf); + } + + fn render_delete_tag_dialog(&self, area: Rect, buf: &mut Buffer, index: usize) { + Clear.render(area, buf); + let block = Block::bordered() + .title("Delete Tag") + .border_set(border::ROUNDED) + .border_style(Style::new().fg(Color::Blue)); + let inner_area = block.inner(area); + block.render(area, buf); + + if !self.tags.is_empty() && index < self.tags.len() { + let current_tag = &self.tags[index].1; + let tag_display = format!("{} ({}/{})", current_tag, index + 1, self.tags.len()); + + let tag_paragraph = Paragraph::new(tag_display) + .style(Style::default().add_modifier(Modifier::REVERSED)) + .alignment(Alignment::Center); + + let tag_area = Rect { + x: inner_area.x, + y: inner_area.y + inner_area.height / 2, + width: inner_area.width, + height: 1, + }; + + tag_paragraph.render(tag_area, buf); + } + + let instructions = + Paragraph::new("Navigate with Left/Right, Enter to confirm, Esc to cancel") + .alignment(Alignment::Center); + let instructions_area = Rect { + x: inner_area.x, + y: inner_area.y + inner_area.height.saturating_sub(1), + width: inner_area.width, + height: 1, + }; + instructions.render(instructions_area, buf); + } +} + +impl TagManagement<'_> { + fn handle_choice_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + if let TagManagementState::Undecided(choice) = &mut self.state { + match key_event.code { + KeyCode::Right => { + *choice = false; + None + } + KeyCode::Left => { + *choice = true; + None + } + KeyCode::Enter => { + if *choice { + self.state = TagManagementState::New(Box::new(TextArea::default())); + } else { + self.tags = db.get_tags().expect("Database error"); + self.state = TagManagementState::Delete(0, false, false); + } + None + } + _ => None, + } + } else { + None + } + } + + fn handle_new_tag_input(&mut self, key_event: KeyEvent, db: &mut DB) -> Option> { + if let TagManagementState::New(text_area) = &mut self.state { + match key_event.code { + KeyCode::Enter => { + let tag_name = text_area.lines()[0].trim(); + if !tag_name.is_empty() { + db.create_tag(tag_name.to_string()).expect("Database error"); + self.state = TagManagementState::Undecided(false); + } + + None + } + _ => { + text_area.input(key_event); + None + } + } + } else { + None + } + } + + fn handle_delete_tag_input( + &mut self, + key_event: KeyEvent, + db: &mut DB, + ) -> Option> { + if let TagManagementState::Delete(index, confirm, choosing_yes) = &mut self.state { + match key_event.code { + KeyCode::Right => { + if *confirm { + *choosing_yes = false; + } else { + *index = if self.tags.is_empty() { + 0 + } else { + (*index + 1) % self.tags.len() + }; + } + None + } + KeyCode::Left => { + if *confirm { + *choosing_yes = true; + } else { + *index = if *index == 0 { + self.tags.len().saturating_sub(1) + } else { + *index - 1 + }; + } + None + } + KeyCode::Enter => { + if *confirm { + if *choosing_yes && let Some(&(tag_id, _)) = self.tags.get(*index) { + db.delete_tag(tag_id).expect("Database error"); + self.state = TagManagementState::Undecided(false); + } else if !*choosing_yes { + self.state = TagManagementState::Delete(*index, false, false) + } + } else { + self.state = TagManagementState::Delete(*index, true, false) + } + None + } + _ => None, + } + } else { + None + } + } +} diff --git a/src/year.rs b/src/year.rs index 6016930..bd20914 100644 --- a/src/year.rs +++ b/src/year.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ buffer::Buffer, layout::Rect,