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
This commit is contained in:
@@ -12,3 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- `ipx`, iPX's reader in a terminal: your feeds, a feed's items and an item's text, read and flagged as on the page, signed in with an API token (#437).
|
||||
- `p` plays the item's file in mpv (#439).
|
||||
- Moving onto an item with `j` or `k` marks it read, as on the page (#441).
|
||||
- Feeds are listed as on the page: a subscribed OPML is a folder `Space` opens, and pinned feeds are at the top; `P` pins and unpins (#440).
|
||||
|
||||
@@ -15,13 +15,18 @@ IPX_URL=http://192.168.1.130:8099 IPX_API_TOKEN=... target/release/ipx
|
||||
| key | does |
|
||||
|---|---|
|
||||
| `j` `k`, arrows | move, or scroll the item's text |
|
||||
| `Enter`, `l`, `Tab` | open the feed or the item; opening an item marks it read |
|
||||
| `Enter`, `l`, `Tab` | open the feed or the item |
|
||||
| `h`, `Esc` | back a pane; `Esc` on the feeds quits |
|
||||
| `Space`, `PgUp` `PgDn` | page through the item's text |
|
||||
| `Space` | on the feeds, open or close a folder: a subscribed OPML, its feeds inside |
|
||||
| `P` | pin the feed or folder to the top of the list, or unpin it |
|
||||
| `p` | play the item's file in [mpv](https://mpv.io), in a window of its own |
|
||||
| `m` | mark the item read or unread |
|
||||
| `f` | flag or unflag it |
|
||||
| `u` | unread items only, or every item (unread only to start) |
|
||||
| `r` | reload |
|
||||
| `q` | quit |
|
||||
|
||||
It shows a feed's newest 200 items.
|
||||
An item is marked read when you move onto it or open it, as on the page. It shows a feed's newest
|
||||
200 items. Playing needs `mpv` on the path (`brew install mpv` on a Mac),
|
||||
and where you stop is not saved to iPX as it is on the page.
|
||||
|
||||
@@ -11,6 +11,7 @@ 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)]
|
||||
@@ -34,6 +35,29 @@ struct Feed {
|
||||
/// 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.
|
||||
@@ -47,6 +71,20 @@ struct Entry {
|
||||
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)]
|
||||
@@ -77,6 +115,29 @@ impl Api {
|
||||
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()?)
|
||||
}
|
||||
@@ -109,8 +170,11 @@ enum Pane {
|
||||
|
||||
struct App {
|
||||
api: Api,
|
||||
/// Your feeds, after All Subscriptions, which is index 0 of `feed_list`.
|
||||
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,
|
||||
@@ -122,9 +186,29 @@ struct App {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn feed_id(&self) -> Option<&str> {
|
||||
fn feed(&self) -> Option<&Feed> {
|
||||
let i = self.feed_list.selected()?;
|
||||
self.feeds.get(i.checked_sub(1)?).map(|f| f.id.as_str())
|
||||
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> {
|
||||
@@ -139,6 +223,7 @@ impl App {
|
||||
fn load_feeds(&mut self) {
|
||||
if let Some(feeds) = self.attempt(self.api.feeds()) {
|
||||
self.feeds = feeds;
|
||||
self.arrange();
|
||||
self.status.clear();
|
||||
}
|
||||
}
|
||||
@@ -171,13 +256,18 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
if self.entry().is_some_and(|e| !e.read) {
|
||||
self.flag("read", |_| true);
|
||||
}
|
||||
self.mark_read();
|
||||
}
|
||||
|
||||
fn step(&mut self, down: bool) {
|
||||
@@ -196,6 +286,7 @@ impl App {
|
||||
}
|
||||
if self.pane == Pane::Items {
|
||||
self.scroll = 0;
|
||||
self.mark_read();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +300,22 @@ impl App {
|
||||
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 => {
|
||||
@@ -224,6 +331,18 @@ impl App {
|
||||
_ => 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') => {
|
||||
@@ -258,10 +377,23 @@ impl App {
|
||||
.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.feeds.iter().map(|f| {
|
||||
let name = f.title.as_deref().unwrap_or(&f.url);
|
||||
if f.unread > 0 { format!("{name} ({})", f.unread) } else { name.to_string() }
|
||||
}));
|
||||
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);
|
||||
|
||||
@@ -281,6 +413,9 @@ impl App {
|
||||
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())));
|
||||
@@ -291,7 +426,7 @@ impl App {
|
||||
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 m read f flag u unread only r reload q quit";
|
||||
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);
|
||||
}
|
||||
@@ -419,6 +554,8 @@ fn main() -> Result<()> {
|
||||
let mut app = App {
|
||||
api,
|
||||
feeds,
|
||||
rows: vec![],
|
||||
open: HashSet::new(),
|
||||
feed_list: ListState::default().with_selected(Some(0)),
|
||||
entries: vec![],
|
||||
total: 0,
|
||||
@@ -428,6 +565,7 @@ fn main() -> Result<()> {
|
||||
unread_only: true,
|
||||
status: String::new(),
|
||||
};
|
||||
app.arrange();
|
||||
app.load_entries();
|
||||
let mut terminal = ratatui::init();
|
||||
let result = run(&mut terminal, &mut app);
|
||||
@@ -448,4 +586,56 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user