use crate::client::MemosClient;
use crate::config::Config;
use crate::models::Memo;
use anyhow::Result;
use chrono::Local;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Clear, List, ListDirection, ListItem, ListState, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState,
};
use ratatui::Frame;
use std::sync::Arc;
use tokio::sync::Mutex;
pub fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
}
let mut lines = Vec::new();
for line in text.lines() {
if line.is_empty() {
lines.push(String::new());
continue;
}
let mut current_line = String::new();
let mut current_width = 0;
for ch in line.chars() {
let char_width = if ch.is_ascii() { 1 } else { 2 };
if current_width + char_width > width {
if !current_line.is_empty() {
lines.push(current_line.clone());
}
current_line = ch.to_string();
current_width = char_width;
} else {
current_line.push(ch);
current_width += char_width;
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
MainMenu,
Config,
CreateMemo,
ListMemos,
ViewMemo,
SelectVisibility,
Help,
}
pub struct App {
pub mode: AppMode,
pub config: Config,
pub memos: Vec<Memo>,
pub selected_memo: Option<Memo>,
pub input_content: String,
pub input_visibility: String,
pub input_base_url: String,
pub input_token: String,
pub current_field: usize,
pub message: Option<String>,
pub loading: bool,
client: Arc<Mutex<Option<MemosClient>>>,
pub list_selected: Option<usize>,
pub list_state: ListState,
pub visibility_list_state: ListState,
pub content_scroll: usize,
pub help_scroll: usize,
pub memo_content_scroll: usize,
pub memo_scrollbar_state: ScrollbarState,
pub scrollbar_state: ScrollbarState,
pub is_editing: bool,
pub current_page: usize,
pub items_per_page: usize,
}
impl App {
pub fn new(config: Config) -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
let mut visibility_list_state = ListState::default();
visibility_list_state.select(Some(0));
Self {
mode: AppMode::MainMenu,
config,
memos: Vec::new(),
selected_memo: None,
input_content: String::new(),
input_visibility: "PRIVATE".to_string(),
input_base_url: String::new(),
input_token: String::new(),
current_field: 0,
message: None,
loading: false,
client: Arc::new(Mutex::new(None)),
list_selected: None,
list_state,
visibility_list_state,
content_scroll: 0usize,
help_scroll: 0usize,
memo_content_scroll: 0usize,
memo_scrollbar_state: ScrollbarState::default(),
scrollbar_state: ScrollbarState::default(),
is_editing: false,
current_page: 0,
items_per_page: 10,
}
}
pub async fn init_client(&self) -> Result<()> {
if self.config.is_configured() {
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
let mut guard = self.client.lock().await;
*guard = Some(client);
}
Ok(())
}
pub async fn refresh_client(&mut self) -> Result<()> {
if self.config.is_configured() {
let client = MemosClient::new(&self.config.base_url, &self.config.user_token)?;
let mut guard = self.client.lock().await;
*guard = Some(client);
}
Ok(())
}
pub async fn get_client(&self) -> Result<MemosClient> {
let guard = self.client.lock().await;
guard
.clone()
.ok_or_else(|| anyhow::anyhow!("Client not initialized"))
}
}
pub fn render_app(frame: &mut Frame, app: &mut App) {
match app.mode {
AppMode::MainMenu => render_main_menu(frame, app),
AppMode::Config => render_config(frame, app),
AppMode::CreateMemo => render_create_memo(frame, app),
AppMode::ListMemos => render_list_memos(frame, app),
AppMode::ViewMemo => render_view_memo(frame, app),
AppMode::SelectVisibility => render_select_visibility(frame, app),
AppMode::Help => render_help(frame, app),
}
if let Some(msg) = &app.message {
let y = frame.area().height.saturating_sub(3);
let height = 2;
if y + height <= frame.area().height {
let area = Rect::new(10, y, frame.area().width - 20, height);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::Yellow));
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let text = Paragraph::new(msg.as_str()).alignment(Alignment::Center);
frame.render_widget(text, area);
}
}
if app.loading {
let width = 20;
let height = 6;
let x = (frame.area().width / 2).saturating_sub(width / 2);
let y = (frame.area().height / 2).saturating_sub(height / 2);
if x + width <= frame.area().width && y + height <= frame.area().height {
let area = Rect::new(x, y, width, height);
let block = Block::default()
.borders(Borders::ALL)
.title("Loading...")
.title_alignment(Alignment::Center);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
}
}
}
fn render_main_menu(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(6), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(2), ])
.split(frame.area());
let art_title = vec![
Line::from("███╗ ███╗███████╗███╗ ███╗ ██████╗ ███████╗".to_string()),
Line::from("████╗ ████║██╔════╝████╗ ████║██╔═══██╗██╔════╝".to_string()),
Line::from("██╔████╔██║█████╗ ██╔████╔██║██║ ██║███████╗".to_string()),
Line::from("██║╚██╔╝██║██╔══╝ ██║╚██╔╝██║██║ ██║╚════██║".to_string()),
Line::from("██║ ╚═╝ ██║███████╗██║ ╚═╝ ██║╚██████╔╝███████║".to_string()),
Line::from("╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝".to_string()),
];
let title = Paragraph::new(art_title)
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center);
frame.render_widget(title, chunks[0]);
let version = Paragraph::new(format!("Version: {}", env!("CARGO_PKG_VERSION")))
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center);
frame.render_widget(version, chunks[1]);
let authors = env!("CARGO_PKG_AUTHORS");
let first_author = authors.split(';').next().unwrap_or(authors);
let author = Paragraph::new(format!("Author: {}", first_author))
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center);
frame.render_widget(author, chunks[2]);
let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let clock =
Paragraph::new(format!("🕒 {}", current_time)).style(Style::default().fg(Color::Yellow));
frame.render_widget(clock, chunks[3]);
let items = vec![
("1", "List Memos"),
("2", "Create Memo"),
("3", "Configuration"),
("Q", "Quit"),
];
let menu_items: Vec<ListItem> = items
.iter()
.map(|(key, desc)| {
let key_str = *key;
let desc_str = *desc;
ListItem::new(Line::from(vec![
Span::raw("["),
Span::raw(key_str).style(Style::default().fg(Color::Yellow).bold()),
Span::raw("] "),
Span::raw(desc_str),
]))
})
.collect();
let menu = List::new(menu_items)
.block(Block::default().borders(Borders::ALL).title("Menu"))
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Cyan).bold())
.direction(ListDirection::TopToBottom);
frame.render_stateful_widget(menu, chunks[4], &mut app.list_state);
let hint = Paragraph::new("↑↓: Navigate | Enter: Select | Q: Quit")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(hint, chunks[5]);
}
fn render_config(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Configuration")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Settings"));
frame.render_widget(title, chunks[0]);
let base_url_label = Paragraph::new("Base URL:")
.style(Style::default().fg(Color::White))
.alignment(Alignment::Right);
frame.render_widget(base_url_label, chunks[1]);
let base_url_input = Paragraph::new(app.input_base_url.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Base URL"));
frame.render_widget(base_url_input, chunks[1]);
let token_label = Paragraph::new("User Token:")
.style(Style::default().fg(Color::White))
.alignment(Alignment::Right);
frame.render_widget(token_label, chunks[2]);
let token_input = Paragraph::new(app.input_token.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Token"));
frame.render_widget(token_input, chunks[2]);
let help = Paragraph::new("Enter: Save | Esc: Cancel | Tab: Switch Field")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[5]);
}
fn render_create_memo(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new("Create Memo")
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(title, chunks[0]);
let visibility_label =
Paragraph::new("Visibility (PUBLIC/PRIVATE):").style(Style::default().fg(Color::White));
frame.render_widget(visibility_label, chunks[1]);
let visibility_input = Paragraph::new(app.input_visibility.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title("Visibility"));
frame.render_widget(visibility_input, chunks[1]);
let content_label = Paragraph::new("Content:").style(Style::default().fg(Color::White));
frame.render_widget(content_label, chunks[2]);
let content_title = if app.is_editing {
"Content [Editing]"
} else {
"Content"
};
let content = Paragraph::new(app.input_content.clone())
.style(Style::default().fg(Color::Yellow))
.block(Block::default().borders(Borders::ALL).title(content_title))
.scroll((app.content_scroll.try_into().unwrap(), 0));
frame.render_widget(content, chunks[2]);
let help = if app.is_editing {
Paragraph::new("Esc: Exit Edit Mode | Enter: Newline | Type to input")
.style(Style::default().fg(Color::Green).bold())
.alignment(Alignment::Center)
} else {
Paragraph::new("Enter: Submit | i: Edit Mode | v: Visibility | /: Help")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center)
};
frame.render_widget(help, chunks[3]);
}
fn render_list_memos(frame: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let total_items = app.memos.len();
let available_height = chunks[1].height.saturating_sub(2) as usize;
app.items_per_page = if available_height > 0 {
available_height
} else {
10
};
let total_pages = if total_items == 0 {
1
} else {
(total_items + app.items_per_page - 1) / app.items_per_page
};
if app.current_page >= total_pages {
app.current_page = total_pages.saturating_sub(1);
}
let start_idx = app.current_page * app.items_per_page;
let end_idx = std::cmp::min(start_idx + app.items_per_page, total_items);
let page_info = format!(
"Memos List - Page {}/{} ({} items)",
app.current_page + 1,
total_pages,
total_items
);
let title = Paragraph::new(page_info)
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Memos"));
frame.render_widget(title, chunks[0]);
if app.memos.is_empty() {
let empty_msg = Paragraph::new("No memos found")
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(empty_msg, chunks[1]);
} else {
let memos_list: Vec<ListItem> = app.memos[start_idx..end_idx]
.iter()
.enumerate()
.map(|(idx, memo)| {
let actual_idx = start_idx + idx;
let content_preview = if memo.content.chars().count() > 50 {
format!("{}...", memo.content.chars().take(50).collect::<String>())
} else {
memo.content.clone()
};
let created = chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let style = if app.list_selected == Some(actual_idx) {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::raw(format!("[{}] ", memo.id)),
Span::styled(content_preview, Style::default().fg(Color::White)),
Span::raw(" - ").style(Style::default().fg(Color::DarkGray)),
Span::raw(created).style(Style::default().fg(Color::Green)),
]))
.style(style)
})
.collect();
let list = List::new(memos_list).block(Block::default().borders(Borders::ALL));
frame.render_widget(list, chunks[1]);
}
let help = Paragraph::new("Enter: View | R: Refresh | ←→: Page | Esc: Back | ↑↓: Navigate")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[2]);
}
fn render_view_memo(frame: &mut Frame, app: &mut App) {
if let Some(memo) = &app.selected_memo {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let title = Paragraph::new(format!("Memo #{}", memo.id))
.style(Style::default().fg(Color::Cyan).bold())
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("View Memo"));
frame.render_widget(title, chunks[0]);
let meta = format!(
"Created: {} | Updated: {} | Visibility: {}",
chrono::DateTime::from_timestamp_millis(memo.created_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "Unknown".to_string()),
chrono::DateTime::from_timestamp_millis(memo.updated_ts)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "Unknown".to_string()),
memo.visibility
);
let meta_para = Paragraph::new(meta)
.style(Style::default().fg(Color::Green))
.alignment(Alignment::Center);
frame.render_widget(meta_para, chunks[1]);
let content_width = chunks[2].width.saturating_sub(2) as usize;
let wrapped_lines = wrap_text(&memo.content, content_width);
let total_lines = wrapped_lines.len();
let content_lines: Vec<Line> = wrapped_lines
.iter()
.map(|line| Line::from(line.clone()))
.collect();
let viewport_height = chunks[2].height.saturating_sub(2) as usize;
if app.memo_content_scroll >= total_lines {
app.memo_content_scroll = total_lines.saturating_sub(1);
}
let content = Paragraph::new(content_lines)
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL).title("Content"))
.scroll((app.memo_content_scroll as u16, 0));
frame.render_widget(content, chunks[2]);
let scrollbar_area = Rect::new(
chunks[2].x + chunks[2].width - 1,
chunks[2].y + 1,
1,
chunks[2].height.saturating_sub(2),
);
app.memo_scrollbar_state = ScrollbarState::default()
.position(app.memo_content_scroll)
.content_length(total_lines)
.viewport_content_length(viewport_height);
frame.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().bg(Color::Cyan))
.track_style(Style::default().bg(Color::DarkGray)),
scrollbar_area,
&mut app.memo_scrollbar_state,
);
let help = Paragraph::new("↑↓: Scroll | Esc: Back")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, chunks[3]);
}
}
fn render_select_visibility(frame: &mut Frame, app: &mut App) {
let width = 45;
let height = 12;
let x = (frame.area().width / 2).saturating_sub(width / 2);
let y = (frame.area().height / 2).saturating_sub(height / 2);
let area = Rect::new(x, y, width, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title("Select Visibility")
.title_alignment(Alignment::Center);
frame.render_widget(block, area);
let visibility_options = vec!["VISIBILITY_UNSPECIFIED", "PRIVATE", "PROTECTED", "PUBLIC"];
let list_items: Vec<ListItem> = visibility_options
.iter()
.map(|option| ListItem::new(*option))
.collect();
let list = List::new(list_items)
.block(Block::default())
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Cyan).bold())
.direction(ListDirection::TopToBottom);
let list_area = Rect::new(x + 2, y + 2, width - 4, height - 4);
frame.render_stateful_widget(list, list_area, &mut app.visibility_list_state);
let help = Paragraph::new("↑↓: Navigate | Enter: Select | Esc: Cancel")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
let help_area = Rect::new(x, y + height, width, 2);
frame.render_widget(help, help_area);
}
fn render_help(frame: &mut Frame, app: &mut App) {
let width = 60;
let height = 15;
let x = (frame.area().width / 2).saturating_sub(width / 2);
let y = (frame.area().height / 2).saturating_sub(height / 2);
let area = Rect::new(x, y, width, height);
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title("HELP")
.title_alignment(Alignment::Center);
frame.render_widget(block, area);
let help_lines = vec![
Line::from("Input Help:"),
Line::from(""),
Line::from("- Enter: Submit memo"),
Line::from("- Esc: Cancel and return to main menu"),
Line::from("- Ctrl+Enter: Insert newline"),
Line::from("- v: Change visibility"),
Line::from("- /: Show this help"),
Line::from(""),
Line::from("Visibility Options:"),
Line::from("- PRIVATE: Only visible to you"),
Line::from("- PUBLIC: Visible to everyone"),
Line::from("- PROTECTED: Visible to shared users"),
];
let help_area = Rect::new(x + 2, y + 2, width - 6, height - 4);
let scrollbar_area = Rect::new(x + width - 4, y + 2, 1, height - 4);
let help_text = Paragraph::new(help_lines.clone())
.style(Style::default().fg(Color::White))
.scroll((app.help_scroll.try_into().unwrap(), 0));
frame.render_widget(help_text, help_area);
app.scrollbar_state = ScrollbarState::default()
.position(app.help_scroll)
.content_length(help_lines.len())
.viewport_content_length((height - 4) as usize);
frame.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.thumb_style(Style::default().bg(Color::DarkGray))
.track_style(Style::default().bg(Color::Black)),
scrollbar_area,
&mut app.scrollbar_state,
);
let hint = Paragraph::new("Press any key to close | ↑↓: Scroll")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
let hint_area = Rect::new(x, y + height, width, 2);
frame.render_widget(hint, hint_area);
}