use ratatui::{
buffer::Buffer,
crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
symbols::border,
text::{Line, Text},
widgets::{Block, Clear, Paragraph, Widget},
DefaultTerminal, Frame,
};
use serde::{Deserialize, Serialize};
use std::io;
use std::path::Path;
use tui_input::Input;
const DATA_FILE: &str = "transactions.json";
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputField {
Title,
Amount,
Category,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
title: String,
amount: f64,
category: String,
}
impl Default for InputField {
fn default() -> Self {
InputField::Title
}
}
pub struct App {
title_input: Input,
amount_input: Input,
category_input: Input,
active_field: InputField,
transactions: Vec<Transaction>,
show_transactions: bool,
exit: bool,
}
impl Default for App {
fn default() -> Self {
let transactions = Self::load_transactions();
Self {
title_input: Input::default(),
amount_input: Input::default(),
category_input: Input::default(),
active_field: InputField::default(),
transactions,
show_transactions: false,
exit: false,
}
}
}
impl App {
fn load_transactions() -> Vec<Transaction> {
if Path::new(DATA_FILE).exists() {
match std::fs::read_to_string(DATA_FILE) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => Vec::new(),
}
} else {
Vec::new()
}
}
fn save_transactions(&self) -> io::Result<()> {
let json = serde_json::to_string_pretty(&self.transactions)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
std::fs::write(DATA_FILE, json)
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
fn handle_events(&mut self) -> io::Result<()> {
if let Event::Key(key) = ratatui::crossterm::event::read()? {
if key.kind == KeyEventKind::Press {
if self.show_transactions {
match key.code {
KeyCode::Esc => self.show_transactions = false,
_ => {}
}
} else {
match key.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Tab => self.next_field(),
KeyCode::BackTab => self.prev_field(),
KeyCode::Enter => self.save_transaction(),
KeyCode::Char('l') => self.show_transactions = true,
_ => self.handle_input(key),
}
}
}
}
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) {
use tui_input::backend::crossterm::EventHandler;
match self.active_field {
InputField::Title => {
self.title_input.handle_event(&Event::Key(key));
}
InputField::Amount => {
self.amount_input.handle_event(&Event::Key(key));
}
InputField::Category => {
self.category_input.handle_event(&Event::Key(key));
}
};
}
fn next_field(&mut self) {
self.active_field = match self.active_field {
InputField::Title => InputField::Amount,
InputField::Amount => InputField::Category,
InputField::Category => InputField::Title,
};
}
fn prev_field(&mut self) {
self.active_field = match self.active_field {
InputField::Title => InputField::Category,
InputField::Amount => InputField::Title,
InputField::Category => InputField::Amount,
};
}
fn save_transaction(&mut self) {
let title = self.title_input.value().to_string();
let amount_str = self.amount_input.value();
let category = self.category_input.value().to_string();
if title.is_empty() || amount_str.is_empty() {
return;
}
if let Ok(amount) = amount_str.parse::<f64>() {
let transaction = Transaction {
title: title.clone(),
amount,
category,
};
self.transactions.push(transaction);
let _ = self.save_transactions();
self.title_input = Input::default();
self.amount_input = Input::default();
self.category_input = Input::default();
self.active_field = InputField::Title;
}
}
fn exit(&mut self) {
self.exit = true;
}
fn get_input_widget(&self, field: InputField) -> Paragraph<'_> {
let is_active = self.active_field == field;
let (label, input) = match field {
InputField::Title => ("Title", &self.title_input),
InputField::Amount => ("Amount", &self.amount_input),
InputField::Category => ("Category", &self.category_input),
};
let border_color = if is_active {
Color::Yellow
} else {
Color::Gray
};
let title_style = if is_active {
Style::default().fg(Color::Yellow).bold()
} else {
Style::default().fg(Color::White)
};
Paragraph::new(input.value())
.block(
Block::bordered()
.title(label)
.title_style(title_style)
.border_style(Style::default().fg(border_color)),
)
.style(Style::default().fg(Color::White))
}
fn render_transactions(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::bordered()
.title("Transactions")
.title_alignment(Alignment::Center)
.border_style(Style::default().fg(Color::Cyan));
let inner_area = block.inner(area);
block.render(area, buf);
if self.transactions.is_empty() {
Paragraph::new("No transactions yet. Press Esc to close.")
.alignment(Alignment::Center)
.render(inner_area, buf);
return;
}
let mut lines: Vec<Line> = vec![
Line::from(vec![
"Title".bold(),
" | ".into(),
"Amount".bold(),
" | ".into(),
"Category".bold(),
]),
Line::from("-".repeat(50)),
];
for transaction in &self.transactions {
let amount_str = format!("₹{:.2}", transaction.amount);
lines.push(Line::from(vec![
transaction.title.clone().into(),
" | ".into(),
amount_str.yellow(),
" | ".into(),
transaction.category.clone().green(),
]));
}
lines.push(Line::from(""));
lines.push(Line::from("Press Esc to close".gray()));
Paragraph::new(Text::from(lines)).render(inner_area, buf);
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.show_transactions {
let popup_area = centered_rect(80, 80, area);
self.render_transactions(popup_area, buf);
return;
}
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(12),
Constraint::Min(0),
])
.split(area);
let title = Line::from("Financer".bold().fg(Color::Red));
let header = Paragraph::new(title).alignment(Alignment::Center);
header.render(main_layout[0], buf);
let form_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
])
.margin(1)
.split(main_layout[1]);
let block = Block::bordered()
.title("Transaction Entry")
.title_alignment(Alignment::Center)
.border_set(border::HEAVY_DOUBLE_DASHED)
.border_style(Style::default().fg(Color::Green))
.style(Style::default().bg(Color::Black));
block.render(main_layout[1], buf);
self.get_input_widget(InputField::Title)
.render(form_layout[0], buf);
self.get_input_widget(InputField::Amount)
.render(form_layout[1], buf);
self.get_input_widget(InputField::Category)
.render(form_layout[2], buf);
let instructions = Line::from(vec![
" Enter ".into(),
"Save".green().bold(),
" Tab ".into(),
"Next".blue().bold(),
" L ".into(),
"List".cyan().bold(),
" Q ".into(),
"Quit".red().bold(),
]);
Paragraph::new(instructions)
.alignment(Alignment::Center)
.render(main_layout[2], buf);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}