You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
4009 lines
109 KiB
4009 lines
109 KiB
/* |
|
* nncmpp -- the MPD client you never knew you needed |
|
* |
|
* Copyright (c) 2016 - 2020, Přemysl Eric Janouch <p@janouch.name> |
|
* |
|
* Permission to use, copy, modify, and/or distribute this software for any |
|
* purpose with or without fee is hereby granted. |
|
* |
|
* 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" |
|
|
|
// We "need" to have an enum for attributes before including liberty. |
|
// Avoiding colours in the defaults here in order to support dumb terminals. |
|
#define ATTRIBUTE_TABLE(XX) \ |
|
XX( NORMAL, normal, -1, -1, 0 ) \ |
|
XX( HIGHLIGHT, highlight, -1, -1, A_BOLD ) \ |
|
/* Gauge */ \ |
|
XX( ELAPSED, elapsed, -1, -1, A_REVERSE ) \ |
|
XX( REMAINS, remains, -1, -1, A_UNDERLINE ) \ |
|
/* Tab bar */ \ |
|
XX( TAB_BAR, tab_bar, -1, -1, A_REVERSE ) \ |
|
XX( TAB_ACTIVE, tab_active, -1, -1, A_UNDERLINE ) \ |
|
/* Listview */ \ |
|
XX( HEADER, header, -1, -1, A_UNDERLINE ) \ |
|
XX( EVEN, even, -1, -1, 0 ) \ |
|
XX( ODD, odd, -1, -1, 0 ) \ |
|
XX( DIRECTORY, directory, -1, -1, 0 ) \ |
|
XX( SELECTION, selection, -1, -1, A_REVERSE ) \ |
|
/* Cyan is good with both black and white. |
|
* Can't use A_REVERSE because bold'd be bright. |
|
* Unfortunately ran out of B&W attributes. */ \ |
|
XX( MULTISELECT, multiselect, -1, 6, 0 ) \ |
|
XX( SCROLLBAR, scrollbar, -1, -1, 0 ) \ |
|
/* These are for debugging only */ \ |
|
XX( WARNING, warning, 3, -1, 0 ) \ |
|
XX( ERROR, error, 1, -1, 0 ) \ |
|
XX( INCOMING, incoming, 2, -1, 0 ) \ |
|
XX( OUTGOING, outgoing, 4, -1, 0 ) |
|
|
|
enum |
|
{ |
|
#define XX(name, config, fg_, bg_, attrs_) ATTRIBUTE_ ## name, |
|
ATTRIBUTE_TABLE (XX) |
|
#undef XX |
|
ATTRIBUTE_COUNT |
|
}; |
|
|
|
// 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. |
|
|
|
// User data for logger functions to enable formatted logging |
|
#define print_fatal_data ((void *) ATTRIBUTE_ERROR) |
|
#define print_error_data ((void *) ATTRIBUTE_ERROR) |
|
#define print_warning_data ((void *) ATTRIBUTE_WARNING) |
|
|
|
#define LIBERTY_WANT_POLLER |
|
#define LIBERTY_WANT_ASYNC |
|
#define LIBERTY_WANT_PROTO_HTTP |
|
#define LIBERTY_WANT_PROTO_MPD |
|
#include "liberty/liberty.c" |
|
#include "liberty/liberty-tui.c" |
|
|
|
#define HAVE_LIBERTY |
|
#include "line-editor.c" |
|
|
|
#include <math.h> |
|
#include <locale.h> |
|
#include <termios.h> |
|
#ifndef TIOCGWINSZ |
|
#include <sys/ioctl.h> |
|
#endif // ! TIOCGWINSZ |
|
|
|
// ncurses is notoriously retarded for input handling, we need something |
|
// different if only to receive mouse events reliably. |
|
|
|
#include "termo.h" |
|
|
|
// We need cURL to extract links from Internet stream playlists. It'd be way |
|
// too much code to do this all by ourselves, and there's nothing better around. |
|
|
|
#include <curl/curl.h> |
|
|
|
#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 |
|
} |
|
|
|
static int64_t |
|
clock_msec (clockid_t clock) |
|
{ |
|
struct timespec tp; |
|
hard_assert (clock_gettime (clock, &tp) != -1); |
|
return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000; |
|
} |
|
|
|
static bool |
|
xstrtoul_map (const struct str_map *map, const char *key, unsigned long *out) |
|
{ |
|
const char *field = str_map_find (map, key); |
|
return field && xstrtoul (out, field, 10); |
|
} |
|
|
|
static const char * |
|
xbasename (const char *path) |
|
{ |
|
const char *last_slash = strrchr (path, '/'); |
|
return last_slash ? last_slash + 1 : path; |
|
} |
|
|
|
static char * |
|
latin1_to_utf8 (const char *latin1) |
|
{ |
|
struct str converted = str_make (); |
|
while (*latin1) |
|
{ |
|
uint8_t c = *latin1++; |
|
if (c < 0x80) |
|
str_append_c (&converted, c); |
|
else |
|
{ |
|
str_append_c (&converted, 0xC0 | (c >> 6)); |
|
str_append_c (&converted, 0x80 | (c & 0x3F)); |
|
} |
|
} |
|
return str_steal (&converted); |
|
} |
|
|
|
static void |
|
cstr_uncapitalize (char *s) |
|
{ |
|
if (isupper (s[0]) && islower (s[1])) |
|
s[0] = tolower_ascii (s[0]); |
|
} |
|
|
|
static int |
|
print_curl_debug (CURL *easy, curl_infotype type, char *data, size_t len, |
|
void *ud) |
|
{ |
|
(void) easy; |
|
(void) ud; |
|
(void) type; |
|
|
|
char copy[len + 1]; |
|
for (size_t i = 0; i < len; i++) |
|
{ |
|
uint8_t c = data[i]; |
|
copy[i] = c >= 32 || c == '\n' ? c : '.'; |
|
} |
|
copy[len] = '\0'; |
|
|
|
char *next; |
|
for (char *p = copy; p; p = next) |
|
{ |
|
if ((next = strchr (p, '\n'))) |
|
*next++ = '\0'; |
|
if (!*p) |
|
continue; |
|
|
|
if (!utf8_validate (p, strlen (p))) |
|
{ |
|
char *fixed = latin1_to_utf8 (p); |
|
print_debug ("cURL: %s", fixed); |
|
free (fixed); |
|
} |
|
else |
|
print_debug ("cURL: %s", p); |
|
} |
|
return 0; |
|
} |
|
|
|
static char * |
|
mpd_parse_kv (char *line, char **value) |
|
{ |
|
char *key = mpd_client_parse_kv (line, value); |
|
if (!key) print_debug ("%s: %s", "erroneous MPD output", line); |
|
return key; |
|
} |
|
|
|
// --- cURL async wrapper ------------------------------------------------------ |
|
|
|
// You are meant to subclass this structure, no user_data pointers needed |
|
struct poller_curl_task; |
|
|
|
/// Receives notification for finished transfers |
|
typedef void (*poller_curl_done_fn) |
|
(CURLMsg *msg, struct poller_curl_task *task); |
|
|
|
struct poller_curl_task |
|
{ |
|
CURL *easy; ///< cURL easy interface handle |
|
char curl_error[CURL_ERROR_SIZE]; ///< cURL error info buffer |
|
poller_curl_done_fn on_done; ///< Done callback |
|
}; |
|
|
|
struct poller_curl_fd |
|
{ |
|
LIST_HEADER (struct poller_curl_fd) |
|
struct poller_fd fd; ///< Poller FD |
|
}; |
|
|
|
struct poller_curl |
|
{ |
|
struct poller *poller; ///< Parent poller |
|
struct poller_timer timer; ///< cURL timer |
|
CURLM *multi; ///< cURL multi interface handle |
|
struct poller_curl_fd *fds; ///< List of all FDs |
|
}; |
|
|
|
static void |
|
poller_curl_collect (struct poller_curl *self, curl_socket_t s, int ev_bitmask) |
|
{ |
|
int running = 0; |
|
CURLMcode res; |
|
// XXX: ignoring errors, in particular CURLM_CALL_MULTI_PERFORM |
|
if ((res = curl_multi_socket_action (self->multi, s, ev_bitmask, &running))) |
|
print_debug ("cURL: %s", curl_multi_strerror (res)); |
|
|
|
CURLMsg *msg; |
|
while ((msg = curl_multi_info_read (self->multi, &running))) |
|
if (msg->msg == CURLMSG_DONE) |
|
{ |
|
struct poller_curl_task *task = NULL; |
|
hard_assert (!curl_easy_getinfo |
|
(msg->easy_handle, CURLINFO_PRIVATE, &task)); |
|
task->on_done (msg, task); |
|
} |
|
} |
|
|
|
static void |
|
poller_curl_on_socket (const struct pollfd *pfd, void *user_data) |
|
{ |
|
int mask = 0; |
|
if (pfd->revents & POLLIN) mask |= CURL_CSELECT_IN; |
|
if (pfd->revents & POLLOUT) mask |= CURL_CSELECT_OUT; |
|
if (pfd->revents & POLLERR) mask |= CURL_CSELECT_ERR; |
|
poller_curl_collect (user_data, pfd->fd, mask); |
|
} |
|
|
|
static int |
|
poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what, |
|
void *user_data, void *socket_data) |
|
{ |
|
(void) easy; |
|
struct poller_curl *self = user_data; |
|
|
|
struct poller_curl_fd *fd; |
|
if (!(fd = socket_data)) |
|
{ |
|
fd = xmalloc (sizeof *fd); |
|
LIST_PREPEND (self->fds, fd); |
|
|
|
fd->fd = poller_fd_make (self->poller, s); |
|
fd->fd.dispatcher = poller_curl_on_socket; |
|
fd->fd.user_data = self; |
|
curl_multi_assign (self->multi, s, fd); |
|
} |
|
if (what == CURL_POLL_REMOVE) |
|
{ |
|
// Some annoying cURL bug. Never trust libraries. |
|
fd->fd.closed = fcntl(fd->fd.fd, F_GETFL) < 0 && errno == EBADF; |
|
|
|
poller_fd_reset (&fd->fd); |
|
LIST_UNLINK (self->fds, fd); |
|
free (fd); |
|
} |
|
else |
|
{ |
|
short events = 0; |
|
if (what == CURL_POLL_IN) events = POLLIN; |
|
if (what == CURL_POLL_OUT) events = POLLOUT; |
|
if (what == CURL_POLL_INOUT) events = POLLIN | POLLOUT; |
|
poller_fd_set (&fd->fd, events); |
|
} |
|
return 0; |
|
} |
|
|
|
static void |
|
poller_curl_on_timer (void *user_data) |
|
{ |
|
poller_curl_collect (user_data, CURL_SOCKET_TIMEOUT, 0); |
|
} |
|
|
|
static int |
|
poller_curl_on_timer_change (CURLM *multi, long timeout_ms, void *user_data) |
|
{ |
|
(void) multi; |
|
struct poller_curl *self = user_data; |
|
|
|
if (timeout_ms < 0) |
|
poller_timer_reset (&self->timer); |
|
else |
|
poller_timer_set (&self->timer, timeout_ms); |
|
return 0; |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static bool |
|
poller_curl_init (struct poller_curl *self, struct poller *poller, |
|
struct error **e) |
|
{ |
|
memset (self, 0, sizeof *self); |
|
if (!(self->multi = curl_multi_init ())) |
|
return error_set (e, "cURL setup failed"); |
|
|
|
CURLMcode mres; |
|
if ((mres = curl_multi_setopt (self->multi, |
|
CURLMOPT_SOCKETFUNCTION, poller_curl_on_socket_action)) |
|
|| (mres = curl_multi_setopt (self->multi, |
|
CURLMOPT_TIMERFUNCTION, poller_curl_on_timer_change)) |
|
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_SOCKETDATA, self)) |
|
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_TIMERDATA, self))) |
|
{ |
|
curl_multi_cleanup (self->multi); |
|
return error_set (e, "%s: %s", |
|
"cURL setup failed", curl_multi_strerror (mres)); |
|
} |
|
|
|
self->timer = poller_timer_make ((self->poller = poller)); |
|
self->timer.dispatcher = poller_curl_on_timer; |
|
self->timer.user_data = self; |
|
return true; |
|
} |
|
|
|
static void |
|
poller_curl_free (struct poller_curl *self) |
|
{ |
|
curl_multi_cleanup (self->multi); |
|
poller_timer_reset (&self->timer); |
|
|
|
LIST_FOR_EACH (struct poller_curl_fd, iter, self->fds) |
|
{ |
|
poller_fd_reset (&iter->fd); |
|
free (iter); |
|
} |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
/// Initialize a task with a new easy instance that can be used with the poller |
|
static bool |
|
poller_curl_spawn (struct poller_curl_task *task, struct error **e) |
|
{ |
|
CURL *easy; |
|
if (!(easy = curl_easy_init ())) |
|
return error_set (e, "cURL setup failed"); |
|
|
|
// We already take care of SIGPIPE, and native DNS timeouts are only |
|
// a problem for people without the AsynchDNS feature. |
|
// |
|
// Unfortunately, cURL doesn't allow custom callbacks for DNS. |
|
// The most we could try is parse out the hostname and provide an address |
|
// override for it using CURLOPT_RESOLVE. Or be our own SOCKS4A/5 proxy. |
|
|
|
CURLcode res; |
|
if ((res = curl_easy_setopt (easy, CURLOPT_NOSIGNAL, 1L)) |
|
|| (res = curl_easy_setopt (easy, CURLOPT_ERRORBUFFER, task->curl_error)) |
|
|| (res = curl_easy_setopt (easy, CURLOPT_PRIVATE, task))) |
|
{ |
|
curl_easy_cleanup (easy); |
|
return error_set (e, "%s", curl_easy_strerror (res)); |
|
} |
|
|
|
task->easy = easy; |
|
return true; |
|
} |
|
|
|
static bool |
|
poller_curl_add (struct poller_curl *self, CURL *easy, struct error **e) |
|
{ |
|
CURLMcode mres; |
|
// "CURLMOPT_TIMERFUNCTION [...] will be called from within this function" |
|
if ((mres = curl_multi_add_handle (self->multi, easy))) |
|
return error_set (e, "%s", curl_multi_strerror (mres)); |
|
return true; |
|
} |
|
|
|
static bool |
|
poller_curl_remove (struct poller_curl *self, CURL *easy, struct error **e) |
|
{ |
|
CURLMcode mres; |
|
if ((mres = curl_multi_remove_handle (self->multi, easy))) |
|
return error_set (e, "%s", curl_multi_strerror (mres)); |
|
return true; |
|
} |
|
|
|
// --- Compact map ------------------------------------------------------------- |
|
|
|
// MPD provides us with a hefty amount of little key-value maps. The overhead |
|
// of str_map for such constant (string -> string) maps is too high and it's |
|
// much better to serialize them (mainly cache locality and memory efficiency). |
|
// |
|
// This isn't intended to be reusable and has case insensitivity built-in. |
|
|
|
typedef uint8_t *compact_map_t; ///< Compacted (string -> string) map |
|
|
|
static compact_map_t |
|
compact_map (struct str_map *map) |
|
{ |
|
struct str s = str_make (); |
|
struct str_map_iter iter = str_map_iter_make (map); |
|
|
|
char *value; |
|
static const size_t zero = 0, alignment = sizeof zero; |
|
while ((value = str_map_iter_next (&iter))) |
|
{ |
|
size_t entry_len = iter.link->key_length + 1 + strlen (value) + 1; |
|
size_t padding_len = (alignment - entry_len % alignment) % alignment; |
|
entry_len += padding_len; |
|
|
|
str_append_data (&s, &entry_len, sizeof entry_len); |
|
str_append_printf (&s, "%s%c%s%c", iter.link->key, 0, value, 0); |
|
str_append_data (&s, &zero, padding_len); |
|
} |
|
str_append_data (&s, &zero, sizeof zero); |
|
return (compact_map_t) str_steal (&s); |
|
} |
|
|
|
static char * |
|
compact_map_find (compact_map_t data, const char *needle) |
|
{ |
|
size_t entry_len; |
|
while ((entry_len = *(size_t *) data)) |
|
{ |
|
data += sizeof entry_len; |
|
if (!strcasecmp_ascii (needle, (const char *) data)) |
|
return (char *) data + strlen (needle) + 1; |
|
data += entry_len; |
|
} |
|
return NULL; |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
struct item_list |
|
{ |
|
compact_map_t *items; ///< Compacted (string -> string) maps |
|
size_t len; ///< Length |
|
size_t alloc; ///< Allocated items |
|
}; |
|
|
|
static struct item_list |
|
item_list_make (void) |
|
{ |
|
struct item_list self = {}; |
|
self.items = xcalloc (sizeof *self.items, (self.alloc = 16)); |
|
return self; |
|
} |
|
|
|
static void |
|
item_list_free (struct item_list *self) |
|
{ |
|
for (size_t i = 0; i < self->len; i++) |
|
free (self->items[i]); |
|
free (self->items); |
|
} |
|
|
|
static bool |
|
item_list_set (struct item_list *self, int i, struct str_map *item) |
|
{ |
|
if (i < 0 || (size_t) i >= self->len) |
|
return false; |
|
|
|
free (self->items[i]); |
|
self->items[i] = compact_map (item); |
|
return true; |
|
} |
|
|
|
static compact_map_t |
|
item_list_get (struct item_list *self, int i) |
|
{ |
|
if (i < 0 || (size_t) i >= self->len || !self->items[i]) |
|
return NULL; |
|
return self->items[i]; |
|
} |
|
|
|
static void |
|
item_list_resize (struct item_list *self, size_t len) |
|
{ |
|
// Make the allocated array big enough but not too large |
|
size_t new_alloc = self->alloc; |
|
while (new_alloc < len) |
|
new_alloc <<= 1; |
|
while ((new_alloc >> 1) >= len |
|
&& (new_alloc - len) >= 1024) |
|
new_alloc >>= 1; |
|
|
|
for (size_t i = len; i < self->len; i++) |
|
free (self->items[i]); |
|
if (new_alloc != self->alloc) |
|
self->items = xreallocarray (self->items, |
|
sizeof *self->items, (self->alloc = new_alloc)); |
|
for (size_t i = self->len; i < len; i++) |
|
self->items[i] = NULL; |
|
|
|
self->len = len; |
|
} |
|
|
|
// --- Application ------------------------------------------------------------- |
|
|
|
// Function names are prefixed mostly because of curses which clutters the |
|
// global namespace and makes it harder to distinguish what functions relate to. |
|
|
|
// 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 "row_buffer" 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; |
|
enum action; |
|
|
|
/// Try to handle an action in the tab |
|
typedef bool (*tab_action_fn) (enum action action); |
|
|
|
/// Draw an item to the screen using the row buffer API |
|
typedef void (*tab_item_draw_fn) |
|
(size_t item_index, struct row_buffer *buffer, int width); |
|
|
|
struct tab |
|
{ |
|
LIST_HEADER (struct tab) |
|
|
|
char *name; ///< Visible identifier |
|
size_t name_width; ///< Visible width of the name |
|
|
|
char *header; ///< The header, should there be any |
|
|
|
// Implementation: |
|
|
|
tab_action_fn on_action; ///< User action 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 |
|
int item_mark; ///< Multiselect second point index |
|
}; |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
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 |
|
|
|
struct poller_timer message_timer; ///< Message timeout |
|
char *message; ///< Message to show in the statusbar |
|
|
|
// Connection: |
|
|
|
struct mpd_client client; ///< MPD client interface |
|
struct poller_timer connect_event; ///< MPD reconnect timer |
|
|
|
enum player_state state; ///< Player state |
|
struct str_map playback_info; ///< Current song info |
|
|
|
struct poller_timer elapsed_event; ///< Seconds elapsed event |
|
int64_t elapsed_since; ///< Time of the next tick |
|
|
|
// TODO: initialize these to -1 |
|
int song; ///< Current song index |
|
int song_elapsed; ///< Song elapsed in seconds |
|
int song_duration; ///< Song duration in seconds |
|
int volume; ///< Current volume |
|
|
|
struct item_list playlist; ///< Current playlist |
|
uint32_t playlist_version; ///< Playlist version |
|
int playlist_time; ///< Play time in seconds |
|
|
|
// Data: |
|
|
|
struct config config; ///< Program configuration |
|
struct strv streams; ///< List of "name NUL URI NUL" |
|
|
|
struct tab *help_tab; ///< Special help tab |
|
struct tab *tabs; ///< All other tabs |
|
struct tab *active_tab; ///< Active tab |
|
struct tab *last_tab; ///< Previous tab |
|
|
|
// Emulated widgets: |
|
|
|
int header_height; ///< Height of the header |
|
|
|
int tabs_offset; ///< Offset to tabs or -1 |
|
int controls_offset; ///< Offset to player controls or -1 |
|
int gauge_offset; ///< Offset to the gauge or -1 |
|
int gauge_width; ///< Width of the gauge, if present |
|
|
|
struct line_editor editor; ///< Line editor |
|
struct poller_idle refresh_event; ///< Refresh the screen |
|
|
|
// Terminal: |
|
|
|
termo_t *tk; ///< termo handle |
|
struct poller_timer tk_timer; ///< termo timeout timer |
|
bool locale_is_utf8; ///< The locale is Unicode |
|
bool use_partial_boxes; ///< Use Unicode box drawing chars |
|
|
|
struct attrs attrs[ATTRIBUTE_COUNT]; |
|
} |
|
g; |
|
|
|
/// Shortcut to retrieve named terminal attributes |
|
#define APP_ATTR(name) g.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 = 0; |
|
self->item_mark = -1; |
|
} |
|
|
|
static void |
|
tab_free (struct tab *self) |
|
{ |
|
free (self->name); |
|
} |
|
|
|
static struct tab_range { int from, upto; } |
|
tab_selection_range (struct tab *self) |
|
{ |
|
if (self->item_selected < 0 || !self->item_count) |
|
return (struct tab_range) { -1, -1 }; |
|
if (self->item_mark < 0) |
|
return (struct tab_range) { self->item_selected, self->item_selected }; |
|
return (struct tab_range) { MIN (self->item_selected, self->item_mark), |
|
MAX (self->item_selected, self->item_mark) }; |
|
} |
|
|
|
// --- 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; |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
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. |
|
const char *value; |
|
#define XX(name, config, fg_, bg_, attrs_) \ |
|
if ((value = get_config_string (subtree, #config))) \ |
|
g.attrs[ATTRIBUTE_ ## name] = attrs_decode (value); |
|
ATTRIBUTE_TABLE (XX) |
|
#undef XX |
|
} |
|
|
|
static int |
|
app_casecmp (const uint8_t *a, const uint8_t *b) |
|
{ |
|
int res; |
|
// XXX: this seems to produce some strange results |
|
if (u8_casecmp (a, strlen ((const char *) a), b, strlen ((const char *) b), |
|
NULL, NULL, &res)) |
|
res = u8_strcmp (a, b); |
|
return res; |
|
} |
|
|
|
static int |
|
strv_sort_utf8_cb (const void *a, const void *b) |
|
{ |
|
return app_casecmp (*(const uint8_t **) a, *(const uint8_t **) b); |
|
} |
|
|
|
static void |
|
load_config_streams (struct config_item *subtree, void *user_data) |
|
{ |
|
(void) user_data; |
|
|
|
// XXX: we can't use the tab in load_config_streams() because it hasn't |
|
// been initialized yet, and we cannot initialize it before the |
|
// configuration has been loaded. Thus we load it into the app_context. |
|
struct str_map_iter iter = str_map_iter_make (&subtree->value.object); |
|
struct config_item *item; |
|
while ((item = str_map_iter_next (&iter))) |
|
if (!config_item_type_is_string (item->type)) |
|
print_warning ("`%s': stream URIs must be strings", iter.link->key); |
|
else |
|
{ |
|
strv_append_owned (&g.streams, xstrdup_printf ("%s%c%s", |
|
iter.link->key, 0, item->value.string.str)); |
|
} |
|
qsort (g.streams.vector, g.streams.len, |
|
sizeof *g.streams.vector, strv_sort_utf8_cb); |
|
} |
|
|
|
static void |
|
app_load_configuration (void) |
|
{ |
|
struct config *config = &g.config; |
|
config_register_module (config, "settings", load_config_settings, NULL); |
|
config_register_module (config, "colors", load_config_colors, NULL); |
|
config_register_module (config, "streams", load_config_streams, NULL); |
|
|
|
// Bootstrap configuration, so that we can access schema items at all |
|
config_load (config, config_item_object ()); |
|
|
|
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.config, root); |
|
config_schema_call_changed (g.config.root); |
|
} |
|
} |
|
|
|
// --- Application ------------------------------------------------------------- |
|
|
|
static void |
|
app_init_attributes (void) |
|
{ |
|
#define XX(name, config, fg_, bg_, attrs_) \ |
|
g.attrs[ATTRIBUTE_ ## name].fg = fg_; \ |
|
g.attrs[ATTRIBUTE_ ## name].bg = bg_; \ |
|
g.attrs[ATTRIBUTE_ ## name].attrs = attrs_; |
|
ATTRIBUTE_TABLE (XX) |
|
#undef XX |
|
} |
|
|
|
static void |
|
app_init_context (void) |
|
{ |
|
poller_init (&g.poller); |
|
g.client = mpd_client_make (&g.poller); |
|
g.config = config_make (); |
|
g.streams = strv_make (); |
|
g.playlist = item_list_make (); |
|
|
|
g.playback_info = str_map_make (free); |
|
g.playback_info.key_xfrm = tolower_ascii_strxfrm; |
|
|
|
// 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.locale_is_utf8 = !strcasecmp_ascii (locale_charset (), "UTF-8"); |
|
|
|
// It doesn't work 100% (e.g. incompatible with undelining in urxvt) |
|
// TODO: make this configurable |
|
g.use_partial_boxes = g.locale_is_utf8; |
|
|
|
app_init_attributes (); |
|
} |
|
|
|
static void |
|
app_init_terminal (void) |
|
{ |
|
TERMO_CHECK_VERSION; |
|
if (!(g.tk = termo_new (STDIN_FILENO, NULL, 0))) |
|
abort (); |
|
if (!initscr () || nonl () == ERR) |
|
abort (); |
|
|
|
// 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.attrs[a].fg >= COLORS || g.attrs[a].fg < -1 |
|
|| g.attrs[a].bg >= COLORS || g.attrs[a].bg < -1) |
|
{ |
|
app_init_attributes (); |
|
return; |
|
} |
|
|
|
init_pair (a + 1, g.attrs[a].fg, g.attrs[a].bg); |
|
g.attrs[a].attrs |= COLOR_PAIR (a + 1); |
|
} |
|
} |
|
|
|
static void |
|
app_free_context (void) |
|
{ |
|
mpd_client_free (&g.client); |
|
str_map_free (&g.playback_info); |
|
strv_free (&g.streams); |
|
item_list_free (&g.playlist); |
|
|
|
line_editor_free (&g.editor); |
|
|
|
config_free (&g.config); |
|
poller_free (&g.poller); |
|
free (g.message); |
|
|
|
if (g.tk) |
|
termo_destroy (g.tk); |
|
} |
|
|
|
static void |
|
app_quit (void) |
|
{ |
|
g.quitting = true; |
|
|
|
// So far there's nothing for us to wait on, so let's just stop looping; |
|
// otherwise we might want to e.g. cleanly bring down the MPD interface |
|
g.polling = false; |
|
} |
|
|
|
static bool |
|
app_is_character_in_locale (ucs4_t ch) |
|
{ |
|
// Avoid the overhead joined with calling iconv() for all characters. |
|
if (g.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; |
|
} |
|
|
|
// --- Rendering --------------------------------------------------------------- |
|
|
|
static void |
|
app_invalidate (void) |
|
{ |
|
poller_idle_set (&g.refresh_event); |
|
} |
|
|
|
static void |
|
app_flush_buffer (struct row_buffer *buf, int width, chtype attrs) |
|
{ |
|
row_buffer_align (buf, width, attrs); |
|
row_buffer_flush (buf); |
|
row_buffer_free (buf); |
|
} |
|
|
|
/// Write the given UTF-8 string padded with spaces. |
|
/// @param[in] attrs Text attributes for the text, including padding. |
|
static void |
|
app_write_line (const char *str, chtype attrs) |
|
{ |
|
struct row_buffer buf = row_buffer_make (); |
|
row_buffer_append (&buf, str, attrs); |
|
app_flush_buffer (&buf, COLS, attrs); |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static void |
|
app_flush_header (struct row_buffer *buf, chtype attrs) |
|
{ |
|
move (g.header_height++, 0); |
|
app_flush_buffer (buf, COLS, attrs); |
|
} |
|
|
|
static void |
|
app_draw_song_info (void) |
|
{ |
|
compact_map_t map; |
|
if (!(map = item_list_get (&g.playlist, g.song))) |
|
return; |
|
|
|
chtype attr_normal = APP_ATTR (NORMAL); |
|
chtype attr_highlight = APP_ATTR (HIGHLIGHT); |
|
|
|
char *title; |
|
if ((title = compact_map_find (map, "title")) |
|
|| (title = compact_map_find (map, "name")) |
|
|| (title = compact_map_find (map, "file"))) |
|
{ |
|
struct row_buffer buf = row_buffer_make (); |
|
row_buffer_append (&buf, title, attr_highlight); |
|
app_flush_header (&buf, attr_normal); |
|
} |
|
|
|
char *artist = compact_map_find (map, "artist"); |
|
char *album = compact_map_find (map, "album"); |
|
if (!artist && !album) |
|
return; |
|
|
|
struct row_buffer buf = row_buffer_make (); |
|
if (artist) |
|
row_buffer_append_args (&buf, " by " + !buf.total_width, attr_normal, |
|
artist, attr_highlight, NULL); |
|
if (album) |
|
row_buffer_append_args (&buf, " from " + !buf.total_width, attr_normal, |
|
album, attr_highlight, NULL); |
|
app_flush_header (&buf, attr_normal); |
|
} |
|
|
|
static char * |
|
app_time_string (int seconds) |
|
{ |
|
int minutes = seconds / 60; seconds %= 60; |
|
int hours = minutes / 60; minutes %= 60; |
|
|
|
struct str s = str_make (); |
|
if (hours) |
|
str_append_printf (&s, "%d:%02d:", hours, minutes); |
|
else |
|
str_append_printf (&s, "%d:", minutes); |
|
|
|
str_append_printf (&s, "%02d", seconds); |
|
return str_steal (&s); |
|
} |
|
|
|
static void |
|
app_write_time (struct row_buffer *buf, int seconds, chtype attrs) |
|
{ |
|
char *s = app_time_string (seconds); |
|
row_buffer_append (buf, s, attrs); |
|
free (s); |
|
} |
|
|
|
static void |
|
app_write_gauge (struct row_buffer *buf, float ratio, int width) |
|
{ |
|
if (ratio < 0) ratio = 0; |
|
if (ratio > 1) ratio = 1; |
|
|
|
// Always compute it in exactly eight times the resolution, |
|
// because sometimes Unicode is even useful |
|
int len_left = ratio * width * 8 + 0.5; |
|
|
|
static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" }; |
|
int remainder = len_left % 8; |
|
len_left /= 8; |
|
|
|
const char *partial = NULL; |
|
if (g.use_partial_boxes) |
|
partial = partials[remainder]; |
|
else |
|
len_left += remainder >= (int) 4; |
|
|
|
int len_right = width - len_left; |
|
row_buffer_space (buf, len_left, APP_ATTR (ELAPSED)); |
|
if (partial && len_right-- > 0) |
|
row_buffer_append (buf, partial, APP_ATTR (REMAINS)); |
|
row_buffer_space (buf, len_right, APP_ATTR (REMAINS)); |
|
} |
|
|
|
static void |
|
app_draw_status (void) |
|
{ |
|
if (g.state != PLAYER_STOPPED) |
|
app_draw_song_info (); |
|
|
|
chtype attr_normal = APP_ATTR (NORMAL); |
|
chtype attr_highlight = APP_ATTR (HIGHLIGHT); |
|
|
|
struct row_buffer buf = row_buffer_make (); |
|
bool stopped = g.state == PLAYER_STOPPED; |
|
chtype attr_song_action = stopped ? attr_normal : attr_highlight; |
|
|
|
const char *toggle = g.state == PLAYER_PLAYING ? "||" : "|>"; |
|
row_buffer_append_args (&buf, |
|
"<<", attr_song_action, " ", attr_normal, |
|
toggle, attr_highlight, " ", attr_normal, |
|
"[]", attr_song_action, " ", attr_normal, |
|
">>", attr_song_action, " ", attr_normal, |
|
NULL); |
|
|
|
if (stopped) |
|
row_buffer_append (&buf, "Stopped", attr_normal); |
|
else |
|
{ |
|
if (g.song_elapsed >= 0) |
|
{ |
|
app_write_time (&buf, g.song_elapsed, attr_normal); |
|
row_buffer_append (&buf, " ", attr_normal); |
|
} |
|
if (g.song_duration >= 1) |
|
{ |
|
row_buffer_append (&buf, "/ ", attr_normal); |
|
app_write_time (&buf, g.song_duration, attr_normal); |
|
row_buffer_append (&buf, " ", attr_normal); |
|
} |
|
row_buffer_append (&buf, " ", attr_normal); |
|
} |
|
|
|
// It gets a bit complicated due to the only right-aligned item on the row |
|
char *volume = NULL; |
|
int remaining = COLS - buf.total_width; |
|
if (g.volume >= 0) |
|
{ |
|
volume = xstrdup_printf (" %3d%%", g.volume); |
|
remaining -= strlen (volume); |
|
} |
|
|
|
if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1 |
|
&& remaining > 0) |
|
{ |
|
g.gauge_offset = buf.total_width; |
|
g.gauge_width = remaining; |
|
app_write_gauge (&buf, |
|
(float) g.song_elapsed / g.song_duration, remaining); |
|
} |
|
else |
|
row_buffer_space (&buf, remaining, attr_normal); |
|
|
|
if (volume) |
|
{ |
|
row_buffer_append (&buf, volume, attr_normal); |
|
free (volume); |
|
} |
|
g.controls_offset = g.header_height; |
|
app_flush_header (&buf, attr_normal); |
|
} |
|
|
|
static void |
|
app_draw_header (void) |
|
{ |
|
g.header_height = 0; |
|
|
|
g.tabs_offset = -1; |
|
g.controls_offset = -1; |
|
g.gauge_offset = -1; |
|
g.gauge_width = 0; |
|
|
|
switch (g.client.state) |
|
{ |
|
case MPD_CONNECTED: |
|
app_draw_status (); |
|
break; |
|
case MPD_CONNECTING: |
|
move (g.header_height++, 0); |
|
app_write_line ("Connecting to MPD...", APP_ATTR (NORMAL)); |
|
break; |
|
case MPD_DISCONNECTED: |
|
move (g.header_height++, 0); |
|
app_write_line ("Disconnected", APP_ATTR (NORMAL)); |
|
} |
|
|
|
chtype attrs[2] = { APP_ATTR (TAB_BAR), APP_ATTR (TAB_ACTIVE) }; |
|
|
|
// The help tab is disguised so that it's not too intruding |
|
struct row_buffer buf = row_buffer_make (); |
|
row_buffer_append (&buf, APP_TITLE, attrs[g.active_tab == g.help_tab]); |
|
row_buffer_append (&buf, " ", attrs[false]); |
|
|
|
g.tabs_offset = g.header_height; |
|
LIST_FOR_EACH (struct tab, iter, g.tabs) |
|
row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]); |
|
app_flush_header (&buf, attrs[false]); |
|
|
|
const char *header = g.active_tab->header; |
|
if (header) |
|
{ |
|
buf = row_buffer_make (); |
|
row_buffer_append (&buf, header, APP_ATTR (HEADER)); |
|
app_flush_header (&buf, APP_ATTR (HEADER)); |
|
} |
|
} |
|
|
|
static int |
|
app_fitting_items (void) |
|
{ |
|
// The raw number of items that would have fit on the terminal |
|
return LINES - g.header_height - 1 /* status bar */; |
|
} |
|
|
|
static int |
|
app_visible_items (void) |
|
{ |
|
return MAX (0, app_fitting_items ()); |
|
} |
|
|
|
/// Figure out scrollbar appearance. @a s is the minimal slider length as well |
|
/// as the scrollbar resolution per @a visible item. |
|
struct scrollbar { long length, start; } |
|
app_compute_scrollbar (struct tab *tab, long visible, long s) |
|
{ |
|
long top = tab->item_top, total = tab->item_count; |
|
if (total < visible) |
|
return (struct scrollbar) { 0, 0 }; |
|
if (visible == 1) |
|
return (struct scrollbar) { s, 0 }; |
|
if (visible == 2) |
|
return (struct scrollbar) { s, top >= total / 2 ? s : 0 }; |
|
|
|
// Only be at the top or bottom when the top or bottom item can be seen. |
|
// The algorithm isn't optimal but it's a bitch to get right. |
|
double available_length = s * visible - 2 - s + 1; |
|
|
|
double lenf = s + available_length * visible / total, length = 0.; |
|
long offset = 1 + available_length * top / total + modf (lenf, &length); |
|
|
|
if (top == 0) |
|
return (struct scrollbar) { length, 0 }; |
|
if (top + visible >= total) |
|
return (struct scrollbar) { length, s * visible - length }; |
|
|
|
return (struct scrollbar) { length, offset }; |
|
} |
|
|
|
static void |
|
app_draw_scrollbar (void) |
|
{ |
|
// This assumes that we can write to the one-before-last column, |
|
// i.e. that it's not covered by any double-wide character (and that |
|
// ncurses comes to the right results when counting characters). |
|
// |
|
// We could also precompute the scrollbar and append it to each row |
|
// as we render them, plus all the unoccupied rows. |
|
struct tab *tab = g.active_tab; |
|
int visible_items = app_visible_items (); |
|
|
|
hard_assert (tab->item_count != 0); |
|
if (!g.use_partial_boxes) |
|
{ |
|
struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1); |
|
for (int row = 0; row < visible_items; row++) |
|
{ |
|
move (g.header_height + row, COLS - 1); |
|
if (row < bar.start || row >= bar.start + bar.length) |
|
addch (' ' | APP_ATTR (SCROLLBAR)); |
|
else |
|
addch (' ' | APP_ATTR (SCROLLBAR) | A_REVERSE); |
|
} |
|
return; |
|
} |
|
|
|
struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 8); |
|
bar.length += bar.start; |
|
|
|
int start_part = bar.start % 8; bar.start /= 8; |
|
int end_part = bar.length % 8; bar.length /= 8; |
|
|
|
// Even with this, the solid part must be at least one character high |
|
static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" }; |
|
|
|
for (int row = 0; row < visible_items; row++) |
|
{ |
|
chtype attrs = APP_ATTR (SCROLLBAR); |
|
if (row > bar.start && row <= bar.length) |
|
attrs ^= A_REVERSE; |
|
|
|
const char *c = " "; |
|
if (row == bar.start) c = partials[start_part]; |
|
if (row == bar.length) c = partials[end_part]; |
|
|
|
move (g.header_height + row, COLS - 1); |
|
|
|
struct row_buffer buf = row_buffer_make (); |
|
row_buffer_append (&buf, c, attrs); |
|
row_buffer_flush (&buf); |
|
row_buffer_free (&buf); |
|
} |
|
} |
|
|
|
static void |
|
app_draw_view (void) |
|
{ |
|
move (g.header_height, 0); |
|
clrtobot (); |
|
|
|
struct tab *tab = g.active_tab; |
|
bool want_scrollbar = (int) tab->item_count > app_visible_items (); |
|
int view_width = COLS - want_scrollbar; |
|
|
|
int to_show = |
|
MIN (app_fitting_items (), (int) tab->item_count - tab->item_top); |
|
for (int row = 0; row < to_show; row++) |
|
{ |
|
int item_index = tab->item_top + row; |
|
int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN); |
|
|
|
bool override_colors = true; |
|
if (item_index == tab->item_selected) |
|
row_attrs = APP_ATTR (SELECTION); |
|
else if (tab->item_mark > -1 && |
|
((item_index >= tab->item_mark && item_index <= tab->item_selected) |
|
|| (item_index >= tab->item_selected && item_index <= tab->item_mark))) |
|
row_attrs = APP_ATTR (MULTISELECT); |
|
else |
|
override_colors = false; |
|
|
|
struct row_buffer buf = row_buffer_make (); |
|
tab->on_item_draw (item_index, &buf, view_width); |
|
|
|
// Combine attributes used by the handler with the defaults. |
|
// Avoiding attrset() because of row_buffer_flush(). |
|
for (size_t i = 0; i < buf.chars_len; i++) |
|
{ |
|
chtype *attrs = &buf.chars[i].attrs; |
|
if (override_colors) |
|
*attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs; |
|
else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR)) |
|
*attrs |= (row_attrs & ~A_COLOR); |
|
else |
|
*attrs |= row_attrs; |
|
} |
|
|
|
move (g.header_height + row, 0); |
|
app_flush_buffer (&buf, view_width, row_attrs); |
|
} |
|
|
|
if (want_scrollbar) |
|
app_draw_scrollbar (); |
|
} |
|
|
|
static void |
|
app_write_mpd_status_playlist (struct row_buffer *buf) |
|
{ |
|
struct str stats = str_make (); |
|
if (g.playlist.len == 1) |
|
str_append_printf (&stats, "1 song "); |
|
else |
|
str_append_printf (&stats, "%zu songs ", g.playlist.len); |
|
|
|
int hours = g.playlist_time / 3600; |
|
int minutes = g.playlist_time % 3600 / 60; |
|
if (hours || minutes) |
|
{ |
|
str_append_c (&stats, ' '); |
|
|
|
if (hours == 1) |
|
str_append_printf (&stats, " 1 hour"); |
|
else if (hours) |
|
str_append_printf (&stats, " %d hours", hours); |
|
|
|
if (minutes == 1) |
|
str_append_printf (&stats, " 1 minute"); |
|
else if (minutes) |
|
str_append_printf (&stats, " %d minutes", minutes); |
|
} |
|
row_buffer_append (buf, stats.str, APP_ATTR (NORMAL)); |
|
str_free (&stats); |
|
} |
|
|
|
static void |
|
app_write_mpd_status (struct row_buffer *buf) |
|
{ |
|
struct str_map *map = &g.playback_info; |
|
if (g.active_tab->item_mark > -1) |
|
{ |
|
struct tab_range r = tab_selection_range (g.active_tab); |
|
char *msg = xstrdup_printf (r.from == r.upto |
|
? "Selected %d item" : "Selected %d items", r.upto - r.from + 1); |
|
row_buffer_append (buf, msg, APP_ATTR (HIGHLIGHT)); |
|
free (msg); |
|
} |
|
else if (str_map_find (map, "updating_db")) |
|
row_buffer_append (buf, "Updating database...", APP_ATTR (NORMAL)); |
|
else |
|
app_write_mpd_status_playlist (buf); |
|
|
|
const char *s; |
|
bool repeat = (s = str_map_find (map, "repeat")) && strcmp (s, "0"); |
|
bool random = (s = str_map_find (map, "random")) && strcmp (s, "0"); |
|
bool single = (s = str_map_find (map, "single")) && strcmp (s, "0"); |
|
bool consume = (s = str_map_find (map, "consume")) && strcmp (s, "0"); |
|
|
|
struct row_buffer right = row_buffer_make (); |
|
chtype a[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) }; |
|
if (repeat) row_buffer_append_args (&right, |
|
" ", APP_ATTR (NORMAL), "repeat", a[repeat], NULL); |
|
if (random) row_buffer_append_args (&right, |
|
" ", APP_ATTR (NORMAL), "random", a[random], NULL); |
|
if (single) row_buffer_append_args (&right, |
|
" ", APP_ATTR (NORMAL), "single", a[single], NULL); |
|
if (consume) row_buffer_append_args (&right, |
|
" ", APP_ATTR (NORMAL), "consume", a[consume], NULL); |
|
|
|
row_buffer_space (buf, |
|
MAX (0, COLS - buf->total_width - right.total_width), |
|
APP_ATTR (NORMAL)); |
|
row_buffer_append_buffer (buf, &right); |
|
row_buffer_free (&right); |
|
} |
|
|
|
static void |
|
app_draw_statusbar (void) |
|
{ |
|
int caret = -1; |
|
|
|
struct row_buffer buf = row_buffer_make (); |
|
if (g.message) |
|
row_buffer_append (&buf, g.message, APP_ATTR (HIGHLIGHT)); |
|
else if (g.editor.line) |
|
caret = line_editor_write (&g.editor, &buf, COLS, APP_ATTR (HIGHLIGHT)); |
|
else if (g.client.state == MPD_CONNECTED) |
|
app_write_mpd_status (&buf); |
|
|
|
move (LINES - 1, 0); |
|
app_flush_buffer (&buf, COLS, APP_ATTR (NORMAL)); |
|
|
|
curs_set (0); |
|
if (caret != -1) |
|
{ |
|
move (LINES - 1, caret); |
|
curs_set (1); |
|
} |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
/// Checks what items are visible and returns if the range was alright |
|
static bool |
|
app_fix_view_range (void) |
|
{ |
|
struct tab *tab = g.active_tab; |
|
if (tab->item_top < 0) |
|
{ |
|
tab->item_top = 0; |
|
return false; |
|
} |
|
|
|
// If the contents are at least as long as the screen, always fill it |
|
int max_item_top = (int) tab->item_count - app_visible_items (); |
|
// But don't let that suggest a negative offset |
|
max_item_top = MAX (max_item_top, 0); |
|
|
|
if (tab->item_top > max_item_top) |
|
{ |
|
tab->item_top = max_item_top; |
|
return false; |
|
} |
|
return true; |
|
} |
|
|
|
static void |
|
app_on_refresh (void *user_data) |
|
{ |
|
(void) user_data; |
|
poller_idle_reset (&g.refresh_event); |
|
|
|
app_draw_header (); |
|
app_fix_view_range(); |
|
app_draw_view (); |
|
app_draw_statusbar (); |
|
|
|
refresh (); |
|
} |
|
|
|
// --- Actions ----------------------------------------------------------------- |
|
|
|
/// Scroll down (positive) or up (negative) @a n items |
|
static bool |
|
app_scroll (int n) |
|
{ |
|
g.active_tab->item_top += n; |
|
app_invalidate (); |
|
return app_fix_view_range (); |
|
} |
|
|
|
static void |
|
app_ensure_selection_visible (void) |
|
{ |
|
struct tab *tab = g.active_tab; |
|
if (tab->item_selected < 0 || !tab->item_count) |
|
return; |
|
|
|
int too_high = tab->item_top - tab->item_selected; |
|
if (too_high > 0) |
|
app_scroll (-too_high); |
|
|
|
int too_low = tab->item_selected |
|
- (tab->item_top + app_visible_items () - 1); |
|
if (too_low > 0) |
|
app_scroll (too_low); |
|
} |
|
|
|
static bool |
|
app_move_selection (int diff) |
|
{ |
|
struct tab *tab = g.active_tab; |
|
int fixed = tab->item_selected + diff; |
|
fixed = MIN (fixed, (int) tab->item_count - 1); |
|
fixed = MAX (fixed, 0); |
|
|
|
bool result = !diff || tab->item_selected != fixed; |
|
tab->item_selected = fixed; |
|
app_invalidate (); |
|
|
|
app_ensure_selection_visible (); |
|
return result; |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static void |
|
app_prepend_tab (struct tab *tab) |
|
{ |
|
LIST_PREPEND (g.tabs, tab); |
|
app_invalidate (); |
|
} |
|
|
|
static void |
|
app_switch_tab (struct tab *tab) |
|
{ |
|
if (tab == g.active_tab) |
|
return; |
|
|
|
g.last_tab = g.active_tab; |
|
g.active_tab = tab; |
|
app_invalidate (); |
|
} |
|
|
|
static bool |
|
app_goto_tab (int tab_index) |
|
{ |
|
int i = 0; |
|
LIST_FOR_EACH (struct tab, iter, g.tabs) |
|
if (i++ == tab_index) |
|
{ |
|
app_switch_tab (iter); |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
// --- Actions ----------------------------------------------------------------- |
|
|
|
#define ACTIONS(XX) \ |
|
XX( NONE, Do nothing ) \ |
|
\ |
|
XX( QUIT, Quit ) \ |
|
XX( REDRAW, Redraw screen ) \ |
|
XX( TAB_HELP, Switch to help tab ) \ |
|
XX( TAB_LAST, Switch to last tab ) \ |
|
XX( TAB_PREVIOUS, Switch to previous tab ) \ |
|
XX( TAB_NEXT, Switch to next tab ) \ |
|
\ |
|
XX( MPD_TOGGLE, Toggle play/pause ) \ |
|
XX( MPD_STOP, Stop playback ) \ |
|
XX( MPD_PREVIOUS, Previous song ) \ |
|
XX( MPD_NEXT, Next song ) \ |
|
XX( MPD_BACKWARD, Seek backwards ) \ |
|
XX( MPD_FORWARD, Seek forwards ) \ |
|
XX( MPD_VOLUME_UP, Increase volume ) \ |
|
XX( MPD_VOLUME_DOWN, Decrease volume ) \ |
|
\ |
|
XX( MPD_SEARCH, Global search ) \ |
|
XX( MPD_ADD, Add selection to playlist ) \ |
|
XX( MPD_REPLACE, Replace playlist ) \ |
|
XX( MPD_REPEAT, Toggle repeat ) \ |
|
XX( MPD_RANDOM, Toggle random playback ) \ |
|
XX( MPD_SINGLE, Toggle single song playback ) \ |
|
XX( MPD_CONSUME, Toggle consume ) \ |
|
XX( MPD_UPDATE_DB, Update MPD database ) \ |
|
XX( MPD_COMMAND, Send raw command to MPD ) \ |
|
\ |
|
XX( CHOOSE, Choose item ) \ |
|
XX( DELETE, Delete item ) \ |
|
XX( UP, Go up a level ) \ |
|
XX( MULTISELECT, Toggle multiselect ) \ |
|
\ |
|
XX( SCROLL_UP, Scroll up ) \ |
|
XX( SCROLL_DOWN, Scroll down ) \ |
|
XX( MOVE_UP, Move selection up ) \ |
|
XX( MOVE_DOWN, Move selection down ) \ |
|
\ |
|
XX( GOTO_TOP, Go to top ) \ |
|
XX( GOTO_BOTTOM, Go to bottom ) \ |
|
XX( GOTO_ITEM_PREVIOUS, Go to previous item ) \ |
|
XX( GOTO_ITEM_NEXT, Go to next item ) \ |
|
XX( GOTO_PAGE_PREVIOUS, Go to previous page ) \ |
|
XX( GOTO_PAGE_NEXT, Go to next page ) \ |
|
\ |
|
XX( GOTO_VIEW_TOP, Select top item ) \ |
|
XX( GOTO_VIEW_CENTER, Select center item ) \ |
|
XX( GOTO_VIEW_BOTTOM, Select bottom item ) \ |
|
\ |
|
XX( EDITOR_CONFIRM, Confirm input ) \ |
|
\ |
|
XX( EDITOR_B_CHAR, Go back a character ) \ |
|
XX( EDITOR_F_CHAR, Go forward a character ) \ |
|
XX( EDITOR_B_WORD, Go back a word ) \ |
|
XX( EDITOR_F_WORD, Go forward a word ) \ |
|
XX( EDITOR_HOME, Go to start of line ) \ |
|
XX( EDITOR_END, Go to end of line ) \ |
|
\ |
|
XX( EDITOR_B_DELETE, Delete last character ) \ |
|
XX( EDITOR_F_DELETE, Delete next character ) \ |
|
XX( EDITOR_B_KILL_WORD, Delete last word ) \ |
|
XX( EDITOR_B_KILL_LINE, Delete everything up to BOL ) \ |
|
XX( EDITOR_F_KILL_LINE, Delete everything up to EOL ) |
|
|
|
enum action |
|
{ |
|
#define XX(name, description) ACTION_ ## name, |
|
ACTIONS (XX) |
|
#undef XX |
|
ACTION_COUNT |
|
}; |
|
|
|
static struct action_info |
|
{ |
|
const char *name; ///< Name for user bindings |
|
const char *description; ///< Human-readable description |
|
} |
|
g_actions[] = |
|
{ |
|
#define XX(name, description) { #name, #description }, |
|
ACTIONS (XX) |
|
#undef XX |
|
}; |
|
|
|
/// Accept a more human format of action-name instead of ACTION_NAME |
|
static int action_toupper (int c) { return c == '-' ? '_' : toupper_ascii (c); } |
|
|
|
static int |
|
action_resolve (const char *name) |
|
{ |
|
const unsigned char *s = (const unsigned char *) name; |
|
for (int i = 0; i < ACTION_COUNT; i++) |
|
{ |
|
const char *target = g_actions[i].name; |
|
for (size_t k = 0; action_toupper (s[k]) == target[k]; k++) |
|
if (!s[k] && !target[k]) |
|
return i; |
|
} |
|
return -1; |
|
} |
|
|
|
// --- User input handling ----------------------------------------------------- |
|
|
|
static void |
|
mpd_client_vsend_command (struct mpd_client *self, va_list ap) |
|
{ |
|
struct strv v = strv_make (); |
|
const char *command; |
|
while ((command = va_arg (ap, const char *))) |
|
strv_append (&v, command); |
|
mpd_client_send_commandv (self, v.vector); |
|
strv_free (&v); |
|
} |
|
|
|
/// Send a command to MPD without caring about the response |
|
static bool mpd_client_send_simple (struct mpd_client *self, ...) |
|
ATTRIBUTE_SENTINEL; |
|
|
|
static void |
|
mpd_on_simple_response (const struct mpd_response *response, |
|
const struct strv *data, void *user_data) |
|
{ |
|
(void) data; |
|
(void) user_data; |
|
|
|
if (!response->success) |
|
print_error ("%s: %s", "command failed", response->message_text); |
|
} |
|
|
|
static bool |
|
mpd_client_send_simple (struct mpd_client *self, ...) |
|
{ |
|
if (self->state != MPD_CONNECTED) |
|
return false; |
|
|
|
va_list ap; |
|
va_start (ap, self); |
|
mpd_client_vsend_command (self, ap); |
|
va_end (ap); |
|
|
|
mpd_client_add_task (self, mpd_on_simple_response, NULL); |
|
mpd_client_idle (self, 0); |
|
return true; |
|
} |
|
|
|
#define MPD_SIMPLE(...) \ |
|
mpd_client_send_simple (&g.client, __VA_ARGS__, NULL) |
|
|
|
static bool |
|
app_setvol (int value) |
|
{ |
|
char *volume = xstrdup_printf ("%d", MAX (0, MIN (100, value))); |
|
bool result = g.volume >= 0 && MPD_SIMPLE ("setvol", volume); |
|
free (volume); |
|
return result; |
|
} |
|
|
|
static void |
|
app_on_editor_end (bool confirmed) |
|
{ |
|
struct mpd_client *c = &g.client; |
|
if (!confirmed) |
|
return; |
|
|
|
size_t len; |
|
char *u8 = (char *) u32_to_u8 (g.editor.line, g.editor.len + 1, NULL, &len); |
|
mpd_client_send_command_raw (c, u8); |
|
free (u8); |
|
|
|
mpd_client_add_task (c, mpd_on_simple_response, NULL); |
|
mpd_client_idle (c, 0); |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static bool |
|
app_mpd_toggle (const char *name) |
|
{ |
|
const char *s = str_map_find (&g.playback_info, name); |
|
bool value = s && strcmp (s, "0"); |
|
return MPD_SIMPLE (name, value ? "0" : "1"); |
|
} |
|
|
|
static bool |
|
app_process_action (enum action action) |
|
{ |
|
// First let the tab try to handle this |
|
struct tab *tab = g.active_tab; |
|
if (tab->on_action && tab->on_action (action)) |
|
{ |
|
app_invalidate (); |
|
return true; |
|
} |
|
|
|
switch (action) |
|
{ |
|
case ACTION_NONE: |
|
return true; |
|
case ACTION_QUIT: |
|
// It is a pseudomode, avoid surprising the user |
|
if (tab->item_mark > -1) |
|
{ |
|
tab->item_mark = -1; |
|
app_invalidate (); |
|
return true; |
|
} |
|
|
|
app_quit (); |
|
return true; |
|
case ACTION_REDRAW: |
|
clear (); |
|
app_invalidate (); |
|
return true; |
|
case ACTION_MPD_COMMAND: |
|
line_editor_start (&g.editor, ':'); |
|
g.editor.on_end = app_on_editor_end; |
|
app_invalidate (); |
|
return true; |
|
default: |
|
return false; |
|
|
|
case ACTION_MULTISELECT: |
|
if (!tab->can_multiselect |
|
|| !tab->item_count || tab->item_selected < 0) |
|
return false; |
|
|
|
app_invalidate (); |
|
if (tab->item_mark > -1) |
|
tab->item_mark = -1; |
|
else |
|
tab->item_mark = tab->item_selected; |
|
return true; |
|
|
|
case ACTION_TAB_LAST: |
|
if (!g.last_tab) |
|
return false; |
|
app_switch_tab (g.last_tab); |
|
return true; |
|
case ACTION_TAB_HELP: |
|
app_switch_tab (g.help_tab); |
|
return true; |
|
case ACTION_TAB_PREVIOUS: |
|
if (g.active_tab == g.help_tab) |
|
return false; |
|
if (!g.active_tab->prev) |
|
app_switch_tab (g.help_tab); |
|
else |
|
app_switch_tab (g.active_tab->prev); |
|
return true; |
|
case ACTION_TAB_NEXT: |
|
if (g.active_tab == g.help_tab) |
|
app_switch_tab (g.tabs); |
|
else if (g.active_tab->next) |
|
app_switch_tab (g.active_tab->next); |
|
else |
|
return false; |
|
return true; |
|
|
|
case ACTION_MPD_TOGGLE: |
|
if (g.state == PLAYER_PLAYING) return MPD_SIMPLE ("pause", "1"); |
|
if (g.state == PLAYER_PAUSED) return MPD_SIMPLE ("pause", "0"); |
|
return MPD_SIMPLE ("play"); |
|
case ACTION_MPD_STOP: return MPD_SIMPLE ("stop"); |
|
case ACTION_MPD_PREVIOUS: return MPD_SIMPLE ("previous"); |
|
case ACTION_MPD_NEXT: return MPD_SIMPLE ("next"); |
|
case ACTION_MPD_FORWARD: return MPD_SIMPLE ("seekcur", "+10"); |
|
case ACTION_MPD_BACKWARD: return MPD_SIMPLE ("seekcur", "-10"); |
|
case ACTION_MPD_REPEAT: return app_mpd_toggle ("repeat"); |
|
case ACTION_MPD_RANDOM: return app_mpd_toggle ("random"); |
|
case ACTION_MPD_SINGLE: return app_mpd_toggle ("single"); |
|
case ACTION_MPD_CONSUME: return app_mpd_toggle ("consume"); |
|
case ACTION_MPD_UPDATE_DB: return MPD_SIMPLE ("update"); |
|
|
|
case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 10); |
|
case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 10); |
|
|
|
// XXX: these should rather be parametrized |
|
case ACTION_SCROLL_UP: return app_scroll (-3); |
|
case ACTION_SCROLL_DOWN: return app_scroll (3); |
|
|
|
case ACTION_GOTO_TOP: |
|
if (tab->item_count) |
|
{ |
|
g.active_tab->item_selected = 0; |
|
app_ensure_selection_visible (); |
|
app_invalidate (); |
|
} |
|
return true; |
|
case ACTION_GOTO_BOTTOM: |
|
if (tab->item_count) |
|
{ |
|
g.active_tab->item_selected = |
|
MAX (0, (int) g.active_tab->item_count - 1); |
|
app_ensure_selection_visible (); |
|
app_invalidate (); |
|
} |
|
return true; |
|
|
|
case ACTION_GOTO_ITEM_PREVIOUS: return app_move_selection (-1); |
|
case ACTION_GOTO_ITEM_NEXT: return app_move_selection (1); |
|
|
|
case ACTION_GOTO_PAGE_PREVIOUS: |
|
app_scroll (-app_visible_items ()); |
|
return app_move_selection (-app_visible_items ()); |
|
case ACTION_GOTO_PAGE_NEXT: |
|
app_scroll (app_visible_items ()); |
|
return app_move_selection (app_visible_items ()); |
|
|
|
case ACTION_GOTO_VIEW_TOP: |
|
g.active_tab->item_selected = g.active_tab->item_top; |
|
return app_move_selection (0); |
|
case ACTION_GOTO_VIEW_CENTER: |
|
g.active_tab->item_selected = g.active_tab->item_top; |
|
return app_move_selection (MAX (0, app_visible_items () / 2 - 1)); |
|
case ACTION_GOTO_VIEW_BOTTOM: |
|
g.active_tab->item_selected = g.active_tab->item_top; |
|
return app_move_selection (MAX (0, app_visible_items () - 1)); |
|
} |
|
return false; |
|
} |
|
|
|
static bool |
|
app_editor_process_action (enum action action) |
|
{ |
|
app_invalidate (); |
|
switch (action) |
|
{ |
|
case ACTION_QUIT: |
|
line_editor_abort (&g.editor, false); |
|
g.editor.on_end = NULL; |
|
return true; |
|
case ACTION_EDITOR_CONFIRM: |
|
line_editor_abort (&g.editor, true); |
|
g.editor.on_end = NULL; |
|
return true; |
|
default: |
|
return false; |
|
|
|
case ACTION_EDITOR_B_CHAR: |
|
return line_editor_action (&g.editor, LINE_EDITOR_B_CHAR); |
|
case ACTION_EDITOR_F_CHAR: |
|
return line_editor_action (&g.editor, LINE_EDITOR_F_CHAR); |
|
case ACTION_EDITOR_B_WORD: |
|
return line_editor_action (&g.editor, LINE_EDITOR_B_WORD); |
|
case ACTION_EDITOR_F_WORD: |
|
return line_editor_action (&g.editor, LINE_EDITOR_F_WORD); |
|
case ACTION_EDITOR_HOME: |
|
return line_editor_action (&g.editor, LINE_EDITOR_HOME); |
|
case ACTION_EDITOR_END: |
|
return line_editor_action (&g.editor, LINE_EDITOR_END); |
|
|
|
case ACTION_EDITOR_B_DELETE: |
|
return line_editor_action (&g.editor, LINE_EDITOR_B_DELETE); |
|
case ACTION_EDITOR_F_DELETE: |
|
return line_editor_action (&g.editor, LINE_EDITOR_F_DELETE); |
|
case ACTION_EDITOR_B_KILL_WORD: |
|
return line_editor_action (&g.editor, LINE_EDITOR_B_KILL_WORD); |
|
case ACTION_EDITOR_B_KILL_LINE: |
|
return line_editor_action (&g.editor, LINE_EDITOR_B_KILL_LINE); |
|
case ACTION_EDITOR_F_KILL_LINE: |
|
return line_editor_action (&g.editor, LINE_EDITOR_F_KILL_LINE); |
|
} |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static bool |
|
app_process_left_mouse_click (int line, int column, bool double_click) |
|
{ |
|
if (line == g.controls_offset) |
|
{ |
|
// XXX: there could be a push_widget(buf, text, attrs, handler) |
|
// function to help with this but it might not be worth it |
|
enum action action = ACTION_NONE; |
|
if (column >= 0 && column <= 1) action = ACTION_MPD_PREVIOUS; |
|
if (column >= 3 && column <= 4) action = ACTION_MPD_TOGGLE; |
|
if (column >= 6 && column <= 7) action = ACTION_MPD_STOP; |
|
if (column >= 9 && column <= 10) action = ACTION_MPD_NEXT; |
|
|
|
if (action) |
|
return app_process_action (action); |
|
|
|
int gauge_offset = column - g.gauge_offset; |
|
if (g.gauge_offset < 0 |
|
|| gauge_offset < 0 || gauge_offset >= g.gauge_width) |
|
return false; |
|
|
|
float position = (float) gauge_offset / g.gauge_width; |
|
if (g.song_duration >= 1) |
|
{ |
|
char *where = xstrdup_printf ("%f", position * g.song_duration); |
|
MPD_SIMPLE ("seekcur", where); |
|
free (where); |
|
} |
|
} |
|
else if (line == g.tabs_offset) |
|
{ |
|
struct tab *winner = NULL; |
|
int indent = strlen (APP_TITLE); |
|
if (column < indent) |
|
{ |
|
app_switch_tab (g.help_tab); |
|
return true; |
|
} |
|
for (struct tab *iter = g.tabs; !winner && iter; iter = iter->next) |
|
{ |
|
if (column < (indent += iter->name_width)) |
|
winner = iter; |
|
} |
|
if (!winner) |
|
return false; |
|
|
|
app_switch_tab (winner); |
|
} |
|
else if (line >= g.header_height) |
|
{ |
|
struct tab *tab = g.active_tab; |
|
int row_index = line - g.header_height; |
|
if (row_index < 0 |
|
|| row_index >= (int) tab->item_count - tab->item_top) |
|
return false; |
|
|
|
// TODO: handle the scrollbar a bit better than this |
|
int visible_items = app_visible_items (); |
|
if ((int) tab->item_count > visible_items && column == COLS - 1) |
|
tab->item_top = (float) row_index / visible_items |
|
* (int) tab->item_count - visible_items / 2; |
|
else |
|
tab->item_selected = row_index + tab->item_top; |
|
app_invalidate (); |
|
|
|
if (double_click) |
|
app_process_action (ACTION_CHOOSE); |
|
} |
|
return true; |
|
} |
|
|
|
static bool |
|
app_process_mouse (termo_mouse_event_t type, int line, int column, int button, |
|
bool double_click) |
|
{ |
|
if (type != TERMO_MOUSE_PRESS) |
|
return true; |
|
|
|
if (g.editor.line) |
|
line_editor_abort (&g.editor, false); |
|
|
|
if (button == 1) |
|
return app_process_left_mouse_click (line, column, double_click); |
|
else if (button == 4) |
|
return app_process_action (ACTION_SCROLL_UP); |
|
else if (button == 5) |
|
return app_process_action (ACTION_SCROLL_DOWN); |
|
return false; |
|
} |
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - |
|
|
|
static struct binding |
|
{ |
|
termo_key_t decoded; ///< Decoded key definition |
|
enum action action; ///< Action to take |
|
int order; ///< Order for stable sorting |
|
} |
|
*g_normal_keys, *g_editor_keys; |
|
static size_t g_normal_keys_len, g_editor_keys_len; |
|
|
|
static struct binding_default |
|
{ |
|
const char *key; ///< Key definition |
|
enum action action; ///< Action to take |
|
} |
|
g_normal_defaults[] = |
|
{ |
|
{ "Escape", ACTION_QUIT }, |
|
{ "q", ACTION_QUIT }, |
|
{ "C-l", ACTION_REDRAW }, |
|
{ "M-Tab", ACTION_TAB_LAST }, |
|
{ "F1", ACTION_TAB_HELP }, |
|
{ "C-Left", ACTION_TAB_PREVIOUS }, |
|
{ "C-Right", ACTION_TAB_NEXT }, |
|
{ "C-PageUp", ACTION_TAB_PREVIOUS }, |
|
{ "C-PageDown", ACTION_TAB_NEXT }, |
|
|
|
{ "Home", ACTION_GOTO_TOP }, |
|
{ "End", ACTION_GOTO_BOTTOM }, |
|
{ "M-<", ACTION_GOTO_TOP }, |
|
{ "M->", ACTION_GOTO_BOTTOM }, |
|
{ "g", ACTION_GOTO_TOP }, |
|
{ "G", ACTION_GOTO_BOTTOM }, |
|
{ "S-Up", ACTION_MOVE_UP }, |
|
{ "S-Down", ACTION_MOVE_DOWN }, |
|
{ "Up", ACTION_GOTO_ITEM_PREVIOUS }, |
|
{ "Down", ACTION_GOTO_ITEM_NEXT }, |
|
{ "k", ACTION_GOTO_ITEM_PREVIOUS }, |
|
{ "j", ACTION_GOTO_ITEM_NEXT }, |
|
{ "PageUp", ACTION_GOTO_PAGE_PREVIOUS }, |
|
{ "PageDown", ACTION_GOTO_PAGE_NEXT }, |
|
{ "C-p", ACTION_GOTO_ITEM_PREVIOUS }, |
|
{ "C-n", ACTION_GOTO_ITEM_NEXT }, |
|
{ "C-b", ACTION_GOTO_PAGE_PREVIOUS }, |
|
{ "C-f", ACTION_GOTO_PAGE_NEXT }, |
|
{ "C-y", ACTION_SCROLL_UP }, |
|
{ "C-e", ACTION_SCROLL_DOWN }, |
|
|
|
{ "H", ACTION_GOTO_VIEW_TOP }, |
|
{ "M", ACTION_GOTO_VIEW_CENTER }, |
|
{ "L", ACTION_GOTO_VIEW_BOTTOM }, |
|
|
|
// Not sure how to set these up, they're pretty arbitrary so far |
|
{ "Enter", ACTION_CHOOSE }, |
|
{ "Delete", ACTION_DELETE }, |
|
{ "d", ACTION_DELETE }, |
|
{ "Backspace", ACTION_UP }, |
|
{ "v", ACTION_MULTISELECT }, |
|
{ "/", ACTION_MPD_SEARCH }, |
|
{ "a", ACTION_MPD_ADD }, |
|
{ "r", ACTION_MPD_REPLACE }, |
|
{ ":", ACTION_MPD_COMMAND }, |
|
|
|
{ "<", ACTION_MPD_PREVIOUS }, |
|
{ ">", ACTION_MPD_NEXT }, |
|
{ "Left", ACTION_MPD_PREVIOUS }, |
|
{ "Right", ACTION_MPD_NEXT }, |
|
{ "M-Left", ACTION_MPD_BACKWARD }, |
|
{ "M-Right", ACTION_MPD_FORWARD }, |
|
{ "h", ACTION_MPD_PREVIOUS }, |
|
{ "l", ACTION_MPD_NEXT }, |
|
{ "Space", ACTION_MPD_TOGGLE }, |
|
{ "C-Space", ACTION_MPD_STOP }, |
|
{ "u", ACTION_MPD_UPDATE_DB }, |
|
{ "M-PageUp", ACTION_MPD_VOLUME_UP }, |
|
{ "M-PageDown", ACTION_MPD_VOLUME_DOWN }, |
|
}, |
|
g_editor_defaults[] = |
|
{ |
|
{ "Left", ACTION_EDITOR_B_CHAR }, |
|
{ "Right", ACTION_EDITOR_F_CHAR }, |
|
{ "C-b", ACTION_EDITOR_B_CHAR }, |
|
{ "C-f", ACTION_EDITOR_F_CHAR }, |
|
{ "M-b", ACTION_EDITOR_B_WORD }, |
|
{ "M-f", ACTION_EDITOR_F_WORD }, |
|
{ "Home", ACTION_EDITOR_HOME }, |
|
{ "End", ACTION_EDITOR_END }, |
|
{ "C-a", ACTION_EDITOR_HOME }, |
|
{ "C-e", ACTION_EDITOR_END }, |
|
|
|
{ "C-h", ACTION_EDITOR_B_DELETE }, |
|
{ "DEL", ACTION_EDITOR_B_DELETE }, |
|
{ "Backspace", ACTION_EDITOR_B_DELETE }, |
|
{ "C-d", ACTION_EDITOR_F_DELETE }, |
|
{ "Delete", ACTION_EDITOR_F_DELETE }, |
|
{ "C-u", ACTION_EDITOR_B_KILL_LINE }, |
|
{ "C-k", ACTION_EDITOR_F_KILL_LINE }, |
|
{ "C-w", ACTION_EDITOR_B_KILL_WORD }, |
|
|
|
{ "C-g", ACTION_QUIT }, |
|
{ "Escape", ACTION_QUIT }, |
|
{ "Enter", ACTION_EDITOR_CONFIRM }, |
|
}; |
|
|
|
static int |
|
app_binding_cmp (const void *a, const void *b) |
|
{ |
|
const struct binding *aa = a, *bb = b; |
|
int cmp = termo_keycmp (g.tk, &aa->decoded, &bb->decoded); |
|
return cmp ? cmp : bb->order - aa->order; |
|
} |
|
|
|
static bool |
|
app_next_binding (struct str_map_iter *iter, termo_key_t *key, int *action) |
|
{ |
|
struct config_item *v; |
|
while ((v = str_map_iter_next (iter))) |
|
{ |
|
*action = ACTION_NONE; |
|
if (*termo_strpkey_utf8 (g.tk, |
|
iter->link->key, key, TERMO_FORMAT_ALTISMETA)) |
|
print_error ("%s: invalid binding", iter->link->key); |
|
else if (v->type == CONFIG_ITEM_NULL) |
|
return true; |
|
else if (v->type != CONFIG_ITEM_STRING) |
|
print_error ("%s: bindings must be strings", iter->link->key); |
|
else if ((*action = action_resolve (v->value.string.str)) >= 0) |
|
return true; |
|
else |
|
print_error ("%s: unknown action: %s", |
|
iter->link->key, v->value.string.str); |
|
} |
|
return false; |
|
} |
|
|
|
static struct binding * |
|
app_init_bindings (const char *keymap, |
|
struct binding_default *defaults, size_t defaults_len, size_t *result_len) |
|
{ |
|
ARRAY (struct binding, a) |
|
ARRAY_INIT_SIZED (a, defaults_len); |
|
|
|
// Order for stable sorting |
|
size_t order = 0; |
|
|
|
termo_key_t decoded; |
|
for (size_t i = 0; i < defaults_len; i++) |
|
{ |
|
hard_assert (!*termo_strpkey_utf8 (g.tk, |
|
defaults[i].key, &decoded, TERMO_FORMAT_ALTISMETA)); |
|
a[a_len++] = (struct binding) { decoded, defaults[i].action, order++ }; |
|
} |
|
|
|
struct config_item *root = config_item_get (g.config.root, keymap, NULL); |
|
if (root && root->type == CONFIG_ITEM_OBJECT) |
|
{ |
|
struct str_map_iter iter = str_map_iter_make (&root->value.object); |
|
ARRAY_RESERVE (a, iter.map->len); |
|
|
|
int action; |
|
while (app_next_binding (&iter, &decoded, &action)) |
|
a[a_len++] = (struct binding) { decoded, action, order++ }; |
|
} |
|
|
|
// Use the helper field to use the last mappings of identical bindings |
|
size_t out = 0; |
|
qsort (a, a_len, sizeof *a, app_binding_cmp); |
|
for (size_t in = 0; in < a_len; in++) |
|
{ |
|
a[in].order = 0; |
|
if (!out || termo_keycmp (g.tk, &a[in].decoded, &a[out - 1].decoded)) |
|
a[out++] = a[in]; |
|
} |
|
|
|
*result_len = out; |
|
return a; |
|
} |
|
|
|
static bool |
|
app_process_termo_event (termo_key_t *event) |
|
{ |
|
struct binding dummy = { *event, 0, 0 }, *binding; |
|
if (g.editor.line) |
|
{ |
|
if ((binding = bsearch (&dummy, g_editor_keys, g_editor_keys_len, |
|
sizeof *binding, app_binding_cmp))) |
|
return app_editor_process_action (binding->action); |
|
if (event->type != TERMO_TYPE_KEY || event->modifiers != 0) |
|
return false; |
|
|
|
line_editor_insert (&g.editor, event->code.codepoint); |
|
app_invalidate (); |
|
return true; |
|
} |
|
if ((binding = bsearch (&dummy, g_normal_keys, g_normal_keys_len, |
|
sizeof *binding, app_binding_cmp))) |
|
return app_process_action (binding->action); |
|
|
|
// TODO: parametrize actions, put this among other bindings |
|
if (!(event->modifiers & ~TERMO_KEYMOD_ALT) |
|
&& event->code.codepoint >= '0' |
|
&& event->code.codepoint <= '9') |
|
{ |
|
int n = event->code.codepoint - '0'; |
|
if (app_goto_tab ((n == 0 ? 10 : n) - 1)) |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
// --- Current tab ------------------------------------------------------------- |
|
|
|
static struct tab g_current_tab; |
|
|
|
static void |
|
current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, |
|
int width) |
|
{ |
|
// TODO: configurable output, maybe dynamically sized columns |
|
int length_len = 1 /*separator */ + 2 /* h */ + 3 /* m */+ 3 /* s */; |
|
|
|
compact_map_t map = item_list_get (&g.playlist, item_index); |
|
const char *artist = compact_map_find (map, "artist"); |
|
const char *title = compact_map_find (map, "title"); |
|
|
|
chtype attrs = (int) item_index == g.song ? A_BOLD : 0; |
|
if (artist && title) |
|
row_buffer_append_args (buffer, |
|
artist, attrs, " - ", attrs, title, attrs, NULL); |
|
else |
|
row_buffer_append (buffer, compact_map_find (map, "file"), attrs); |
|
|
|
row_buffer_align (buffer, width - length_len, attrs); |
|
|
|
char *s = NULL; |
|
unsigned long n; |
|
const char *time = compact_map_find (map, "time"); |
|
if (!time || !xstrtoul (&n, time, 10) || !(s = app_time_string (n))) |
|
s = xstrdup ("?"); |
|
|
|
char *right_aligned = xstrdup_printf ("%*s", length_len, s); |
|
row_buffer_append (buffer, right_aligned, attrs); |
|
free (right_aligned); |
|
free (s); |
|
} |
|
|
|
static void |
|
mpd_on_move_response (const struct mpd_response *response, |
|
const struct strv *data, void *user_data) |
|
{ |
|
(void) data; |
|
|
|
*(bool *) user_data = false; |
|
if (!response->success) |
|
print_error ("%s: %s", "command failed", response->message_text); |
|
} |
|
|
|
static void |
|
current_tab_move (int from, int to) |
|
{ |
|
compact_map_t map; |
|
const char *id; |
|
if (!(map = item_list_get (&g.playlist, from)) |
|
|| !(id = compact_map_find (map, "id"))) |
|
return; |
|
|
|
char *target_str = xstrdup_printf ("%d", to); |
|
mpd_client_send_command (&g.client, "moveid", id, target_str, NULL); |
|
free (target_str); |
|
} |
|
|
|
static bool |
|
current_tab_move_selection (int diff) |
|
{ |
|
static bool already_moving; |
|
if (already_moving || diff == 0) |
|
return true; |
|
|
|
struct mpd_client *c = &g.client; |
|
if (c->state != MPD_CONNECTED) |
|
return false; |
|
|
|
struct tab *tab = &g_current_tab; |
|
struct tab_range range = tab_selection_range (tab); |
|
if (range.from + diff < 0 |
|
|| range.upto + diff >= (int) tab->item_count) |
|
return false; |
|
|
|
mpd_client_list_begin (c); |
|
if (diff < 0) |
|
for (int i = range.from; i <= range.upto; i++) |
|
current_tab_move (i, i + diff); |
|
else |
|
for (int i = range.upto; i >= range.from; i--) |
|
current_tab_move (i, i + diff); |
|
mpd_client_list_end (c); |
|
|
|
mpd_client_add_task (c, mpd_on_move_response, &already_moving); |
|
mpd_client_idle (c, 0); |
|
return already_moving = true; |
|
} |
|
|
|
static bool |
|
current_tab_on_action (enum action action) |
|
{ |
|
struct tab *tab = &g_current_tab; |
|
compact_map_t map = item_list_get (&g.playlist, tab->item_selected); |
|
switch (action) |
|
{ |
|
const char *id; |
|
case ACTION_MOVE_UP: |
|
return current_tab_move_selection (-1); |
|
case ACTION_MOVE_DOWN: |
|
return current_tab_move_selection (+1); |
|
case ACTION_CHOOSE: |
|
tab->item_mark = -1; |
|
return map && (id = compact_map_find (map, "id")) |
|
&& MPD_SIMPLE ("playid", id); |
|
case ACTION_DELETE: |
|
{ |
|
struct mpd_client *c = &g.client; |
|
struct tab_range range = tab_selection_range (tab); |
|
if (range.from < 0 || c->state != MPD_CONNECTED) |
|
return false; |
|
|
|
mpd_client_list_begin (c); |
|
for (int i = range.from; i <= range.upto; i++) |
|
{ |
|
if ((map = item_list_get (&g.playlist, i)) |
|
&& (id = compact_map_find (map, "id"))) |
|
mpd_client_send_command (c, "deleteid", id, NULL); |
|
} |
|
mpd_client_list_end (c); |
|
mpd_client_add_task (c, mpd_on_simple_response, NULL); |
|
mpd_client_idle (c, 0); |
|
return true; |
|
} |
|
default: |
|
return false; |
|
} |
|
} |
|
|
|
static void |
|
current_tab_update (void) |
|
{ |
|
g_current_tab.item_count = g.playlist.len; |
|
g_current_tab.item_mark = |
|
MIN ((int) g.playlist.len - 1, g_current_tab.item_mark); |
|
app_invalidate (); |
|
} |
|
|
|
static struct tab * |
|
current_tab_init (void) |
|
{ |
|
struct tab *super = &g_current_tab; |
|
tab_init (super, "Current"); |
|
super->can_multiselect = true; |
|
super->on_action = current_tab_on_action; |
|
super->on_item_draw = current_tab_on_item_draw; |
|
return super; |
|
} |
|
|
|
// --- Library tab ------------------------------------------------------------- |
|
|
|
struct library_level |
|
{ |
|
LIST_HEADER (struct library_level) |
|
|
|
int item_top; ///< Stored state |
|
int item_selected; ///< Stored state |
|
char path[]; ///< Path of the level |
|
}; |
|
|
|
static struct |
|
{ |
|
struct tab super; ///< Parent class |
|
struct str path; ///< Current path |
|
struct strv items; ///< Current items (type, name, path) |
|
struct library_level *above; ///< Upper levels |
|
|
|
bool searching; ///< Search mode is active |
|
} |
|
g_library_tab; |
|
|
|
enum |
|
{ |
|
// This list is also ordered by ASCII and important for sorting |
|
|
|
LIBRARY_ROOT = '/', ///< Root entry |
|
LIBRARY_UP = '^', ///< Upper directory |
|
LIBRARY_DIR = 'd', ///< Directory |
|
LIBRARY_FILE = 'f' ///< File |
|
}; |
|
|
|
struct library_tab_item |
|
{ |
|
int type; ///< Type of the item |
|
const char *name; ///< Visible name |
|
const char *path; ///< MPD path |
|
}; |
|
|
|
static void |
|
library_tab_add (int type, const char *name, const char *path) |
|
{ |
|
strv_append_owned (&g_library_tab.items, |
|
xstrdup_printf ("%c%s%c%s", type, name, 0, path)); |
|
} |
|
|
|
static struct library_tab_item |
|
library_tab_resolve (const char *raw) |
|
{ |
|
struct library_tab_item item; |
|
item.type = *raw |