tuipub: terminal Micropub client (IndieAuth PKCE login, compose and publish notes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-10-31 19:05:50 -06:00
co-authored by Claude Opus 5.5
commit d3ad760798
13 changed files with 6512 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
//! 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, Focus, Mode, Status, Targets};
const ACCENT: Color = Color::Cyan;
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(Color::DarkGray))
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
let targets_height = match app.targets_state {
Targets::Loaded if !app.targets.is_empty() => (app.targets.len() as u16 + 2).min(8),
_ => 3,
};
let [header, title, content, tags, targets, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(targets_height),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(f.area());
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!(" via {}", app.endpoint),
Style::default().fg(Color::DarkGray),
),
]),
header,
);
app.title
.set_block(field_block("Title", app.focus == Focus::Title));
f.render_widget(&app.title, title);
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
app.content.set_block(field_block(
&format!("Note · {chars} chars"),
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);
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);
let help_text = match app.focus {
Focus::Syndication => {
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit"
}
_ => " Tab next field · Ctrl-S publish · Ctrl-R refresh · Ctrl-O open last post · Esc quit",
};
f.render_widget(
Line::styled(help_text, Style::default().fg(Color::DarkGray)),
help,
);
match &app.mode {
Mode::Editing => {}
Mode::ConfirmPublish => popup(
f,
"Publish?",
vec![
Line::from(vec![
Span::raw("Post to "),
Span::styled(app.me.clone(), Style::default().bold()),
]),
Line::from(vec![
Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
]),
Line::raw(""),
Line::styled(
"y / Enter publish · any other key cancel",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]),
Mode::Published(url) => popup(
f,
"Published",
vec![
Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()),
Line::raw(""),
Line::styled(
"o open in browser · any other key new note",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::ConfirmQuit => popup(
f,
"Quit?",
vec![
Line::raw("This note hasn't been published and will be lost."),
Line::raw(""),
Line::styled(
"y quit · any other key keep editing",
Style::default().fg(Color::DarkGray),
),
],
),
}
}
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Syndication;
let block = field_block("Crosspost to", focused);
match &app.targets_state {
Targets::Loading => f.render_widget(
Paragraph::new("Loading targets…")
.fg(Color::DarkGray)
.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(Color::DarkGray)
.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 { Color::DarkGray }),
),
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);
}
}
}
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
}