use crate::app::{App, LoginField, Mode, Pane, ViewMode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
const ORANGE: Color = Color::Rgb(255, 102, 0);
const GRAY: Color = Color::Rgb(150, 150, 150);
const DARK: Color = Color::Rgb(30, 30, 30);
const SELECTED_BG: Color = Color::Rgb(50, 40, 30);
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
.split(area);
draw_header(f, app, root[0]);
draw_body(f, app, root[1]);
draw_hints(f, app, root[2]);
draw_statusbar(f, app, root[3]);
match app.mode {
Mode::Login => draw_login_overlay(f, app, area),
Mode::Compose => draw_compose_overlay(f, app, area),
_ => {}
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let feeds = [
("1", "Top"),
("2", "New"),
("3", "Best"),
("4", "Ask"),
("5", "Show"),
];
let current = app.feed.label();
let mut spans = vec![
Span::styled(" hnr ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
Span::raw("│ "),
];
for (num, label) in &feeds {
let is_current = current.starts_with(label);
if is_current {
spans.push(Span::styled(
format!("{num}:{label}"),
Style::default().fg(Color::Black).bg(ORANGE).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(
num.to_string(),
Style::default().fg(ORANGE),
));
spans.push(Span::styled(
format!(":{label}"),
Style::default().fg(GRAY),
));
}
spans.push(Span::raw(" "));
}
if let Some(session) = &app.session {
spans.push(Span::raw("│ "));
spans.push(Span::styled(
format!(" {} ", session.username),
Style::default().fg(Color::Black).bg(Color::Green).add_modifier(Modifier::BOLD),
));
}
let p = Paragraph::new(Line::from(spans)).style(Style::default().bg(DARK));
f.render_widget(p, area);
}
fn draw_hints(f: &mut Frame, app: &App, area: Rect) {
let spans = match app.mode {
Mode::Login => vec![
Span::styled(" Tab", Style::default().fg(ORANGE)),
Span::styled(" field ", Style::default().fg(GRAY)),
Span::styled("Enter", Style::default().fg(ORANGE)),
Span::styled(" submit ", Style::default().fg(GRAY)),
Span::styled("Esc", Style::default().fg(ORANGE)),
Span::styled(" cancel", Style::default().fg(GRAY)),
],
Mode::Compose => vec![
Span::styled(" Ctrl+S", Style::default().fg(ORANGE)),
Span::styled(" post ", Style::default().fg(GRAY)),
Span::styled("Esc", Style::default().fg(ORANGE)),
Span::styled(" cancel", Style::default().fg(GRAY)),
],
Mode::Command => vec![
Span::styled(" Enter", Style::default().fg(ORANGE)),
Span::styled(" run ", Style::default().fg(GRAY)),
Span::styled("Esc", Style::default().fg(ORANGE)),
Span::styled(" cancel", Style::default().fg(GRAY)),
],
Mode::Normal => {
let login_hint = if app.session.is_some() { "logout" } else { "login" };
let global: &[(&str, &str)] = &[
("h", "help"),
("r", "refresh"),
("l", login_hint),
("/", "cmd"),
("q", "quit"),
];
let pane: &[(&str, &str)] = match app.active_pane {
Pane::Stories => &[
("j/k", "nav"),
("Enter", "comments"),
("Tab", "→pane"),
("o", "url"),
("O", "hn"),
("v", "vote"),
("c", "reply"),
("u", "profile"),
],
Pane::Comments => &[
("j/k", "nav"),
("Space", "collapse"),
("Tab", "←back"),
("o", "url"),
("O", "hn"),
("v", "vote"),
("c", "reply"),
("u", "profile"),
],
};
let sep = Span::styled(" ", Style::default().fg(GRAY));
let divider = Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 60, 60)));
let mut s = vec![Span::raw(" ")];
for (i, (key, desc)) in global.iter().enumerate() {
s.push(Span::styled(key.to_string(), Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)));
s.push(Span::styled(format!(" {desc}"), Style::default().fg(GRAY)));
if i < global.len() - 1 { s.push(sep.clone()); }
}
s.push(divider);
for (i, (key, desc)) in pane.iter().enumerate() {
s.push(Span::styled(key.to_string(), Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)));
s.push(Span::styled(format!(" {desc}"), Style::default().fg(GRAY)));
if i < pane.len() - 1 { s.push(sep.clone()); }
}
s
}
};
let p = Paragraph::new(Line::from(spans))
.style(Style::default().bg(Color::Rgb(20, 20, 20)));
f.render_widget(p, area);
}
fn draw_body(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(area);
draw_story_list(f, app, chunks[0]);
draw_detail_panel(f, app, chunks[1]);
}
fn draw_story_list(f: &mut Frame, app: &App, area: Rect) {
let focused = app.active_pane == Pane::Stories;
let border_style = if focused { Style::default().fg(ORANGE) } else { Style::default().fg(GRAY) };
let visible = (area.height as usize).saturating_sub(2);
let items: Vec<ListItem> = app
.stories
.iter()
.enumerate()
.skip(app.story_scroll)
.take(visible)
.map(|(i, story)| {
let selected = i == app.story_cursor;
let rank = format!("{:>3}. ", i + 1);
let meta = format!(" ▲{} {} | {} comments", story.score(), story.display_by(), story.comment_count());
let title_style = if selected {
Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let rank_style = if selected { Style::default().fg(ORANGE) } else { Style::default().fg(GRAY) };
let bg = if selected { SELECTED_BG } else { Color::Reset };
let line1 = Line::from(vec![
Span::styled(rank, rank_style),
Span::styled(story.display_title(), title_style),
]);
let line2 = Line::from(vec![
Span::raw(" "),
Span::styled(meta, Style::default().fg(GRAY)),
]);
ListItem::new(ratatui::text::Text::from(vec![line1, line2])).style(Style::default().bg(bg))
})
.collect();
let mut state = ListState::default();
if focused {
state.select(Some(app.story_cursor.saturating_sub(app.story_scroll)));
}
let list = List::new(items).block(
Block::default()
.title(format!(" {} Stories ", app.feed.label()))
.borders(Borders::ALL)
.border_style(border_style),
);
f.render_stateful_widget(list, area, &mut state);
}
fn draw_detail_panel(f: &mut Frame, app: &App, area: Rect) {
if app.view_mode == ViewMode::User {
if let Some(user) = &app.user_profile {
draw_user_profile(f, user, area);
return;
}
}
let focused = app.active_pane == Pane::Comments;
if let Some(story) = app.selected_story() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(6), Constraint::Min(0)])
.split(area);
draw_story_header(f, story, chunks[0]);
draw_comments(f, app, chunks[1], focused);
} else {
let p = Paragraph::new("Select a story to read comments.")
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(GRAY)))
.style(Style::default().fg(GRAY));
f.render_widget(p, area);
}
}
fn draw_story_header(f: &mut Frame, story: &crate::api::Item, area: Rect) {
let url = story.url.as_deref().unwrap_or("(self post)");
let meta = format!("▲ {} by {} | {} comments | o: url O: HN", story.score(), story.display_by(), story.comment_count());
let text_body = story.text_plain();
let mut lines = vec![
Line::from(Span::styled(story.display_title(), Style::default().fg(ORANGE).add_modifier(Modifier::BOLD))),
Line::from(Span::styled(url, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED))),
Line::from(Span::styled(meta, Style::default().fg(GRAY))),
];
for l in text_body.lines().take(2) {
lines.push(Line::from(Span::styled(l.to_string(), Style::default().fg(Color::White))));
}
let p = Paragraph::new(lines)
.block(Block::default().title(" Story ").borders(Borders::ALL).border_style(Style::default().fg(ORANGE)))
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
fn draw_comments(f: &mut Frame, app: &App, area: Rect, focused: bool) {
let border_style = if focused { Style::default().fg(ORANGE) } else { Style::default().fg(GRAY) };
let flat = app.flat_comments();
let visible = (area.height as usize).saturating_sub(2);
let depth_colors = [Color::Cyan, Color::Green, Color::Yellow, Color::Magenta, Color::Red, Color::Blue, Color::White];
let items: Vec<ListItem> = flat
.iter()
.enumerate()
.skip(app.comment_scroll)
.take(visible)
.map(|(i, (node, depth))| {
let selected = i + app.comment_scroll == app.comment_cursor;
let indent = " ".repeat(*depth);
let collapse = if node.collapsed && !node.children.is_empty() {
" [+]"
} else if !node.collapsed && !node.children.is_empty() {
" [-]"
} else {
""
};
let dc = depth_colors[depth % depth_colors.len()];
let header = format!("{indent}▸ {}{collapse}", node.item.display_by());
let header_style = if selected {
Style::default().fg(dc).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(dc)
};
let text = node.item.text_plain();
let preview: String = text
.lines()
.next()
.unwrap_or("")
.chars()
.take(area.width as usize - depth * 2 - 4)
.collect();
let bg = if selected { SELECTED_BG } else { Color::Reset };
ListItem::new(ratatui::text::Text::from(vec![
Line::from(Span::styled(header, header_style)),
Line::from(vec![Span::raw(format!("{indent} ")), Span::styled(preview, Style::default().fg(Color::White))]),
]))
.style(Style::default().bg(bg))
})
.collect();
let title = if focused { " Comments (Space collapse · v vote · c reply) " } else { " Comments " };
f.render_widget(
List::new(items).block(Block::default().title(title).borders(Borders::ALL).border_style(border_style)),
area,
);
}
fn draw_user_profile(f: &mut Frame, user: &crate::api::User, area: Rect) {
let about = user.about_plain();
let mut lines = vec![
Line::from(Span::raw("")),
Line::from(Span::styled(format!(" {}", user.id), Style::default().fg(ORANGE).add_modifier(Modifier::BOLD))),
Line::from(vec![
Span::raw(" "),
Span::styled(format!("▲ {} karma", user.karma), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("Joined {} · {} submissions", user.joined_ago(), user.submission_count()),
Style::default().fg(GRAY),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("https://news.ycombinator.com/user?id={}", user.id),
Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED),
),
]),
];
if !about.is_empty() {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(" About", Style::default().fg(GRAY))));
lines.push(Line::from(Span::raw(" ─────────────────────────────────────")));
for l in about.lines().take(20) {
lines.push(Line::from(vec![Span::raw(" "), Span::styled(l.to_string(), Style::default().fg(Color::White))]));
}
}
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(" Esc back · o open in browser", Style::default().fg(GRAY))));
let p = Paragraph::new(lines)
.block(Block::default().title(format!(" User: {} ", user.id)).borders(Borders::ALL).border_style(Style::default().fg(ORANGE)))
.wrap(Wrap { trim: false });
f.render_widget(p, area);
}
fn draw_login_overlay(f: &mut Frame, app: &App, area: Rect) {
let popup = centered_rect(50, 40, area);
f.render_widget(Clear, popup);
let state = &app.login_state;
let username_style = if state.field == LoginField::Username {
Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let password_style = if state.field == LoginField::Password {
Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let masked = "*".repeat(state.password.len());
let cursor = "█";
let lines = vec![
Line::from(Span::raw("")),
Line::from(Span::styled(" Username", Style::default().fg(GRAY))),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}{}", state.username, if state.field == LoginField::Username { cursor } else { "" }),
username_style,
),
]),
Line::from(Span::raw("")),
Line::from(Span::styled(" Password", Style::default().fg(GRAY))),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{}{}", masked, if state.field == LoginField::Password { cursor } else { "" }),
password_style,
),
]),
Line::from(Span::raw("")),
if state.error.is_empty() {
Line::from(Span::raw(""))
} else {
Line::from(Span::styled(format!(" {}", state.error), Style::default().fg(Color::Red)))
},
Line::from(Span::raw("")),
Line::from(Span::styled(
" Tab: switch field · Enter: login · Esc: cancel",
Style::default().fg(GRAY),
)),
];
let p = Paragraph::new(lines)
.block(
Block::default()
.title(" Login to Hacker News ")
.borders(Borders::ALL)
.border_style(Style::default().fg(ORANGE)),
)
.style(Style::default().bg(DARK));
f.render_widget(p, popup);
}
fn draw_compose_overlay(f: &mut Frame, app: &App, area: Rect) {
let popup = centered_rect(70, 60, area);
f.render_widget(Clear, popup);
let state = match &app.compose_state {
Some(s) => s,
None => return,
};
let mut lines = vec![];
for line in state.text.lines() {
lines.push(Line::from(Span::styled(line.to_string(), Style::default().fg(Color::White))));
}
let cursor_line = if state.text.ends_with('\n') || state.text.is_empty() {
Line::from(Span::styled("█", Style::default().fg(ORANGE)))
} else {
let last = state.text.lines().last().unwrap_or("").to_string();
lines.pop();
Line::from(vec![
Span::styled(last, Style::default().fg(Color::White)),
Span::styled("█", Style::default().fg(ORANGE)),
])
};
lines.push(cursor_line);
let title = format!(" Reply to {} ", state.parent_by);
let p = Paragraph::new(lines)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(ORANGE))
.title_bottom(Line::from(Span::styled(
" Ctrl+S submit · Esc cancel ",
Style::default().fg(GRAY),
))),
)
.style(Style::default().bg(DARK))
.wrap(Wrap { trim: false });
f.render_widget(p, popup);
}
fn draw_statusbar(f: &mut Frame, app: &App, area: Rect) {
let p = match app.mode {
Mode::Command => Paragraph::new(format!(":{}", app.command_input))
.style(Style::default().fg(Color::White).bg(DARK)),
_ => {
let style = if app.loading {
Style::default().fg(ORANGE)
} else {
Style::default().fg(GRAY)
};
Paragraph::new(Line::from(Span::styled(&app.status_message, style)))
.style(Style::default().bg(DARK))
}
};
f.render_widget(p, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vert[1])[1]
}