This commit is contained in:
maxstrb 2025-09-24 22:24:50 +02:00
parent 234b17a8b0
commit 3681aa2a22
15 changed files with 1109 additions and 143 deletions

View file

@ -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);
}
}
}
}

View file

@ -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<Option<DayInfo>, Error> {
pub fn get_day(&self, day: NaiveDate) -> Result<Option<Vec<Event>>, 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),

View file

@ -1,8 +1,3 @@
#[derive(Default, Clone)]
pub struct DayInfo {
pub events: Vec<Event>,
}
#[derive(Default, Clone)]
pub struct Event {
pub tag: Option<u64>,
@ -14,9 +9,3 @@ impl Event {
Self { tag, description }
}
}
impl DayInfo {
pub fn new(events: Vec<Event>) -> Self {
Self { events }
}
}

View file

@ -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<NaiveDate>,
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<Vec<AppEvent>> {
@ -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<usize>,
}
impl Day {
pub fn new(day: NaiveDate) -> Self {
Self {
day,
info: DayInfo::default(),
Self { day, info: None }
}
}
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<NaiveDate>,
date_of_first_day: i32,
) -> Line<'_> {
Line::styled(
format!(
fn get_line(&self, current_day: NaiveDate, pivot: Option<NaiveDate>, 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()
),
Style::new().bg(if Days::is_date_between(current_day, pivot, self.day) {
);
let bg_color = 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
}),
)
};
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()));
}
}
Line::from(spans).style(Style::new().bg(bg_color))
}
}

View file

@ -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,
}

View file

@ -1,4 +1,4 @@
use crossterm::event::KeyEvent;
use ratatui::crossterm::event::KeyEvent;
use crate::database::DB;
use crate::events::AppEvent;

View file

@ -6,7 +6,6 @@ mod day_info;
mod days;
mod events;
mod focused;
mod global_shortcuts;
mod joiner;
mod month;
mod popup;

View file

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,

View file

@ -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<Vec<AppEvent>> {
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);
}
}

225
src/popup/day_details.rs Normal file
View file

@ -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<Event>,
tag_names: Vec<String>,
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<Event>, tag_names: Vec<String>) -> 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<Vec<AppEvent>> {
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);
}
}
}
}

301
src/popup/event_adding.rs Normal file
View file

@ -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<TextArea<'a>>), // 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<Vec<AppEvent>> {
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<ListItem> = 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<Vec<AppEvent>> {
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<Vec<AppEvent>> {
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
}
}
}

91
src/popup/mod.rs Normal file
View file

@ -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<Event>, tag_names: Vec<String>) {
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<Vec<AppEvent>> {
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),
}
}
}

340
src/popup/tag_management.rs Normal file
View file

@ -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<TextArea<'a>>),
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<Vec<AppEvent>> {
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<Vec<AppEvent>> {
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<Vec<AppEvent>> {
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<Vec<AppEvent>> {
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
}
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,