pub mod app; pub mod compose; pub mod detail; pub mod timeline; use crate::ui::app::{App, AppMode}; use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, Terminal, }; use std::io; use std::time::Duration; pub async fn run_tui(mut app: App) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; app.load_timeline().await?; let result = run_app(&mut terminal, &mut app).await; disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; result } async fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> { loop { terminal.draw(|f| ui(f, app))?; if !app.running { break; } let event_available = event::poll(Duration::from_millis(100))?; if event_available { let event = event::read()?; match event { Event::Key(key) => { match (key.code, key.modifiers) { (KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => { app.post_status().await?; } (KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { app.toggle_favourite().await?; } (KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { app.toggle_reblog().await?; } (KeyCode::F(5), _) => { if app.mode == AppMode::Timeline { app.load_timeline().await?; } else if app.mode == AppMode::Notifications { app.load_notifications().await?; } } _ => { app.handle_key_event(key); } } } _ => {} } } } Ok(()) } fn ui(f: &mut Frame, app: &App) { let size = f.area(); match app.mode { AppMode::Timeline => { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) .split(size); timeline::render_timeline(f, app, chunks[0]); render_help(f, chunks[1]); } AppMode::Compose => { compose::render_compose(f, app, size); } AppMode::StatusDetail => { detail::render_status_detail(f, app, size); } AppMode::Notifications => { render_notifications(f, app, size); } AppMode::Search => { render_search(f, app, size); } } } fn render_help(f: &mut Frame, area: Rect) { let help_text = vec![ Line::from(vec![ Span::styled("j/k", Style::default().fg(Color::Cyan)), Span::raw("/"), Span::styled("↑↓", Style::default().fg(Color::Cyan)), Span::raw(" navigate • "), Span::styled("r", Style::default().fg(Color::Cyan)), Span::raw(" reply • "), Span::styled("n", Style::default().fg(Color::Cyan)), Span::raw(" new post • "), Span::styled("t", Style::default().fg(Color::Cyan)), Span::raw(" timeline • "), Span::styled("Enter", Style::default().fg(Color::Cyan)), Span::raw(" detail • "), Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)), Span::raw(" fav • "), Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), Span::raw(" boost • "), Span::styled("F5", Style::default().fg(Color::Cyan)), Span::raw(" refresh • "), Span::styled("q", Style::default().fg(Color::Cyan)), Span::raw(" quit"), ]), ]; let paragraph = Paragraph::new(help_text) .block(Block::default().borders(Borders::ALL).title("Help")) .style(Style::default().fg(Color::Gray)); f.render_widget(paragraph, area); } fn render_notifications(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) .split(area); let header = Paragraph::new("Notifications") .block(Block::default().borders(Borders::ALL).title("Mastui")) .style(Style::default().fg(Color::Magenta)); f.render_widget(header, chunks[0]); let notifications = &app.notifications; if notifications.is_empty() { let paragraph = Paragraph::new("No notifications") .block(Block::default().borders(Borders::ALL)) .style(Style::default().fg(Color::Yellow)); f.render_widget(paragraph, chunks[1]); } else { let content = notifications .iter() .map(|notif| { format!( "{:?}: {} ({})", notif.notification_type, notif.account.display_name, format_time(¬if.created_at) ) }) .collect::>() .join("\n"); let paragraph = Paragraph::new(content) .block(Block::default().borders(Borders::ALL)) .wrap(ratatui::widgets::Wrap { trim: true }); f.render_widget(paragraph, chunks[1]); } } fn render_search(f: &mut Frame, _app: &App, _area: Rect) { let paragraph = Paragraph::new("Search functionality coming soon!") .block(Block::default().borders(Borders::ALL).title("Search")) .style(Style::default().fg(Color::Yellow)); f.render_widget(paragraph, _area); } fn format_time(time: &chrono::DateTime) -> String { let now = chrono::Utc::now(); let duration = now.signed_duration_since(*time); if duration.num_days() > 0 { format!("{}d", duration.num_days()) } else if duration.num_hours() > 0 { format!("{}h", duration.num_hours()) } else if duration.num_minutes() > 0 { format!("{}m", duration.num_minutes()) } else { "now".to_string() } }