diff options
Diffstat (limited to 'nncmpp.c')
-rw-r--r-- | nncmpp.c | 1506 |
1 files changed, 1506 insertions, 0 deletions
diff --git a/nncmpp.c b/nncmpp.c new file mode 100644 index 0000000..27c175b --- /dev/null +++ b/nncmpp.c @@ -0,0 +1,1506 @@ +/* + * nncmpp -- the MPD client you never knew you needed + * + * Copyright (c) 2016, Přemysl Janouch <p.janouch@gmail.com> + * All rights reserved. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + */ + +#include "config.h" + +// My battle-tested C framework acting as a GLib replacement. Its one big +// disadvantage is missing support for i18n but that can eventually be added +// as an optional feature. Localised applications look super awkward, though. + +#define LIBERTY_WANT_POLLER +#define LIBERTY_WANT_ASYNC +#include "liberty/liberty.c" + +#include <sys/un.h> +#include "mpd.c" + +#include <locale.h> +#include <termios.h> +#ifndef TIOCGWINSZ +#include <sys/ioctl.h> +#endif // ! TIOCGWINSZ +#include <ncurses.h> + +// ncurses is notoriously retarded for input handling, we need something +// different if only to receive mouse events reliably. + +#include "termo.h" + +// It is surprisingly hard to find a good library to handle Unicode shenanigans, +// and there's enough of those for it to be impractical to reimplement them. +// +// GLib ICU libunistring utf8proc +// Decently sized . . x x +// Grapheme breaks . x . x +// Character width x . x x +// Locale handling . . x . +// Liberal license . x . x +// +// Also note that the ICU API is icky and uses UTF-16 for its primary encoding. +// +// Currently we're chugging along with libunistring but utf8proc seems viable. +// Non-Unicode locales can mostly be handled with simple iconv like in sdtui. +// Similarly grapheme breaks can be guessed at using character width (a basic +// test here is Zalgo text). +// +// None of this is ever going to work too reliably anyway because terminals +// and Unicode don't go awfully well together. In particular, character cell +// devices have some problems with double-wide characters. + +#include <unistr.h> +#include <uniwidth.h> +#include <uniconv.h> + +#define CTRL_KEY(x) ((x) - 'A' + 1) + +#define APP_TITLE PROGRAM_NAME " " ///< Left top corner + +// --- Utilities --------------------------------------------------------------- + +// The standard endwin/refresh sequence makes the terminal flicker +static void +update_curses_terminal_size (void) +{ +#if defined (HAVE_RESIZETERM) && defined (TIOCGWINSZ) + struct winsize size; + if (!ioctl (STDOUT_FILENO, TIOCGWINSZ, (char *) &size)) + { + char *row = getenv ("LINES"); + char *col = getenv ("COLUMNS"); + unsigned long tmp; + resizeterm ( + (row && xstrtoul (&tmp, row, 10)) ? tmp : size.ws_row, + (col && xstrtoul (&tmp, col, 10)) ? tmp : size.ws_col); + } +#else // HAVE_RESIZETERM && TIOCGWINSZ + endwin (); + refresh (); +#endif // HAVE_RESIZETERM && TIOCGWINSZ +} + +// --- Application ------------------------------------------------------------- + +// Function names are prefixed mostly because of curses which clutters the +// global namespace and makes it harder to distinguish what functions relate to. + +// Avoiding colours in the defaults here in order to support dumb terminals +#define ATTRIBUTE_TABLE(XX) \ + XX( HEADER, "header", -1, -1, A_REVERSE ) \ + XX( ACTIVE, "header_active", -1, -1, A_UNDERLINE ) \ + XX( EVEN, "even", -1, -1, 0 ) \ + XX( ODD, "odd", -1, -1, 0 ) + +enum +{ +#define XX(name, config, fg_, bg_, attrs_) ATTRIBUTE_ ## name, + ATTRIBUTE_TABLE (XX) +#undef XX + ATTRIBUTE_COUNT +}; + +struct attrs +{ + short fg; ///< Foreground colour index + short bg; ///< Background colour index + chtype attrs; ///< Other attributes +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// The user interface is focused on conceptual simplicity. That is important +// since we're not using any TUI framework (which are mostly a lost cause to me +// in the post-Unicode era and not worth pursuing), and the code would get +// bloated and incomprehensible fast. We mostly rely on app_add_utf8_string() +// to write text from left to right row after row while keeping track of cells. +// +// There is an independent top pane displaying general status information, +// followed by a tab bar and a listview served by a per-tab event handler. +// +// For simplicity, the listview can only work with items that are one row high. + +struct tab; +struct row_buffer; + +/// Try to handle an event in the tab +typedef bool (*tab_event_fn) (struct tab *self, termo_key_t *event); + +/// Draw an item to the screen using the row buffer API +typedef void (*tab_item_draw_fn) + (struct tab *self, unsigned item_index, struct row_buffer *buffer); + +struct tab +{ + LIST_HEADER (struct tab) + + char *name; ///< Visible identifier + size_t name_width; ///< Visible width of the name + + // Implementation: + + // TODO: free() callback? + tab_event_fn on_event; ///< Event handler callback + tab_item_draw_fn on_item_draw; ///< Item draw callback + + // Provided by tab owner: + + bool can_multiselect; ///< Multiple items can be selected + size_t item_count; ///< Total item count + + // Managed by the common handler: + + int item_top; ///< Index of the topmost item + int item_selected; ///< Index of the selected item +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum player_state { PLAYER_STOPPED, PLAYER_PLAYING, PLAYER_PAUSED }; + +// Basically a container for most of the globals; no big sense in handing +// around a pointer to this, hence it is a simple global variable as well. +// There is enough global state as it is. + +static struct app_context +{ + // Event loop: + + struct poller poller; ///< Poller + bool quitting; ///< Quit signal for the event loop + bool polling; ///< The event loop is running + + struct poller_fd tty_event; ///< Terminal input event + struct poller_fd signal_event; ///< Signal FD event + + // Connection: + + struct mpd_client client; ///< MPD client interface + struct poller_timer reconnect_event;///< MPD reconnect timer + + enum player_state state; ///< Player state + // TODO: probably save the full info reply + char *song; ///< Currently playing song + + // Data: + + struct config config; ///< Program configuration + + struct tab *tabs; ///< All tabs + struct tab *active_tab; ///< Active tab + + // Terminal: + + termo_t *tk; ///< termo handle + struct poller_timer tk_timer; ///< termo timeout timer + bool locale_is_utf8; ///< The locale is Unicode + + int list_offset; ///< Height of the top part + + struct attrs attrs[ATTRIBUTE_COUNT]; +} +g_ctx; + +/// Shortcut to retrieve named terminal attributes +#define APP_ATTR(name) g_ctx.attrs[ATTRIBUTE_ ## name].attrs + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +tab_init (struct tab *self, const char *name) +{ + memset (self, 0, sizeof *self); + + // Add some padding for decorative purposes + self->name = xstrdup_printf (" %s ", name); + // Assuming tab names are pure ASCII, otherwise this would be inaccurate + // and we'd need to filter it first to replace invalid chars with '?' + self->name_width = u8_strwidth ((uint8_t *) self->name, locale_charset ()); + self->item_selected = -1; +} + +static void +tab_free (struct tab *self) +{ + free (self->name); +} + +// --- Configuration ----------------------------------------------------------- + +static struct config_schema g_config_settings[] = +{ + { .name = "address", + .comment = "Address to connect to the MPD server", + .type = CONFIG_ITEM_STRING, + .default_ = "localhost" }, + { .name = "password", + .comment = "Password to use for MPD authentication", + .type = CONFIG_ITEM_STRING }, + { .name = "root", + .comment = "Where all the files MPD is playing are located", + .type = CONFIG_ITEM_STRING }, + {} +}; + +static struct config_schema g_config_colors[] = +{ +#define XX(name_, config, fg_, bg_, attrs_) \ + { .name = config, .type = CONFIG_ITEM_STRING }, + ATTRIBUTE_TABLE (XX) +#undef XX + {} +}; + +static const char * +get_config_string (struct config_item *root, const char *key) +{ + struct config_item *item = config_item_get (root, key, NULL); + hard_assert (item); + if (item->type == CONFIG_ITEM_NULL) + return NULL; + hard_assert (config_item_type_is_string (item->type)); + return item->value.string.str; +} + +/// Load configuration for a color using a subset of git config colors +static void +app_load_color (struct config_item *subtree, const char *name, int id) +{ + const char *value = get_config_string (subtree, name); + if (!value) + return; + + struct str_vector v; + str_vector_init (&v); + cstr_split_ignore_empty (value, ' ', &v); + + int colors = 0; + struct attrs attrs = { -1, -1, 0 }; + for (char **it = v.vector; *it; it++) + { + char *end = NULL; + long n = strtol (*it, &end, 10); + if (*it != end && !*end && n >= SHRT_MIN && n <= SHRT_MAX) + { + if (colors == 0) attrs.fg = n; + if (colors == 1) attrs.bg = n; + colors++; + } + else if (!strcmp (*it, "bold")) attrs.attrs |= A_BOLD; + else if (!strcmp (*it, "dim")) attrs.attrs |= A_DIM; + else if (!strcmp (*it, "ul")) attrs.attrs |= A_UNDERLINE; + else if (!strcmp (*it, "blink")) attrs.attrs |= A_BLINK; + else if (!strcmp (*it, "reverse")) attrs.attrs |= A_REVERSE; +#ifdef A_ITALIC + else if (!strcmp (*it, "italic")) attrs.attrs |= A_ITALIC; +#endif // A_ITALIC + } + str_vector_free (&v); + g_ctx.attrs[id] = attrs; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +load_config_settings (struct config_item *subtree, void *user_data) +{ + config_schema_apply_to_object (g_config_settings, subtree, user_data); +} + +static void +load_config_colors (struct config_item *subtree, void *user_data) +{ + config_schema_apply_to_object (g_config_colors, subtree, user_data); + + // The attributes cannot be changed dynamically right now, so it doesn't + // make much sense to make use of "on_change" callbacks either. + // For simplicity, we should reload the entire table on each change anyway. +#define XX(name, config, fg_, bg_, attrs_) \ + app_load_color (subtree, config, ATTRIBUTE_ ## name); + ATTRIBUTE_TABLE (XX) +#undef XX +} + +static void +app_load_configuration (void) +{ + struct config *config = &g_ctx.config; + config_register_module (config, "settings", load_config_settings, NULL); + config_register_module (config, "colors", load_config_colors, NULL); + + char *filename = resolve_filename + (PROGRAM_NAME ".conf", resolve_relative_config_filename); + if (!filename) + return; + + struct error *e = NULL; + struct config_item *root = config_read_from_file (filename, &e); + free (filename); + + if (e) + { + print_error ("error loading configuration: %s", e->message); + error_free (e); + exit (EXIT_FAILURE); + } + if (root) + { + config_load (&g_ctx.config, root); + config_schema_call_changed (g_ctx.config.root); + } +} + +// --- Application ------------------------------------------------------------- + +static void +app_init_attributes (void) +{ +#define XX(name, config, fg_, bg_, attrs_) \ + g_ctx.attrs[ATTRIBUTE_ ## name].fg = fg_; \ + g_ctx.attrs[ATTRIBUTE_ ## name].bg = bg_; \ + g_ctx.attrs[ATTRIBUTE_ ## name].attrs = attrs_; + ATTRIBUTE_TABLE (XX) +#undef XX +} + +static void +app_init_context (void) +{ + memset (&g_ctx, 0, sizeof g_ctx); + + poller_init (&g_ctx.poller); + mpd_client_init (&g_ctx.client, &g_ctx.poller); + config_init (&g_ctx.config); + + // This is also approximately what libunistring does internally, + // since the locale name is canonicalized by locale_charset(). + // Note that non-Unicode locales are handled pretty inefficiently. + g_ctx.locale_is_utf8 = !strcasecmp_ascii (locale_charset (), "UTF-8"); + + app_init_attributes (); +} + +static void +app_init_terminal (void) +{ + TERMO_CHECK_VERSION; + if (!(g_ctx.tk = termo_new (STDIN_FILENO, NULL, 0))) + abort (); + if (!initscr () || nonl () == ERR) + abort (); + + // Disable cursor, we're not going to use it most of the time + curs_set (0); + + // By default we don't use any colors so they're not required... + if (start_color () == ERR + || use_default_colors () == ERR + || COLOR_PAIRS <= ATTRIBUTE_COUNT) + return; + + for (int a = 0; a < ATTRIBUTE_COUNT; a++) + { + // ...thus we can reset back to defaults even after initializing some + if (g_ctx.attrs[a].fg >= COLORS || g_ctx.attrs[a].fg < -1 + || g_ctx.attrs[a].bg >= COLORS || g_ctx.attrs[a].bg < -1) + { + app_init_attributes (); + return; + } + + init_pair (a + 1, g_ctx.attrs[a].fg, g_ctx.attrs[a].bg); + g_ctx.attrs[a].attrs |= COLOR_PAIR (a + 1); + } +} + +static void +app_free_context (void) +{ + mpd_client_free (&g_ctx.client); + free (g_ctx.song); + + config_free (&g_ctx.config); + poller_free (&g_ctx.poller); + + if (g_ctx.tk) + termo_destroy (g_ctx.tk); +} + +static void +app_quit (void) +{ + g_ctx.quitting = true; + + // TODO: bring down the MPD interface (if that's needed at all); + // so far there's nothing for us to wait on, so let's just stop looping + g_ctx.polling = false; +} + +static bool +app_is_character_in_locale (ucs4_t ch) +{ + // Avoid the overhead joined with calling iconv() for all characters. + if (g_ctx.locale_is_utf8) + return true; + + // The library really creates a new conversion object every single time + // and doesn't provide any smarter APIs. Luckily, most users use UTF-8. + size_t len; + char *tmp = u32_conv_to_encoding (locale_charset (), iconveh_error, + &ch, 1, NULL, NULL, &len); + if (!tmp) + return false; + free (tmp); + return true; +} + +// --- Terminal output --------------------------------------------------------- + +// Necessary abstraction to simplify aligned, formatted character output + +struct row_char +{ + LIST_HEADER (struct row_char) + + ucs4_t c; ///< Unicode codepoint + chtype attrs; ///< Special attributes + int width; ///< How many cells this takes +}; + +struct row_buffer +{ + struct row_char *chars; ///< Characters + struct row_char *chars_tail; ///< Tail of characters + size_t chars_len; ///< Character count + int total_width; ///< Total width of all characters +}; + +static void +row_buffer_init (struct row_buffer *self) +{ + memset (self, 0, sizeof *self); +} + +static void +row_buffer_free (struct row_buffer *self) +{ + LIST_FOR_EACH (struct row_char, it, self->chars) + free (it); +} + +/// Replace invalid chars and push all codepoints to the array w/ attributes. +static void +row_buffer_append (struct row_buffer *self, const char *str, chtype attrs) +{ + // The encoding is only really used internally for some corner cases + const char *encoding = locale_charset (); + + ucs4_t c; + const uint8_t *start = (const uint8_t *) str, *next = start; + while ((next = u8_next (&c, next))) + { + if (uc_width (c, encoding) < 0 + || !app_is_character_in_locale (c)) + c = '?'; + + struct row_char *rc = xmalloc (sizeof *rc); + *rc = (struct row_char) + { .c = c, .attrs = attrs, .width = uc_width (c, encoding) }; + LIST_APPEND_WITH_TAIL (self->chars, self->chars_tail, rc); + self->chars_len++; + self->total_width += rc->width; + } +} + +/// Pop as many codepoints as needed to free up "space" character cells. +/// Given the suffix nature of combining marks, this should work pretty fine. +static int +row_buffer_pop_cells (struct row_buffer *self, int space) +{ + int made = 0; + while (self->chars && made < space) + { + struct row_char *tail = self->chars_tail; + LIST_UNLINK_WITH_TAIL (self->chars, self->chars_tail, tail); + self->chars_len--; + made += tail->width; + free (tail); + } + self->total_width -= made; + return made; +} + +static void +row_buffer_ellipsis (struct row_buffer *self, int target, chtype attrs) +{ + row_buffer_pop_cells (self, self->total_width - target); + + ucs4_t ellipsis = L'…'; + if (app_is_character_in_locale (ellipsis)) + { + if (self->total_width >= target) + row_buffer_pop_cells (self, 1); + if (self->total_width + 1 <= target) + row_buffer_append (self, "…", attrs); + } + else if (target >= 3) + { + if (self->total_width >= target) + row_buffer_pop_cells (self, 3); + if (self->total_width + 3 <= target) + row_buffer_append (self, "...", attrs); + } +} + +static void +row_buffer_print (uint32_t *ucs4, chtype attrs) +{ + // Cannot afford to convert negative numbers to the unsigned chtype. + uint8_t *str = (uint8_t *) u32_strconv_to_locale (ucs4); + if (str) + { + for (uint8_t *p = str; *p; p++) + addch (*p | attrs); + free (str); + } +} + +static void +row_buffer_flush (struct row_buffer *self) +{ + if (!self->chars) + return; + + // We only NUL-terminate the chunks because of the libunistring API + uint32_t chunk[self->chars_len + 1], *insertion_point = chunk; + LIST_FOR_EACH (struct row_char, it, self->chars) + { + if (it->prev && it->attrs != it->prev->attrs) + { + row_buffer_print (chunk, it->prev->attrs); + insertion_point = chunk; + } + *insertion_point++ = it->c; + *insertion_point = 0; + } + row_buffer_print (chunk, self->chars_tail->attrs); +} + +// --- Help tab ---------------------------------------------------------------- + +// TODO: either find something else to put in here or remove the wrapper struct +static struct +{ + struct tab super; ///< Parent class +} +g_help_tab; + +static struct help_tab_item +{ + const char *text; ///< Item text +} +g_help_items[] = +{ + { "First entry on the list" }, + { "Something different" }, + { "Yet another item" }, +}; + +static void +help_tab_on_item_draw (struct tab *self, unsigned item_index, + struct row_buffer *buffer) +{ + (void) self; + + hard_assert (item_index <= N_ELEMENTS (g_help_items)); + row_buffer_append (buffer, g_help_items[item_index].text, 0); +} + +static struct tab * +help_tab_create () +{ + struct tab *super = &g_help_tab.super; + tab_init (super, "Help"); + super->on_item_draw = help_tab_on_item_draw; + super->item_count = N_ELEMENTS (g_help_items); + super->item_selected = 0; + return super; +} + +// --- Application ------------------------------------------------------------- + +/// Write the given UTF-8 string padded with spaces. +/// @param[in] n The number of characters to write, or -1 for the whole string. +/// @param[in] attrs Text attributes for the text, without padding. +/// To change the attributes of all output, use attrset(). +/// @return The number of characters output. +static size_t +app_write_utf8 (const char *str, chtype attrs, int n) +{ + if (!n) + return 0; + + struct row_buffer buf; + row_buffer_init (&buf); + row_buffer_append (&buf, str, attrs); + + if (n < 0) + n = buf.total_width; + if (buf.total_width > n) + row_buffer_ellipsis (&buf, n, attrs); + + row_buffer_flush (&buf); + for (int i = buf.total_width; i < n; i++) + addch (' '); + + row_buffer_free (&buf); + return n; +} + +static void +app_redraw_top (void) +{ + // TODO: this will eventually be dynamically computed depending on contents + g_ctx.list_offset = 2; + + attrset (0); + mvwhline (stdscr, 0, 0, 0, COLS); + switch (g_ctx.client.state) + { + case MPD_CONNECTED: + switch (g_ctx.state) + { + case PLAYER_PLAYING: + case PLAYER_PAUSED: + app_write_utf8 (g_ctx.song, 0, COLS); + break; + case PLAYER_STOPPED: + app_write_utf8 ("Stopped", 0, COLS); + } + break; + case MPD_CONNECTING: + app_write_utf8 ("Connecting to MPD...", 0, COLS); + break; + case MPD_DISCONNECTED: + app_write_utf8 ("Disconnected", 0, COLS); + } + + attrset (APP_ATTR (HEADER)); + mvwhline (stdscr, 1, 0, APP_ATTR (HEADER), COLS); + // TODO: render this with APP_ATTR (ACTIVE) when the help tab is selected; + // ...maybe the help tab should not even be on the list? + size_t indent = app_write_utf8 (APP_TITLE, A_BOLD, -1); + + attrset (0); + LIST_FOR_EACH (struct tab, it, g_ctx.tabs) + { + indent += app_write_utf8 (it->name, + it == g_ctx.active_tab ? APP_ATTR (ACTIVE) : APP_ATTR (HEADER), + MIN (COLS - indent, it->name_width)); + } + refresh (); +} + +static void +app_redraw_view (void) +{ + move (g_ctx.list_offset, 0); + clrtobot (); + + // TODO: display a scrollbar on the right side + struct tab *tab = g_ctx.active_tab; + int to_show = MIN (LINES - g_ctx.list_offset, + (int) tab->item_count - tab->item_top); + for (int row_index = 0; row_index < to_show; row_index++) + { + unsigned item_index = tab->item_top + row_index; + int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN); + if ((int) item_index == tab->item_selected) + row_attrs |= A_REVERSE; + + attrset (row_attrs); + + struct row_buffer buf; + row_buffer_init (&buf); + + tab->on_item_draw (tab, item_index, &buf); + if (buf.total_width > COLS) + row_buffer_ellipsis (&buf, COLS, row_attrs); + + row_buffer_flush (&buf); + for (int i = buf.total_width; i < COLS; i++) + addch (' '); + row_buffer_free (&buf); + } + + attrset (0); + refresh (); +} + +static void +app_redraw (void) +{ + app_redraw_top (); + app_redraw_view (); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// Scroll up @a n items. Doesn't redraw. +static bool +app_scroll_up (int n) +{ + struct tab *tab = g_ctx.active_tab; + if (tab->item_top < n) + { + tab->item_top = 0; + return false; + } + tab->item_top -= n; + return true; +} + +/// Scroll down @a n items. Doesn't redraw. +static bool +app_scroll_down (int n) +{ + struct tab *tab = g_ctx.active_tab; + // TODO: if (n_items >= lines), don't allow to scroll off past the end + if ((tab->item_top += n) >= (int) tab->item_count) + { + if (tab->item_count) + tab->item_top = tab->item_count - 1; + else + tab->item_top = 0; + return false; + } + return true; +} + +/// Moves the selection one item up. +static bool +app_one_item_up (void) +{ + struct tab *tab = g_ctx.active_tab; + if (tab->item_selected < 1) + return false; + + if (--tab->item_selected < tab->item_top) + app_scroll_up (tab->item_top - tab->item_selected); + + app_redraw_view (); + return true; +} + +/// Moves the selection one item down. +static bool +app_one_item_down (void) +{ + struct tab *tab = g_ctx.active_tab; + if (tab->item_selected + 1 >= (int) tab->item_count) + return false; + + int n_visible = LINES - g_ctx.list_offset; + if (++tab->item_selected >= tab->item_top + n_visible) + app_scroll_down (1); + + app_redraw_view (); + return true; +} + +static bool +app_goto_tab (unsigned n) +{ + // TODO: go to tab n, return false if out of range + return false; + + app_redraw (); + return true; +} + +static void +app_process_resize (void) +{ + struct tab *tab = g_ctx.active_tab; + if (tab->item_selected < 0) + return; + + int n_visible = LINES - g_ctx.list_offset; + if (n_visible < 0) + return; + + // Scroll up as needed to keep the selection visible + int selected_offset = tab->item_selected - tab->item_top; + if (selected_offset >= n_visible) + app_scroll_up (selected_offset - n_visible + 1); + + app_redraw (); +} + +// --- User input handling ----------------------------------------------------- + +enum user_action +{ + USER_ACTION_NONE, + + USER_ACTION_QUIT, + USER_ACTION_REDRAW, + + USER_ACTION_GOTO_ITEM_PREVIOUS, + USER_ACTION_GOTO_ITEM_NEXT, + USER_ACTION_GOTO_PAGE_PREVIOUS, + USER_ACTION_GOTO_PAGE_NEXT, + + USER_ACTION_COUNT +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +app_process_user_action (enum user_action action) +{ + switch (action) + { + case USER_ACTION_QUIT: + return false; + case USER_ACTION_REDRAW: + clear (); + app_redraw (); + return true; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + case USER_ACTION_GOTO_ITEM_PREVIOUS: + app_one_item_up (); + return true; + case USER_ACTION_GOTO_ITEM_NEXT: + app_one_item_down (); + return true; + + case USER_ACTION_GOTO_PAGE_PREVIOUS: + app_scroll_up (LINES - (int) g_ctx.list_offset); + app_redraw_view (); + return true; + case USER_ACTION_GOTO_PAGE_NEXT: + app_scroll_down (LINES - (int) g_ctx.list_offset); + app_redraw_view (); + return true; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + case USER_ACTION_NONE: + return true; + default: + hard_assert (!"unhandled user action"); + } + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +app_process_keysym (termo_key_t *event) +{ + enum user_action action = USER_ACTION_NONE; + typedef const enum user_action ActionMap[TERMO_N_SYMS]; + + static ActionMap actions = + { + [TERMO_SYM_ESCAPE] = USER_ACTION_QUIT, + + [TERMO_SYM_UP] = USER_ACTION_GOTO_ITEM_PREVIOUS, + [TERMO_SYM_DOWN] = USER_ACTION_GOTO_ITEM_NEXT, + [TERMO_SYM_PAGEUP] = USER_ACTION_GOTO_PAGE_PREVIOUS, + [TERMO_SYM_PAGEDOWN] = USER_ACTION_GOTO_PAGE_NEXT, + }; + static ActionMap actions_alt = + { + }; + static ActionMap actions_ctrl = + { + }; + + if (!event->modifiers) + action = actions[event->code.sym]; + else if (event->modifiers == TERMO_KEYMOD_ALT) + action = actions_alt[event->code.sym]; + else if (event->modifiers == TERMO_KEYMOD_CTRL) + action = actions_ctrl[event->code.sym]; + + return app_process_user_action (action); +} + +static bool +app_process_ctrl_key (termo_key_t *event) +{ + static const enum user_action actions[32] = + { + [CTRL_KEY ('L')] = USER_ACTION_REDRAW, + + [CTRL_KEY ('P')] = USER_ACTION_GOTO_ITEM_PREVIOUS, + [CTRL_KEY ('N')] = USER_ACTION_GOTO_ITEM_NEXT, + [CTRL_KEY ('B')] = USER_ACTION_GOTO_PAGE_PREVIOUS, + [CTRL_KEY ('F')] = USER_ACTION_GOTO_PAGE_NEXT, + }; + + int64_t i = (int64_t) event->code.codepoint - 'a' + 1; + if (i > 0 && i < (int64_t) N_ELEMENTS (actions)) + return app_process_user_action (actions[i]); + + return true; +} + +static bool +app_process_alt_key (termo_key_t *event) +{ + if (event->code.codepoint >= '0' + && event->code.codepoint <= '9') + { + int n = event->code.codepoint - '0'; + if (!app_goto_tab ((n == 0 ? 10 : n) - 1)) + beep (); + } + return true; +} + +static bool +app_process_key (termo_key_t *event) +{ + if (event->modifiers == TERMO_KEYMOD_CTRL) + return app_process_ctrl_key (event); + if (event->modifiers == TERMO_KEYMOD_ALT) + return app_process_alt_key (event); + if (event->modifiers) + return true; + + // TODO: normal unmodified keys will have functions as well + ucs4_t c = event->code.codepoint; + return true; +} + +static void +app_process_left_mouse_click (int line, int column) +{ + if (line < g_ctx.list_offset - 1) + { + // TODO: emulate some GUI widgets; this is going to be wild + } + else if (line == g_ctx.list_offset - 1) + { + struct tab *winner = NULL; + int indent = strlen (APP_TITLE); + // TODO: set the winner to the special help tab in this case + if (column < indent) + return; + for (struct tab *iter = g_ctx.tabs; !winner && iter; iter = iter->next) + { + if (column < (indent += iter->name_width)) + winner = iter; + } + if (winner) + { + g_ctx.active_tab = winner; + app_redraw (); + } + } + else + { + struct tab *tab = g_ctx.active_tab; + int row_index = line - g_ctx.list_offset; + if (row_index >= (int) tab->item_count - tab->item_top) + return; + + tab->item_selected = row_index + tab->item_top; + app_redraw_view (); + } +} + +static bool +app_process_mouse (termo_key_t *event) +{ + int line, column, button; + termo_mouse_event_t type; + termo_interpret_mouse (g_ctx.tk, event, &type, &button, &line, &column); + + if (type != TERMO_MOUSE_PRESS) + return true; + + if (button == 1) + app_process_left_mouse_click (line, column); + else if (button == 4) + app_process_user_action (USER_ACTION_GOTO_ITEM_PREVIOUS); + else if (button == 5) + app_process_user_action (USER_ACTION_GOTO_ITEM_NEXT); + + return true; +} + +static bool +app_process_termo_event (termo_key_t *event) +{ + switch (event->type) + { + case TERMO_TYPE_MOUSE: + return app_process_mouse (event); + case TERMO_TYPE_KEY: + return app_process_key (event); + case TERMO_TYPE_KEYSYM: + return app_process_keysym (event); + default: + return true; + } +} + +// --- Signals ----------------------------------------------------------------- + +static int g_signal_pipe[2]; ///< A pipe used to signal... signals + +/// Program termination has been requested by a signal +static volatile sig_atomic_t g_termination_requested; +/// The window has changed in size +static volatile sig_atomic_t g_winch_received; + +static void +signals_postpone_handling (char id) +{ + int original_errno = errno; + if (write (g_signal_pipe[1], &id, 1) == -1) + soft_assert (errno == EAGAIN); + errno = original_errno; +} + +static void +signals_superhandler (int signum) +{ + switch (signum) + { + case SIGWINCH: + g_winch_received = true; + signals_postpone_handling ('w'); + break; + case SIGINT: + case SIGTERM: + g_termination_requested = true; + signals_postpone_handling ('t'); + break; + default: + hard_assert (!"unhandled signal"); + } +} + +static void +signals_setup_handlers (void) +{ + if (pipe (g_signal_pipe) == -1) + exit_fatal ("%s: %s", "pipe", strerror (errno)); + + set_cloexec (g_signal_pipe[0]); + set_cloexec (g_signal_pipe[1]); + + // So that the pipe cannot overflow; it would make write() block within + // the signal handler, which is something we really don't want to happen. + // The same holds true for read(). + set_blocking (g_signal_pipe[0], false); + set_blocking (g_signal_pipe[1], false); + + signal (SIGPIPE, SIG_IGN); + + struct sigaction sa; + sa.sa_flags = SA_RESTART; + sa.sa_handler = signals_superhandler; + sigemptyset (&sa.sa_mask); + + if (sigaction (SIGWINCH, &sa, NULL) == -1 + || sigaction (SIGINT, &sa, NULL) == -1 + || sigaction (SIGTERM, &sa, NULL) == -1) + exit_fatal ("sigaction: %s", strerror (errno)); +} + +// --- MPD interface ----------------------------------------------------------- + +// TODO: this entire thing has been slavishly copy-pasted from dwmstatus +// TODO: try to move some of this code to mpd.c + +// Sometimes it's not that easy and there can be repeating entries +static void +mpd_vector_to_map (const struct str_vector *data, struct str_map *map) +{ + str_map_init (map); + map->key_xfrm = tolower_ascii_strxfrm; + map->free = free; + + char *key, *value; + for (size_t i = 0; i < data->len; i++) + { + if ((key = mpd_client_parse_kv (data->vector[i], &value))) + str_map_set (map, key, xstrdup (value)); + else + print_debug ("%s: %s", "erroneous MPD output", data->vector[i]); + } +} + +static void +mpd_on_info_response (const struct mpd_response *response, + const struct str_vector *data, void *user_data) +{ + (void) user_data; + if (!response->success) + { + print_debug ("%s: %s", + "retrieving MPD info failed", response->message_text); + return; + } + + struct str_map map; + mpd_vector_to_map (data, &map); + + const char *value; + g_ctx.state = PLAYER_PLAYING; + if ((value = str_map_find (&map, "state"))) + { + if (!strcmp (value, "stop")) + g_ctx.state = PLAYER_STOPPED; + if (!strcmp (value, "pause")) + g_ctx.state = PLAYER_PAUSED; + } + + struct str s; + str_init (&s); + + char *mpd_song = NULL; + if ((value = str_map_find (&map, "title")) + || (value = str_map_find (&map, "name")) + || (value = str_map_find (&map, "file"))) + str_append_printf (&s, "\"%s\"", value); + if ((value = str_map_find (&map, "artist"))) + str_append_printf (&s, " by \"%s\"", value); + if ((value = str_map_find (&map, "album"))) + str_append_printf (&s, " from \"%s\"", value); + mpd_song = str_steal (&s); + + str_map_free (&map); + + free (g_ctx.song); + g_ctx.song = mpd_song; + app_redraw (); +} + +static void +mpd_request_info (void) +{ + struct mpd_client *c = &g_ctx.client; + + mpd_client_list_begin (c); + mpd_client_send_command (c, "currentsong", NULL); + mpd_client_send_command (c, "status", NULL); + mpd_client_list_end (c); + mpd_client_add_task (c, mpd_on_info_response, NULL); + + mpd_client_idle (c, 0); +} + +static void +mpd_on_events (unsigned subsystems, void *user_data) +{ + (void) user_data; + struct mpd_client *c = &g_ctx.client; + + if (subsystems & (MPD_SUBSYSTEM_PLAYER | MPD_SUBSYSTEM_PLAYLIST)) + mpd_request_info (); + else + mpd_client_idle (c, 0); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +mpd_queue_reconnect (void) +{ + poller_timer_set (&g_ctx.reconnect_event, 5 * 1000); +} + +static void +mpd_on_password_response (const struct mpd_response *response, + const struct str_vector *data, void *user_data) +{ + (void) data; + (void) user_data; + struct mpd_client *c = &g_ctx.client; + + if (response->success) + mpd_request_info (); + else + { + print_error ("%s: %s", + "couldn't authenticate to MPD", response->message_text); + mpd_client_send_command (c, "close", NULL); + } +} + +static void +mpd_on_connected (void *user_data) +{ + (void) user_data; + struct mpd_client *c = &g_ctx.client; + + const char *password = + get_config_string (g_ctx.config.root, "settings.password"); + if (password) + { + mpd_client_send_command (c, "password", password, NULL); + mpd_client_add_task (c, mpd_on_password_response, NULL); + } + else + mpd_request_info (); +} + +static void +mpd_on_failure (void *user_data) +{ + (void) user_data; + // This is also triggered both by a failed connect and a clean disconnect + print_error ("connection to MPD failed"); + mpd_queue_reconnect (); +} + +static void +app_on_reconnect (void *user_data) +{ + (void) user_data; + + struct mpd_client *c = &g_ctx.client; + c->on_failure = mpd_on_failure; + c->on_connected = mpd_on_connected; + c->on_event = mpd_on_events; + + // We accept hostname/IPv4/IPv6 in pseudo-URL format, as well as sockets + char *address = xstrdup (get_config_string (g_ctx.config.root, + "settings.address")), *p = address, *host = address, *port = "6600"; + + // Unwrap IPv6 addresses in format_host_port_pair() format + char *right_bracket = strchr (p, ']'); + if (p[0] == '[' && right_bracket) + { + *right_bracket = '\0'; + host = p + 1; + p = right_bracket + 1; + } + + char *colon = strchr (p, ':'); + if (colon) + { + *colon = '\0'; + port = colon + 1; + } + + struct error *e = NULL; + if (!mpd_client_connect (c, host, port, &e)) + { + print_error ("%s: %s", "cannot connect to MPD", e->message); + error_free (e); + mpd_queue_reconnect (); + } + free (address); +} + +// --- Initialisation, event handling ------------------------------------------ + +static void +app_on_tty_readable (const struct pollfd *fd, void *user_data) +{ + (void) user_data; + if (fd->revents & ~(POLLIN | POLLHUP | POLLERR)) + print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents); + + poller_timer_reset (&g_ctx.tk_timer); + termo_advisereadable (g_ctx.tk); + + termo_key_t event; + termo_result_t res; + while ((res = termo_getkey (g_ctx.tk, &event)) == TERMO_RES_KEY) + if (!app_process_termo_event (&event)) + { + app_quit (); + return; + } + + if (res == TERMO_RES_AGAIN) + poller_timer_set (&g_ctx.tk_timer, termo_get_waittime (g_ctx.tk)); + else if (res == TERMO_RES_ERROR || res == TERMO_RES_EOF) + { + app_quit (); + return; + } +} + +static void +app_on_key_timer (void *user_data) +{ + (void) user_data; + + termo_key_t event; + if (termo_getkey_force (g_ctx.tk, &event) == TERMO_RES_KEY) + if (!app_process_termo_event (&event)) + app_quit (); +} + +static void +app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data) +{ + (void) user_data; + + char id = 0; + (void) read (fd->fd, &id, 1); + + if (g_termination_requested && !g_ctx.quitting) + app_quit (); + + if (g_winch_received) + { + update_curses_terminal_size (); + app_process_resize (); + g_winch_received = false; + } +} + +static void +app_log_handler (void *user_data, const char *quote, const char *fmt, + va_list ap) +{ + // TODO: we might want to make use of the user_data (attribute?) + (void) user_data; + + // We certainly don't want to end up in a possibly infinite recursion + static bool in_processing; + if (in_processing) + return; + + in_processing = true; + + struct str message; + str_init (&message); + str_append (&message, quote); + str_append_vprintf (&message, fmt, ap); + + // If the standard error output isn't redirected, try our best at showing + // the message to the user; it will probably get overdrawn soon + // TODO: remember it somewhere so that it stays shown for a while + if (isatty (STDERR_FILENO)) + { + // TODO: remember the position and attributes and restore them + attrset (A_REVERSE); + mvwhline (stdscr, LINES - 1, 0, A_REVERSE, COLS); + app_write_utf8 (message.str, 0, COLS); + } + else + fprintf (stderr, "%s\n", message.str); + str_free (&message); + + in_processing = false; +} + +static void +app_init_poller_events (void) +{ + poller_fd_init (&g_ctx.signal_event, &g_ctx.poller, g_signal_pipe[0]); + g_ctx.signal_event.dispatcher = app_on_signal_pipe_readable; + poller_fd_set (&g_ctx.signal_event, POLLIN); + + poller_fd_init (&g_ctx.tty_event, &g_ctx.poller, STDIN_FILENO); + g_ctx.tty_event.dispatcher = app_on_tty_readable; + poller_fd_set (&g_ctx.tty_event, POLLIN); + + poller_timer_init (&g_ctx.tk_timer, &g_ctx.poller); + g_ctx.tk_timer.dispatcher = app_on_key_timer; + + poller_timer_init (&g_ctx.reconnect_event, &g_ctx.poller); + g_ctx.reconnect_event.dispatcher = app_on_reconnect; + poller_timer_set (&g_ctx.reconnect_event, 0); +} + +int +main (int argc, char *argv[]) +{ + static const struct opt opts[] = + { + { 'd', "debug", NULL, 0, "run in debug mode" }, + { 'h', "help", NULL, 0, "display this help and exit" }, + { 'V', "version", NULL, 0, "output version information and exit" }, + { 0, NULL, NULL, 0, NULL } + }; + + struct opt_handler oh; + opt_handler_init (&oh, argc, argv, opts, NULL, "MPD client."); + + int c; + while ((c = opt_handler_get (&oh)) != -1) + switch (c) + { + case 'd': + g_debug_mode = true; + break; + case 'h': + opt_handler_usage (&oh, stdout); + exit (EXIT_SUCCESS); + case 'V': + printf (PROGRAM_NAME " " PROGRAM_VERSION "\n"); + exit (EXIT_SUCCESS); + default: + print_error ("wrong options"); + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + + argc -= optind; + argv += optind; + + if (argc) + { + opt_handler_usage (&oh, stderr); + exit (EXIT_FAILURE); + } + opt_handler_free (&oh); + + // We only need to convert to and from the terminal encoding + if (!setlocale (LC_CTYPE, "")) + print_warning ("failed to set the locale"); + + app_init_context (); + app_load_configuration (); + app_init_terminal (); + g_log_message_real = app_log_handler; + + // TODO: create more tabs + // TODO: in debug mode add a tab with all messages + LIST_PREPEND (g_ctx.tabs, help_tab_create ()); + g_ctx.active_tab = g_ctx.tabs; + app_redraw (); + + signals_setup_handlers (); + app_init_poller_events (); + + g_ctx.polling = true; + while (g_ctx.polling) + poller_run (&g_ctx.poller); + + endwin (); + g_log_message_real = log_message_stdio; + app_free_context (); + return 0; +} + |