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:
+523
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user