From 3cb7df56e0204bb8b0799a8317ef5a5f38802c7b Mon Sep 17 00:00:00 2001
From: Hiltjo Posthuma <hiltjo@codemadness.org>
Date: Fri, 26 Nov 2021 12:10:05 +0100
Subject: import sfeed_curses

Import sfeed_curses into sfeed.
The files are based of the commit 8e151ce48b503ad0ff0e24cb1be3bc93d6fbd895
---
 README.curses           |  176 ++++
 minicurses.h            |   38 +
 sfeed_content           |   53 +
 sfeed_content.1         |   57 ++
 sfeed_curses.1          |  320 ++++++
 sfeed_curses.c          | 2459 +++++++++++++++++++++++++++++++++++++++++++++++
 sfeed_markread          |   35 +
 sfeed_markread.1        |   47 +
 themes/mono.h           |   13 +
 themes/mono_highlight.h |   15 +
 themes/newsboat.h       |   13 +
 themes/templeos.h       |   24 +
 12 files changed, 3250 insertions(+)
 create mode 100644 README.curses
 create mode 100644 minicurses.h
 create mode 100755 sfeed_content
 create mode 100644 sfeed_content.1
 create mode 100644 sfeed_curses.1
 create mode 100644 sfeed_curses.c
 create mode 100755 sfeed_markread
 create mode 100644 sfeed_markread.1
 create mode 100644 themes/mono.h
 create mode 100644 themes/mono_highlight.h
 create mode 100644 themes/newsboat.h
 create mode 100644 themes/templeos.h

diff --git a/README.curses b/README.curses
new file mode 100644
index 0000000..f5e61dd
--- /dev/null
+++ 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
new file mode 100644
index 0000000..3bc3e8a
--- /dev/null
+++ 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
new file mode 100755
index 0000000..2d0ee23
--- /dev/null
+++ 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
new file mode 100644
index 0000000..a5bc4d4
--- /dev/null
+++ 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
new file mode 100644
index 0000000..264b77a
--- /dev/null
+++ 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
new file mode 100644
index 0000000..b84c391
--- /dev/null
+++ 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
new file mode 100755
index 0000000..d0f80e2
--- /dev/null
+++ 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
new file mode 100644
index 0000000..17504c4
--- /dev/null
+++ 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
new file mode 100644
index 0000000..586ede0
--- /dev/null
+++ 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
new file mode 100644
index 0000000..180f651
--- /dev/null
+++ 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
new file mode 100644
index 0000000..b00111d
--- /dev/null
+++ 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
new file mode 100644
index 0000000..7f9d770
--- /dev/null
+++ 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" */
-- 
cgit v1.2.3