//! Rendering. use ratatui::{ Frame, layout::{Constraint, Flex, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets}; use crate::micropub::{Kind, Post}; const ACCENT: Color = Color::Cyan; const DIM: Color = Color::DarkGray; fn field_block(title: &str, focused: bool) -> Block<'static> { let block = Block::bordered().title(format!(" {title} ")); if focused { block .border_type(BorderType::Thick) .border_style(Style::default().fg(ACCENT)) .title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)) } else { block.border_style(Style::default().fg(DIM)) } } fn hint(s: &str) -> Line<'static> { Line::styled(s.to_string(), Style::default().fg(DIM)) } pub fn draw(f: &mut Frame, app: &mut App) { let [header, body, status, help] = Layout::vertical([ Constraint::Length(1), Constraint::Min(8), Constraint::Length(1), Constraint::Length(1), ]) .areas(f.area()); let place = match (app.screen, &app.editing) { (Screen::Posts, _) => "Posts".to_string(), (Screen::Compose, Some(p)) => format!("Editing {}", p.url), (Screen::Compose, None) => format!("New {}", app.kind.label().to_lowercase()), }; f.render_widget( Line::from(vec![ Span::styled( " tuipub ", Style::default().fg(Color::Black).bg(ACCENT).bold(), ), Span::raw(" "), Span::styled(app.me.clone(), Style::default().bold()), Span::styled(format!(" · {place}"), Style::default().fg(DIM)), ]), header, ); let help_text = match app.screen { Screen::Compose => draw_compose(f, app, body), Screen::Posts => draw_posts(f, app, body), }; let status_line = match &app.status { Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)), Some(Status::Error(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Red)), None => Line::raw(""), }; f.render_widget(status_line, status); f.render_widget(hint(help_text), help); draw_popup(f, app); } fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str { let editing = app.editing.is_some(); let targets_height = match (&app.targets_state, editing) { (_, true) => 3, (Targets::Loaded, false) if !app.targets.is_empty() => { (app.targets.len() as u16 + 2).min(8) } _ => 3, }; let [title, reply, content, tags, targets] = Layout::vertical([ Constraint::Length(3), Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), Constraint::Length(targets_height), ]) .areas(area); app.title .set_block(field_block("Title", app.focus == Focus::Title)); f.render_widget(&app.title, title); let link_label = match (app.kind, app.link_locked()) { (Kind::Note, _) => "In reply to", (Kind::Like, false) => "Like of", (Kind::Repost, false) => "Repost of", (Kind::Like, true) => "Like of · can't be changed", (Kind::Repost, true) => "Repost of · can't be changed", }; app.link .set_block(field_block(link_label, app.focus == Focus::Link)); f.render_widget(&app.link, reply); let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum(); let name = if app.kind == Kind::Note { "Note" } else { "Comment (optional)" }; let label = match &app.editing { Some(p) if p.draft => format!("{name} · draft · {chars} chars"), _ => format!("{name} · {chars} chars"), }; app.content .set_block(field_block(&label, app.focus == Focus::Content)); f.render_widget(&app.content, content); app.tags .set_block(field_block("Tags", app.focus == Focus::Tags)); f.render_widget(&app.tags, tags); draw_targets(f, app, targets); match (app.focus, editing) { (Focus::Syndication, _) => { " ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit" } (_, true) => { " Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts" } (_, false) => { " Tab next · Ctrl-T note/like/repost · Ctrl-S publish · Ctrl-P posts · Ctrl-O open last · Esc quit" } } } fn draw_targets(f: &mut Frame, app: &App, area: Rect) { let focused = app.focus == Focus::Syndication; let block = field_block("Crosspost to", focused); if app.editing.is_some() { let text = format!( "{} · targets can't be changed after posting", app.syndication_summary() ); f.render_widget(Paragraph::new(text).fg(DIM).block(block), area); return; } match &app.targets_state { Targets::Loading => f.render_widget( Paragraph::new("Loading targets…").fg(DIM).block(block), area, ), Targets::Failed(_) => f.render_widget( Paragraph::new("Couldn't load targets; the server's default applies (Ctrl-R to retry)") .fg(Color::Yellow) .block(block), area, ), Targets::Loaded if app.targets.is_empty() => f.render_widget( Paragraph::new("This server has no crosspost targets") .fg(DIM) .block(block), area, ), Targets::Loaded => { let items: Vec = app .targets .iter() .map(|(t, on)| { let mark = if *on { "[x] " } else { "[ ] " }; ListItem::new(Line::from(vec![ Span::styled( mark, Style::default().fg(if *on { Color::Green } else { DIM }), ), Span::raw(t.name.clone()), ])) }) .collect(); let mut list = List::new(items).block(block); if focused { list = list.highlight_style(Style::default().add_modifier(Modifier::REVERSED)); } let mut state = ListState::default().with_selected(Some(app.target_cursor)); f.render_stateful_widget(list, area, &mut state); } } } /// "2026-11-01T01:02:09.6" -> "2026-11-01 01:02" fn short_date(iso: &str) -> String { let date = iso.get(..10).unwrap_or(iso); match iso.get(11..16) { Some(time) => format!("{date} {time}"), None => date.to_string(), } } /// The post's title, or the start of its first line (for a bare like or repost, its URL). fn headline(p: &Post) -> String { p.title.clone().unwrap_or_else(|| { p.content .lines() .find(|l| !l.trim().is_empty()) .map(|l| l.trim().to_string()) .or_else(|| p.target().map(str::to_string)) .unwrap_or_else(|| "(empty)".to_string()) }) } fn post_item(p: &Post) -> ListItem<'static> { let mut spans = vec![Span::styled( format!("{} ", short_date(&p.published)), Style::default().fg(DIM), )]; if p.draft { spans.push(Span::styled( "draft ", Style::default().fg(Color::Yellow).bold(), )); } match p.kind() { Kind::Like => spans.push(Span::styled("♥ ", Style::default().fg(Color::Magenta))), Kind::Repost => spans.push(Span::styled("🔁 ", Style::default().fg(Color::Green))), Kind::Note if p.in_reply_to.is_some() => { spans.push(Span::styled("↩ ", Style::default().fg(ACCENT))) } Kind::Note => {} } let head = headline(p); spans.push(if p.title.is_some() { Span::styled(head, Style::default().bold()) } else { Span::raw(head) }); if !p.tags.is_empty() { let tags: Vec = p.tags.iter().map(|t| format!("#{t}")).collect(); spans.push(Span::styled( format!(" {}", tags.join(" ")), Style::default().fg(DIM), )); } if !p.syndication.is_empty() { spans.push(Span::styled( format!(" ↗{}", p.syndication.len()), Style::default().fg(Color::Green), )); } ListItem::new(Line::from(spans)) } fn draw_posts(f: &mut Frame, app: &App, area: Rect) -> &'static str { let [list_area, preview_area] = Layout::vertical([Constraint::Min(5), Constraint::Percentage(40)]).areas(area); let count = match (&app.posts_state, app.posts_more) { (Posts::Loading, _) => "loading…".to_string(), (_, true) => format!("{}+", app.posts.len()), _ => app.posts.len().to_string(), }; let block = field_block(&format!("Posts · {count}"), true); match &app.posts_state { Posts::Failed(e) if app.posts.is_empty() => f.render_widget( Paragraph::new(format!("Couldn't load posts: {e} (r to retry)")) .fg(Color::Red) .wrap(Wrap { trim: true }) .block(block), list_area, ), _ if app.posts.is_empty() => { let msg = if app.posts_state == Posts::Loading { "Loading…" } else { "No posts yet." }; f.render_widget(Paragraph::new(msg).fg(DIM).block(block), list_area) } _ => { let mut items: Vec = app.posts.iter().map(post_item).collect(); if app.posts_more { items.push(ListItem::new(hint(" ↓ more"))); } let list = List::new(items) .block(block) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); let mut state = ListState::default().with_selected(Some(app.posts_cursor)); f.render_stateful_widget(list, list_area, &mut state); } } let preview = match app.selected_post() { Some(p) => { let mut lines = Vec::new(); if let Some(t) = &p.title { lines.push(Line::styled(t.clone(), Style::default().bold())); } if let Some(u) = &p.like_of { lines.push(Line::styled( format!("♥ Like of {u}"), Style::default().fg(Color::Magenta), )); } if let Some(u) = &p.repost_of { lines.push(Line::styled( format!("🔁 Repost of {u}"), Style::default().fg(Color::Green), )); } if let Some(r) = &p.in_reply_to { lines.push(Line::styled( format!("In reply to {r}"), Style::default().fg(ACCENT), )); } lines.extend(p.content.lines().map(|l| Line::raw(l.to_string()))); lines.push(Line::raw("")); lines.push(hint(&p.url)); for s in &p.syndication { lines.push(Line::styled( format!("↗ {s}"), Style::default().fg(Color::Green), )); } Paragraph::new(lines).wrap(Wrap { trim: false }) } None => Paragraph::new(""), }; f.render_widget(preview.block(field_block("Preview", false)), preview_area); if app.scope_hint().is_some() { " ↑↓ move · Enter edit · n new · r reload · Esc back · (log in again to edit/delete)" } else if app.selected_post().is_some_and(|p| p.draft) { " ↑↓ move · Enter edit · p publish · d delete · u undo · n new · r reload · Esc back" } else { " ↑↓ move · Enter edit · d delete · u undo delete · o open · n new · r reload · Esc back" } } fn draw_popup(f: &mut Frame, app: &App) { let editing = app.editing.is_some(); match &app.mode { Mode::Normal => {} Mode::ConfirmPublish => { let verb = match (editing, app.kind) { (true, _) => "Save changes to ", (false, Kind::Note) => "Post to ", (false, Kind::Like) => "Like, on ", (false, Kind::Repost) => "Repost, on ", }; let mut lines = vec![Line::from(vec![ Span::raw(verb), Span::styled(app.me.clone(), Style::default().bold()), ])]; lines.push(Line::from(vec![ Span::raw("Crosspost: "), Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)), ])); if app.kind == Kind::Like && !editing { lines.push(hint("Likes of ordinary web pages aren't crossposted.")); } lines.push(Line::raw("")); let keys = match (editing, app.default_draft()) { (false, _) => "y / Enter publish · d save as draft · Esc cancel", (true, true) => "y / Enter save as draft · p publish · Esc cancel", (true, false) => "y / Enter save · d unpublish to draft · Esc cancel", }; lines.push(hint(keys)); popup(f, if editing { "Save?" } else { "Publish?" }, lines); } Mode::Sending => popup(f, "Working", vec![Line::raw("Talking to your site…")]), Mode::Published { url, draft } => { let title = if *draft { "Draft saved" } else { "Published" }; let mut lines = vec![Line::styled( url.clone(), Style::default().fg(ACCENT).underlined(), )]; lines.push(Line::raw("")); lines.push(hint(if *draft { "Not public yet; publish it from the post list (Ctrl-P) · any key to continue" } else { "o open in browser · any other key new note" })); popup(f, title, lines); } Mode::ConfirmQuit => popup( f, "Quit?", vec![ Line::raw(if editing { "Your changes haven't been saved and will be lost." } else { "This note hasn't been published and will be lost." }), Line::raw(""), hint("y quit · any other key keep editing"), ], ), Mode::ConfirmDiscard(action) => { let what = match action { Discard::New | Discard::BackToPosts if editing => { "Discard your changes to this post?" } Discard::Edit(_) if editing => "Discard your changes to the post you're editing?", _ => "Discard the note you're writing?", }; popup( f, "Discard?", vec![ Line::raw(what), Line::raw(""), hint("y discard · any other key keep it"), ], ); } Mode::ConfirmDelete(url) => { let head = app .posts .iter() .find(|p| &p.url == url) .map(headline) .unwrap_or_else(|| url.clone()); let head: String = head.chars().take(60).collect(); popup( f, "Delete?", vec![ Line::styled(head, Style::default().bold()), Line::raw("Its crossposts are removed too. You can undo with u."), Line::raw(""), hint("y delete · any other key cancel"), ], ); } } } fn popup(f: &mut Frame, title: &str, lines: Vec>) { let width = lines .iter() .map(|l| l.width() as u16) .max() .unwrap_or(20) .max(30) + 4; let height = lines.len() as u16 + 2; let area = centered(f.area(), width, height); f.render_widget(Clear, area); f.render_widget( Paragraph::new(lines).wrap(Wrap { trim: false }).block( Block::bordered() .border_type(BorderType::Rounded) .border_style(Style::default().fg(ACCENT)) .title(format!(" {title} ")) .title_style(Style::default().bold()), ), area, ); } fn centered(area: Rect, width: u16, height: u16) -> Rect { let [row] = Layout::vertical([Constraint::Length(height.min(area.height))]) .flex(Flex::Center) .areas(area); let [cell] = Layout::horizontal([Constraint::Length(width.min(area.width))]) .flex(Flex::Center) .areas(row); cell } #[cfg(test)] mod tests { use super::*; #[test] fn dates_and_headlines() { assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02"); assert_eq!(short_date("2026-11-01"), "2026-11-01"); assert_eq!(short_date(""), ""); let mut p = Post { content: "\nFirst line\nsecond".into(), ..Default::default() }; assert_eq!(headline(&p), "First line"); p.title = Some("Title".into()); assert_eq!(headline(&p), "Title"); let like = Post { like_of: Some("https://x.example/".into()), ..Default::default() }; assert_eq!(headline(&like), "https://x.example/"); } }