sfeed

simple feed reader - forked from git.codemadness.org/sfeed
git clone git://src.gearsix.net/sfeed
Log | Files | Refs | Atom | README | LICENSE

commit 3cb7df56e0204bb8b0799a8317ef5a5f38802c7b
parent 618a6561cd09f489e30ab652da1649a0d1fec1d5
Author: Hiltjo Posthuma <hiltjo@codemadness.org>
Date:   Fri, 26 Nov 2021 12:10:05 +0100

import sfeed_curses

Import sfeed_curses into sfeed.
The files are based of the commit 8e151ce48b503ad0ff0e24cb1be3bc93d6fbd895

Diffstat:
AREADME.curses | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aminicurses.h | 38++++++++++++++++++++++++++++++++++++++
Asfeed_content | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_content.1 | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_curses.1 | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_curses.c | 2459++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asfeed_markread | 35+++++++++++++++++++++++++++++++++++
Asfeed_markread.1 | 47+++++++++++++++++++++++++++++++++++++++++++++++
Athemes/mono.h | 13+++++++++++++
Athemes/mono_highlight.h | 15+++++++++++++++
Athemes/newsboat.h | 13+++++++++++++
Athemes/templeos.h | 24++++++++++++++++++++++++
12 files changed, 3250 insertions(+), 0 deletions(-)

diff --git a/README.curses b/README.curses @@ -0,0 +1,176 @@ +sfeed_curses +------------ + +sfeed_curses is a curses UI front-end for sfeed. + +It shows the TAB-separated feed items in a graphical command-line UI. The +interface has a look inspired by the mutt mail client. It has a sidebar panel +for the feeds, a panel with a listing of the items and a small statusbar for +the selected item/URL. Some functions like searching and scrolling are +integrated in the interface itself. + + +Build and install +----------------- + +$ make +# make install + + +Usage +----- + +Like the format programs included in sfeed you can run it like this: + + sfeed_curses ~/.sfeed/feeds/* + +... or by reading from stdin: + + sfeed_curses < ~/.sfeed/feeds/xkcd + +By default sfeed_curses marks the items of the last day as new/bold. To manage +read/unread items in a different way a plain-text file with a list of the read +URLs can be used. To enable this behaviour the path to this file can be +specified by setting the environment variable $SFEED_URL_FILE to the URL file: + + export SFEED_URL_FILE="$HOME/.sfeed/urls" + [ -f "$SFEED_URL_FILE" ] || touch "$SFEED_URL_FILE" + sfeed_curses ~/.sfeed/feeds/* + +There is a shellscript "sfeed_markread" to process the read and unread items. +See the man page for more detailed information. + + +Dependencies +------------ + +- C compiler (C99). +- libc (recommended: C99 and POSIX >= 200809). +- curses (typically ncurses), optional but recommended: but see minicurses.h. + + +Optional dependencies +--------------------- + +- POSIX make(1) for Makefile. +- mandoc for documentation: https://mdocml.bsd.lv/ + + +Run-time dependencies +--------------------- + +- A (POSIX) shell. +- A terminal (emulator) supporting UTF-8 and the used capabilities. + + +Optional run-time dependencies +------------------------------ + +- xclip for yanking the URL or enclosure. See $SFEED_YANKER to change it. +- xdg-open, used as a plumber by default. See $SFEED_PLUMBER to change it. +- awk, used by the sfeed_content and sfeed_markread script. + See the ENVIRONMENT VARIABLES section in the man page to change it. +- lynx, used by the sfeed_content script to convert HTML content. + See the ENVIRONMENT VARIABLES section in the man page to change it. + + +OS tested +--------- + +- Linux (compilers: clang, gcc, tcc, libc: glibc, musl). +- OpenBSD (clang, gcc). +- NetBSD +- FreeBSD +- DragonFlyBSD +- Illumos (OpenIndiana). +- Windows (cygwin gcc + mintty). +- HaikuOS + + +Known terminal issues +--------------------- + +Below lists some bugs or missing features in terminals that are found while +testing. Some of them might be fixed already upstream: + +- cygwin + mintty: the xterm mouse-encoding of the mouse position is broken for + scrolling. +- HaikuOS terminal: the xterm mouse-encoding of the mouse button number of the + middle-button, right-button is incorrect / reversed. +- putty: the full reset attribute (ESC c, typically `rs1`) does not reset the + window title. + + +Color themes +------------ + +To change the default theme you can set SFEED_THEME using make or in the +Makefile or include the a header file in sfeed_curses.c. See also the themes/ +directory. + + +Running custom commands inside the program +------------------------------------------ + +Running commands inside the program can be useful for example to sync items or +mark all items across all feeds as read. It can be comfortable to have a +keybind for this inside the program to perform a scripted action and then +reload the feeds by sending the signal SIGHUP. + +In the input handling code you can then add a case: + + case 'M': + forkexec((char *[]) { "markallread.sh", NULL }, 0); + break; + +or + + case 'S': + forkexec((char *[]) { "syncnews.sh", NULL }, 1); + break; + +The specified script should be in $PATH or an absolute path. + +Example of a `markallread.sh` shellscript to mark all URLs as read: + + #!/bin/sh + # mark all items/URLs as read. + + tmp=$(mktemp) + (cat ~/.sfeed/urls; cut -f 3 ~/.sfeed/feeds/*) | \ + awk '!x[$0]++' > "$tmp" && + mv "$tmp" ~/.sfeed/urls && + pkill -SIGHUP sfeed_curses # reload feeds. + +Example of a `syncnews.sh` shellscript to update the feeds and reload them: + + #!/bin/sh + sfeed_update && pkill -SIGHUP sfeed_curses + + +Open an URL directly in the same terminal +----------------------------------------- + +To open an URL directly in the same terminal using the text-mode lynx browser: + + SFEED_PLUMBER=lynx SFEED_PLUMBER_INTERACTIVE=1 sfeed_curses ~/.sfeed/feeds/* + + +Yank to tmux buffer +------------------- + +This changes the yank command to set the tmux buffer, instead of X11 xclip: + + SFEED_YANKER="tmux set-buffer \`cat\`" + + +License +------- + +ISC, see LICENSE file. + + +Author +------ + +Hiltjo Posthuma <hiltjo@codemadness.org> diff --git a/minicurses.h b/minicurses.h @@ -0,0 +1,38 @@ +#include <sys/ioctl.h> + +#undef OK +#define OK (0) + +const char *clr_eol = "\x1b[K"; +const char *clear_screen = "\x1b[H\x1b[2J"; +const char *cursor_address = "\x1b[%ld;%ldH"; +const char *cursor_normal = "\x1b[?25h"; /* DECTCEM (in)Visible cursor */ +const char *cursor_invisible = "\x1b[?25l"; /* DECTCEM (in)Visible cursor */ +const char *eat_newline_glitch = (void *)1; +const char *enter_ca_mode = "\x1b[?1049h"; /* smcup */ +const char *exit_ca_mode = "\x1b[?1049l"; /* rmcup */ +const char *save_cursor = "\x1b""7"; +const char *restore_cursor = "\x1b""8"; +const char *exit_attribute_mode = "\x1b[0m"; +const char *enter_bold_mode = "\x1b[1m"; +const char *enter_dim_mode = "\x1b[2m"; +const char *enter_reverse_mode = "\x1b[7m"; + +int +setupterm(char *term, int fildes, int *errret) +{ + return OK; +} + +char * +tparm(const char *s, long p1, long p2, ...) +{ + static char buf[32]; + + if (s == cursor_address) { + snprintf(buf, sizeof(buf), s, p1 + 1, p2 + 1); + return buf; + } + + return (char *)s; +} diff --git a/sfeed_content b/sfeed_content @@ -0,0 +1,53 @@ +#!/bin/sh +# Content viewer for sfeed(5) lines. + +# The locale is set to "C" for performance. The input is always UTF-8. +LC_ALL=C awk -F '\t' ' +function unescape(s) { + # use the character "\x01" as a temporary replacement for "\". + gsub("\\\\\\\\", "\x01", s); + gsub("\\\\n", "\n", s); + gsub("\\\\t", "\t", s); + gsub("\x01", "\\", s); # restore "\x01" to "\". + return s; +} +BEGIN { + htmlconv = "lynx -stdin -dump " \ + "-underline_links -image_links " \ + "-display_charset=\"utf-8\" -assume_charset=\"utf-8\" "; +} +{ + if (previtem) + print "\f"; + previtem = 1; + + print "Title: " $2; + if (length($7)) + print "Author: " $7; + if (length($9)) { + categories = $9; + gsub("\\|", ", ", categories); + print "Category: " categories; + } + if (length($3)) + print "Link: " $3; + if (length($8)) + print "Enclosure: " $8; + if (!length($4)) + next; + print ""; + if ($5 == "html") { + # use the link of the item as the base URL for relative URLs in + # HTML content. + base = $3; + if (length(base)) { + gsub("\"", "%22", base); # encode quotes. + base = "<base href=\"" base "\"/>\n"; + } + print base unescape($4) | htmlconv; + close(htmlconv); + } else { + print unescape($4); + } +}' "$@" | \ +${PAGER:-less -R} diff --git a/sfeed_content.1 b/sfeed_content.1 @@ -0,0 +1,57 @@ +.Dd July 25, 2021 +.Dt SFEED_CONTENT 1 +.Os +.Sh NAME +.Nm sfeed_content +.Nd view RSS/Atom content +.Sh SYNOPSIS +.Nm +.Op Ar +.Sh DESCRIPTION +.Nm +formats feed data (TSV) from +.Xr sfeed 1 +from stdin or for each +.Ar file +to stdout as plain-text content. +For HTML content it uses +.Xr lynx 1 +to convert it to plain-text. +At the end it uses the pager to view the output. +The +.Nm +script can be used by +.Xr sfeed_curses 1 +to view content. +.Sh ENVIRONMENT VARIABLES +.Bl -tag -width Ds +.It Ev PAGER +The pager used to view the content. +If it is not set it will use "less -R" by default. +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +.Bd -literal +curl -s 'https://codemadness.org/atom_content.xml' | sfeed | sfeed_content +.Ed +.Pp +The output format looks like this: +.Bd -literal +Title: The title. +Author: The line with the author if it is set. +Category: The line with the categories if it is set. +Link: The line with the link if it is set. +Enclosure: The line with the enclosure if it is set. + +The content converted to plain-text. + +<form feed character> if there are multiple items. +.Ed +.Sh SEE ALSO +.Xr awk 1 , +.Xr less 1 , +.Xr lynx 1 , +.Xr sfeed_curses 1 +.Sh AUTHORS +.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org diff --git a/sfeed_curses.1 b/sfeed_curses.1 @@ -0,0 +1,320 @@ +.Dd August 10, 2021 +.Dt SFEED_CURSES 1 +.Os +.Sh NAME +.Nm sfeed_curses +.Nd curses UI for viewing feed data +.Sh SYNOPSIS +.Nm +.Op Ar +.Sh DESCRIPTION +.Nm +formats feed data (TSV) from +.Xr sfeed 1 +from stdin or for each +.Ar file +into a curses UI. +If one or more +.Ar file +arguments are specified then the basename of the +.Ar file +is used as the feed name in the output such as the feeds sidebar. +The +.Ar file +arguments are processed and shown in the specified argument order in the feeds +sidebar. +If no +.Ar file +arguments are specified then the data is read from stdin and the feed name is +"stdin" and no sidebar is visible by default in this case. +.Pp +Items with a timestamp from the last day compared to the system time at the +time of loading the feed are marked as new and bold. +There is also an alternative mode available to mark items as read by matching +it against a list of URLs from a plain-text file. +.Pp +.Nm +aligns the output. +Make sure the environment variable +.Ev LC_CTYPE +is set to a UTF-8 locale, so it can determine the proper column-width +per rune, using +.Xr mbtowc 3 +and +.Xr wcwidth 3 . +.Sh KEYBINDS +.Bl -tag -width Ds +.It k, ARROW UP +Go one row up. +.It j, ARROW DOWN +Go one row down. +.It K +Go one row up and open the item. +.It J +Go one row down and open the item. +.It h, ARROW LEFT +Focus feeds pane. +.It l, ARROW RIGHT +Focus items pane. +.It TAB +Cycle focused pane (between feeds and items). +.It g +Go to the first row. +.It G +Go to the last row. +.It PAGE UP, CTRL-B +Scroll one page up. +.It PAGE DOWN, CTRL-F, SPACE +Scroll one page down. +.It / +Prompt for a new search and search forward (case-insensitive). +.It ? +Prompt for a new search and search backward (case-insensitive). +.It n +Search forward with the previously set search term. +.It N +Search backward with the previously set search term. +.It \&[ +Go to the previous feed in the feeds pane and open it. +.It ] +Go to the next feed in the feeds pane and open it. +.It CTRL-L +Redraw screen. +.It R +Reload all feed files which were specified as arguments on startup. +.It m +Toggle mouse-mode. +It supports xterm X10 and extended SGR encoding. +.It s +Toggle between monocle layout and the previous non-monocle layout. +.It < +Use a fixed sidebar size for the current layout and decrease the fixed width or +height by 1 column. +.It > +Use a fixed sidebar size for the current layout and increase the fixed width or +height by 1 column. +.It = +Reset the sidebar size to automaticly adjust for the current layout. +With the vertical layout the width is the longest feed name with the item +counts right-aligned. +With the horizontal layout the height is half of the window height (minus the +statusbar) or otherwise the total amount of visible feeds, whichever fits the +best. +.It t +Toggle showing only feeds with new items in the sidebar. +.It a, e, @ +Plumb URL of the enclosure. +The URL is passed as a parameter to the program specified in +.Ev SFEED_PLUMBER . +.It o, ENTER, RETURN +Feeds pane: load feed and its items. +In the monocle layout it will also switch to the items pane after loading the +feed items. +Items pane: plumb current item URL, the URL is passed as a parameter to +the program specified in +.Ev SFEED_PLUMBER . +.It c, p, | +Pipe the whole TAB-Separated Value line to a program. +This program can be specified with +.Ev SFEED_PIPER . +.It y +Pipe the TAB-Separated Value field for yanking the URL to a program. +This program can be specified with +.Ev SFEED_YANKER . +.It E +Pipe the TAB-Separated Value field for yanking the enclosure to a program. +This program can be specified with +.Ev SFEED_YANKER . +.It r +Mark item as read. +This will only work when +.Ev SFEED_URL_FILE +is set. +.It u +Mark item as unread. +This will only work when +.Ev SFEED_URL_FILE +is set. +.It f +Mark all items of the current loaded feed as read. +This will only work when +.Ev SFEED_URL_FILE +is set. +.It F +Mark all items of the current loaded feed as unread. +This will only work when +.Ev SFEED_URL_FILE +is set. +.It 1 +Set the current layout to a vertical mode. +Showing a feeds sidebar to the left and the feed items to the right. +.It 2 +Set the current layout to a horizontal mode. +Showing a feeds sidebar on the top and the feed items on the bottom. +.It 3 +Set the current layout to a monocle mode. +Showing either a feeds or a feed items pane. +.It q, EOF +Quit +.El +.Sh MOUSE ACTIONS +When mouse-mode is enabled the below actions are available. +.Bl -tag -width Ds +.It LEFT-CLICK +Feeds pane: select and load the feed and its items. +In the monocle layout it will also switch to the items pane after loading the +feed items. +Items pane: select item, when already selected then plumb it. +.It RIGHT-CLICK +Feeds pane: select feed, but do not load it. +Items pane: pipe the item. +.It SCROLL UP +Scroll one page up. +.It SCROLL DOWN +Scroll one page down. +.It FORWARD +Switch to the items pane. +.It BACKWARD +Switch to the feeds pane. +.El +.Sh SIGNALS +.Bl -tag -width Ds +.It SIGHUP +Reload all feed files which were specified as arguments on startup. +.It SIGINT, SIGTERM +Quit +.It SIGWINCH +Resize the pane dimensions relative to the terminal size. +.El +.Sh ENVIRONMENT VARIABLES +.Bl -tag -width Ds +.It Ev SFEED_AUTOCMD +Read and process a sequence of keys as input commands from this environment +variable first, afterwards read from stdin as usual. +This can be useful to automate certain actions at the start. +.It Ev SFEED_PIPER +A program where the whole TAB-Separated Value line is piped to. +By default this is "sfeed_content". +.It Ev SFEED_PIPER_INTERACTIVE +Handle the program interactively in the same terminal or not. +If set to "1" then before execution it restores the terminal attributes and +.Nm +will wait until the program is finished. +If set to "0" then it will suppress stdout and stderr output. +By default this is set to "1". +.It Ev SFEED_PLUMBER +A program that receives the link URL or enclosure URL as a parameter. +By default this is "xdg-open". +.It Ev SFEED_PLUMBER_INTERACTIVE +Handle the program interactively in the same terminal or not. +If set to "1" then before execution it restores the terminal attributes and +.Nm +will wait until the program is finished. +If set to "0" then it will suppress stdout and stderr output. +For example this option is useful to open a text-mode browser in the same +terminal. +By default this is set to "0". +.It Ev SFEED_YANKER +A program where the URL or enclosure field is piped to, to copy it to a +clipboard. +By default this is "xclip -r". +.It Ev SFEED_YANKER_INTERACTIVE +Handle the program interactively in the same terminal or not. +If set to "1" then before execution it restores the terminal attributes and +.Nm +will wait until the program is finished. +If set to "0" then it will suppress stdout and stderr output. +By default this is set to "0". +.It Ev SFEED_URL_FILE +If this variable is set then a different mode is used to mark items as read, +instead of checking the timestamp, which is the default. +The value specified is a plain-text file containing a list of read URLs, one +URL per line. +This URL is matched on the link field if it is set, otherwise it is matched on +the id field. +.It Ev SFEED_MARK_READ +A program to mark items as read if +.Ev SFEED_URL_FILE +is also set, if unset the default program used is "sfeed_markread read". +The marked items are piped to the program line by line. +If the feed item has a link then this line is the link field, otherwise it is +the id field. +The program is expected to merge items in a safe/transactional manner. +The program should return the exit status 0 on success or non-zero on failure. +.It Ev SFEED_MARK_UNREAD +A program to mark items as unread if +.Ev SFEED_URL_FILE +is also set, if unset the default program used is "sfeed_markread unread". +The unmarked items are piped to the program line by line. +If the feed item has a link then this line is the link field, otherwise it is +the id field. +The program is expected to merge items in a safe/transactional manner. +The program should return the exit status 0 on success or non-zero on failure. +.It Ev SFEED_LAZYLOAD +Lazyload items when reading the feed data from files. +This can reduce memory usage but increases latency when seeking items, +especially on slower disk drives. +It can also cause a race-condition issue if the feed data on disk is changed +while having the UI open and offsets for the lines are different. +A workaround for the race-condition issue is by sending the SIGHUP signal to +.Nm +directly after the data was updated. +This forces +.Nm +to reload the latest feed data and update the correct line offsets. +By default this is set to "0". +.It Ev SFEED_FEED_PATH +This variable is set by +.Nm +when a feed is loaded. +If the data was read from stdin this variable is unset. +It can be used by the plumb or pipe program for scripting purposes. +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +.Bd -literal +sfeed_curses ~/.sfeed/feeds/* +.Ed +.Pp +Another example which shows some of the features +.Nm +has: +.Bd -literal +export SFEED_AUTOCMD="2tgo" +export SFEED_URL_FILE="$HOME/.sfeed/urls" +[ -f "$SFEED_URL_FILE" ] || touch "$SFEED_URL_FILE" +sfeed_curses ~/.sfeed/feeds/* +.Ed +.Pp +Which does the following: +.Bl -enum +.It +Set the current layout to a horizontal mode ('2' keybind'). +Showing a feeds sidebar on the top and the feed items on the bottom. +.It +Toggle showing only feeds with new items in the sidebar ('t' keybind). +.It +Go to the first row in the current panel ('g' keybind). +.It +Load the current selected feed ('o' keybind'). +.It +Set a file to use for managing read and unread items. +This file is a plain-text file containing a list of read URLs, one URL per +line. +.It +Check if this file for managing the read and unread items exists. +If it doesn't exist yet then create an empty file. +.It +Start +.Nm . +.El +.Sh SEE ALSO +.Xr sfeed 1 , +.Xr sfeed_content 1 , +.Xr sfeed_markread 1 , +.Xr sfeed_plain 1 , +.Xr xclip 1 , +.Xr sfeed 5 +.Sh AUTHORS +.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org diff --git a/sfeed_curses.c b/sfeed_curses.c @@ -0,0 +1,2459 @@ +#include <sys/ioctl.h> +#include <sys/select.h> +#include <sys/time.h> +#include <sys/types.h> +#include <sys/wait.h> + +#include <ctype.h> +#include <errno.h> +#include <fcntl.h> +#include <locale.h> +#include <signal.h> +#include <stdarg.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <termios.h> +#include <time.h> +#include <unistd.h> +#include <wchar.h> + +/* curses */ +#ifndef SFEED_MINICURSES +#include <curses.h> +#include <term.h> +#else +#include "minicurses.h" +#endif + +#define LEN(a) sizeof((a))/sizeof((a)[0]) +#define MAX(a,b) ((a) > (b) ? (a) : (b)) +#define MIN(a,b) ((a) < (b) ? (a) : (b)) + +#define PAD_TRUNCATE_SYMBOL "\xe2\x80\xa6" /* symbol: "ellipsis" */ +#define SCROLLBAR_SYMBOL_BAR "\xe2\x94\x82" /* symbol: "light vertical" */ +#define SCROLLBAR_SYMBOL_TICK " " +#define LINEBAR_SYMBOL_BAR "\xe2\x94\x80" /* symbol: "light horizontal" */ +#define LINEBAR_SYMBOL_RIGHT "\xe2\x94\xa4" /* symbol: "light vertical and left" */ +#define UTF_INVALID_SYMBOL "\xef\xbf\xbd" /* symbol: "replacement" */ + +/* color-theme */ +#ifndef SFEED_THEME +#define SFEED_THEME "themes/mono.h" +#endif +#include SFEED_THEME + +enum { + ATTR_RESET = 0, ATTR_BOLD_ON = 1, ATTR_FAINT_ON = 2, ATTR_REVERSE_ON = 7 +}; + +enum Layout { + LayoutVertical = 0, LayoutHorizontal, LayoutMonocle, LayoutLast +}; + +enum Pane { PaneFeeds, PaneItems, PaneLast }; + +enum { + FieldUnixTimestamp = 0, FieldTitle, FieldLink, FieldContent, + FieldContentType, FieldId, FieldAuthor, FieldEnclosure, + FieldCategory, FieldLast +}; + +struct win { + int width; /* absolute width of the window */ + int height; /* absolute height of the window */ + int dirty; /* needs draw update: clears screen */ +}; + +struct row { + char *text; /* text string, optional if using row_format() callback */ + int bold; + void *data; /* data binding */ +}; + +struct pane { + int x; /* absolute x position on the screen */ + int y; /* absolute y position on the screen */ + int width; /* absolute width of the pane */ + int height; /* absolute height of the pane, should be > 0 */ + off_t pos; /* focused row position */ + struct row *rows; + size_t nrows; /* total amount of rows */ + int focused; /* has focus or not */ + int hidden; /* is visible or not */ + int dirty; /* needs draw update */ + /* (optional) callback functions */ + struct row *(*row_get)(struct pane *, off_t pos); + char *(*row_format)(struct pane *, struct row *); + int (*row_match)(struct pane *, struct row *, const char *); +}; + +struct scrollbar { + int tickpos; + int ticksize; + int x; /* absolute x position on the screen */ + int y; /* absolute y position on the screen */ + int size; /* absolute size of the bar, should be > 0 */ + int focused; /* has focus or not */ + int hidden; /* is visible or not */ + int dirty; /* needs draw update */ +}; + +struct statusbar { + int x; /* absolute x position on the screen */ + int y; /* absolute y position on the screen */ + int width; /* absolute width of the bar */ + char *text; /* data */ + int hidden; /* is visible or not */ + int dirty; /* needs draw update */ +}; + +struct linebar { + int x; /* absolute x position on the screen */ + int y; /* absolute y position on the screen */ + int width; /* absolute width of the line */ + int hidden; /* is visible or not */ + int dirty; /* needs draw update */ +}; + +/* /UI */ + +struct item { + char *fields[FieldLast]; + char *line; /* allocated split line */ + /* field to match new items, if link is set match on link, else on id */ + char *matchnew; + time_t timestamp; + int timeok; + int isnew; + off_t offset; /* line offset in file for lazyload */ +}; + +struct items { + struct item *items; /* array of items */ + size_t len; /* amount of items */ + size_t cap; /* available capacity */ +}; + +struct feed { + char *name; /* feed name */ + char *path; /* path to feed or NULL for stdin */ + unsigned long totalnew; /* amount of new items per feed */ + unsigned long total; /* total items */ + FILE *fp; /* file pointer */ +}; + +void alldirty(void); +void cleanup(void); +void draw(void); +int getsidebarsize(void); +void markread(struct pane *, off_t, off_t, int); +void pane_draw(struct pane *); +void sighandler(int); +void updategeom(void); +void updatesidebar(void); +void urls_free(void); +int urls_isnew(const char *); +void urls_read(void); + +static struct linebar linebar; +static struct statusbar statusbar; +static struct pane panes[PaneLast]; +static struct scrollbar scrollbars[PaneLast]; /* each pane has a scrollbar */ +static struct win win; +static size_t selpane; +/* fixed sidebar size, < 0 is automatic */ +static int fixedsidebarsizes[LayoutLast] = { -1, -1, -1 }; +static int layout = LayoutVertical, prevlayout = LayoutVertical; +static int onlynew = 0; /* show only new in sidebar */ +static int usemouse = 1; /* use xterm mouse tracking */ + +static struct termios tsave; /* terminal state at startup */ +static struct termios tcur; +static int devnullfd; +static int istermsetup, needcleanup; + +static struct feed *feeds; +static struct feed *curfeed; +static size_t nfeeds; /* amount of feeds */ +static time_t comparetime; +static char *urlfile, **urls; +static size_t nurls; + +volatile sig_atomic_t sigstate = 0; + +static char *plumbercmd = "xdg-open"; /* env variable: $SFEED_PLUMBER */ +static char *pipercmd = "sfeed_content"; /* env variable: $SFEED_PIPER */ +static char *yankercmd = "xclip -r"; /* env variable: $SFEED_YANKER */ +static char *markreadcmd = "sfeed_markread read"; /* env variable: $SFEED_MARK_READ */ +static char *markunreadcmd = "sfeed_markread unread"; /* env variable: $SFEED_MARK_UNREAD */ +static char *cmdenv; /* env variable: $SFEED_AUTOCMD */ +static int plumberia = 0; /* env variable: $SFEED_PLUMBER_INTERACTIVE */ +static int piperia = 1; /* env variable: $SFEED_PIPER_INTERACTIVE */ +static int yankeria = 0; /* env variable: $SFEED_YANKER_INTERACTIVE */ +static int lazyload = 0; /* env variable: $SFEED_LAZYLOAD */ + +int +ttywritef(const char *fmt, ...) +{ + va_list ap; + int n; + + va_start(ap, fmt); + n = vfprintf(stdout, fmt, ap); + va_end(ap); + fflush(stdout); + + return n; +} + +int +ttywrite(const char *s) +{ + if (!s) + return 0; /* for tparm() returning NULL */ + return write(1, s, strlen(s)); +} + +/* hint for compilers and static analyzers that a function exits */ +#ifndef __dead +#define __dead +#endif + +/* print to stderr, call cleanup() and _exit(). */ +__dead void +die(const char *fmt, ...) +{ + va_list ap; + int saved_errno; + + saved_errno = errno; + cleanup(); + + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + + if (saved_errno) + fprintf(stderr, ": %s", strerror(saved_errno)); + fflush(stderr); + write(2, "\n", 1); + + _exit(1); +} + +void * +erealloc(void *ptr, size_t size) +{ + void *p; + + if (!(p = realloc(ptr, size))) + die("realloc"); + return p; +} + +void * +ecalloc(size_t nmemb, size_t size) +{ + void *p; + + if (!(p = calloc(nmemb, size))) + die("calloc"); + return p; +} + +char * +estrdup(const char *s) +{ + char *p; + + if (!(p = strdup(s))) + die("strdup"); + return p; +} + +/* wrapper for tparm which allows NULL parameter for str. */ +char * +tparmnull(const char *str, long p1, long p2, long p3, long p4, long p5, + long p6, long p7, long p8, long p9) +{ + if (!str) + return NULL; + return tparm(str, p1, p2, p3, p4, p5, p6, p7, p8, p9); +} + +/* strcasestr() included for portability */ +#undef strcasestr +char * +strcasestr(const char *h, const char *n) +{ + size_t i; + + if (!n[0]) + return (char *)h; + + for (; *h; ++h) { + for (i = 0; n[i] && tolower((unsigned char)n[i]) == + tolower((unsigned char)h[i]); ++i) + ; + if (n[i] == '\0') + return (char *)h; + } + + return NULL; +} + +/* Splits fields in the line buffer by replacing TAB separators with NUL ('\0') + terminators and assign these fields as pointers. If there are less fields + than expected then the field is an empty string constant. */ +void +parseline(char *line, char *fields[FieldLast]) +{ + char *prev, *s; + size_t i; + + for (prev = line, i = 0; + (s = strchr(prev, '\t')) && i < FieldLast - 1; + i++) { + *s = '\0'; + fields[i] = prev; + prev = s + 1; + } + fields[i++] = prev; + /* make non-parsed fields empty. */ + for (; i < FieldLast; i++) + fields[i] = ""; +} + +/* Parse time to time_t, assumes time_t is signed, ignores fractions. */ +int +strtotime(const char *s, time_t *t) +{ + long long l; + char *e; + + errno = 0; + l = strtoll(s, &e, 10); + if (errno || *s == '\0' || *e) + return -1; + /* NOTE: assumes time_t is 64-bit on 64-bit platforms: + long long (at least 32-bit) to time_t. */ + if (t) + *t = (time_t)l; + + return 0; +} + +size_t +colw(const char *s) +{ + wchar_t wc; + size_t col = 0, i, slen; + int inc, rl, w; + + slen = strlen(s); + for (i = 0; i < slen; i += inc) { + inc = 1; /* next byte */ + if ((unsigned char)s[i] < 32) { + continue; + } else if ((unsigned char)s[i] >= 127) { + rl = mbtowc(&wc, &s[i], slen - i < 4 ? slen - i : 4); + inc = rl; + if (rl < 0) { + mbtowc(NULL, NULL, 0); /* reset state */ + inc = 1; /* invalid, seek next byte */ + w = 1; /* replacement char is one width */ + } else if ((w = wcwidth(wc)) == -1) { + continue; + } + col += w; + } else { + col++; + } + } + return col; +} + +/* Format `len' columns of characters. If string is shorter pad the rest + with characters `pad`. */ +int +utf8pad(char *buf, size_t bufsiz, const char *s, size_t len, int pad) +{ + wchar_t wc; + size_t col = 0, i, slen, siz = 0; + int inc, rl, w; + + if (!bufsiz) + return -1; + if (!len) { + buf[0] = '\0'; + return 0; + } + + slen = strlen(s); + for (i = 0; i < slen; i += inc) { + inc = 1; /* next byte */ + if ((unsigned char)s[i] < 32) + continue; + + rl = mbtowc(&wc, &s[i], slen - i < 4 ? slen - i : 4); + inc = rl; + if (rl < 0) { + mbtowc(NULL, NULL, 0); /* reset state */ + inc = 1; /* invalid, seek next byte */ + w = 1; /* replacement char is one width */ + } else if ((w = wcwidth(wc)) == -1) { + continue; + } + + if (col + w > len || (col + w == len && s[i + inc])) { + if (siz + 4 >= bufsiz) + return -1; + memcpy(&buf[siz], PAD_TRUNCATE_SYMBOL, sizeof(PAD_TRUNCATE_SYMBOL) - 1); + siz += sizeof(PAD_TRUNCATE_SYMBOL) - 1; + buf[siz] = '\0'; + col++; + break; + } else if (rl < 0) { + if (siz + 4 >= bufsiz) + return -1; + memcpy(&buf[siz], UTF_INVALID_SYMBOL, sizeof(UTF_INVALID_SYMBOL) - 1); + siz += sizeof(UTF_INVALID_SYMBOL) - 1; + buf[siz] = '\0'; + col++; + continue; + } + if (siz + inc + 1 >= bufsiz) + return -1; + memcpy(&buf[siz], &s[i], inc); + siz += inc; + buf[siz] = '\0'; + col += w; + } + + len -= col; + if (siz + len + 1 >= bufsiz) + return -1; + memset(&buf[siz], pad, len); + siz += len; + buf[siz] = '\0'; + + return 0; +} + +/* print `len' columns of characters. If string is shorter pad the rest with + * characters `pad`. */ +void +printutf8pad(FILE *fp, const char *s, size_t len, int pad) +{ + wchar_t wc; + size_t col = 0, i, slen; + int inc, rl, w; + + if (!len) + return; + + slen = strlen(s); + for (i = 0; i < slen; i += inc) { + inc = 1; /* next byte */ + if ((unsigned char)s[i] < 32) { + continue; /* skip control characters */ + } else if ((unsigned char)s[i] >= 127) { + rl = mbtowc(&wc, s + i, slen - i < 4 ? slen - i : 4); + inc = rl; + if (rl < 0) { + mbtowc(NULL, NULL, 0); /* reset state */ + inc = 1; /* invalid, seek next byte */ + w = 1; /* replacement char is one width */ + } else if ((w = wcwidth(wc)) == -1) { + continue; + } + + if (col + w > len || (col + w == len && s[i + inc])) { + fputs("\xe2\x80\xa6", fp); /* ellipsis */ + col++; + break; + } else if (rl < 0) { + fputs("\xef\xbf\xbd", fp); /* replacement */ + col++; + continue; + } + fwrite(&s[i], 1, rl, fp); + col += w; + } else { + /* optimization: simple ASCII character */ + if (col + 1 > len || (col + 1 == len && s[i + 1])) { + fputs("\xe2\x80\xa6", fp); /* ellipsis */ + col++; + break; + } + putc(s[i], fp); + col++; + } + + } + for (; col < len; ++col) + putc(pad, fp); +} + +void +resetstate(void) +{ + ttywrite("\x1b""c"); /* rs1: reset title and state */ +} + +void +updatetitle(void) +{ + unsigned long totalnew = 0, total = 0; + size_t i; + + for (i = 0; i < nfeeds; i++) { + totalnew += feeds[i].totalnew; + total += feeds[i].total; + } + ttywritef("\x1b]2;(%lu/%lu) - sfeed_curses\x1b\\", totalnew, total); +} + +void +appmode(int on) +{ + ttywrite(tparmnull(on ? enter_ca_mode : exit_ca_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0)); +} + +void +mousemode(int on) +{ + ttywrite(on ? "\x1b[?1000h" : "\x1b[?1000l"); /* xterm X10 mouse mode */ + ttywrite(on ? "\x1b[?1006h" : "\x1b[?1006l"); /* extended SGR mouse mode */ +} + +void +cursormode(int on) +{ + ttywrite(tparmnull(on ? cursor_normal : cursor_invisible, 0, 0, 0, 0, 0, 0, 0, 0, 0)); +} + +void +cursormove(int x, int y) +{ + ttywrite(tparmnull(cursor_address, y, x, 0, 0, 0, 0, 0, 0, 0)); +} + +void +cursorsave(void) +{ + /* do not save the cursor if it won't be restored anyway */ + if (cursor_invisible) + ttywrite(tparmnull(save_cursor, 0, 0, 0, 0, 0, 0, 0, 0, 0)); +} + +void +cursorrestore(void) +{ + /* if the cursor cannot be hidden then move to a consistent position */ + if (cursor_invisible) + ttywrite(tparmnull(restore_cursor, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + else + cursormove(0, 0); +} + +void +attrmode(int mode) +{ + switch (mode) { + case ATTR_RESET: + ttywrite(tparmnull(exit_attribute_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + break; + case ATTR_BOLD_ON: + ttywrite(tparmnull(enter_bold_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + break; + case ATTR_FAINT_ON: + ttywrite(tparmnull(enter_dim_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + break; + case ATTR_REVERSE_ON: + ttywrite(tparmnull(enter_reverse_mode, 0, 0, 0, 0, 0, 0, 0, 0, 0)); + break; + default: + break; + } +} + +void +cleareol(void) +{ + ttywrite(tparmnull(clr_eol, 0, 0, 0, 0, 0, 0, 0, 0, 0)); +} + +void +clearscreen(void) +{ + ttywrite(tparmnull(clear_screen, 0, 0, 0, 0, 0, 0, 0, 0, 0)); +} + +void +cleanup(void) +{ + struct sigaction sa; + + if (!needcleanup) + return; + needcleanup = 0; + + if (istermsetup) { + resetstate(); + cursormode(1); + appmode(0); + clearscreen(); + + if (usemouse) + mousemode(0); + } + + /* restore terminal settings */ + tcsetattr(0, TCSANOW, &tsave); + + memset(&sa, 0, sizeof(sa)); + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; /* require BSD signal semantics */ + sa.sa_handler = SIG_DFL; + sigaction(SIGWINCH, &sa, NULL); +} + +void +win_update(struct win *w, int width, int height) +{ + if (width != w->width || height != w->height) + w->dirty = 1; + w->width = width; + w->height = height; +} + +void +resizewin(void) +{ + struct winsize winsz; + int width, height; + + if (ioctl(1, TIOCGWINSZ, &winsz) != -1) { + width = winsz.ws_col > 0 ? winsz.ws_col : 80; + height = winsz.ws_row > 0 ? winsz.ws_row : 24; + win_update(&win, width, height); + } + if (win.dirty) + alldirty(); +} + +void +init(void) +{ + struct sigaction sa; + int errret = 1; + + needcleanup = 1; + + tcgetattr(0, &tsave); + memcpy(&tcur, &tsave, sizeof(tcur)); + tcur.c_lflag &= ~(ECHO|ICANON); + tcur.c_cc[VMIN] = 1; + tcur.c_cc[VTIME] = 0; + tcsetattr(0, TCSANOW, &tcur); + + if (!istermsetup && + (setupterm(NULL, 1, &errret) != OK || errret != 1)) { + errno = 0; + die("setupterm: terminfo database or entry for $TERM not found"); + } + istermsetup = 1; + resizewin(); + + appmode(1); + cursormode(0); + + if (usemouse) + mousemode(usemouse); + + memset(&sa, 0, sizeof(sa)); + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; /* require BSD signal semantics */ + sa.sa_handler = sighandler; + sigaction(SIGHUP, &sa, NULL); + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + sigaction(SIGWINCH, &sa, NULL); +} + +void +processexit(pid_t pid, int interactive) +{ + pid_t wpid; + struct sigaction sa; + + memset(&sa, 0, sizeof(sa)); + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESTART; /* require BSD signal semantics */ + sa.sa_handler = SIG_IGN; + sigaction(SIGINT, &sa, NULL); + + if (interactive) { + while ((wpid = wait(NULL)) >= 0 && wpid != pid) + ; + init(); + updatesidebar(); + updategeom(); + updatetitle(); + } else { + sa.sa_handler = sighandler; + sigaction(SIGINT, &sa, NULL); + } +} + +/* Pipe item line or item field to a program. + If `field` is -1 then pipe the TSV line, else a specified field. + if `interactive` is 1 then cleanup and restore the tty and wait on the + process. + if 0 then don't do that and also write stdout and stderr to /dev/null. */ +void +pipeitem(const char *cmd, struct item *item, int field, int interactive) +{ + FILE *fp; + pid_t pid; + int i, status; + + if (interactive) + cleanup(); + + switch ((pid = fork())) { + case -1: + die("fork"); + case 0: + if (!interactive) { + dup2(devnullfd, 1); + dup2(devnullfd, 2); + } + + errno = 0; + if (!(fp = popen(cmd, "w"))) + die("popen: %s", cmd); + if (field == -1) { + for (i = 0; i < FieldLast; i++) { + if (i) + putc('\t', fp); + fputs(item->fields[i], fp); + } + } else { + fputs(item->fields[field], fp); + } + putc('\n', fp); + status = pclose(fp); + status = WIFEXITED(status) ? WEXITSTATUS(status) : 127; + _exit(status); + default: + processexit(pid, interactive); + } +} + +void +forkexec(char *argv[], int interactive) +{ + pid_t pid; + + if (interactive) + cleanup(); + + switch ((pid = fork())) { + case -1: + die("fork"); + case 0: + if (!interactive) { + dup2(devnullfd, 1); + dup2(devnullfd, 2); + } + if (execvp(argv[0], argv) == -1) + _exit(1); + default: + processexit(pid, interactive); + } +} + +struct row * +pane_row_get(struct pane *p, off_t pos) +{ + if (pos < 0 || pos >= p->nrows) + return NULL; + + if (p->row_get) + return p->row_get(p, pos); + return p->rows + pos; +} + +char * +pane_row_text(struct pane *p, struct row *row) +{ + /* custom formatter */ + if (p->row_format) + return p->row_format(p, row); + return row->text; +} + +int +pane_row_match(struct pane *p, struct row *row, const char *s) +{ + if (p->row_match) + return p->row_match(p, row, s); + return (strcasestr(pane_row_text(p, row), s) != NULL); +} + +void +pane_row_draw(struct pane *p, off_t pos, int selected) +{ + struct row *row; + + if (p->hidden || !p->width || !p->height || + p->x >= win.width || p->y + (pos % p->height) >= win.height) + return; + + row = pane_row_get(p, pos); + + cursorsave(); + cursormove(p->x, p->y + (pos % p->height)); + + if (p->focused) + THEME_ITEM_FOCUS(); + else + THEME_ITEM_NORMAL(); + if (row && row->bold) + THEME_ITEM_BOLD(); + if (selected) + THEME_ITEM_SELECTED(); + if (row) { + printutf8pad(stdout, pane_row_text(p, row), p->width, ' '); + fflush(stdout); + } else { + ttywritef("%-*.*s", p->width, p->width, ""); + } + + attrmode(ATTR_RESET); + cursorrestore(); +} + +void +pane_setpos(struct pane *p, off_t pos) +{ + if (pos < 0) + pos = 0; /* clamp */ + if (!p->nrows) + return; /* invalid */ + if (pos >= p->nrows) + pos = p->nrows - 1; /* clamp */ + if (pos == p->pos) + return; /* no change */ + + /* is on different scroll region? mark whole pane dirty */ + if (((p->pos - (p->pos % p->height)) / p->height) != + ((pos - (pos % p->height)) / p->height)) { + p->dirty = 1; + } else { + /* only redraw the 2 dirty rows */ + pane_row_draw(p, p->pos, 0); + pane_row_draw(p, pos, 1); + } + p->pos = pos; +} + +void +pane_scrollpage(struct pane *p, int pages) +{ + off_t pos; + + if (pages < 0) { + pos = p->pos - (-pages * p->height); + pos -= (p->pos % p->height); + pos += p->height - 1; + pane_setpos(p, pos); + } else if (pages > 0) { + pos = p->pos + (pages * p->height); + if ((p->pos % p->height)) + pos -= (p->pos % p->height); + pane_setpos(p, pos); + } +} + +void +pane_scrolln(struct pane *p, int n) +{ + pane_setpos(p, p->pos + n); +} + +void +pane_setfocus(struct pane *p, int on) +{ + if (p->focused != on) { + p->focused = on; + p->dirty = 1; + } +} + +void +pane_draw(struct pane *p) +{ + off_t pos, y; + + if (!p->dirty) + return; + p->dirty = 0; + if (p->hidden || !p->width || !p->height) + return; + + /* draw visible rows */ + pos = p->pos - (p->pos % p->height); + for (y = 0; y < p->height; y++) + pane_row_draw(p, y + pos, (y + pos) == p->pos); +} + +void +setlayout(int n) +{ + if (layout != LayoutMonocle) + prevlayout = layout; /* previous non-monocle layout */ + layout = n; +} + +void +updategeom(void) +{ + int h, w, x = 0, y = 0; + + panes[PaneFeeds].hidden = layout == LayoutMonocle && (selpane != PaneFeeds); + panes[PaneItems].hidden = layout == LayoutMonocle && (selpane != PaneItems); + linebar.hidden = layout != LayoutHorizontal; + + w = win.width; + /* always reserve space for statusbar */ + h = MAX(win.height - 1, 1); + + panes[PaneFeeds].x = x; + panes[PaneFeeds].y = y; + + switch (layout) { + case LayoutVertical: + panes[PaneFeeds].width = getsidebarsize(); + + x += panes[PaneFeeds].width; + w -= panes[PaneFeeds].width; + + /* space for scrollbar if sidebar is visible */ + w--; + x++; + + panes[PaneFeeds].height = MAX(h, 1); + break; + case LayoutHorizontal: + panes[PaneFeeds].height = getsidebarsize(); + + h -= panes[PaneFeeds].height; + y += panes[PaneFeeds].height; + + linebar.x = 0; + linebar.y = y; + linebar.width = win.width; + + h--; + y++; + + panes[PaneFeeds].width = MAX(w - 1, 0); + break; + case LayoutMonocle: + panes[PaneFeeds].height = MAX(h, 1); + panes[PaneFeeds].width = MAX(w - 1, 0); + break; + } + + panes[PaneItems].x = x; + panes[PaneItems].y = y; + panes[PaneItems].width = MAX(w - 1, 0); + panes[PaneItems].height = MAX(h, 1); + if (x >= win.width || y + 1 >= win.height) + panes[PaneItems].hidden = 1; + + scrollbars[PaneFeeds].x = panes[PaneFeeds].x + panes[PaneFeeds].width; + scrollbars[PaneFeeds].y = panes[PaneFeeds].y; + scrollbars[PaneFeeds].size = panes[PaneFeeds].height; + scrollbars[PaneFeeds].hidden = panes[PaneFeeds].hidden; + + scrollbars[PaneItems].x = panes[PaneItems].x + panes[PaneItems].width; + scrollbars[PaneItems].y = panes[PaneItems].y; + scrollbars[PaneItems].size = panes[PaneItems].height; + scrollbars[PaneItems].hidden = panes[PaneItems].hidden; + + statusbar.width = win.width; + statusbar.x = 0; + statusbar.y = MAX(win.height - 1, 0); + + alldirty(); +} + +void +scrollbar_setfocus(struct scrollbar *s, int on) +{ + if (s->focused != on) { + s->focused = on; + s->dirty = 1; + } +} + +void +scrollbar_update(struct scrollbar *s, off_t pos, off_t nrows, int pageheight) +{ + int tickpos = 0, ticksize = 0; + + /* do not show a scrollbar if all items fit on the page */ + if (nrows > pageheight) { + ticksize = s->size / ((double)nrows / (double)pageheight); + if (ticksize == 0) + ticksize = 1; + + tickpos = (pos / (double)nrows) * (double)s->size; + + /* fixup due to cell precision */ + if (pos + pageheight >= nrows || + tickpos + ticksize >= s->size) + tickpos = s->size - ticksize; + } + + if (s->tickpos != tickpos || s->ticksize != ticksize) + s->dirty = 1; + s->tickpos = tickpos; + s->ticksize = ticksize; +} + +void +scrollbar_draw(struct scrollbar *s) +{ + off_t y; + + if (!s->dirty) + return; + s->dirty = 0; + if (s->hidden || !s->size || s->x >= win.width || s->y >= win.height) + return; + + cursorsave(); + + /* draw bar (not tick) */ + if (s->focused) + THEME_SCROLLBAR_FOCUS(); + else + THEME_SCROLLBAR_NORMAL(); + for (y = 0; y < s->size; y++) { + if (y >= s->tickpos && y < s->tickpos + s->ticksize) + continue; /* skip tick */ + cursormove(s->x, s->y + y); + ttywrite(SCROLLBAR_SYMBOL_BAR); + } + + /* draw tick */ + if (s->focused) + THEME_SCROLLBAR_TICK_FOCUS(); + else + THEME_SCROLLBAR_TICK_NORMAL(); + for (y = s->tickpos; y < s->size && y < s->tickpos + s->ticksize; y++) { + cursormove(s->x, s->y + y); + ttywrite(SCROLLBAR_SYMBOL_TICK); + } + + attrmode(ATTR_RESET); + cursorrestore(); +} + +int +readch(void) +{ + unsigned char b; + fd_set readfds; + struct timeval tv; + + if (cmdenv && *cmdenv) + return *(cmdenv++); + + for (;;) { + FD_ZERO(&readfds); + FD_SET(0, &readfds); + tv.tv_sec = 0; + tv.tv_usec = 250000; /* 250ms */ + switch (select(1, &readfds, NULL, NULL, &tv)) { + case -1: + if (errno != EINTR) + die("select"); + return -2; /* EINTR: like a signal */ + case 0: + return -3; /* time-out */ + } + + switch (read(0, &b, 1)) { + case -1: die("read"); + case 0: return EOF; + default: return (int)b; + } + } +} + +char * +lineeditor(void) +{ + char *input = NULL; + size_t cap = 0, nchars = 0; + int ch; + + for (;;) { + if (nchars + 1 >= cap) { + cap = cap ? cap * 2 : 32; + input = erealloc(input, cap); + } + + ch = readch(); + if (ch == EOF || ch == '\r' || ch == '\n') { + input[nchars] = '\0'; + break; + } else if (ch == '\b' || ch == 0x7f) { + if (!nchars) + continue; + input[--nchars] = '\0'; + write(1, "\b \b", 3); /* back, blank, back */ + continue; + } else if (ch >= ' ') { + input[nchars] = ch; + write(1, &input[nchars], 1); + nchars++; + } else if (ch < 0) { + switch (sigstate) { + case 0: + case SIGWINCH: + continue; /* process signals later */ + case SIGINT: + sigstate = 0; /* exit prompt, do not quit */ + case SIGTERM: + break; /* exit prompt and quit */ + } + free(input); + return NULL; + } + } + return input; +} + +char * +uiprompt(int x, int y, char *fmt, ...) +{ + va_list ap; + char *input, buf[32]; + + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf), fmt, ap); + va_end(ap); + + cursorsave(); + cursormove(x, y); + THEME_INPUT_LABEL(); + ttywrite(buf); + attrmode(ATTR_RESET); + + THEME_INPUT_NORMAL(); + cleareol(); + cursormode(1); + cursormove(x + colw(buf) + 1, y); + + input = lineeditor(); + attrmode(ATTR_RESET); + + cursormode(0); + cursorrestore(); + + return input; +} + +void +linebar_draw(struct linebar *b) +{ + int i; + + if (!b->dirty) + return; + b->dirty = 0; + if (b->hidden || !b->width) + return; + + cursorsave(); + cursormove(b->x, b->y); + THEME_LINEBAR(); + for (i = 0; i < b->width - 1; i++) + ttywrite(LINEBAR_SYMBOL_BAR); + ttywrite(LINEBAR_SYMBOL_RIGHT); + attrmode(ATTR_RESET); + cursorrestore(); +} + +void +statusbar_draw(struct statusbar *s) +{ + if (!s->dirty) + return; + s->dirty = 0; + if (s->hidden || !s->width || s->x >= win.width || s->y >= win.height) + return; + + cursorsave(); + cursormove(s->x, s->y); + THEME_STATUSBAR(); + /* terminals without xenl (eat newline glitch) mess up scrolling when + using the last cell on the last line on the screen. */ + printutf8pad(stdout, s->text, s->width - (!eat_newline_glitch), ' '); + fflush(stdout); + attrmode(ATTR_RESET); + cursorrestore(); +} + +void +statusbar_update(struct statusbar *s, const char *text) +{ + if (s->text && !strcmp(s->text, text)) + return; + + free(s->text); + s->text = estrdup(text); + s->dirty = 1; +} + +/* Line to item, modifies and splits line in-place. */ +int +linetoitem(char *line, struct item *item) +{ + char *fields[FieldLast]; + time_t parsedtime; + + item->line = line; + parseline(line, fields); + memcpy(item->fields, fields, sizeof(fields)); + if (urlfile) + item->matchnew = estrdup(fields[fields[FieldLink][0] ? FieldLink : FieldId]); + else + item->matchnew = NULL; + + parsedtime = 0; + if (!strtotime(fields[FieldUnixTimestamp], &parsedtime)) { + item->timestamp = parsedtime; + item->timeok = 1; + } else { + item->timestamp = 0; + item->timeok = 0; + } + + return 0; +} + +void +feed_items_free(struct items *items) +{ + size_t i; + + for (i = 0; i < items->len; i++) { + free(items->items[i].line); + free(items->items[i].matchnew); + } + free(items->items); + items->items = NULL; + items->len = 0; + items->cap = 0; +} + +void +feed_items_get(struct feed *f, FILE *fp, struct items *itemsret) +{ + struct item *item, *items = NULL; + char *line = NULL; + size_t cap, i, linesize = 0, nitems; + ssize_t linelen, n; + off_t offset; + + cap = nitems = 0; + offset = 0; + for (i = 0; ; i++) { + if (i + 1 >= cap) { + cap = cap ? cap * 2 : 16; + items = erealloc(items, cap * sizeof(struct item)); + } + if ((n = linelen = getline(&line, &linesize, fp)) > 0) { + item = &items[i]; + + item->offset = offset; + offset += linelen; + + if (line[linelen - 1] == '\n') + line[--linelen] = '\0'; + + if (lazyload && f->path) { + linetoitem(line, item); + + /* data is ignored here, will be lazy-loaded later. */ + item->line = NULL; + memset(item->fields, 0, sizeof(item->fields)); + } else { + linetoitem(estrdup(line), item); + } + + nitems++; + } + if (ferror(fp)) + die("getline: %s", f->name); + if (n <= 0 || feof(fp)) + break; + } + itemsret->cap = cap; + itemsret->items = items; + itemsret->len = nitems; + free(line); +} + +void +updatenewitems(struct feed *f) +{ + struct pane *p; + struct row *row; + struct item *item; + size_t i; + + p = &panes[PaneItems]; + f->totalnew = 0; + for (i = 0; i < p->nrows; i++) { + row = &(p->rows[i]); /* do not use pane_row_get */ + item = row->data; + if (urlfile) + item->isnew = urls_isnew(item->matchnew); + else + item->isnew = (item->timeok && item->timestamp >= comparetime); + row->bold = item->isnew; + f->totalnew += item->isnew; + } + f->total = p->nrows; +} + +void +feed_load(struct feed *f, FILE *fp) +{ + /* static, reuse local buffers */ + static struct items items; + struct pane *p; + size_t i; + + feed_items_free(&items); + feed_items_get(f, fp, &items); + p = &panes[PaneItems]; + p->pos = 0; + p->nrows = items.len; + free(p->rows); + p->rows = ecalloc(sizeof(p->rows[0]), items.len + 1); + for (i = 0; i < items.len; i++) + p->rows[i].data = &(items.items[i]); /* do not use pane_row_get */ + + updatenewitems(f); + + p->dirty = 1; +} + +void +feed_count(struct feed *f, FILE *fp) +{ + char *fields[FieldLast]; + char *line = NULL; + size_t linesize = 0; + ssize_t linelen; + time_t parsedtime; + + f->totalnew = f->total = 0; + while ((linelen = getline(&line, &linesize, fp)) > 0) { + if (line[linelen - 1] == '\n') + line[--linelen] = '\0'; + parseline(line, fields); + + if (urlfile) { + f->totalnew += urls_isnew(fields[fields[FieldLink][0] ? FieldLink : FieldId]); + } else { + parsedtime = 0; + if (!strtotime(fields[FieldUnixTimestamp], &parsedtime)) + f->totalnew += (parsedtime >= comparetime); + } + f->total++; + } + if (ferror(fp)) + die("getline: %s", f->name); + free(line); +} + +void +feed_setenv(struct feed *f) +{ + if (f && f->path) + setenv("SFEED_FEED_PATH", f->path, 1); + else + unsetenv("SFEED_FEED_PATH"); +} + +/* Change feed, have one file open, reopen file if needed. */ +void +feeds_set(struct feed *f) +{ + if (curfeed) { + if (curfeed->path && curfeed->fp) { + fclose(curfeed->fp); + curfeed->fp = NULL; + } + } + + if (f && f->path) { + if (!f->fp && !(f->fp = fopen(f->path, "rb"))) + die("fopen: %s", f->path); + } + + feed_setenv(f); + + curfeed = f; +} + +void +feeds_load(struct feed *feeds, size_t nfeeds) +{ + struct feed *f; + size_t i; + + if ((comparetime = time(NULL)) == -1) + die("time"); + /* 1 day is old news */ + comparetime -= 86400; + + for (i = 0; i < nfeeds; i++) { + f = &feeds[i]; + + if (f->path) { + if (f->fp) { + if (fseek(f->fp, 0, SEEK_SET)) + die("fseek: %s", f->path); + } else { + if (!(f->fp = fopen(f->path, "rb"))) + die("fopen: %s", f->path); + } + } + if (!f->fp) { + /* reading from stdin, just recount new */ + if (f == curfeed) + updatenewitems(f); + continue; + } + + /* load first items, because of first selection or stdin. */ + if (f == curfeed) { + feed_load(f, f->fp); + } else { + feed_count(f, f->fp); + if (f->path && f->fp) { + fclose(f->fp); + f->fp = NULL; + } + } + } +} + +/* find row position of the feed if visible, else return -1 */ +off_t +feeds_row_get(struct pane *p, struct feed *f) +{ + struct row *row; + struct feed *fr; + off_t pos; + + for (pos = 0; pos < p->nrows; pos++) { + if (!(row = pane_row_get(p, pos))) + continue; + fr = row->data; + if (!strcmp(fr->name, f->name)) + return pos; + } + return -1; +} + +void +feeds_reloadall(void) +{ + struct pane *p; + struct feed *f = NULL; + struct row *row; + off_t pos; + + p = &panes[PaneFeeds]; + if ((row = pane_row_get(p, p->pos))) + f = row->data; + + pos = panes[PaneItems].pos; /* store numeric item position */ + feeds_set(curfeed); /* close and reopen feed if possible */ + urls_read(); + feeds_load(feeds, nfeeds); + urls_free(); + /* restore numeric item position */ + pane_setpos(&panes[PaneItems], pos); + updatesidebar(); + updatetitle(); + + /* try to find the same feed in the pane */ + if (f && (pos = feeds_row_get(p, f)) != -1) + pane_setpos(p, pos); + else + pane_setpos(p, 0); +} + +void +feed_open_selected(struct pane *p) +{ + struct feed *f; + struct row *row; + + if (!(row = pane_row_get(p, p->pos))) + return; + f = row->data; + feeds_set(f); + urls_read(); + if (f->fp) + feed_load(f, f->fp); + urls_free(); + /* redraw row: counts could be changed */ + updatesidebar(); + updatetitle(); + + if (layout == LayoutMonocle) { + selpane = PaneItems; + updategeom(); + } +} + +void +feed_plumb_selected_item(struct pane *p, int field) +{ + struct row *row; + struct item *item; + + if (!(row = pane_row_get(p, p->pos))) + return; + item = row->data; + markread(p, p->pos, p->pos, 1); + forkexec((char *[]) { plumbercmd, item->fields[field], NULL }, plumberia); +} + +void +feed_pipe_selected_item(struct pane *p) +{ + struct row *row; + struct item *item; + + if (!(row = pane_row_get(p, p->pos))) + return; + item = row->data; + markread(p, p->pos, p->pos, 1); + pipeitem(pipercmd, item, -1, piperia); +} + +void +feed_yank_selected_item(struct pane *p, int field) +{ + struct row *row; + struct item *item; + + if (!(row = pane_row_get(p, p->pos))) + return; + item = row->data; + pipeitem(yankercmd, item, field, yankeria); +} + +/* calculate optimal (default) size */ +int +getsidebarsizedefault(void) +{ + struct feed *feed; + size_t i; + int len, size; + + switch (layout) { + case LayoutVertical: + for (i = 0, size = 0; i < nfeeds; i++) { + feed = &feeds[i]; + len = snprintf(NULL, 0, " (%lu/%lu)", + feed->totalnew, feed->total) + + colw(feed->name); + if (len > size) + size = len; + + if (onlynew && feed->totalnew == 0) + continue; + } + return MAX(MIN(win.width - 1, size), 0); + case LayoutHorizontal: + for (i = 0, size = 0; i < nfeeds; i++) { + feed = &feeds[i]; + if (onlynew && feed->totalnew == 0) + continue; + size++; + } + return MAX(MIN((win.height - 1) / 2, size), 1); + } + return 0; +} + +int +getsidebarsize(void) +{ + int size; + + if ((size = fixedsidebarsizes[layout]) < 0) + size = getsidebarsizedefault(); + return size; +} + +void +adjustsidebarsize(int n) +{ + int size; + + if ((size = fixedsidebarsizes[layout]) < 0) + size = getsidebarsizedefault(); + if (n > 0) { + if ((layout == LayoutVertical && size + 1 < win.width) || + (layout == LayoutHorizontal && size + 1 < win.height)) + size++; + } else if (n < 0) { + if ((layout == LayoutVertical && size > 0) || + (layout == LayoutHorizontal && size > 1)) + size--; + } + + if (size != fixedsidebarsizes[layout]) { + fixedsidebarsizes[layout] = size; + updategeom(); + } +} + +void +updatesidebar(void) +{ + struct pane *p; + struct row *row; + struct feed *feed; + size_t i, nrows; + int oldvalue = 0, newvalue = 0; + + p = &panes[PaneFeeds]; + if (!p->rows) + p->rows = ecalloc(sizeof(p->rows[0]), nfeeds + 1); + + switch (layout) { + case LayoutVertical: + oldvalue = p->width; + newvalue = getsidebarsize(); + p->width = newvalue; + break; + case LayoutHorizontal: + oldvalue = p->height; + newvalue = getsidebarsize(); + p->height = newvalue; + break; + } + + nrows = 0; + for (i = 0; i < nfeeds; i++) { + feed = &feeds[i]; + + row = &(p->rows[nrows]); + row->bold = (feed->totalnew > 0); + row->data = feed; + + if (onlynew && feed->totalnew == 0) + continue; + + nrows++; + } + p->nrows = nrows; + + if (oldvalue != newvalue) + updategeom(); + else + p->dirty = 1; + + if (!p->nrows) + p->pos = 0; + else if (p->pos >= p->nrows) + p->pos = p->nrows - 1; +} + +void +sighandler(int signo) +{ + switch (signo) { + case SIGHUP: + case SIGINT: + case SIGTERM: + case SIGWINCH: + /* SIGTERM is more important, do not override it */ + if (sigstate != SIGTERM) + sigstate = signo; + break; + } +} + +void +alldirty(void) +{ + win.dirty = 1; + panes[PaneFeeds].dirty = 1; + panes[PaneItems].dirty = 1; + scrollbars[PaneFeeds].dirty = 1; + scrollbars[PaneItems].dirty = 1; + linebar.dirty = 1; + statusbar.dirty = 1; +} + +void +draw(void) +{ + struct row *row; + struct item *item; + size_t i; + + if (win.dirty) + win.dirty = 0; + + for (i = 0; i < LEN(panes); i++) { + pane_setfocus(&panes[i], i == selpane); + pane_draw(&panes[i]); + + /* each pane has a scrollbar */ + scrollbar_setfocus(&scrollbars[i], i == selpane); + scrollbar_update(&scrollbars[i], + panes[i].pos - (panes[i].pos % panes[i].height), + panes[i].nrows, panes[i].height); + scrollbar_draw(&scrollbars[i]); + } + + linebar_draw(&linebar); + + /* if item selection text changed then update the status text */ + if ((row = pane_row_get(&panes[PaneItems], panes[PaneItems].pos))) { + item = row->data; + statusbar_update(&statusbar, item->fields[FieldLink]); + } else { + statusbar_update(&statusbar, ""); + } + statusbar_draw(&statusbar); +} + +void +mousereport(int button, int release, int keymask, int x, int y) +{ + struct pane *p; + size_t i; + int changedpane, dblclick, pos; + + if (!usemouse || release || button == -1) + return; + + for (i = 0; i < LEN(panes); i++) { + p = &panes[i]; + if (p->hidden || !p->width || !p->height) + continue; + + /* these button actions are done regardless of the position */ + switch (button) { + case 7: /* side-button: backward */ + if (selpane == PaneFeeds) + return; + selpane = PaneFeeds; + if (layout == LayoutMonocle) + updategeom(); + return; + case 8: /* side-button: forward */ + if (selpane == PaneItems) + return; + selpane = PaneItems; + if (layout == LayoutMonocle) + updategeom(); + return; + } + + /* check if mouse position is in pane or in its scrollbar */ + if (!(x >= p->x && x < p->x + p->width + (!scrollbars[i].hidden) && + y >= p->y && y < p->y + p->height)) + continue; + + changedpane = (selpane != i); + selpane = i; + /* relative position on screen */ + pos = y - p->y + p->pos - (p->pos % p->height); + dblclick = (pos == p->pos); /* clicking the same row twice */ + + switch (button) { + case 0: /* left-click */ + if (!p->nrows || pos >= p->nrows) + break; + pane_setpos(p, pos); + if (i == PaneFeeds) + feed_open_selected(&panes[PaneFeeds]); + else if (i == PaneItems && dblclick && !changedpane) + feed_plumb_selected_item(&panes[PaneItems], FieldLink); + break; + case 2: /* right-click */ + if (!p->nrows || pos >= p->nrows) + break; + pane_setpos(p, pos); + if (i == PaneItems) + feed_pipe_selected_item(&panes[PaneItems]); + break; + case 3: /* scroll up */ + case 4: /* scroll down */ + pane_scrollpage(p, button == 3 ? -1 : +1); + break; + } + return; /* do not bubble events */ + } +} + +/* Custom formatter for feed row. */ +char * +feed_row_format(struct pane *p, struct row *row) +{ + /* static, reuse local buffers */ + static char *bufw, *text; + static size_t bufwsize, textsize; + struct feed *feed; + size_t needsize; + char counts[128]; + int len, w; + + feed = row->data; + + /* align counts to the right and pad the rest with spaces */ + len = snprintf(counts, sizeof(counts), "(%lu/%lu)", + feed->totalnew, feed->total); + if (len > p->width) + w = p->width; + else + w = p->width - len; + + needsize = (w + 1) * 4; + if (needsize > bufwsize) { + bufw = erealloc(bufw, needsize); + bufwsize = needsize; + } + + needsize = bufwsize + sizeof(counts) + 1; + if (needsize > textsize) { + text = erealloc(text, needsize); + textsize = needsize; + } + + if (utf8pad(bufw, bufwsize, feed->name, w, ' ') != -1) + snprintf(text, textsize, "%s%s", bufw, counts); + else + text[0] = '\0'; + + return text; +} + +int +feed_row_match(struct pane *p, struct row *row, const char *s) +{ + struct feed *feed; + + feed = row->data; + + return (strcasestr(feed->name, s) != NULL); +} + +struct row * +item_row_get(struct pane *p, off_t pos) +{ + struct row *itemrow; + struct item *item; + struct feed *f; + char *line = NULL; + size_t linesize = 0; + ssize_t linelen; + + itemrow = p->rows + pos; + item = itemrow->data; + + f = curfeed; + if (f && f->path && f->fp && !item->line) { + if (fseek(f->fp, item->offset, SEEK_SET)) + die("fseek: %s", f->path); + + if ((linelen = getline(&line, &linesize, f->fp)) <= 0) { + if (ferror(f->fp)) + die("getline: %s", f->path); + return NULL; + } + + if (line[linelen - 1] == '\n') + line[--linelen] = '\0'; + + linetoitem(estrdup(line), item); + free(line); + + itemrow->data = item; + } + return itemrow; +} + +/* Custom formatter for item row. */ +char * +item_row_format(struct pane *p, struct row *row) +{ + /* static, reuse local buffers */ + static char *text; + static size_t textsize; + struct item *item; + struct tm tm; + size_t needsize; + + item = row->data; + + needsize = strlen(item->fields[FieldTitle]) + 21; + if (needsize > textsize) { + text = erealloc(text, needsize); + textsize = needsize; + } + + if (item->timeok && localtime_r(&(item->timestamp), &tm)) { + snprintf(text, textsize, "%c %04d-%02d-%02d %02d:%02d %s", + item->fields[FieldEnclosure][0] ? '@' : ' ', + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, item->fields[FieldTitle]); + } else { + snprintf(text, textsize, "%c %s", + item->fields[FieldEnclosure][0] ? '@' : ' ', + item->fields[FieldTitle]); + } + + return text; +} + +void +markread(struct pane *p, off_t from, off_t to, int isread) +{ + struct row *row; + struct item *item; + FILE *fp; + off_t i; + const char *cmd; + int isnew = !isread, pid, wpid, status, visstart; + + if (!urlfile || !p->nrows) + return; + + cmd = isread ? markreadcmd : markunreadcmd; + + switch ((pid = fork())) { + case -1: + die("fork"); + case 0: + dup2(devnullfd, 1); + dup2(devnullfd, 2); + + errno = 0; + if (!(fp = popen(cmd, "w"))) + die("popen: %s", cmd); + + for (i = from; i <= to && i < p->nrows; i++) { + /* do not use pane_row_get: no need for lazyload */ + row = &(p->rows[i]); + item = row->data; + if (item->isnew != isnew) { + fputs(item->matchnew, fp); + putc('\n', fp); + } + } + status = pclose(fp); + status = WIFEXITED(status) ? WEXITSTATUS(status) : 127; + _exit(status); + default: + while ((wpid = wait(&status)) >= 0 && wpid != pid) + ; + + /* fail: exit statuscode was non-zero */ + if (status) + break; + + visstart = p->pos - (p->pos % p->height); /* visible start */ + for (i = from; i <= to && i < p->nrows; i++) { + row = &(p->rows[i]); + item = row->data; + if (item->isnew == isnew) + continue; + + row->bold = item->isnew = isnew; + curfeed->totalnew += isnew ? 1 : -1; + + /* draw if visible on screen */ + if (i >= visstart && i < visstart + p->height) + pane_row_draw(p, i, i == p->pos); + } + updatesidebar(); + updatetitle(); + } +} + +int +urls_cmp(const void *v1, const void *v2) +{ + return strcmp(*((char **)v1), *((char **)v2)); +} + +int +urls_isnew(const char *url) +{ + return bsearch(&url, urls, nurls, sizeof(char *), urls_cmp) == NULL; +} + +void +urls_free(void) +{ + while (nurls > 0) + free(urls[--nurls]); + free(urls); + urls = NULL; + nurls = 0; +} + +void +urls_read(void) +{ + FILE *fp; + char *line = NULL; + size_t linesiz = 0, cap = 0; + ssize_t n; + + urls_free(); + + if (!urlfile) + return; + if (!(fp = fopen(urlfile, "rb"))) + die("fopen: %s", urlfile); + + while ((n = getline(&line, &linesiz, fp)) > 0) { + if (line[n - 1] == '\n') + line[--n] = '\0'; + if (nurls + 1 >= cap) { + cap = cap ? cap * 2 : 16; + urls = erealloc(urls, cap * sizeof(char *)); + } + urls[nurls++] = estrdup(line); + } + if (ferror(fp)) + die("getline: %s", urlfile); + fclose(fp); + free(line); + + qsort(urls, nurls, sizeof(char *), urls_cmp); +} + +int +main(int argc, char *argv[]) +{ + struct pane *p; + struct feed *f; + struct row *row; + size_t i; + char *name, *tmp; + char *search = NULL; /* search text */ + int button, ch, fd, keymask, release, x, y; + off_t pos; + +#ifdef __OpenBSD__ + if (pledge("stdio rpath tty proc exec", NULL) == -1) + die("pledge"); +#endif + + setlocale(LC_CTYPE, ""); + + if ((tmp = getenv("SFEED_PLUMBER"))) + plumbercmd = tmp; + if ((tmp = getenv("SFEED_PIPER"))) + pipercmd = tmp; + if ((tmp = getenv("SFEED_YANKER"))) + yankercmd = tmp; + if ((tmp = getenv("SFEED_PLUMBER_INTERACTIVE"))) + plumberia = !strcmp(tmp, "1"); + if ((tmp = getenv("SFEED_PIPER_INTERACTIVE"))) + piperia = !strcmp(tmp, "1"); + if ((tmp = getenv("SFEED_YANKER_INTERACTIVE"))) + yankeria = !strcmp(tmp, "1"); + if ((tmp = getenv("SFEED_MARK_READ"))) + markreadcmd = tmp; + if ((tmp = getenv("SFEED_MARK_UNREAD"))) + markunreadcmd = tmp; + if ((tmp = getenv("SFEED_LAZYLOAD"))) + lazyload = !strcmp(tmp, "1"); + urlfile = getenv("SFEED_URL_FILE"); /* can be NULL */ + cmdenv = getenv("SFEED_AUTOCMD"); /* can be NULL */ + + setlayout(argc <= 1 ? LayoutMonocle : LayoutVertical); + selpane = layout == LayoutMonocle ? PaneItems : PaneFeeds; + + panes[PaneFeeds].row_format = feed_row_format; + panes[PaneFeeds].row_match = feed_row_match; + panes[PaneItems].row_format = item_row_format; + if (lazyload) + panes[PaneItems].row_get = item_row_get; + + feeds = ecalloc(argc, sizeof(struct feed)); + if (argc == 1) { + nfeeds = 1; + f = &feeds[0]; + f->name = "stdin"; + if (!(f->fp = fdopen(0, "rb"))) + die("fdopen"); + } else { + for (i = 1; i < argc; i++) { + f = &feeds[i - 1]; + f->path = argv[i]; + name = ((name = strrchr(argv[i], '/'))) ? name + 1 : argv[i]; + f->name = name; + } + nfeeds = argc - 1; + } + feeds_set(&feeds[0]); + urls_read(); + feeds_load(feeds, nfeeds); + urls_free(); + + if (!isatty(0)) { + if ((fd = open("/dev/tty", O_RDONLY)) == -1) + die("open: /dev/tty"); + if (dup2(fd, 0) == -1) + die("dup2(%d, 0): /dev/tty -> stdin", fd); + close(fd); + } + if (argc == 1) + feeds[0].fp = NULL; + + if ((devnullfd = open("/dev/null", O_WRONLY)) == -1) + die("open: /dev/null"); + + init(); + updatesidebar(); + updategeom(); + updatetitle(); + draw(); + + while (1) { + if ((ch = readch()) < 0) + goto event; + switch (ch) { + case '\x1b': + if ((ch = readch()) < 0) + goto event; + if (ch != '[' && ch != 'O') + continue; /* unhandled */ + if ((ch = readch()) < 0) + goto event; + switch (ch) { + case 'M': /* mouse: X10 encoding */ + if ((ch = readch()) < 0) + goto event; + button = ch - 32; + if ((ch = readch()) < 0) + goto event; + x = ch - 32; + if ((ch = readch()) < 0) + goto event; + y = ch - 32; + + keymask = button & (4 | 8 | 16); /* shift, meta, ctrl */ + button &= ~keymask; /* unset key mask */ + + /* button numbers (0 - 2) encoded in lowest 2 bits + release does not indicate which button (so set to 0). + Handle extended buttons like scrollwheels + and side-buttons by each range. */ + release = 0; + if (button == 3) { + button = -1; + release = 1; + } else if (button >= 128) { + button -= 121; + } else if (button >= 64) { + button -= 61; + } + mousereport(button, release, keymask, x - 1, y - 1); + break; + case '<': /* mouse: SGR encoding */ + for (button = 0; ; button *= 10, button += ch - '0') { + if ((ch = readch()) < 0) + goto event; + else if (ch == ';') + break; + } + for (x = 0; ; x *= 10, x += ch - '0') { + if ((ch = readch()) < 0) + goto event; + else if (ch == ';') + break; + } + for (y = 0; ; y *= 10, y += ch - '0') { + if ((ch = readch()) < 0) + goto event; + else if (ch == 'm' || ch == 'M') + break; /* release or press */ + } + release = ch == 'm'; + keymask = button & (4 | 8 | 16); /* shift, meta, ctrl */ + button &= ~keymask; /* unset key mask */ + + if (button >= 128) + button -= 121; + else if (button >= 64) + button -= 61; + + mousereport(button, release, keymask, x - 1, y - 1); + break; + case 'A': goto keyup; /* arrow up */ + case 'B': goto keydown; /* arrow down */ + case 'C': goto keyright; /* arrow left */ + case 'D': goto keyleft; /* arrow right */ + case 'F': goto endpos; /* end */ + case 'H': goto startpos; /* home */ + case '4': /* end */ + if ((ch = readch()) < 0) + goto event; + if (ch == '~') + goto endpos; + continue; + case '5': /* page up */ + if ((ch = readch()) < 0) + goto event; + if (ch == '~') + goto prevpage; + continue; + case '6': /* page down */ + if ((ch = readch()) < 0) + goto event; + if (ch == '~') + goto nextpage; + continue; + } + break; +keyup: + case 'k': + case 'K': + pane_scrolln(&panes[selpane], -1); + if (ch == 'K') + goto openitem; + break; +keydown: + case 'j': + case 'J': + pane_scrolln(&panes[selpane], +1); + if (ch == 'J') + goto openitem; + break; +keyleft: + case 'h': + if (selpane == PaneFeeds) + break; + selpane = PaneFeeds; + if (layout == LayoutMonocle) + updategeom(); + break; +keyright: + case 'l': + if (selpane == PaneItems) + break; + selpane = PaneItems; + if (layout == LayoutMonocle) + updategeom(); + break; + case '\t': + selpane = selpane == PaneFeeds ? PaneItems : PaneFeeds; + if (layout == LayoutMonocle) + updategeom(); + break; +startpos: + case 'g': + pane_setpos(&panes[selpane], 0); + break; +endpos: + case 'G': + p = &panes[selpane]; + if (p->nrows) + pane_setpos(p, p->nrows - 1); + break; +prevpage: + case 2: /* ^B */ + pane_scrollpage(&panes[selpane], -1); + break; +nextpage: + case ' ': + case 6: /* ^F */ + pane_scrollpage(&panes[selpane], +1); + break; + case '[': + case ']': + pane_scrolln(&panes[PaneFeeds], ch == '[' ? -1 : +1); + feed_open_selected(&panes[PaneFeeds]); + break; + case '/': /* new search (forward) */ + case '?': /* new search (backward) */ + case 'n': /* search again (forward) */ + case 'N': /* search again (backward) */ + p = &panes[selpane]; + + /* prompt for new input */ + if (ch == '?' || ch == '/') { + tmp = ch == '?' ? "backward" : "forward"; + free(search); + search = uiprompt(statusbar.x, statusbar.y, + "Search (%s):", tmp); + statusbar.dirty = 1; + } + if (!search || !p->nrows) + break; + + if (ch == '/' || ch == 'n') { + /* forward */ + for (pos = p->pos + 1; pos < p->nrows; pos++) { + if (pane_row_match(p, pane_row_get(p, pos), search)) { + pane_setpos(p, pos); + break; + } + } + } else { + /* backward */ + for (pos = p->pos - 1; pos >= 0; pos--) { + if (pane_row_match(p, pane_row_get(p, pos), search)) { + pane_setpos(p, pos); + break; + } + } + } + break; + case 12: /* ^L, redraw */ + alldirty(); + break; + case 'R': /* reload all files */ + feeds_reloadall(); + break; + case 'a': /* attachment */ + case 'e': /* enclosure */ + case '@': + if (selpane == PaneItems) + feed_plumb_selected_item(&panes[selpane], FieldEnclosure); + break; + case 'm': /* toggle mouse mode */ + usemouse = !usemouse; + mousemode(usemouse); + break; + case '<': /* decrease fixed sidebar width */ + case '>': /* increase fixed sidebar width */ + adjustsidebarsize(ch == '<' ? -1 : +1); + break; + case '=': /* reset fixed sidebar to automatic size */ + fixedsidebarsizes[layout] = -1; + updategeom(); + break; + case 't': /* toggle showing only new in sidebar */ + p = &panes[PaneFeeds]; + if ((row = pane_row_get(p, p->pos))) + f = row->data; + else + f = NULL; + + onlynew = !onlynew; + updatesidebar(); + + /* try to find the same feed in the pane */ + if (f && f->totalnew && + (pos = feeds_row_get(p, f)) != -1) + pane_setpos(p, pos); + else + pane_setpos(p, 0); + break; + case 'o': /* feeds: load, items: plumb URL */ + case '\n': +openitem: + if (selpane == PaneFeeds && panes[selpane].nrows) + feed_open_selected(&panes[selpane]); + else if (selpane == PaneItems && panes[selpane].nrows) + feed_plumb_selected_item(&panes[selpane], FieldLink); + break; + case 'c': /* items: pipe TSV line to program */ + case 'p': + case '|': + if (selpane == PaneItems) + feed_pipe_selected_item(&panes[selpane]); + break; + case 'y': /* yank: pipe TSV field to yank URL to clipboard */ + case 'E': /* yank: pipe TSV field to yank enclosure to clipboard */ + if (selpane == PaneItems) + feed_yank_selected_item(&panes[selpane], + ch == 'y' ? FieldLink : FieldEnclosure); + break; + case 'f': /* mark all read */ + case 'F': /* mark all unread */ + if (panes[PaneItems].nrows) { + p = &panes[PaneItems]; + markread(p, 0, p->nrows - 1, ch == 'f'); + } + break; + case 'r': /* mark item as read */ + case 'u': /* mark item as unread */ + if (selpane == PaneItems && panes[selpane].nrows) { + p = &panes[selpane]; + markread(p, p->pos, p->pos, ch == 'r'); + } + break; + case 's': /* toggle layout between monocle or non-monocle */ + setlayout(layout == LayoutMonocle ? prevlayout : LayoutMonocle); + updategeom(); + break; + case '1': /* vertical layout */ + case '2': /* horizontal layout */ + case '3': /* monocle layout */ + setlayout(ch - '1'); + updategeom(); + break; + case 4: /* EOT */ + case 'q': goto end; + } +event: + if (ch == EOF) + goto end; + else if (ch == -3 && sigstate == 0) + continue; /* just a time-out, nothing to do */ + + switch (sigstate) { + case SIGHUP: + feeds_reloadall(); + sigstate = 0; + break; + case SIGINT: + case SIGTERM: + cleanup(); + _exit(128 + sigstate); + case SIGWINCH: + resizewin(); + updategeom(); + sigstate = 0; + break; + } + + draw(); + } +end: + cleanup(); + + return 0; +} diff --git a/sfeed_markread b/sfeed_markread @@ -0,0 +1,35 @@ +#!/bin/sh +# Mark items as read/unread: the input is the read / unread URL per line. + +usage() { + echo "usage: $0 <read|unread> [urlfile]" >&2 + echo "" >&2 + echo "An urlfile must be specified as an argument or with the environment variable \$SFEED_URL_FILE" >&2 + exit 1 +} + +urlfile="${2:-${SFEED_URL_FILE}}" +if test -z "${urlfile}"; then + usage +fi + +case "$1" in +read) + cat >> "${urlfile}" + ;; +unread) + tmp=$(mktemp) + trap "rm -f ${tmp}" EXIT + test -f "${urlfile}" || touch "${urlfile}" 2>/dev/null + LC_CTYPE=C awk -F '\t' ' + { FILENR += (FNR == 1) } + FILENR == 1 { urls[$0] = 1 } + FILENR == 2 { if (!urls[$0]) { print $0 } } + END { exit(FILENR != 2) }' \ + "-" "${urlfile}" > "${tmp}" && \ + cp "${tmp}" "${urlfile}" + ;; +*) + usage + ;; +esac diff --git a/sfeed_markread.1 b/sfeed_markread.1 @@ -0,0 +1,47 @@ +.Dd July 25, 2021 +.Dt SFEED_MARKREAD 1 +.Os +.Sh NAME +.Nm sfeed_markread +.Nd mark items as read/unread +.Sh SYNOPSIS +.Nm +.Ar read | Ar unread +.Op Ar urlfile +.Sh DESCRIPTION +.Nm +reads a plain-text list of URLs from stdin. +The file format for the list of URLs is one URL per line. +.Nm +will write to the file specified as +.Ar urlfile +or with the environment variable +.Ev SFEED_URL_FILE . +The +.Nm +script can be used by +.Xr sfeed_curses 1 +to mark items as read and unread. +.Sh ENVIRONMENT VARIABLES +.Bl -tag -width Ds +.It Ev SFEED_URL_FILE +This variable can be set to use as the path to the file containing a +plain-text list of read URLs. +.El +.Sh EXIT STATUS +.Ex -std +.Sh EXAMPLES +.Bd -literal +export SFEED_URL_FILE="$HOME/.sfeed/urls" +echo 'https://codemadness.org/sfeed.html' | sfeed_markread read +.Ed +.Pp +or +.Bd -literal +echo 'https://codemadness.org/sfeed.html' | sfeed_markread read ~/.sfeed/urls +.Ed +.Sh SEE ALSO +.Xr awk 1 , +.Xr sfeed_curses 1 +.Sh AUTHORS +.An Hiltjo Posthuma Aq Mt hiltjo@codemadness.org diff --git a/themes/mono.h b/themes/mono.h @@ -0,0 +1,13 @@ +/* default mono theme */ +#define THEME_ITEM_NORMAL() do { } while(0) +#define THEME_ITEM_FOCUS() do { } while(0) +#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); } while(0) +#define THEME_ITEM_SELECTED() do { if (p->focused) attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_SCROLLBAR_FOCUS() do { } while(0) +#define THEME_SCROLLBAR_NORMAL() do { attrmode(ATTR_FAINT_ON); } while(0) +#define THEME_SCROLLBAR_TICK_FOCUS() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_SCROLLBAR_TICK_NORMAL() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_LINEBAR() do { attrmode(ATTR_FAINT_ON); } while(0) +#define THEME_STATUSBAR() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_INPUT_LABEL() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_INPUT_NORMAL() do { } while(0) diff --git a/themes/mono_highlight.h b/themes/mono_highlight.h @@ -0,0 +1,15 @@ +/* mono theme with highlighting of the active panel. + The faint attribute may not work on all terminals though. + The combination bold with faint generally does not work either. */ +#define THEME_ITEM_NORMAL() do { } while(0) +#define THEME_ITEM_FOCUS() do { } while(0) +#define THEME_ITEM_BOLD() do { if (p->focused || !selected) attrmode(ATTR_BOLD_ON); } while(0) +#define THEME_ITEM_SELECTED() do { attrmode(ATTR_REVERSE_ON); if (!p->focused) attrmode(ATTR_FAINT_ON); } while(0) +#define THEME_SCROLLBAR_FOCUS() do { } while(0) +#define THEME_SCROLLBAR_NORMAL() do { attrmode(ATTR_FAINT_ON); } while(0) +#define THEME_SCROLLBAR_TICK_FOCUS() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_SCROLLBAR_TICK_NORMAL() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_LINEBAR() do { attrmode(ATTR_FAINT_ON); } while(0) +#define THEME_STATUSBAR() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_INPUT_LABEL() do { attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_INPUT_NORMAL() do { } while(0) diff --git a/themes/newsboat.h b/themes/newsboat.h @@ -0,0 +1,13 @@ +/* newsboat-like (blue, yellow) */ +#define THEME_ITEM_NORMAL() do { } while(0) +#define THEME_ITEM_FOCUS() do { } while(0) +#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); } while(0) +#define THEME_ITEM_SELECTED() do { if (p->focused) ttywrite("\x1b[93;44m"); } while(0) /* bright yellow fg, blue bg */ +#define THEME_SCROLLBAR_FOCUS() do { ttywrite("\x1b[34m"); } while(0) /* blue fg */ +#define THEME_SCROLLBAR_NORMAL() do { ttywrite("\x1b[34m"); } while(0) +#define THEME_SCROLLBAR_TICK_FOCUS() do { ttywrite("\x1b[44m"); } while(0) /* blue bg */ +#define THEME_SCROLLBAR_TICK_NORMAL() do { ttywrite("\x1b[44m"); } while(0) +#define THEME_LINEBAR() do { ttywrite("\x1b[34m"); } while(0) +#define THEME_STATUSBAR() do { attrmode(ATTR_BOLD_ON); ttywrite("\x1b[93;44m"); } while(0) +#define THEME_INPUT_LABEL() do { } while(0) +#define THEME_INPUT_NORMAL() do { } while(0) diff --git a/themes/templeos.h b/themes/templeos.h @@ -0,0 +1,24 @@ +/* TempleOS-like (for fun and god) */ +/* set true-color foreground / background, Terry would've preferred ANSI */ +#define SETFGCOLOR(r,g,b) ttywritef("\x1b[38;2;%d;%d;%dm", r, g, b) +#define SETBGCOLOR(r,g,b) ttywritef("\x1b[48;2;%d;%d;%dm", r, g, b) + +#define THEME_ITEM_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_ITEM_FOCUS() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_ITEM_BOLD() do { attrmode(ATTR_BOLD_ON); SETFGCOLOR(0xaa, 0x00, 0x00); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_ITEM_SELECTED() do { if (p->focused) attrmode(ATTR_REVERSE_ON); } while(0) +#define THEME_SCROLLBAR_FOCUS() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_SCROLLBAR_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_SCROLLBAR_TICK_FOCUS() do { SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_SCROLLBAR_TICK_NORMAL() do { SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_LINEBAR() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_STATUSBAR() do { ttywrite("\x1b[6m"); SETBGCOLOR(0x00, 0x00, 0xaa); SETFGCOLOR(0xff, 0xff, 0xff); } while(0) /* blink statusbar */ +#define THEME_INPUT_LABEL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) +#define THEME_INPUT_NORMAL() do { SETFGCOLOR(0x00, 0x00, 0xaa); SETBGCOLOR(0xff, 0xff, 0xff); } while(0) + +#undef SCROLLBAR_SYMBOL_BAR +#define SCROLLBAR_SYMBOL_BAR "\xe2\x95\x91" /* symbol: "double vertical" */ +#undef LINEBAR_SYMBOL_BAR +#define LINEBAR_SYMBOL_BAR "\xe2\x95\x90" /* symbol: "double horizontal" */ +#undef LINEBAR_SYMBOL_RIGHT +#define LINEBAR_SYMBOL_RIGHT "\xe2\x95\xa3" /* symbol: "double vertical and left" */