Mastui/src/ui/mod.rs

214 lines
6.9 KiB
Rust

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<B: Backend>(terminal: &mut Terminal<B>, 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(&notif.created_at)
)
})
.collect::<Vec<_>>()
.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<chrono::Utc>) -> 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()
}
}