932 lines
34 KiB
Rust
932 lines
34 KiB
Rust
pub mod app;
|
|
pub mod compose;
|
|
pub mod content;
|
|
pub mod detail;
|
|
pub mod image;
|
|
pub mod timeline;
|
|
|
|
use crate::ui::app::{App, AppMode};
|
|
use crate::utils::format_time;
|
|
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, List, ListItem, ListState, Paragraph, Wrap},
|
|
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<()> {
|
|
let mut last_refresh = std::time::Instant::now();
|
|
let refresh_interval = Duration::from_secs(app.refresh_interval_seconds);
|
|
|
|
loop {
|
|
terminal.draw(|f| ui(f, app))?;
|
|
|
|
if !app.running {
|
|
break;
|
|
}
|
|
|
|
// Auto-refresh timeline
|
|
if last_refresh.elapsed() >= refresh_interval {
|
|
if app.mode == AppMode::Timeline {
|
|
app.load_timeline().await?;
|
|
}
|
|
last_refresh = std::time::Instant::now();
|
|
}
|
|
|
|
let poll_duration = refresh_interval
|
|
.checked_sub(last_refresh.elapsed())
|
|
.unwrap_or_else(|| Duration::from_secs(0));
|
|
|
|
let event_available = event::poll(poll_duration.min(Duration::from_millis(100)))?;
|
|
|
|
if event_available {
|
|
let event = event::read()?;
|
|
match event {
|
|
Event::Key(key) => {
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Enter, KeyModifiers::NONE) 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::Enter, _) if app.mode == AppMode::Search && app.search_results.is_none() => {
|
|
app.perform_search().await?;
|
|
}
|
|
(KeyCode::Char('h'), _) if app.mode == AppMode::Timeline => {
|
|
app.view_thread().await?;
|
|
}
|
|
(KeyCode::Char('p'), _) if app.mode == AppMode::Timeline => {
|
|
if let Some(status) = app.statuses.get(app.selected_status_index) {
|
|
let account_id = status.account.id.clone();
|
|
app.view_profile(&account_id).await?;
|
|
}
|
|
}
|
|
(KeyCode::F(5), _) => {
|
|
if app.mode == AppMode::Timeline {
|
|
app.load_timeline().await?;
|
|
} else if app.mode == AppMode::Notifications {
|
|
app.load_notifications().await?;
|
|
} else if app.mode == AppMode::ProfileView {
|
|
if let Some(account_id) = app.profile_account.as_ref().map(|a| a.id.clone()) {
|
|
app.view_profile(&account_id).await?;
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
app.handle_key_event(key);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ui(f: &mut Frame, app: &App) {
|
|
let size = f.area();
|
|
|
|
match app.mode {
|
|
AppMode::Timeline => {
|
|
let help_height = if app.show_detailed_help { 5 } else { 3 };
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Min(0), Constraint::Length(help_height)].as_ref())
|
|
.split(size);
|
|
|
|
timeline::render_timeline(f, app, chunks[0]);
|
|
render_help(f, chunks[1], app);
|
|
}
|
|
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::MediaView => {
|
|
render_media_view(f, app, size);
|
|
}
|
|
AppMode::LinksView => {
|
|
render_links_view(f, app, size);
|
|
}
|
|
AppMode::Search => {
|
|
render_search(f, app, size);
|
|
}
|
|
AppMode::ThreadView => {
|
|
render_thread_view(f, app, size);
|
|
}
|
|
AppMode::ProfileView => {
|
|
render_profile_view(f, app, size);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_help(f: &mut Frame, area: Rect, app: &App) {
|
|
let help_lines = if app.show_detailed_help {
|
|
// Detailed help - organized by category
|
|
vec![
|
|
Line::from(vec![
|
|
Span::styled("Navigation: ", Style::default().fg(Color::Yellow)),
|
|
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
|
Span::raw("/"),
|
|
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" move • "),
|
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" detail • "),
|
|
Span::styled("t", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" timeline"),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("Content: ", Style::default().fg(Color::Yellow)),
|
|
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("Ctrl+f", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" fav • "),
|
|
Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" boost"),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("View: ", Style::default().fg(Color::Yellow)),
|
|
Span::styled("v", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" media • "),
|
|
Span::styled("l", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" links • "),
|
|
Span::styled("h", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" thread • "),
|
|
Span::styled("p", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" profile • "),
|
|
Span::styled("s", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" search • "),
|
|
Span::styled("o", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" open"),
|
|
]),
|
|
Line::from(vec![
|
|
Span::styled("System: ", Style::default().fg(Color::Yellow)),
|
|
Span::styled("F5", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" refresh • "),
|
|
Span::styled("F1", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" toggle help • "),
|
|
Span::styled("q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" quit"),
|
|
]),
|
|
]
|
|
} else {
|
|
// Compact help - just essentials
|
|
vec![
|
|
Line::from(vec![
|
|
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" nav • "),
|
|
Span::styled("r", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" reply • "),
|
|
Span::styled("m", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" notifications • "),
|
|
Span::styled("n", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" post • "),
|
|
Span::styled("s", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" search • "),
|
|
Span::styled("h", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" thread • "),
|
|
Span::styled("p", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" profile • "),
|
|
Span::styled("F1", Style::default().fg(Color::Gray)),
|
|
Span::raw(" more • "),
|
|
Span::styled("q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" quit"),
|
|
]),
|
|
]
|
|
};
|
|
|
|
let title = if app.show_detailed_help {
|
|
"Help (F1 to hide)"
|
|
} else {
|
|
"Help (F1 for more)"
|
|
};
|
|
|
|
let paragraph = Paragraph::new(help_lines)
|
|
.block(Block::default().borders(Borders::ALL).title(title))
|
|
.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\n\nPress 'q' or Esc to go back")
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
|
|
f.render_widget(paragraph, chunks[1]);
|
|
} else {
|
|
let items: Vec<ListItem> = notifications
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, notif)| {
|
|
let content = format!(
|
|
"{:?}: {} ({})",
|
|
notif.notification_type,
|
|
notif.account.display_name,
|
|
format_time(¬if.created_at)
|
|
);
|
|
|
|
let style = if i == app.selected_notification_index {
|
|
Style::default().fg(Color::Black).bg(Color::White)
|
|
} else {
|
|
Style::default()
|
|
};
|
|
|
|
ListItem::new(content).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Use ↑↓ to navigate, Enter to open"))
|
|
.highlight_style(Style::default().fg(Color::Black).bg(Color::White));
|
|
|
|
let mut state = ListState::default();
|
|
state.select(Some(app.selected_notification_index));
|
|
|
|
f.render_stateful_widget(list, chunks[1], &mut state);
|
|
}
|
|
}
|
|
|
|
fn render_media_view(f: &mut Frame, app: &App, area: Rect) {
|
|
use crate::ui::image::{MediaViewMode, ImageViewer};
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref())
|
|
.split(area);
|
|
|
|
// Header
|
|
let header = Paragraph::new("Media Viewer")
|
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
|
.style(Style::default().fg(Color::Magenta));
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// Content
|
|
match &app.media_viewer.mode {
|
|
MediaViewMode::None => {
|
|
let paragraph = Paragraph::new("No media to display")
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
}
|
|
MediaViewMode::List => {
|
|
let media_info = ImageViewer::show_media_attachments(&app.media_viewer.attachments);
|
|
let mut content = format!("Selected: {} of {}\n\n",
|
|
app.media_viewer.selected_index + 1,
|
|
app.media_viewer.attachments.len()
|
|
);
|
|
content.push_str(&media_info);
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.wrap(Wrap { trim: true })
|
|
.style(Style::default().fg(Color::Magenta));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
}
|
|
MediaViewMode::Viewing { index } => {
|
|
let content = format!("Viewing media {} of {} (opened externally)",
|
|
index + 1,
|
|
app.media_viewer.attachments.len()
|
|
);
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.style(Style::default().fg(Color::Green));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
}
|
|
}
|
|
|
|
// Instructions
|
|
let instructions = 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("Enter/o", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" open • "),
|
|
Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" close"),
|
|
]),
|
|
];
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(help, chunks[2]);
|
|
}
|
|
|
|
fn render_links_view(f: &mut Frame, app: &App, area: Rect) {
|
|
use crate::ui::content::LinkType;
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref())
|
|
.split(area);
|
|
|
|
// Header
|
|
let header = Paragraph::new("Links in Post")
|
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// Content
|
|
if app.current_links.is_empty() {
|
|
let paragraph = Paragraph::new("No links to display")
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
} else {
|
|
let mut content = format!("Selected: {} of {}\n\n",
|
|
app.selected_link_index + 1,
|
|
app.current_links.len()
|
|
);
|
|
|
|
for (i, link) in app.current_links.iter().enumerate() {
|
|
let prefix = if i == app.selected_link_index { "▶ " } else { " " };
|
|
let link_type_icon = match link.link_type {
|
|
LinkType::Regular => "🔗",
|
|
LinkType::Mention => "👤",
|
|
LinkType::Hashtag => "#️⃣",
|
|
};
|
|
|
|
content.push_str(&format!("{}{}. {} {}\n",
|
|
prefix, i + 1, link_type_icon, link.display_text));
|
|
content.push_str(&format!(" {}\n\n", link.url));
|
|
}
|
|
|
|
let paragraph = Paragraph::new(content)
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.wrap(Wrap { trim: true })
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
}
|
|
|
|
// Instructions
|
|
let instructions = 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("Enter/o", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" open • "),
|
|
Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" close"),
|
|
]),
|
|
];
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(help, chunks[2]);
|
|
}
|
|
|
|
fn render_search(f: &mut Frame, app: &App, area: Rect) {
|
|
use crate::ui::app::SearchCategory;
|
|
use crate::ui::content::ContentParser;
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Length(3), // Search input
|
|
Constraint::Length(3), // Category tabs
|
|
Constraint::Min(0), // Results
|
|
Constraint::Length(3), // Instructions
|
|
].as_ref())
|
|
.split(area);
|
|
|
|
// Header
|
|
let header = Paragraph::new("Search")
|
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// Search input
|
|
let search_text = if app.loading {
|
|
format!("Searching: {}...", app.search_query)
|
|
} else {
|
|
format!("Query: {}", app.search_query)
|
|
};
|
|
|
|
let input = Paragraph::new(search_text)
|
|
.block(Block::default().borders(Borders::ALL).title("Search Query"))
|
|
.style(Style::default().fg(Color::White));
|
|
f.render_widget(input, chunks[1]);
|
|
|
|
// Category tabs (only show if we have results)
|
|
if app.search_results.is_some() {
|
|
let categories = vec!["Posts", "Accounts", "Hashtags"];
|
|
let selected_idx = match app.search_category {
|
|
SearchCategory::Posts => 0,
|
|
SearchCategory::Accounts => 1,
|
|
SearchCategory::Hashtags => 2,
|
|
};
|
|
|
|
let tabs_text = categories
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &cat)| {
|
|
if i == selected_idx {
|
|
format!("[{}]", cat)
|
|
} else {
|
|
cat.to_string()
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" | ");
|
|
|
|
let tabs = Paragraph::new(tabs_text)
|
|
.block(Block::default().borders(Borders::ALL).title("Categories"))
|
|
.style(Style::default().fg(Color::Cyan));
|
|
f.render_widget(tabs, chunks[2]);
|
|
}
|
|
|
|
// Results
|
|
if let Some(results) = &app.search_results {
|
|
let content = match app.search_category {
|
|
SearchCategory::Posts => {
|
|
if results.statuses.is_empty() {
|
|
"No posts found".to_string()
|
|
} else {
|
|
let parser = ContentParser::new();
|
|
results.statuses
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, status)| {
|
|
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
|
let content = parser.parse_content_simple(&status.content);
|
|
let first_line = content.first()
|
|
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect::<String>())
|
|
.unwrap_or_else(|| "No content".to_string());
|
|
format!("{}@{}: {}", prefix, status.account.acct,
|
|
if first_line.len() > 80 { format!("{}...", &first_line[..80]) } else { first_line })
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
}
|
|
SearchCategory::Accounts => {
|
|
if results.accounts.is_empty() {
|
|
"No accounts found".to_string()
|
|
} else {
|
|
results.accounts
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, account)| {
|
|
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
|
format!("{}@{}: {}", prefix, account.acct, account.display_name)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
}
|
|
SearchCategory::Hashtags => {
|
|
if results.hashtags.is_empty() {
|
|
"No hashtags found".to_string()
|
|
} else {
|
|
results.hashtags
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tag)| {
|
|
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
|
format!("{}#{}", prefix, tag.name)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
}
|
|
};
|
|
|
|
let results_widget = Paragraph::new(content)
|
|
.block(Block::default().borders(Borders::ALL).title("Results"))
|
|
.wrap(Wrap { trim: true })
|
|
.style(Style::default().fg(Color::White));
|
|
f.render_widget(results_widget, chunks[3]);
|
|
} else if !app.search_query.is_empty() && !app.loading {
|
|
let msg = Paragraph::new("Press Enter to search")
|
|
.block(Block::default().borders(Borders::ALL).title("Results"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(msg, chunks[3]);
|
|
}
|
|
|
|
// Instructions
|
|
let instructions = if app.search_results.is_some() {
|
|
vec![
|
|
Line::from(vec![
|
|
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" navigate • "),
|
|
Span::styled("t", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" switch category • "),
|
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" open • "),
|
|
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" back"),
|
|
]),
|
|
]
|
|
} else {
|
|
vec![
|
|
Line::from(vec![
|
|
Span::raw("Type to search • "),
|
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" to search • "),
|
|
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" to go back"),
|
|
]),
|
|
]
|
|
};
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Instructions"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(help, chunks[4]);
|
|
}
|
|
|
|
fn render_thread_view(f: &mut Frame, app: &App, area: Rect) {
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Min(0), // Thread content
|
|
Constraint::Length(3), // Instructions
|
|
].as_ref())
|
|
.split(area);
|
|
|
|
// Header
|
|
let thread_info = if let Some(current_id) = &app.current_status_id {
|
|
format!("Thread View ({})", current_id)
|
|
} else {
|
|
"Thread View".to_string()
|
|
};
|
|
|
|
let header = Paragraph::new(thread_info)
|
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
|
.style(Style::default().fg(Color::Green));
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// Thread content
|
|
if app.thread_statuses.is_empty() {
|
|
let empty_msg = if app.loading {
|
|
"Loading thread..."
|
|
} else {
|
|
"No thread data"
|
|
};
|
|
|
|
let paragraph = Paragraph::new(empty_msg)
|
|
.block(Block::default().borders(Borders::ALL))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(paragraph, chunks[1]);
|
|
} else {
|
|
let items: Vec<ratatui::widgets::ListItem> = app.thread_statuses
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, status)| format_thread_status(status, i, app))
|
|
.collect();
|
|
|
|
let mut list_state = ratatui::widgets::ListState::default();
|
|
list_state.select(Some(app.selected_thread_index));
|
|
|
|
let list = ratatui::widgets::List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Conversation"))
|
|
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD))
|
|
.highlight_symbol("▶ ");
|
|
|
|
f.render_stateful_widget(list, chunks[1], &mut list_state);
|
|
}
|
|
|
|
// Instructions
|
|
let instructions = 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("Enter", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" detail • "),
|
|
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" back"),
|
|
]),
|
|
];
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(help, chunks[2]);
|
|
}
|
|
|
|
fn format_thread_status(status: &crate::api::Status, _index: usize, app: &App) -> ratatui::widgets::ListItem<'static> {
|
|
use crate::ui::content::ContentParser;
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
// Determine the relationship and styling
|
|
let (prefix, style) = if let Some(current_id) = &app.current_status_id {
|
|
if status.id == *current_id {
|
|
("● ", Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)) // Current post
|
|
} else if let Some(context) = &app.thread_context {
|
|
if context.ancestors.iter().any(|s| s.id == status.id) {
|
|
("↑ ", Style::default().fg(Color::Blue)) // Ancestor
|
|
} else {
|
|
("↓ ", Style::default().fg(Color::Green)) // Descendant
|
|
}
|
|
} else {
|
|
(" ", Style::default())
|
|
}
|
|
} else {
|
|
(" ", Style::default())
|
|
};
|
|
|
|
// User info - use owned strings
|
|
lines.push(Line::from(vec![
|
|
Span::styled(prefix.to_string(), style),
|
|
Span::styled(
|
|
format!("@{}", status.account.acct),
|
|
Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD),
|
|
),
|
|
Span::raw(" • ".to_string()),
|
|
Span::styled(
|
|
format_time(&status.created_at),
|
|
Style::default().fg(Color::Gray),
|
|
),
|
|
]));
|
|
|
|
// Parse and display content
|
|
let parser = ContentParser::new();
|
|
let content_lines = parser.parse_content_simple(&status.content);
|
|
|
|
// Show first 2 lines of content
|
|
for line in content_lines.iter().take(2) {
|
|
// Convert to owned spans
|
|
let owned_spans: Vec<_> = line.spans.iter()
|
|
.map(|span| Span::styled(span.content.to_string(), span.style))
|
|
.collect();
|
|
lines.push(Line::from(owned_spans));
|
|
}
|
|
|
|
if content_lines.len() > 2 {
|
|
lines.push(Line::from(Span::styled("...".to_string(), Style::default().fg(Color::Gray))));
|
|
}
|
|
|
|
// Add some spacing
|
|
lines.push(Line::from("".to_string()));
|
|
|
|
ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
|
|
}
|
|
|
|
fn render_profile_view(f: &mut Frame, app: &App, area: Rect) {
|
|
use crate::ui::content::ContentParser;
|
|
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints([
|
|
Constraint::Length(3), // Header
|
|
Constraint::Length(7), // Profile info
|
|
Constraint::Min(0), // Posts
|
|
Constraint::Length(3), // Instructions
|
|
].as_ref())
|
|
.split(area);
|
|
|
|
// Header
|
|
let header_text = if let Some(account) = &app.profile_account {
|
|
format!("Profile: @{}", account.acct)
|
|
} else {
|
|
"Profile View".to_string()
|
|
};
|
|
|
|
let header = Paragraph::new(header_text)
|
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
|
.style(Style::default().fg(Color::Magenta));
|
|
f.render_widget(header, chunks[0]);
|
|
|
|
// Profile info
|
|
if let Some(account) = &app.profile_account {
|
|
let mut profile_lines = Vec::new();
|
|
|
|
// Display name and username
|
|
profile_lines.push(Line::from(vec![
|
|
Span::styled(
|
|
&account.display_name,
|
|
Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD),
|
|
),
|
|
Span::raw(" "),
|
|
Span::styled(
|
|
format!("@{}", account.acct),
|
|
Style::default().fg(Color::Gray),
|
|
),
|
|
]));
|
|
|
|
// Stats
|
|
profile_lines.push(Line::from(vec![
|
|
Span::styled("Posts: ", Style::default().fg(Color::Yellow)),
|
|
Span::raw(account.statuses_count.to_string()),
|
|
Span::raw(" • "),
|
|
Span::styled("Following: ", Style::default().fg(Color::Yellow)),
|
|
Span::raw(account.following_count.to_string()),
|
|
Span::raw(" • "),
|
|
Span::styled("Followers: ", Style::default().fg(Color::Yellow)),
|
|
Span::raw(account.followers_count.to_string()),
|
|
]));
|
|
|
|
// Bio (if present)
|
|
if !account.note.is_empty() {
|
|
profile_lines.push(Line::from(""));
|
|
let parser = ContentParser::new();
|
|
let bio_lines = parser.parse_content_simple(&account.note);
|
|
for line in bio_lines.iter().take(3) {
|
|
// Convert to owned spans
|
|
let owned_spans: Vec<_> = line.spans.iter()
|
|
.map(|span| Span::styled(span.content.to_string(), span.style))
|
|
.collect();
|
|
profile_lines.push(Line::from(owned_spans));
|
|
}
|
|
}
|
|
|
|
let profile_widget = Paragraph::new(ratatui::text::Text::from(profile_lines))
|
|
.block(Block::default().borders(Borders::ALL).title("Profile Info"))
|
|
.wrap(Wrap { trim: true });
|
|
f.render_widget(profile_widget, chunks[1]);
|
|
} else {
|
|
let loading_msg = if app.loading {
|
|
"Loading profile..."
|
|
} else {
|
|
"No profile data"
|
|
};
|
|
|
|
let placeholder = Paragraph::new(loading_msg)
|
|
.block(Block::default().borders(Borders::ALL).title("Profile Info"))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(placeholder, chunks[1]);
|
|
}
|
|
|
|
// Posts
|
|
if app.profile_statuses.is_empty() {
|
|
let empty_msg = if app.loading {
|
|
"Loading posts..."
|
|
} else {
|
|
"No posts found"
|
|
};
|
|
|
|
let paragraph = Paragraph::new(empty_msg)
|
|
.block(Block::default().borders(Borders::ALL).title("Recent Posts"))
|
|
.style(Style::default().fg(Color::Yellow));
|
|
f.render_widget(paragraph, chunks[2]);
|
|
} else {
|
|
let items: Vec<ratatui::widgets::ListItem> = app.profile_statuses
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, status)| format_profile_post(status, i == app.selected_profile_post_index))
|
|
.collect();
|
|
|
|
let mut list_state = ratatui::widgets::ListState::default();
|
|
list_state.select(Some(app.selected_profile_post_index));
|
|
|
|
let list = ratatui::widgets::List::new(items)
|
|
.block(Block::default().borders(Borders::ALL).title("Recent Posts"))
|
|
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD))
|
|
.highlight_symbol("▶ ");
|
|
|
|
f.render_stateful_widget(list, chunks[2], &mut list_state);
|
|
}
|
|
|
|
// Instructions
|
|
let instructions = 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("Enter", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" detail • "),
|
|
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
|
Span::raw(" back"),
|
|
]),
|
|
];
|
|
|
|
let help = Paragraph::new(instructions)
|
|
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
|
.style(Style::default().fg(Color::Gray));
|
|
f.render_widget(help, chunks[3]);
|
|
}
|
|
|
|
fn format_profile_post(status: &crate::api::Status, is_selected: bool) -> ratatui::widgets::ListItem<'static> {
|
|
use crate::ui::content::ContentParser;
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
// Time
|
|
let time_style = if is_selected {
|
|
Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)
|
|
} else {
|
|
Style::default().fg(Color::Gray)
|
|
};
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
format_time(&status.created_at),
|
|
time_style,
|
|
)));
|
|
|
|
// Parse and display content
|
|
let parser = ContentParser::new();
|
|
let content_lines = parser.parse_content_simple(&status.content);
|
|
|
|
// Show first 2 lines of content
|
|
for line in content_lines.iter().take(2) {
|
|
let owned_spans: Vec<_> = line.spans.iter()
|
|
.map(|span| Span::styled(span.content.to_string(), span.style))
|
|
.collect();
|
|
lines.push(Line::from(owned_spans));
|
|
}
|
|
|
|
if content_lines.len() > 2 {
|
|
lines.push(Line::from(Span::styled("...".to_string(), Style::default().fg(Color::Gray))));
|
|
}
|
|
|
|
// Stats
|
|
if status.replies_count > 0 || status.reblogs_count > 0 || status.favourites_count > 0 {
|
|
let mut stats = Vec::new();
|
|
if status.replies_count > 0 {
|
|
stats.push(format!("💬 {}", status.replies_count));
|
|
}
|
|
if status.reblogs_count > 0 {
|
|
stats.push(format!("🔄 {}", status.reblogs_count));
|
|
}
|
|
if status.favourites_count > 0 {
|
|
stats.push(format!("⭐ {}", status.favourites_count));
|
|
}
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
stats.join(" • "),
|
|
Style::default().fg(Color::Gray),
|
|
)));
|
|
}
|
|
|
|
lines.push(Line::from("".to_string()));
|
|
|
|
ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
|
|
}
|
|
|