tui done
This commit is contained in:
parent
234b17a8b0
commit
3681aa2a22
15 changed files with 1109 additions and 143 deletions
49
src/app.rs
49
src/app.rs
|
|
@ -1,9 +1,9 @@
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use chrono::{Datelike, Local};
|
use chrono::{Datelike, Local};
|
||||||
use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
|
crossterm::event::{self, KeyEvent, KeyEventKind},
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -20,17 +20,17 @@ pub enum FocusedComponent {
|
||||||
DayPopup,
|
DayPopup,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App<'a> {
|
||||||
year: Year,
|
year: Year,
|
||||||
month: Month,
|
month: Month,
|
||||||
days: Days,
|
days: Days,
|
||||||
day_popup: Popup,
|
day_popup: Popup<'a>,
|
||||||
|
|
||||||
focused: FocusedComponent,
|
focused: FocusedComponent,
|
||||||
exit: bool,
|
exit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App<'_> {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
year: Year::default(),
|
year: Year::default(),
|
||||||
|
|
@ -38,7 +38,7 @@ impl App {
|
||||||
days: Days::default(),
|
days: Days::default(),
|
||||||
day_popup: Popup::default(),
|
day_popup: Popup::default(),
|
||||||
|
|
||||||
focused: FocusedComponent::DayPopup,
|
focused: FocusedComponent::Days,
|
||||||
exit: false,
|
exit: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,13 +75,13 @@ impl App {
|
||||||
let mut db = DB::new().unwrap();
|
let mut db = DB::new().unwrap();
|
||||||
|
|
||||||
while !self.exit {
|
while !self.exit {
|
||||||
terminal.draw(|frame| self.draw(frame))?;
|
terminal.draw(|frame| self.draw(frame, &mut db))?;
|
||||||
self.handle_events(&mut db)?;
|
self.handle_events(&mut db)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&mut self, frame: &mut Frame) {
|
fn draw(&mut self, frame: &mut Frame, db: &mut DB) {
|
||||||
let main_area = Layout::default()
|
let main_area = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(vec![
|
.constraints(vec![
|
||||||
|
|
@ -100,7 +100,7 @@ impl App {
|
||||||
|
|
||||||
self.year.ready_to_render(main_area[0]);
|
self.year.ready_to_render(main_area[0]);
|
||||||
self.month.ready_to_render(main_area[1]);
|
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);
|
self.day_popup.ready_to_render(popup_area);
|
||||||
|
|
||||||
frame.render_widget(&self.year, main_area[0]);
|
frame.render_widget(&self.year, main_area[0]);
|
||||||
|
|
@ -136,7 +136,9 @@ impl App {
|
||||||
|
|
||||||
fn handle_events(&mut self, db: &mut DB) -> io::Result<()> {
|
fn handle_events(&mut self, db: &mut DB) -> io::Result<()> {
|
||||||
match event::read()? {
|
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);
|
let app_event_option = self.handle_input(key_event, db);
|
||||||
|
|
||||||
if let Some(app_event_vec) = app_event_option {
|
if let Some(app_event_vec) = app_event_option {
|
||||||
|
|
@ -160,13 +162,40 @@ impl App {
|
||||||
self.month.minus_month();
|
self.month.minus_month();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
AppEvent::Reload => {
|
||||||
|
self.days.reload_day_counts(db);
|
||||||
|
}
|
||||||
AppEvent::MonthSet(month) => {
|
AppEvent::MonthSet(month) => {
|
||||||
self.days.reload(month, self.year.year, 1);
|
self.days.reload(month, self.year.year, 1);
|
||||||
}
|
}
|
||||||
AppEvent::YearSet(year) => {
|
AppEvent::YearSet(year) => {
|
||||||
self.days.reload(self.month.month, year, 1);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::hash::{Hash, Hasher};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use std::{env::home_dir, fs};
|
use std::{env::home_dir, fs};
|
||||||
|
|
||||||
use crate::day_info::{DayInfo, Event};
|
use crate::day_info::Event;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use redb::{Database, Error, ReadableDatabase, ReadableTable, TableDefinition};
|
use redb::{Database, Error, ReadableDatabase, ReadableTable, TableDefinition};
|
||||||
|
|
||||||
|
|
@ -36,16 +36,16 @@ impl DB {
|
||||||
Ok(Self { 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)? {
|
match self.db.begin_read()?.open_table(MAIN_TABLE)?.get(day)? {
|
||||||
None => Ok(None),
|
None => Ok(None),
|
||||||
Some(data) => {
|
Some(data) => {
|
||||||
let events = data.value();
|
let events = data.value();
|
||||||
let mut output = DayInfo::default();
|
let mut output = vec![];
|
||||||
|
|
||||||
for (tag_id, event_description) in events {
|
for (tag_id, event_description) in events {
|
||||||
let tag_name = self.db.begin_read()?.open_table(TAG_TABLE)?.get(tag_id)?;
|
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),
|
Some(_name) => Event::new(Some(tag_id), event_description),
|
||||||
|
|
||||||
None => Event::new(None, event_description),
|
None => Event::new(None, event_description),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
#[derive(Default, Clone)]
|
|
||||||
pub struct DayInfo {
|
|
||||||
pub events: Vec<Event>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct Event {
|
pub struct Event {
|
||||||
pub tag: Option<u64>,
|
pub tag: Option<u64>,
|
||||||
|
|
@ -14,9 +9,3 @@ impl Event {
|
||||||
Self { tag, description }
|
Self { tag, description }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DayInfo {
|
|
||||||
pub fn new(events: Vec<Event>) -> Self {
|
|
||||||
Self { events }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
144
src/days.rs
144
src/days.rs
|
|
@ -1,8 +1,7 @@
|
||||||
use crate::{
|
use crate::{app::FocusedComponent, database::DB, events::AppEvent, focused::Focused};
|
||||||
app::FocusedComponent, database::DB, day_info::DayInfo, events::AppEvent, focused::Focused,
|
use chrono::{Datelike, Duration, NaiveDate, Weekday};
|
||||||
};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
use chrono::{Datelike, Duration, NaiveDate};
|
use ratatui::text::Span;
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::{Constraint, Layout, Margin, Rect},
|
layout::{Constraint, Layout, Margin, Rect},
|
||||||
|
|
@ -23,8 +22,6 @@ pub struct Days {
|
||||||
selected_day: NaiveDate,
|
selected_day: NaiveDate,
|
||||||
pivot: Option<NaiveDate>,
|
pivot: Option<NaiveDate>,
|
||||||
|
|
||||||
date_of_first_day: i32,
|
|
||||||
|
|
||||||
current_month: i32,
|
current_month: i32,
|
||||||
current_year: i32,
|
current_year: i32,
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +39,7 @@ enum CursorMovement {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Days {
|
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 view_rect = area.inner(Margin::new(1, 1));
|
||||||
let number_of_days_shown = view_rect.height;
|
let number_of_days_shown = view_rect.height;
|
||||||
|
|
||||||
|
|
@ -89,6 +86,28 @@ impl Days {
|
||||||
}
|
}
|
||||||
self.days.push_front(next_day);
|
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) {
|
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(),
|
NaiveDate::from_ymd_opt(year, month as u32, day as u32).unwrap(),
|
||||||
));
|
));
|
||||||
self.selected_day = self.days.iter().next().unwrap().day;
|
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>> {
|
fn handle_main_state_input(&mut self, key_code: KeyCode) -> Option<Vec<AppEvent>> {
|
||||||
|
|
@ -186,7 +204,17 @@ impl Days {
|
||||||
self.state = DaysState::Main;
|
self.state = DaysState::Main;
|
||||||
None
|
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::Down => self.handle_focused_arrows(CursorMovement::Down),
|
||||||
KeyCode::Up => self.handle_focused_arrows(CursorMovement::Up),
|
KeyCode::Up => self.handle_focused_arrows(CursorMovement::Up),
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
|
|
@ -241,7 +269,7 @@ impl Widget for &Days {
|
||||||
.split(inner_area);
|
.split(inner_area);
|
||||||
|
|
||||||
for (day, area) in self.days.iter().zip(day_areas.iter()) {
|
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);
|
.render(*area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -253,43 +281,71 @@ impl Widget for &Days {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Day {
|
pub struct Day {
|
||||||
pub day: NaiveDate,
|
pub day: NaiveDate,
|
||||||
pub info: DayInfo,
|
pub info: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Day {
|
impl Day {
|
||||||
pub fn new(day: NaiveDate) -> Self {
|
pub fn new(day: NaiveDate) -> Self {
|
||||||
Self {
|
Self { day, info: None }
|
||||||
day,
|
}
|
||||||
info: DayInfo::default(),
|
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()
|
||||||
|
);
|
||||||
|
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 {
|
Line::from(spans).style(Style::new().bg(bg_color))
|
||||||
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!(
|
|
||||||
"{}, {}, {}",
|
|
||||||
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
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use crate::app::FocusedComponent;
|
use crate::app::FocusedComponent;
|
||||||
use crate::day_info::DayInfo;
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
SwitchFocus(FocusedComponent),
|
SwitchFocus(FocusedComponent),
|
||||||
DaySelected(DayInfo),
|
DaySelected(NaiveDate),
|
||||||
|
|
||||||
YearSet(i32),
|
YearSet(i32),
|
||||||
MonthSet(i32),
|
MonthSet(i32),
|
||||||
|
|
@ -11,6 +11,10 @@ pub enum AppEvent {
|
||||||
YearScrolled(Direction),
|
YearScrolled(Direction),
|
||||||
MonthScrolled(Direction),
|
MonthScrolled(Direction),
|
||||||
|
|
||||||
|
Reload,
|
||||||
|
|
||||||
|
AddEvent(NaiveDate, NaiveDate),
|
||||||
|
|
||||||
Exit,
|
Exit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crossterm::event::KeyEvent;
|
use ratatui::crossterm::event::KeyEvent;
|
||||||
|
|
||||||
use crate::database::DB;
|
use crate::database::DB;
|
||||||
use crate::events::AppEvent;
|
use crate::events::AppEvent;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ mod day_info;
|
||||||
mod days;
|
mod days;
|
||||||
mod events;
|
mod events;
|
||||||
mod focused;
|
mod focused;
|
||||||
mod global_shortcuts;
|
|
||||||
mod joiner;
|
mod joiner;
|
||||||
mod month;
|
mod month;
|
||||||
mod popup;
|
mod popup;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
|
|
|
||||||
68
src/popup.rs
68
src/popup.rs
|
|
@ -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
225
src/popup/day_details.rs
Normal 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
301
src/popup/event_adding.rs
Normal 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
91
src/popup/mod.rs
Normal 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
340
src/popup/tag_management.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue