tui done
This commit is contained in:
parent
234b17a8b0
commit
3681aa2a22
15 changed files with 1109 additions and 143 deletions
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue