Mastui/src/ui/mod.rs

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(&notif.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))
}