Ctrl-T switches the post type (note, like, repost) among the types the server lists in q=config. Sends like-of / repost-of with an optional comment; editing a like or repost keeps its target locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
496 lines
18 KiB
Rust
496 lines
18 KiB
Rust
//! 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<ListItem> = 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<String> = 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<ListItem> = 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<Line<'static>>) {
|
|
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/");
|
|
}
|
|
}
|