Files
ipx/crates/tui/src/main.rs
rays 75d099474c Terminal reader: play with mpv, mark read on move, folders and pins (#439, #440, #441)
p hands the item's downloaded file (/media/{id}) to mpv in its own window, with the token as a
header. Moving onto an item with j or k marks it read, as selecting one on the page does. The
feed list follows the page's order: pinned first, a subscribed OPML a folder that Space opens,
its unread the sum of its feeds', and P pins or unpins. scripts/build-tui.sh cross-builds the
binary for Linux and macOS under the same cap as the image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:02 +00:00

642 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A terminal reader for ipx (#437): your feeds, a feed's items and an item's text, through the
//! same API the page uses, as the person whose API token it is given.
use anyhow::{Context, Result, bail};
use clap::Parser;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{DefaultTerminal, Frame};
use reqwest::Url;
use serde::Deserialize;
use std::collections::HashSet;
use std::time::Duration;
#[derive(Parser)]
#[command(name = "ipx", version, about = "Read iPX in a terminal")]
struct Args {
/// Where iPX is, as a browser reaches it.
#[arg(long, env = "IPX_URL", default_value = "http://localhost:8099")]
url: Url,
/// An API token, made in the page's Settings.
#[arg(long, env = "IPX_API_TOKEN", hide_env_values = true)]
token: String,
}
/// The part of web.rs's FeedRow this needs.
#[derive(Deserialize)]
struct Feed {
id: String,
url: String,
title: Option<String>,
unread: i64,
/// The OPML it came from.
group: Option<String>,
hide_from_all: bool,
pinned: bool,
}
/// The feed list as the page draws it (feeds.ts renderFeeds), as indexes into `feeds` and
/// whether each is inside a folder: pinned rows first, a subscribed OPML a folder with its feeds
/// under it when open (those with unread first), and a pinned feed lifted out of its folder.
fn order(feeds: &[Feed], open: &HashSet<String>) -> Vec<(usize, bool)> {
let listed = |id: &str| feeds.iter().any(|f| f.id == id);
let mut tops: Vec<usize> =
(0..feeds.len()).filter(|&i| feeds[i].pinned || !feeds[i].group.as_deref().is_some_and(listed)).collect();
tops.sort_by_key(|&i| !feeds[i].pinned);
let mut rows = vec![];
for t in tops {
rows.push((t, false));
if open.contains(&feeds[t].id) {
let mut inside: Vec<usize> = (0..feeds.len())
.filter(|&i| !feeds[i].pinned && feeds[i].group.as_deref() == Some(feeds[t].id.as_str()))
.collect();
inside.sort_by_key(|&i| feeds[i].unread == 0);
rows.extend(inside.into_iter().map(|i| (i, true)));
}
}
rows
}
/// The part of db.rs's EntryRow this needs.
#[derive(Deserialize)]
struct Entry {
guid: String,
feed_id: String,
title: Option<String>,
link: Option<String>,
published: Option<i64>,
description: Option<String>,
read: bool,
flagged: bool,
enclosures: Vec<Enclosure>,
}
/// The part of db.rs's EncRow this needs: its id is its address, `/media/{id}`, which is there
/// only once it is downloaded (`path`); one skipped or still to come answers 404.
#[derive(Deserialize)]
struct Enclosure {
id: i64,
path: Option<String>,
}
/// What `p` plays: the item's first file iPX has.
fn playable(e: &Entry) -> Option<&Enclosure> {
e.enclosures.iter().find(|x| x.path.is_some())
}
#[derive(Deserialize)]
struct EntryPage {
total: i64,
entries: Vec<Entry>,
}
struct Api {
http: reqwest::blocking::Client,
base: Url,
token: String,
}
impl Api {
/// The API's address for these path segments, each escaped: a guid is often a URL itself.
fn url(&self, segments: &[&str]) -> Url {
let mut url = self.base.clone();
url.path_segments_mut().expect("an http URL has a path").pop_if_empty().extend(segments);
url
}
fn send(&self, req: reqwest::blocking::RequestBuilder) -> Result<reqwest::blocking::Response> {
let res = req.bearer_auth(&self.token).send()?;
if !res.status().is_success() {
bail!("{} {}", res.status(), res.text().unwrap_or_default());
}
Ok(res)
}
/// Plays a file in mpv, in a window of its own so the terminal stays the reader's, with the
/// token in a header as every other request has it.
// shortcut: the token is on mpv's command line, which `ps` shows anyone on this machine;
// pass it through a private mpv config file if the reader is used on a shared one.
fn play(&self, enc: &Enclosure) -> Result<()> {
let url = self.url(&["media", &enc.id.to_string()]);
std::process::Command::new("mpv")
.arg("--force-window=yes")
.arg(format!("--http-header-fields=Authorization: Bearer {}", self.token))
.arg(url.as_str())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("could not start mpv; is it installed?")?;
Ok(())
}
fn set_pinned(&self, feed: &str, on: bool) -> Result<()> {
let req = self.http.patch(self.url(&["api", "feeds", feed])).json(&serde_json::json!({ "pinned": on }));
self.send(req).map(drop)
}
fn feeds(&self) -> Result<Vec<Feed>> {
Ok(self.send(self.http.get(self.url(&["api", "feeds"])))?.json()?)
}
/// The newest items of one feed, or of all of them when `feed` is None.
// shortcut: the newest 200 only, no paging; page through `offset` if someone wants older.
fn entries(&self, feed: Option<&str>, unread_only: bool) -> Result<EntryPage> {
let mut url = match feed {
Some(id) => self.url(&["api", "feeds", id, "entries"]),
None => self.url(&["api", "entries"]),
};
let filter = if unread_only { "unread" } else { "all" };
url.query_pairs_mut().append_pair("limit", "200").append_pair("filter", filter);
Ok(self.send(self.http.get(url))?.json()?)
}
/// Sets an item's read or flagged, and answers with its feed's row, for the new unread count.
fn set_flag(&self, e: &Entry, flag: &str, on: bool) -> Result<Option<Feed>> {
let url = self.url(&["api", "entries", &e.feed_id, &e.guid, "flags"]);
Ok(self.send(self.http.post(url).json(&serde_json::json!({ flag: on })))?.json()?)
}
}
#[derive(PartialEq, Clone, Copy)]
enum Pane {
Feeds,
Items,
Text,
}
struct App {
api: Api,
feeds: Vec<Feed>,
/// What the feed list shows, after All Subscriptions, which is index 0 of `feed_list`.
rows: Vec<(usize, bool)>,
/// The folders open; they start closed, as on the page, since one can hold dozens of feeds.
open: HashSet<String>,
feed_list: ListState,
entries: Vec<Entry>,
total: i64,
entry_list: ListState,
pane: Pane,
scroll: u16,
unread_only: bool,
status: String,
}
impl App {
fn feed(&self) -> Option<&Feed> {
let i = self.feed_list.selected()?;
self.rows.get(i.checked_sub(1)?).map(|&(f, _)| &self.feeds[f])
}
fn feed_id(&self) -> Option<&str> {
self.feed().map(|f| f.id.as_str())
}
/// The feed list again after the feeds or the open folders change, on the same feed, or on
/// its folder when that has just closed over it.
fn arrange(&mut self) {
let was = self.feed().map(|f| (f.id.clone(), f.group.clone()));
self.rows = order(&self.feeds, &self.open);
let at = |id: &str| self.rows.iter().position(|&(f, _)| self.feeds[f].id == id).map(|i| i + 1);
let row = was.and_then(|(id, group)| at(&id).or_else(|| group.and_then(|g| at(&g))));
self.feed_list.select(Some(row.unwrap_or(0)));
}
/// The unread count a row shows: a folder holds no items itself, so it is what is inside.
fn unread(&self, f: &Feed) -> i64 {
let inside: Vec<_> = self.feeds.iter().filter(|c| c.group.as_deref() == Some(f.id.as_str())).collect();
if inside.is_empty() { f.unread } else { inside.iter().map(|c| c.unread).sum() }
}
fn entry(&self) -> Option<&Entry> {
self.entries.get(self.entry_list.selected()?)
}
/// Runs an API call, putting what went wrong in the status line instead of ending the program.
fn attempt<T>(&mut self, what: Result<T>) -> Option<T> {
what.map_err(|e| self.status = format!("{e:#}")).ok()
}
fn load_feeds(&mut self) {
if let Some(feeds) = self.attempt(self.api.feeds()) {
self.feeds = feeds;
self.arrange();
self.status.clear();
}
}
fn load_entries(&mut self) {
let page = self.api.entries(self.feed_id(), self.unread_only);
if let Some(page) = self.attempt(page) {
self.entries = page.entries;
self.total = page.total;
self.entry_list.select(if self.entries.is_empty() { None } else { Some(0) });
self.scroll = 0;
self.status.clear();
}
}
/// Sets the selected item's read or flagged, as the page does.
fn flag(&mut self, flag: &str, on: impl Fn(&Entry) -> bool) {
let Some(e) = self.entry() else { return };
let on = on(e);
let result = self.api.set_flag(e, flag, on);
let Some(row) = self.attempt(result) else { return };
if let Some(i) = self.entry_list.selected() {
let e = &mut self.entries[i];
if flag == "read" { e.read = on } else { e.flagged = on }
}
if let Some(row) = row
&& let Some(f) = self.feeds.iter_mut().find(|f| f.id == row.id)
{
f.unread = row.unread;
}
}
/// Selecting an item is reading it, as on the page (items.ts markRead), j and k included.
fn mark_read(&mut self) {
if self.entry().is_some_and(|e| !e.read) {
self.flag("read", |_| true);
}
}
/// Opens the selected item, which marks it read, as opening one on the page does.
fn open(&mut self) {
self.scroll = 0;
self.pane = Pane::Text;
self.mark_read();
}
fn step(&mut self, down: bool) {
let list = match self.pane {
Pane::Feeds => &mut self.feed_list,
Pane::Items => &mut self.entry_list,
Pane::Text => {
self.scroll = if down { self.scroll.saturating_add(1) } else { self.scroll.saturating_sub(1) };
return;
}
};
if down {
list.select_next()
} else {
list.select_previous()
}
if self.pane == Pane::Items {
self.scroll = 0;
self.mark_read();
}
}
/// Handles a key, answering false when it is time to quit.
fn key(&mut self, code: KeyCode) -> bool {
match code {
KeyCode::Char('q') => return false,
KeyCode::Esc if self.pane == Pane::Feeds => return false,
KeyCode::Char('j') | KeyCode::Down => self.step(true),
KeyCode::Char('k') | KeyCode::Up => self.step(false),
KeyCode::PageDown | KeyCode::Char(' ') if self.pane == Pane::Text => {
self.scroll = self.scroll.saturating_add(10)
}
KeyCode::Char(' ') if self.pane == Pane::Feeds => {
if let Some(id) = self.feed_id().map(str::to_owned) {
if !self.open.remove(&id) {
self.open.insert(id);
}
self.arrange();
}
}
KeyCode::Char('P') => {
let Some(f) = self.feed() else { return true };
let (id, on) = (f.id.clone(), !f.pinned);
if self.attempt(self.api.set_pinned(&id, on)).is_some() {
self.load_feeds();
self.status = if on { "pinned to the top" } else { "unpinned" }.into();
}
}
KeyCode::PageUp if self.pane == Pane::Text => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => match self.pane {
Pane::Feeds => {
self.load_entries();
self.pane = Pane::Items;
}
Pane::Items => self.open(),
Pane::Text => {}
},
KeyCode::Esc | KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => {
self.pane = match self.pane {
Pane::Text => Pane::Items,
_ => Pane::Feeds,
}
}
KeyCode::Char('p') => {
let Some(e) = self.entry() else { return true };
match playable(e).map(|enc| self.api.play(enc)) {
Some(r) => {
if self.attempt(r).is_some() {
self.status = "playing in mpv".into();
}
}
None if e.enclosures.is_empty() => self.status = "this item has no file to play".into(),
None => self.status = "iPX has not downloaded this item's file".into(),
}
}
KeyCode::Char('m') => self.flag("read", |e| !e.read),
KeyCode::Char('f') => self.flag("flagged", |e| !e.flagged),
KeyCode::Char('u') => {
self.unread_only = !self.unread_only;
self.load_entries();
}
KeyCode::Char('r') => {
self.load_feeds();
self.load_entries();
}
_ => {}
}
true
}
fn draw(&mut self, frame: &mut Frame) {
let [main, foot] = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(frame.area());
let [left, right] = Layout::horizontal([Constraint::Percentage(28), Constraint::Fill(1)]).areas(main);
let [top, bottom] = Layout::vertical([Constraint::Percentage(40), Constraint::Fill(1)]).areas(right);
let block = |title: String, pane: Pane| {
let b = Block::bordered().title(title);
if self.pane == pane { b.border_style(Style::new().bold()) } else { b.dim() }
};
let picked = Style::new().add_modifier(Modifier::REVERSED);
// What the page counts (feeds.ts inAll): not a feed hidden from All Subscriptions, or
// one from an OPML that is (#288).
let hidden = |id: &str| self.feeds.iter().any(|g| g.id == id && g.hide_from_all);
let all: i64 = self
.feeds
.iter()
.filter(|f| !f.hide_from_all && !f.group.as_deref().is_some_and(hidden))
.map(|f| f.unread)
.sum();
let feeds =
std::iter::once(format!("All Subscriptions ({all})")).chain(self.rows.iter().map(|&(i, inside)| {
let f = &self.feeds[i];
let folder = self.feeds.iter().any(|c| c.group.as_deref() == Some(f.id.as_str()));
let lead = match (inside, folder, self.open.contains(&f.id)) {
(true, ..) => " ",
(_, true, true) => "▾ ",
(_, true, false) => "▸ ",
_ => " ",
};
let pin = if f.pinned && !inside { "↑ " } else { "" };
let name = f.title.as_deref().unwrap_or(&f.url);
match self.unread(f) {
0 => format!("{lead}{pin}{name}"),
n => format!("{lead}{pin}{name} ({n})"),
}
}));
let feeds = List::new(feeds).block(block("Feeds".into(), Pane::Feeds)).highlight_style(picked);
frame.render_stateful_widget(feeds, left, &mut self.feed_list);
let items = self.entries.iter().map(|e| {
let mark = format!("{}{}", if e.read { ' ' } else { '•' }, if e.flagged { '★' } else { ' ' });
let line = format!("{mark} {:<10} {}", date(e.published), e.title.as_deref().unwrap_or("(untitled)"));
if e.read { ListItem::new(line) } else { ListItem::new(line).bold() }
});
let shown = if self.unread_only { "unread" } else { "items" };
let title = format!("{} of {} {shown}", self.entries.len(), self.total);
let items = List::new(items).block(block(title, Pane::Items)).highlight_style(picked);
frame.render_stateful_widget(items, top, &mut self.entry_list);
let text = match self.entry() {
Some(e) => {
let mut lines = vec![Line::from(e.title.clone().unwrap_or_default()).bold()];
if let Some(link) = &e.link {
lines.push(Line::from(link.clone()).dim());
}
if playable(e).is_some() {
lines.push(Line::from("▶ p to play").dim());
}
lines.push(Line::default());
lines
.extend(text_of(e.description.as_deref().unwrap_or("")).lines().map(|l| Line::from(l.to_string())));
lines
}
None => vec![],
};
let text = Paragraph::new(text).wrap(Wrap { trim: false }).scroll((self.scroll, 0));
frame.render_widget(text.block(block("Item".into(), Pane::Text)), bottom);
let help = "j/k move enter open h back space folder P pin p play m read f flag u unread only r reload q quit";
let foot_text = if self.status.is_empty() { help } else { &self.status };
frame.render_widget(Paragraph::new(foot_text).dim(), foot);
}
}
fn date(t: Option<i64>) -> String {
t.and_then(|t| chrono::DateTime::from_timestamp(t, 0)).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default()
}
/// An item's HTML as plain text for a terminal: tags dropped, blocks on lines of their own,
/// entities decoded. ipx has already cleaned it, so there is no script or style to skip.
fn text_of(html: &str) -> String {
let mut out = String::new();
let mut rest = html;
loop {
let (text, tag) = match rest.find('<') {
Some(i) => (&rest[..i], Some(&rest[i + 1..])),
None => (rest, None),
};
// HTML's own whitespace is not layout; the tags say where lines break.
if text.starts_with(char::is_whitespace) && !out.is_empty() && !out.ends_with(['\n', ' ']) {
out.push(' ');
}
out.push_str(&decode(&text.split_whitespace().collect::<Vec<_>>().join(" ")));
if text.ends_with(char::is_whitespace) && !text.trim().is_empty() {
out.push(' ');
}
let Some(tag) = tag else { break };
let Some(end) = tag.find('>') else { break };
let (inner, closing) = match tag[..end].strip_prefix('/') {
Some(t) => (t, true),
None => (&tag[..end], false),
};
let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or("").to_ascii_lowercase();
match name.as_str() {
"br" => out.push('\n'),
"li" => {
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !closing {
out.push_str("• ");
}
}
"p" | "div" | "ul" | "ol" | "blockquote" | "pre" | "tr" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
if !out.is_empty() && !out.ends_with("\n\n") =>
{
out.push_str(if out.ends_with('\n') { "\n" } else { "\n\n" });
}
_ => {}
}
rest = &tag[end + 1..];
}
out.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim().to_string()
}
/// HTML's entities, the named ones feeds use and every numbered one.
fn decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find('&') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let end = rest[..rest.len().min(12)].find(';');
let ch = end.and_then(|end| match &rest[1..end] {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"hellip" => Some('…'),
"mdash" => Some('—'),
"ndash" => Some('–'),
"rsquo" => Some('’'),
"lsquo" => Some('‘'),
"rdquo" => Some('”'),
"ldquo" => Some('“'),
n => {
let n = n.strip_prefix('#')?;
let code = match n.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => n.parse().ok()?,
};
char::from_u32(code)
}
});
match (ch, end) {
(Some(c), Some(end)) => {
out.push(c);
rest = &rest[end + 1..];
}
_ => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| app.draw(f))?;
// A poll, not a blocking read, so a resize redraws without waiting for a key.
if !event::poll(Duration::from_millis(500))? {
continue;
}
if let Event::Key(k) = event::read()?
&& k.kind == KeyEventKind::Press
&& !app.key(k.code)
{
return Ok(());
}
}
}
fn main() -> Result<()> {
let args = Args::parse();
let http = reqwest::blocking::Client::builder().timeout(Duration::from_secs(30)).build()?;
let api = Api { http, base: args.url, token: args.token };
// Before the terminal is taken over, so a wrong address or token is said plainly.
let feeds = api.feeds().context("could not read your feeds")?;
let mut app = App {
api,
feeds,
rows: vec![],
open: HashSet::new(),
feed_list: ListState::default().with_selected(Some(0)),
entries: vec![],
total: 0,
entry_list: ListState::default(),
pane: Pane::Feeds,
scroll: 0,
unread_only: true,
status: String::new(),
};
app.arrange();
app.load_entries();
let mut terminal = ratatui::init();
let result = run(&mut terminal, &mut app);
ratatui::restore();
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_of_html() {
let html = "<p>One &amp; two,\n <b>bold</b>.</p><p>Next&nbsp;line<br>broken &#8212; &#x2019;s &bogus; &</p>\
<ul><li>a</li><li>b</li></ul>";
assert_eq!(text_of(html), "One & two, bold.\n\nNext line\nbroken — ’s &bogus; &\n\n• a\n• b");
assert_eq!(text_of("plain"), "plain");
assert_eq!(text_of("a <i>b</i> c"), "a b c");
assert_eq!(text_of("<b>x</b> <i>y</i>"), "x y");
}
#[test]
fn feeds_in_the_pages_order() {
let feed = |id: &str, group: Option<&str>, pinned: bool, unread: i64| Feed {
id: id.into(),
url: id.into(),
title: None,
unread,
group: group.map(Into::into),
hide_from_all: false,
pinned,
};
let feeds = [
feed("a", None, false, 0),
feed("opml", None, false, 0),
feed("in-read", Some("opml"), false, 0),
feed("in-unread", Some("opml"), false, 3),
feed("in-pinned", Some("opml"), true, 0),
feed("b", None, true, 0),
feed("lost", Some("gone"), false, 0),
];
let ids = |open: &[&str]| -> Vec<(&str, bool)> {
let open = open.iter().map(|s| s.to_string()).collect();
order(&feeds, &open).into_iter().map(|(i, inside)| (feeds[i].id.as_str(), inside)).collect()
};
// Closed, a folder hides its feeds; a pinned one is out of it, at the top; a feed whose
// folder is not listed stands on its own.
assert_eq!(ids(&[]), [("in-pinned", false), ("b", false), ("a", false), ("opml", false), ("lost", false)]);
assert_eq!(
ids(&["opml"]),
[
("in-pinned", false),
("b", false),
("a", false),
("opml", false),
("in-unread", true),
("in-read", true),
("lost", false),
]
);
}
#[test]
fn plays_the_first_downloaded_file() {
let e: Entry = serde_json::from_value(serde_json::json!({
"guid": "g", "feed_id": "f", "title": null, "link": null, "published": null,
"description": null, "read": false, "flagged": false,
"enclosures": [{"id": 1, "path": null}, {"id": 2, "path": "a.mp3"}, {"id": 3, "path": "b.mp3"}],
}))
.unwrap();
assert_eq!(playable(&e).map(|x| x.id), Some(2));
}
}