use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use super::formatting::*;
use super::modals::*;
use super::theme::{get_theme_colors, ThemeColors};
use crate::app::App;
use crate::{log_modal_state, log_rendering};
pub fn render_auth_screen(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let theme = get_theme_colors(app);
let chunks = if app.is_demo_mode {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
.split(area)
};
let (header_chunk, content_chunk, footer_chunk) = if app.is_demo_mode {
let demo_banner = Paragraph::new("🎮 DEMO MODE - Data is temporary and will be lost on refresh")
.style(
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
frame.render_widget(demo_banner, chunks[0]);
(chunks[1], chunks[2], chunks[3])
} else {
(chunks[0], chunks[1], chunks[2])
};
let header = Paragraph::new("Fido - Terminal Social Platform")
.style(
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(header, header_chunk);
let mut lines = vec![Line::from("")];
const LOGO_LINES: &[&str] = &[
" _____ _ _ ",
" | ___(_) __| | ___ ",
" | |_ | |/ _` |/ _ \\ ",
" | _| | | (_| | (_) |",
" |_| |_|\\__,_|\\___/ ",
];
for logo_line in LOGO_LINES {
lines.push(Line::from(Span::styled(
*logo_line,
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(""));
if app.auth_state.github_auth_in_progress {
lines.push(Line::from(Span::styled(
"GitHub Device Authorization",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
if let Some(user_code) = &app.auth_state.github_user_code {
lines.push(Line::from(Span::styled(
"Enter this code on GitHub:",
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
user_code.clone(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED),
)));
lines.push(Line::from(""));
}
if let Some(uri) = &app.auth_state.github_verification_uri {
lines.push(Line::from(Span::styled(
"If the browser didn't open, visit:",
Style::default().fg(Color::White),
)));
lines.push(Line::from(Span::styled(
uri.clone(),
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"Waiting for authorization...",
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Esc to cancel",
Style::default().fg(Color::White),
)));
} else if app.auth_state.loading {
lines.push(Line::from(Span::styled(
"Loading...",
Style::default().fg(Color::White),
)));
} else if let Some(error) = &app.auth_state.error {
lines.push(Line::from(Span::styled(
error.clone(),
Style::default().fg(theme.error),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press any key to continue",
Style::default().fg(Color::White),
)));
} else if app.auth_state.test_users.is_empty() {
lines.push(Line::from(Span::styled(
"Choose authentication method:",
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
if app.auth_state.show_github_option {
lines.push(Line::from(Span::styled(
"Press 'g' to login with GitHub",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
lines.push(Line::from(Span::styled(
"Press 'l' to load test users (development only)",
Style::default().fg(Color::White),
)));
} else {
let user_prompt = if app.is_demo_mode {
"Select a test user to explore the demo:"
} else {
"Select a test user (development only):"
};
lines.push(Line::from(Span::styled(
user_prompt,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
for (i, user) in app.auth_state.test_users.iter().take(3).enumerate() {
let style = if i == app.auth_state.selected_index {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let prefix = if i == app.auth_state.selected_index {
"▶ "
} else {
" "
};
let bio = user.bio.as_deref().unwrap_or("No bio");
lines.push(Line::from(Span::styled(
format!("{}{} - {}", prefix, user.username, bio),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Enter to login with test user",
Style::default().fg(Color::White),
)));
if app.auth_state.show_github_option {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press 'g' to login with GitHub instead",
Style::default().fg(Color::White),
)));
}
}
let content = Paragraph::new(lines).alignment(Alignment::Center).block(
Block::default()
.borders(Borders::ALL)
.title("Authentication"),
);
frame.render_widget(content, content_chunk);
let footer_text = if app.auth_state.github_auth_in_progress {
"Esc: Cancel | q: Quit"
} else if app.auth_state.test_users.is_empty() {
if app.auth_state.show_github_option {
"g: GitHub Login | l: Load test users | q/Esc: Quit"
} else {
"l: Load test users | q/Esc: Quit"
}
} else {
if app.auth_state.show_github_option {
"↑/k: Up | ↓/j: Down | Enter: Login | g: GitHub | q/Esc: Quit"
} else {
"↑/k: Up | ↓/j: Down | Enter: Login | q/Esc: Quit"
}
};
let footer = Paragraph::new(footer_text)
.style(Style::default().fg(Color::White))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(footer, footer_chunk);
}
pub fn render_main_screen(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let (header_height, footer_height) = if area.height < 30 {
(3u16, 2u16) } else {
(3u16, 3u16) };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(header_height), Constraint::Min(0), Constraint::Length(1), Constraint::Length(footer_height), ])
.split(area);
render_tab_header(frame, app, chunks[0]);
match app.current_tab {
crate::app::Tab::Posts => {
render_posts_tab_with_data(frame, app, chunks[1]);
}
crate::app::Tab::DMs => render_dms_tab(frame, app, chunks[1]),
crate::app::Tab::Profile => render_profile_tab(frame, app, chunks[1]),
crate::app::Tab::Settings => render_settings_tab(frame, app, chunks[1]),
}
render_page_actions(frame, app, chunks[2]);
render_global_footer(frame, app, chunks[3]);
let composer_mode = if let Some(mode) = &app.composer_state.mode {
format!("{:?}", mode)
} else {
"None".to_string()
};
log_modal_state!(
app.log_config,
"viewing_post_detail={}, show_full_post_modal={}, composer_open={}, composer_mode={}",
app.viewing_post_detail,
app.post_detail_state
.as_ref()
.map(|s| s.show_full_post_modal)
.unwrap_or(false),
app.composer_state.is_open(),
composer_mode
);
let show_full_post_modal = app
.post_detail_state
.as_ref()
.map(|s| s.show_full_post_modal)
.unwrap_or(false);
if show_full_post_modal {
log_rendering!(app.log_config, "Rendering thread modal (full post modal)");
render_full_post_modal(frame, app, area);
}
let show_delete_confirmation = app
.post_detail_state
.as_ref()
.map(|s| s.show_delete_confirmation)
.unwrap_or(false);
if show_delete_confirmation {
render_delete_confirmation_modal(frame, app, area);
}
if app.composer_state.is_open() {
log_rendering!(
app.log_config,
"Rendering composer modal (mode: {})",
composer_mode
);
render_unified_composer_modal(frame, app, area);
}
if app.dms_state.show_new_conversation_modal {
render_new_conversation_modal(frame, app, area);
}
if app.settings_state.show_save_confirmation {
render_save_confirmation_modal(frame, app, area);
}
if app.dms_state.show_dm_error_modal {
render_dm_error_modal(frame, app, area);
}
if app.friends_state.show_friends_modal {
render_friends_modal(frame, app, area);
}
if app.user_search_state.show_modal {
render_user_search_modal(frame, app, area);
}
if app.user_profile_view.is_some() {
render_user_profile_view(frame, app, area);
}
if app.show_help {
render_help_modal(frame, app, area);
}
}
pub fn render_tab_header(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
let total_unread: usize = app.dms_state.unread_counts.values().sum();
let tabs = ["Posts", "DMs", "Profile", "Settings"];
let current_index = match app.current_tab {
crate::app::Tab::Posts => 0,
crate::app::Tab::DMs => 1,
crate::app::Tab::Profile => 2,
crate::app::Tab::Settings => 3,
};
let mut tab_spans = vec![];
for (i, tab) in tabs.iter().enumerate() {
let style = if i == current_index {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(theme.text_dim)
};
let tab_text = if i == 1 && total_unread > 0 {
format!(" {} ({}) ", tab, total_unread)
} else {
format!(" {} ", tab)
};
tab_spans.push(Span::styled(tab_text, style));
if i < tabs.len() - 1 {
tab_spans.push(Span::raw(" | "));
}
}
let header = Paragraph::new(Line::from(tab_spans))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(header, area);
}
pub fn get_action_bar_text(app: &App) -> &'static str {
if app.viewing_post_detail {
if let Some(detail_state) = &app.post_detail_state {
if detail_state.show_full_post_modal {
return ""; }
}
}
match app.current_tab {
crate::app::Tab::Posts => {
"u/d: Vote | n: Post | f: Filter | s: Search | Space: View | p: Profile"
}
crate::app::Tab::DMs => {
let has_active_conversation = app
.dms_state
.selected_conversation_index
.filter(|&idx| idx != usize::MAX)
.is_some();
let has_pending_draft = app.dms_state.pending_conversation_username.is_some();
let can_compose = has_active_conversation || has_pending_draft;
if app.dms_state.selected_conversation_index == Some(usize::MAX) {
"Enter: Start New Conversation | ↑/↓/j/k: Navigate | Esc: Back"
} else if can_compose {
"↑/↓/j/k: Navigate | Type to compose | Enter: Send | Esc: Clear"
} else {
"↑/↓/j/k: Navigate | Enter: Select conversation | n: New Conversation"
}
}
crate::app::Tab::Profile => "e: Edit Bio | f: Friends",
crate::app::Tab::Settings => "←/→/h/l: Change | s: Save",
}
}
pub fn render_page_actions(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
frame.render_widget(Clear, area);
let background = Block::default().style(Style::default().bg(theme.background));
frame.render_widget(background, area);
let text = get_action_bar_text(app);
let widget = Paragraph::new(text)
.style(Style::default().fg(theme.text).bg(theme.background))
.alignment(Alignment::Center)
.wrap(Wrap { trim: false });
frame.render_widget(widget, area);
}
pub fn render_global_footer(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
frame.render_widget(Clear, area);
let footer =
Paragraph::new("Tab: Next | Shift+Tab: Previous | Shift+L: Logout | ?: Help | q/Esc: Quit | ↑/↓/j/k: Navigate")
.style(Style::default().fg(theme.text_dim).bg(theme.background))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border)),
);
frame.render_widget(footer, area);
}
pub fn render_posts_tab_with_data(frame: &mut Frame, app: &mut App, area: Rect) {
log_rendering!(app.log_config, "render_posts_tab_with_data: START");
let theme = get_theme_colors(app);
let has_message = app.posts_state.message.is_some();
let has_error = app.posts_state.error.is_some() && !app.composer_state.is_open();
let chunks = match (has_message, has_error) {
(true, true) => {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Min(0), ])
.split(area)
}
(true, false) => {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(area)
}
(false, true) => {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(area)
}
(false, false) => {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), ])
.split(area)
}
};
let mut chunk_idx = 0;
if let Some((message, _)) = &app.posts_state.message {
let message_banner = Paragraph::new(message.clone())
.style(
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Message")
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
);
frame.render_widget(message_banner, chunks[chunk_idx]);
chunk_idx += 1;
}
if has_error {
if let Some(error) = &app.posts_state.error {
let error_banner = Paragraph::new(error.clone())
.style(
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Error")
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
);
frame.render_widget(error_banner, chunks[chunk_idx]);
chunk_idx += 1;
}
}
let posts_area = chunks[chunk_idx];
if app.posts_state.loading && app.posts_state.posts.is_empty() {
let loading = Paragraph::new(create_loading_display("Loading posts...", &theme))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Global Feed"));
frame.render_widget(loading, posts_area);
if app.posts_state.show_filter_modal {
render_filter_modal(frame, app, area);
}
return;
}
if app.posts_state.posts.is_empty() && !app.posts_state.loading {
let theme = get_theme_colors(app);
let empty = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
"No posts yet",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Press 'n' to create the first post!",
Style::default().fg(theme.text_dim),
)),
])
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Global Feed"));
frame.render_widget(empty, posts_area);
if app.posts_state.show_filter_modal {
render_filter_modal(frame, app, area);
}
return;
}
let theme = get_theme_colors(app);
let mut items: Vec<ListItem> = Vec::new();
let available_width = posts_area.width.saturating_sub(BORDER_PADDING) as usize;
if app.posts_state.loading && !app.posts_state.posts.is_empty() {
let style = Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD);
let loading_item = create_centered_indicator("⟳ Loading...", style, available_width);
items.push(ListItem::new(loading_item));
}
let post_width = (posts_area.width as usize).saturating_sub(4);
let selected_post_index = app
.posts_state
.list_state
.selected()
.and_then(|list_idx| app.posts_state.list_index_to_post_index(list_idx));
let post_items: Vec<ListItem> = app
.posts_state
.posts
.iter()
.enumerate()
.flat_map(|(i, post)| {
let is_selected = selected_post_index == Some(i);
let mut post_lines: Vec<Line> = Vec::new();
let header_style = if is_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.primary)
};
let prefix = if is_selected { "▶ " } else { " " };
let timestamp = format_timestamp(&post.created_at);
post_lines.push(Line::from(vec![
Span::styled(prefix, header_style),
Span::styled(format!("@{}", post.author_username), header_style),
Span::raw(" • "),
Span::styled(timestamp, Style::default().fg(theme.text_dim)),
]));
let content_lines =
format_post_content_with_width(&post.content, is_selected, &theme, post_width);
post_lines.extend(content_lines);
let user_voted_up = post.user_vote.as_deref() == Some("up");
let user_voted_down = post.user_vote.as_deref() == Some("down");
let upvote_style = if user_voted_up {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};
let downvote_style = if user_voted_down {
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};
post_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("↑ {} ", post.upvotes), upvote_style),
Span::styled(format!("↓ {} ", post.downvotes), downvote_style),
Span::styled(
format!("💬 {}", post.reply_count),
Style::default().fg(theme.text_dim),
),
]));
if i < app.posts_state.posts.len() - 1 {
post_lines.push(Line::from(""));
}
vec![ListItem::new(post_lines)]
})
.collect();
items.extend(post_items);
if !app.posts_state.posts.is_empty() {
let end_of_feed = vec![
Line::from(""),
Line::from(""),
Line::from(Span::styled(
"─── End of feed ───",
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::DIM),
)),
];
items.push(ListItem::new(end_of_feed));
}
let title = match &app.posts_state.current_filter {
crate::app::PostFilter::All => "Global Feed".to_string(),
crate::app::PostFilter::Hashtag(tag) => format!("#{}", tag),
crate::app::PostFilter::User(username) => format!("@{}", username),
crate::app::PostFilter::Multi { hashtags, users } => {
let total = hashtags.len() + users.len();
format!("Filtered ({} items)", total)
}
};
let posts_widget = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(Style::default().bg(theme.highlight_bg));
frame.render_stateful_widget(posts_widget, posts_area, &mut app.posts_state.list_state);
if app.posts_state.show_filter_modal {
render_filter_modal(frame, app, area);
}
}
fn create_error_display(
error_message: &str,
help_text: Option<&str>,
theme: &ThemeColors,
) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
error_message.to_string(),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
if let Some(help) = help_text {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
help.to_string(),
Style::default().fg(theme.text_dim),
)));
}
lines
}
fn create_loading_display(message: &str, theme: &ThemeColors) -> Vec<Line<'static>> {
vec![
Line::from(""),
Line::from(Span::styled(
format!("⟳ {}", message),
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Please wait",
Style::default().fg(theme.text_dim),
)),
]
}
fn create_centered_indicator(
text: &str,
style: Style,
available_width: usize,
) -> Vec<Line<'static>> {
let padding = (available_width.saturating_sub(text.len())) / 2;
vec![
Line::from(""),
Line::from(Span::styled(
format!("{}{}", " ".repeat(padding), text),
style,
)),
Line::from(""),
]
}
fn format_timestamp(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
timestamp.format("%Y-%m-%d %H:%M").to_string()
}
pub fn render_dms_tab(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
if app.dms_state.loading {
let loading = Paragraph::new(create_loading_display("Loading conversations...", &theme))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Direct Messages"),
);
frame.render_widget(loading, area);
return;
}
if let Some(error) = &app.dms_state.error {
let error_lines =
create_error_display(error, Some("Press Esc to go back to conversations"), &theme);
let error_msg = Paragraph::new(error_lines)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Direct Messages"),
);
frame.render_widget(error_msg, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), Constraint::Percentage(70), ])
.split(area);
render_conversations_list(frame, app, chunks[0]);
render_messages_view(frame, app, chunks[1]);
if app.dms_state.show_new_conversation_modal {
render_new_conversation_modal(frame, app, area);
}
}
pub fn render_conversations_list(frame: &mut Frame, app: &App, area: Rect) {
let theme = get_theme_colors(app);
let mut lines = vec![];
lines.push(Line::from(""));
lines.push(Line::from(""));
let new_convo_selected = app.dms_state.selected_conversation_index == Some(usize::MAX);
let new_convo_style = if new_convo_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.primary)
};
let new_convo_prefix = if new_convo_selected { "▶ " } else { " " };
lines.push(Line::from(vec![
Span::styled(new_convo_prefix, new_convo_style),
Span::styled("+ New Conversation", new_convo_style),
]));
lines.push(Line::from(Span::styled(
" Press Enter to start",
Style::default().fg(theme.text_dim),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"─".repeat(area.width.saturating_sub(4) as usize),
Style::default().fg(theme.text_dim),
)));
lines.push(Line::from(""));
if let Some(pending_username) = &app.dms_state.pending_conversation_username {
let is_selected = app.dms_state.selected_conversation_index.is_none();
let style = if is_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let prefix = if is_selected { "▶ " } else { " " };
let mut username_spans = vec![Span::styled(prefix, style)];
username_spans.push(Span::styled(pending_username, style));
username_spans.push(Span::raw(" "));
username_spans.push(Span::styled(
"(Draft)",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::ITALIC),
));
lines.push(Line::from(username_spans));
lines.push(Line::from(Span::styled(
" Type your first message...",
Style::default()
.fg(theme.text_dim)
.add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
}
for (i, convo) in app.dms_state.conversations.iter().enumerate() {
let is_selected = app.dms_state.selected_conversation_index == Some(i);
let style = if is_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let prefix = if is_selected { "▶ " } else { " " };
let mut username_spans = vec![Span::styled(prefix, style)];
username_spans.push(Span::styled(&convo.other_username, style));
if convo.unread_count > 0 {
username_spans.push(Span::raw(" "));
username_spans.push(Span::styled(
format!("({})", convo.unread_count),
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(username_spans));
let preview = if convo.last_message.chars().count() > 30 {
let truncated: String = convo.last_message.chars().take(30).collect();
format!(" {}", truncated)
} else {
format!(" {}", convo.last_message)
};
lines.push(Line::from(Span::styled(
preview,
Style::default().fg(theme.text_dim),
)));
lines.push(Line::from(""));
}
let conversations = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Conversations"),
);
frame.render_widget(conversations, area);
}
pub fn render_messages_view(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(6), ])
.split(area);
render_messages(frame, app, chunks[0]);
render_message_input(frame, app, chunks[1]);
}
pub fn render_messages(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
if let Some(username) = &app.dms_state.pending_conversation_username {
let empty_text = vec![
Line::from(""),
Line::from(Span::styled(
format!("New conversation with @{}", username),
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Type your first message below",
Style::default().fg(theme.text),
)),
Line::from(""),
];
let empty = Paragraph::new(empty_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Messages"));
frame.render_widget(empty, area);
return;
}
if app.dms_state.selected_conversation_index.is_none()
|| app.dms_state.selected_conversation_index == Some(usize::MAX)
{
let empty_text = vec![
Line::from(""),
Line::from(Span::styled(
"No conversation selected",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Use ↑/↓ to select a conversation",
Style::default().fg(theme.text),
)),
Line::from(Span::styled(
"or navigate to 'New Conversation' button",
Style::default().fg(theme.text),
)),
Line::from(""),
];
let empty = Paragraph::new(empty_text)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Messages"));
frame.render_widget(empty, area);
return;
}
if app.dms_state.messages.is_empty() {
let empty = Paragraph::new("No messages yet. Start the conversation!")
.style(Style::default().fg(theme.text_dim))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Messages"));
frame.render_widget(empty, area);
return;
}
let viewport_height = (area.height as usize).saturating_sub(2);
let lines_per_message = 3; let messages_per_screen = viewport_height / lines_per_message;
let total_messages = app.dms_state.messages.len();
let start_index = total_messages.saturating_sub(messages_per_screen);
let current_user_id = app.auth_state.current_user.as_ref().map(|u| u.id);
let mut lines = vec![];
let message_width = (area.width as usize).saturating_sub(6);
for msg in app.dms_state.messages.iter().skip(start_index) {
let is_from_me = Some(msg.from_user_id) == current_user_id;
let timestamp = msg.created_at.format("%H:%M").to_string();
let sender = &msg.from_username;
let header_style = if is_from_me {
Style::default().fg(theme.primary)
} else {
Style::default().fg(theme.success)
};
lines.push(Line::from(vec![
Span::styled(
format!("[{}] ", timestamp),
Style::default().fg(theme.text_dim),
),
Span::styled(sender, header_style.add_modifier(Modifier::BOLD)),
]));
for content_line in msg.content.lines() {
let wrapped = textwrap::wrap(content_line, message_width);
for wrapped_line in wrapped {
let prefix = " ";
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, wrapped_line),
Style::default().fg(theme.text),
)));
}
}
lines.push(Line::from(""));
}
let messages =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Messages"));
frame.render_widget(messages, area);
}
pub fn render_message_input(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
let can_type = app.dms_state.pending_conversation_username.is_some()
|| (app.dms_state.selected_conversation_index.is_some()
&& app.dms_state.selected_conversation_index != Some(usize::MAX));
if !can_type {
let placeholder = if app.dms_state.selected_conversation_index.is_none() {
"Select a conversation to send messages"
} else {
"Press Enter on 'New Conversation' to start"
};
let input = Paragraph::new(placeholder)
.style(Style::default().fg(theme.text_dim))
.block(
Block::default()
.borders(Borders::ALL)
.title("Message Input"),
);
frame.render_widget(input, area);
return;
}
let title = if app.dms_state.pending_conversation_username.is_some() {
"Type your first message (Enter to send)"
} else {
"Message Input (Enter to send)"
};
app.dms_state.message_textarea.set_style(
Style::default().fg(theme.primary), );
app.dms_state.message_textarea.set_cursor_style(
Style::default().fg(theme.background).bg(theme.primary), );
app.dms_state.message_textarea.set_cursor_line_style(
Style::default(), );
app.dms_state
.message_textarea
.set_block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(&app.dms_state.message_textarea, area);
}
pub fn render_profile_tab(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
if app.profile_state.loading {
let loading = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
"⟳ Loading profile...",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Please wait",
Style::default().fg(theme.text_dim),
)),
])
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Profile")
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
);
frame.render_widget(loading, area);
return;
}
if let Some(error) = &app.profile_state.error {
let error_msg = Paragraph::new(error.clone())
.style(Style::default().fg(theme.error))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Profile"));
frame.render_widget(error_msg, area);
return;
}
if let Some(profile) = &app.profile_state.profile {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), Constraint::Min(0), ])
.split(area);
render_profile_stats(frame, app, profile, chunks[0]);
render_user_posts(frame, app, chunks[1]);
} else {
let empty = Paragraph::new("No profile data")
.style(Style::default().fg(theme.text_dim))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Profile"));
frame.render_widget(empty, area);
}
}
pub fn render_profile_stats(
frame: &mut Frame,
app: &App,
profile: &fido_types::UserProfile,
area: Rect,
) {
let theme = get_theme_colors(app);
let mut lines = vec![];
lines.push(Line::from(vec![
Span::styled("Username: ", Style::default().fg(theme.primary)),
Span::styled(
&profile.username,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Bio: ", Style::default().fg(theme.primary)),
Span::styled(
profile.bio.as_deref().unwrap_or("No bio set"),
Style::default().fg(theme.text),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Karma: ", Style::default().fg(theme.success)),
Span::styled(
profile.karma.to_string(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("Posts: ", Style::default().fg(theme.secondary)),
Span::styled(
profile.post_count.to_string(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled("Joined: ", Style::default().fg(theme.text_dim)),
Span::styled(
profile.join_date.format("%Y-%m-%d").to_string(),
Style::default().fg(theme.text),
),
]));
if !profile.recent_hashtags.is_empty() {
lines.push(Line::from(""));
let mut hashtag_spans = vec![Span::styled(
"Recent hashtags: ",
Style::default().fg(theme.accent),
)];
for (i, tag) in profile.recent_hashtags.iter().take(5).enumerate() {
if i > 0 {
hashtag_spans.push(Span::raw(", "));
}
hashtag_spans.push(Span::styled(
format!("#{}", tag),
Style::default().fg(theme.primary),
));
}
lines.push(Line::from(hashtag_spans));
}
let stats = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Profile Stats"),
);
frame.render_widget(stats, area);
}
pub fn render_user_posts(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
if app.profile_state.user_posts.is_empty() {
let empty = Paragraph::new("No posts yet")
.style(Style::default().fg(theme.text_dim))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title("Your Posts")
.border_style(Style::default().fg(theme.border))
.style(Style::default().bg(theme.background)),
);
frame.render_widget(empty, area);
return;
}
let post_width = (area.width as usize).saturating_sub(4);
let items: Vec<ListItem> = app
.profile_state
.user_posts
.iter()
.enumerate()
.flat_map(|(i, post)| {
let is_selected = app.profile_state.list_state.selected() == Some(i);
let mut post_lines: Vec<Line> = Vec::new();
let prefix = if is_selected { "▶ " } else { " " };
let timestamp = post.created_at.format("%Y-%m-%d %H:%M").to_string();
let header_style = if is_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.primary)
};
post_lines.push(Line::from(vec![
Span::styled(prefix, header_style),
Span::styled(timestamp, Style::default().fg(theme.text_dim)),
]));
let content_lines =
format_post_content_with_width(&post.content, is_selected, &theme, post_width);
post_lines.extend(content_lines);
let user_voted_up = post.user_vote.as_deref() == Some("up");
let user_voted_down = post.user_vote.as_deref() == Some("down");
let upvote_style = if user_voted_up {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};
let downvote_style = if user_voted_down {
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};
post_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("↑ {} ", post.upvotes), upvote_style),
Span::styled(format!("↓ {} ", post.downvotes), downvote_style),
Span::styled(
format!("💬 {}", post.reply_count),
Style::default().fg(theme.text_dim),
),
]));
if i < app.profile_state.user_posts.len() - 1 {
post_lines.push(Line::from(""));
}
vec![ListItem::new(post_lines)]
})
.collect();
let posts_widget = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Your Posts"))
.highlight_style(Style::default().bg(theme.highlight_bg));
frame.render_stateful_widget(posts_widget, area, &mut app.profile_state.list_state);
}
pub fn render_settings_tab(frame: &mut Frame, app: &mut App, area: Rect) {
let theme = get_theme_colors(app);
if app.settings_state.loading {
let loading = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
"⟳ Loading settings...",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Please wait",
Style::default().fg(theme.text_dim),
)),
])
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Settings"));
frame.render_widget(loading, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(0), ])
.split(area);
if let Some(error) = &app.settings_state.error {
let is_success = error.contains("successfully");
let style = if is_success {
Style::default().fg(theme.success)
} else {
Style::default().fg(theme.error)
};
let message = Paragraph::new(error.clone())
.style(style)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(message, chunks[0]);
}
if let Some(config) = &app.settings_state.config {
let mut lines = vec![];
lines.push(Line::from(""));
let color_selected =
app.settings_state.selected_field == crate::app::SettingsField::ColorScheme;
let color_style = if color_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
lines.push(Line::from(vec![
Span::styled(if color_selected { "▶ " } else { " " }, color_style),
Span::styled("Color Scheme: ", Style::default().fg(theme.primary)),
Span::styled(config.color_scheme.as_str(), color_style),
Span::raw(" "),
Span::styled("(←/→ to change)", Style::default().fg(theme.text_dim)),
]));
lines.push(Line::from(""));
let sort_selected =
app.settings_state.selected_field == crate::app::SettingsField::SortOrder;
let sort_style = if sort_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
lines.push(Line::from(vec![
Span::styled(if sort_selected { "▶ " } else { " " }, sort_style),
Span::styled("Sort Order: ", Style::default().fg(theme.primary)),
Span::styled(config.sort_order.as_str(), sort_style),
Span::raw(" "),
Span::styled("(←/→ to change)", Style::default().fg(theme.text_dim)),
]));
lines.push(Line::from(""));
let max_posts_selected =
app.settings_state.selected_field == crate::app::SettingsField::MaxPosts;
let max_posts_style = if max_posts_selected {
Style::default()
.fg(theme.success)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let max_posts_value =
if max_posts_selected && !app.settings_state.max_posts_input.is_empty() {
&app.settings_state.max_posts_input
} else {
&config.max_posts_display.to_string()
};
lines.push(Line::from(vec![
Span::styled(
if max_posts_selected { "▶ " } else { " " },
max_posts_style,
),
Span::styled("Max Posts Display: ", Style::default().fg(theme.primary)),
Span::styled(max_posts_value, max_posts_style),
Span::raw(" "),
Span::styled("(←/→ or type number)", Style::default().fg(theme.text_dim)),
]));
lines.push(Line::from(""));
if app.settings_state.has_unsaved_changes {
lines.push(Line::from(vec![
Span::styled(
"⚠ ",
Style::default()
.fg(theme.warning)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"You have unsaved changes. Press 's' to save.",
Style::default().fg(theme.warning),
),
]));
}
let settings_widget =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Settings"));
frame.render_widget(settings_widget, chunks[1]);
} else {
let empty = Paragraph::new("No settings loaded")
.style(Style::default().fg(theme.text_dim))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title("Settings"));
frame.render_widget(empty, chunks[1]);
}
}