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(terminal: &mut Terminal, 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 = 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::>() .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::()) .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::>() .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::>() .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::>() .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 = 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 = 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)) }