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
+523
View File
@@ -0,0 +1,523 @@
//! App state and key handling. Pure logic: no terminal or network here.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui_textarea::{Input, Key, TextArea};
use crate::micropub::{self, Error, Note, ServerConfig, SyndicationTarget};
use crate::worker::{Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Title,
Content,
Tags,
Syndication,
}
impl Focus {
const ORDER: [Focus; 4] = [
Focus::Title,
Focus::Content,
Focus::Tags,
Focus::Syndication,
];
fn step(self, by: isize) -> Focus {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0) as isize;
Self::ORDER[(i + by).rem_euclid(Self::ORDER.len() as isize) as usize]
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Editing,
ConfirmPublish,
Publishing,
Published(String),
ConfirmQuit,
}
/// Where the syndication target list came from.
#[derive(Debug, Clone, PartialEq)]
pub enum Targets {
Loading,
Loaded,
/// Couldn't load q=config. Publishing leaves syndication to the server default.
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Info(String),
Error(String),
}
pub struct App {
pub me: String,
pub endpoint: String,
pub title: TextArea<'static>,
pub content: TextArea<'static>,
pub tags: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets,
pub target_cursor: usize,
pub focus: Focus,
pub mode: Mode,
pub status: Option<Status>,
pub last_permalink: Option<String>,
pub should_quit: bool,
/// Set when a request failed for auth reasons, so main can suggest logging in again.
pub needs_login: bool,
}
fn text_area(placeholder: &str) -> TextArea<'static> {
let mut t = TextArea::default();
t.set_placeholder_text(placeholder);
t.set_cursor_line_style(Style::default());
t
}
impl App {
pub fn new(me: String, endpoint: String) -> Self {
let mut app = App {
me,
endpoint,
title: text_area("Optional title"),
content: text_area("What's on your mind? (Markdown)"),
tags: text_area("Comma-separated, e.g. rust, indieweb"),
targets: Vec::new(),
targets_state: Targets::Loading,
target_cursor: 0,
focus: Focus::Content,
mode: Mode::Editing,
status: None,
last_permalink: None,
should_quit: false,
needs_login: false,
};
app.update_cursors();
app
}
fn update_cursors(&mut self) {
let focus = self.focus;
for (field, area) in [
(Focus::Title, &mut self.title),
(Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags),
] {
let style = if field == focus {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
area.set_cursor_style(style);
}
}
pub fn is_dirty(&self) -> bool {
[&self.title, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty()))
}
fn clear_form(&mut self) {
self.title = text_area("Optional title");
self.content = text_area("What's on your mind? (Markdown)");
self.tags = text_area("Comma-separated, e.g. rust, indieweb");
self.focus = Focus::Content;
self.update_cursors();
}
pub fn note(&self) -> Note {
let title = self.title.lines().join(" ").trim().to_string();
Note {
title: (!title.is_empty()).then_some(title),
content: self.content.lines().join("\n").trim_end().to_string(),
tags: micropub::parse_tags(&self.tags.lines().join(",")),
syndicate_to: match self.targets_state {
Targets::Loaded => Some(
self.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.uid.clone())
.collect(),
),
_ => None,
},
}
}
/// Human summary of where the post will be crossposted.
pub fn syndication_summary(&self) -> String {
match &self.targets_state {
Targets::Loaded => {
let names: Vec<_> = self
.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.name.as_str())
.collect();
if names.is_empty() {
"nowhere".into()
} else {
names.join(", ")
}
}
_ => "server default".into(),
}
}
pub fn handle_response(&mut self, resp: Response) {
match resp {
Response::Config(Ok(cfg)) => self.set_config(cfg),
Response::Config(Err(e)) => {
self.targets_state = Targets::Failed(e.to_string());
self.report(e);
}
Response::Published(Ok(url)) => {
self.clear_form();
self.status = Some(Status::Info(format!("Published {url}")));
self.last_permalink = Some(url.clone());
self.mode = Mode::Published(url);
}
Response::Published(Err(e)) => {
self.mode = Mode::Editing;
self.report(e);
}
}
}
fn report(&mut self, e: Error) {
if matches!(e, Error::Unauthorized(_)) {
self.needs_login = true;
}
self.status = Some(Status::Error(e.to_string()));
}
fn set_config(&mut self, cfg: ServerConfig) {
// Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets);
self.targets = cfg
.syndicate_to
.into_iter()
.map(|t| {
let on = previous
.iter()
.find(|(p, _)| p.uid == t.uid)
.is_none_or(|(_, on)| *on);
(t, on)
})
.collect();
self.target_cursor = self.target_cursor.min(self.targets.len().saturating_sub(1));
self.targets_state = Targets::Loaded;
}
/// Handle a key press. Returns a request for the worker, if any.
pub fn handle_key(&mut self, key: KeyEvent) -> Option<Request> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match self.mode.clone() {
Mode::Publishing => None,
Mode::ConfirmPublish => {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
self.mode = Mode::Publishing;
self.status = Some(Status::Info("Publishing…".into()));
return Some(Request::Publish(self.note()));
}
_ => self.mode = Mode::Editing,
}
None
}
Mode::ConfirmQuit => {
match key.code {
KeyCode::Char('y') => self.should_quit = true,
_ => self.mode = Mode::Editing,
}
None
}
Mode::Published(url) => {
if key.code == KeyCode::Char('o') {
self.open(&url);
}
self.mode = Mode::Editing;
None
}
Mode::Editing => self.handle_editing_key(key, ctrl),
}
}
fn open(&mut self, url: &str) {
if let Err(e) = open::that_detached(url) {
self.status = Some(Status::Error(format!("Could not open browser: {e}")));
}
}
fn handle_editing_key(&mut self, key: KeyEvent, ctrl: bool) -> Option<Request> {
match (ctrl, key.code) {
(true, KeyCode::Char('q' | 'c')) | (false, KeyCode::Esc) => {
if self.is_dirty() {
self.mode = Mode::ConfirmQuit;
} else {
self.should_quit = true;
}
return None;
}
(true, KeyCode::Char('s')) => {
if self.note().content.trim().is_empty() {
self.status = Some(Status::Error("Write something first.".into()));
} else {
self.status = None;
self.mode = Mode::ConfirmPublish;
}
return None;
}
(true, KeyCode::Char('r')) => {
self.targets_state = Targets::Loading;
self.status = Some(Status::Info("Refreshing server config…".into()));
return Some(Request::LoadConfig);
}
(true, KeyCode::Char('o')) => {
if let Some(url) = self.last_permalink.clone() {
self.open(&url);
}
return None;
}
(_, KeyCode::Tab) => {
self.focus = self.focus.step(1);
self.update_cursors();
return None;
}
(_, KeyCode::BackTab) => {
self.focus = self.focus.step(-1);
self.update_cursors();
return None;
}
_ => {}
}
match self.focus {
Focus::Content => {
self.content.input(Input::from(key));
}
Focus::Title | Focus::Tags => {
let input = Input::from(key);
// Single-line fields: Enter moves on instead of adding a line.
if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) {
self.focus = self.focus.step(1);
self.update_cursors();
} else {
let area = if self.focus == Focus::Title {
&mut self.title
} else {
&mut self.tags
};
area.input(input);
}
}
Focus::Syndication => self.handle_target_key(key),
}
None
}
fn handle_target_key(&mut self, key: KeyEvent) {
let n = self.targets.len();
if n == 0 {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.target_cursor = self.target_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.target_cursor = (self.target_cursor + 1).min(n - 1)
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some((_, on)) = self.targets.get_mut(self.target_cursor) {
*on = !*on;
}
}
KeyCode::Char('a') => {
let all_on = self.targets.iter().all(|(_, on)| *on);
for (_, on) in &mut self.targets {
*on = !all_on;
}
}
_ => {}
}
}
/// Bracketed paste: insert text into the focused field.
pub fn handle_paste(&mut self, text: &str) {
if self.mode != Mode::Editing {
return;
}
let text = text.replace("\r\n", "\n").replace('\r', "\n");
match self.focus {
Focus::Content => {
self.content.insert_str(text);
}
Focus::Title => {
self.title.insert_str(text.replace('\n', " "));
}
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
}
Focus::Syndication => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn typ(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key(key(KeyCode::Char(c)));
}
}
fn target(uid: &str) -> SyndicationTarget {
SyndicationTarget {
uid: uid.into(),
name: uid.to_uppercase(),
}
}
fn loaded(targets: &[&str]) -> App {
let mut app = App::new(
"https://example.com/".into(),
"https://example.com/micropub".into(),
);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(),
})));
app
}
#[test]
fn compose_and_publish_flow() {
let mut app = loaded(&["gts", "bsky"]);
typ(&mut app, "Hello");
app.handle_key(key(KeyCode::Enter));
typ(&mut app, "world");
app.handle_key(key(KeyCode::Tab)); // -> tags
typ(&mut app, "rust, #tui");
app.handle_key(key(KeyCode::Tab)); // -> syndication
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Char(' '))); // uncheck bsky
assert!(app.handle_key(ctrl('s')).is_none());
assert_eq!(app.mode, Mode::ConfirmPublish);
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected publish request");
};
assert_eq!(app.mode, Mode::Publishing);
assert_eq!(note.content, "Hello\nworld");
assert_eq!(note.title, None);
assert_eq!(note.tags, vec!["rust", "tui"]);
assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()]));
app.handle_response(Response::Published(
Ok("https://example.com/notes/x".into()),
));
assert_eq!(
app.mode,
Mode::Published("https://example.com/notes/x".into())
);
assert!(!app.is_dirty(), "form clears after publishing");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Editing);
}
#[test]
fn title_enter_moves_to_content() {
let mut app = loaded(&[]);
app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.focus, Focus::Title);
typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.focus, Focus::Content);
assert_eq!(app.title.lines(), ["My title"]);
}
#[test]
fn empty_note_is_not_published() {
let mut app = loaded(&[]);
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::Editing);
assert!(matches!(app.status, Some(Status::Error(_))));
}
#[test]
fn unchecking_all_sends_empty_list_but_unknown_config_sends_none() {
let mut app = loaded(&["gts"]);
app.focus = Focus::Syndication;
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.note().syndicate_to, Some(vec![]));
assert_eq!(app.syndication_summary(), "nowhere");
let mut app = App::new("m".into(), "e".into());
app.handle_response(Response::Config(Err(Error::NoLocation(
reqwest::StatusCode::OK,
))));
assert_eq!(app.note().syndicate_to, None);
assert_eq!(app.syndication_summary(), "server default");
}
#[test]
fn refresh_keeps_choices() {
let mut app = loaded(&["gts", "bsky"]);
app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")],
})));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]);
}
#[test]
fn quit_asks_when_dirty() {
let mut app = loaded(&[]);
typ(&mut app, "draft");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::ConfirmQuit);
app.handle_key(key(KeyCode::Char('n')));
assert!(!app.should_quit);
app.handle_key(ctrl('q'));
app.handle_key(key(KeyCode::Char('y')));
assert!(app.should_quit);
let mut clean = loaded(&[]);
clean.handle_key(key(KeyCode::Esc));
assert!(clean.should_quit);
}
#[test]
fn publish_error_returns_to_editing_and_flags_login() {
let mut app = loaded(&[]);
typ(&mut app, "x");
app.handle_key(ctrl('s'));
app.handle_key(key(KeyCode::Char('y')));
app.handle_response(Response::Published(Err(Error::Unauthorized(
"expired".into(),
))));
assert_eq!(app.mode, Mode::Editing);
assert!(app.needs_login);
assert!(app.is_dirty(), "draft is kept after a failed publish");
}
#[test]
fn paste_into_single_line_fields() {
let mut app = loaded(&[]);
app.focus = Focus::Tags;
app.handle_paste("a\nb");
assert_eq!(app.note().tags, vec!["a", "b"]);
}
}