commit ec339eb0ff6bdb1d2feaa538bdeb12ea18eb540c Author: Přemysl Janouch Date: Wed Sep 28 22:44:59 2016 +0200 Initial commit This is mostly sdtui code ported over from GLib to liberty, with some MPD code from desktop-tools. It tracks the current song and that's it. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6954c64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build files +/build + +# Qt Creator files +/CMakeLists.txt.user* +/nncmpp.config +/nncmpp.files +/nncmpp.creator* +/nncmpp.includes diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4acc2dd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "termo"] + path = termo + url = git://github.com/pjanouch/termo.git +[submodule "liberty"] + path = liberty + url = git://github.com/pjanouch/liberty.git diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ce263ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ + Copyright (c) 2016, Přemysl Janouch + 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. + diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..e956c4e --- /dev/null +++ b/README.adoc @@ -0,0 +1,79 @@ +nncmpp +====== + +'nncmpp' is yet another MPD client. It does exactly what I want it to, more +specifically it's a simplified TUI version of Sonata so that I don't need to +run an ugly undeveloped Python application. + +If it's not obvious enough, the name a pun on all those ridiculous client names, +and should be pronounced as "nincompoop". + +Currently it's under development and doesn't work in any sense yet. + +Packages +-------- +Regular releases are sporadic. git master should be stable enough. You can get +a package with the latest development version from Archlinux's AUR, or from +openSUSE Build Service for the rest of mainstream distributions. Consult the +list of repositories and their respective links at: + +https://build.opensuse.org/project/repositories/home:pjanouch:git + +Building and Running +-------------------- +Build dependencies: CMake, pkg-config, liberty (included), termo (included) + +Runtime dependencies: ncursesw, libunistring + + $ git clone --recursive https://github.com/pjanouch/nncmpp.git + $ mkdir nncmpp/build + $ cd nncmpp/build + $ cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DCMAKE_BUILD_TYPE=Debug + $ make + +To install the application, you can do either the usual: + + # make install + +Or you can try telling CMake to make a package for you. For Debian it is: + + $ cpack -G DEB + # dpkg -i nncmpp-*.deb + +Note that for versions of CMake before 2.8.9, you need to prefix `cpack` with +`fakeroot` or file ownership will end up wrong. + +Having the program installed, create a configuration file and run it. + +Configuration +------------- +Create _~/.config/nncmpp/nncmpp.conf_ with contents like the following: + +.... +settings = { + address = "localhost" + password = "" + root = "~/Music" +} +colors = { + header = "reverse" + header_active = "underline" + even = "16 231" + odd = "16 255" +} +.... + +Contributing and Support +------------------------ +Use this project's GitHub to report any bugs, request features, or submit pull +requests. If you want to discuss this project, or maybe just hang out with +the developer, feel free to join me at irc://irc.janouch.name, channel #dev. + +License +------- +'nncmpp' is written by Přemysl Janouch . + +You may use the software under the terms of the ISC license, the text of which +is included within the package, or, at your option, you may relicense the work +under the MIT or the Modified BSD License, as listed at the following site: + +http://www.gnu.org/licenses/license-list.html diff --git a/cmake/FindNcursesw.cmake b/cmake/FindNcursesw.cmake new file mode 100644 index 0000000..88c1d01 --- /dev/null +++ b/cmake/FindNcursesw.cmake @@ -0,0 +1,17 @@ +# Public Domain + +find_package (PkgConfig REQUIRED) +pkg_check_modules (NCURSESW QUIET ncursesw) + +# OpenBSD doesn't provide a pkg-config file +set (required_vars NCURSESW_LIBRARIES) +if (NOT NCURSESW_FOUND) + find_library (NCURSESW_LIBRARIES NAMES ncursesw) + find_path (NCURSESW_INCLUDE_DIRS ncurses.h) + list (APPEND required_vars NCURSESW_INCLUDE_DIRS) +endif (NOT NCURSESW_FOUND) + +include (FindPackageHandleStandardArgs) +FIND_PACKAGE_HANDLE_STANDARD_ARGS (NCURSESW DEFAULT_MSG ${required_vars}) + +mark_as_advanced (NCURSESW_LIBRARIES NCURSESW_INCLUDE_DIRS) diff --git a/cmake/FindUnistring.cmake b/cmake/FindUnistring.cmake new file mode 100644 index 0000000..6b74efb --- /dev/null +++ b/cmake/FindUnistring.cmake @@ -0,0 +1,10 @@ +# Public Domain + +find_path (UNISTRING_INCLUDE_DIRS unistr.h) +find_library (UNISTRING_LIBRARIES NAMES unistring libunistring) + +include (FindPackageHandleStandardArgs) +FIND_PACKAGE_HANDLE_STANDARD_ARGS (UNISTRING DEFAULT_MSG + UNISTRING_INCLUDE_DIRS UNISTRING_LIBRARIES) + +mark_as_advanced (UNISTRING_LIBRARIES UNISTRING_INCLUDE_DIRS) diff --git a/config.h.in b/config.h.in new file mode 100644 index 0000000..b61ed66 --- /dev/null +++ b/config.h.in @@ -0,0 +1,10 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#define PROGRAM_NAME "${CMAKE_PROJECT_NAME}" +#define PROGRAM_VERSION "${project_VERSION}" + +#cmakedefine HAVE_RESIZETERM + +#endif // ! CONFIG_H + diff --git a/liberty b/liberty new file mode 160000 index 0000000..952cf98 --- /dev/null +++ b/liberty @@ -0,0 +1 @@ +Subproject commit 952cf985dca6a97ee662f3b189788089abd2ef57 diff --git a/mpd.c b/mpd.c new file mode 100644 index 0000000..f093691 --- /dev/null +++ b/mpd.c @@ -0,0 +1,645 @@ +// Copied from desktop-tools, should go to liberty if it proves useful + +// --- MPD client interface ---------------------------------------------------- + +// This is a rather thin MPD client interface intended for basic tasks + +#define MPD_SUBSYSTEM_TABLE(XX) \ + XX (DATABASE, 0, "database") \ + XX (UPDATE, 1, "update") \ + XX (STORED_PLAYLIST, 2, "stored_playlist") \ + XX (PLAYLIST, 3, "playlist") \ + XX (PLAYER, 4, "player") \ + XX (MIXER, 5, "mixer") \ + XX (OUTPUT, 6, "output") \ + XX (OPTIONS, 7, "options") \ + XX (STICKER, 8, "sticker") \ + XX (SUBSCRIPTION, 9, "subscription") \ + XX (MESSAGE, 10, "message") + +enum mpd_subsystem +{ +#define XX(a, b, c) MPD_SUBSYSTEM_ ## a = (1 << b), + MPD_SUBSYSTEM_TABLE (XX) +#undef XX + MPD_SUBSYSTEM_MAX +}; + +static const char *mpd_subsystem_names[] = +{ +#define XX(a, b, c) [b] = c, + MPD_SUBSYSTEM_TABLE (XX) +#undef XX +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +enum mpd_client_state +{ + MPD_DISCONNECTED, ///< Not connected + MPD_CONNECTING, ///< Currently connecting + MPD_CONNECTED ///< Connected +}; + +struct mpd_response +{ + bool success; ///< OK or ACK + + // ACK-only fields: + + int error; ///< Numeric error value (ack.h) + int list_offset; ///< Offset of command in list + char *current_command; ///< Name of the erroring command + char *message_text; ///< Error message +}; + +/// Task completion callback +typedef void (*mpd_client_task_cb) (const struct mpd_response *response, + const struct str_vector *data, void *user_data); + +struct mpd_client_task +{ + LIST_HEADER (struct mpd_client_task) + + mpd_client_task_cb callback; ///< Callback on completion + void *user_data; ///< User data +}; + +struct mpd_client +{ + struct poller *poller; ///< Poller + + // Connection: + + enum mpd_client_state state; ///< Connection state + struct connector *connector; ///< Connection establisher + + int socket; ///< MPD socket + struct str read_buffer; ///< Input yet to be processed + struct str write_buffer; ///< Outut yet to be be sent out + struct poller_fd socket_event; ///< We can read from the socket + + struct poller_timer timeout_timer; ///< Connection seems to be dead + + // Protocol: + + bool got_hello; ///< Got the OK MPD hello message + + bool idling; ///< Sent idle as the last command + unsigned idling_subsystems; ///< Subsystems we're idling for + bool in_list; ///< We're inside a command list + + struct mpd_client_task *tasks; ///< Task queue + struct mpd_client_task *tasks_tail; ///< Tail of task queue + struct str_vector data; ///< Data from last command + + // User configuration: + + void *user_data; ///< User data for callbacks + + /// Callback after connection has been successfully established + void (*on_connected) (void *user_data); + + /// Callback for general failures or even normal disconnection; + /// the interface is reinitialized + void (*on_failure) (void *user_data); + + /// Callback to receive "idle" updates. + /// Remember to restart the idle if needed. + void (*on_event) (unsigned subsystems, void *user_data); +}; + +static void mpd_client_reset (struct mpd_client *self); +static void mpd_client_destroy_connector (struct mpd_client *self); + +static void +mpd_client_init (struct mpd_client *self, struct poller *poller) +{ + memset (self, 0, sizeof *self); + + self->poller = poller; + self->socket = -1; + + str_init (&self->read_buffer); + str_init (&self->write_buffer); + + str_vector_init (&self->data); + + poller_fd_init (&self->socket_event, poller, -1); + poller_timer_init (&self->timeout_timer, poller); +} + +static void +mpd_client_free (struct mpd_client *self) +{ + // So that we don't have to repeat most of the stuff + mpd_client_reset (self); + + str_free (&self->read_buffer); + str_free (&self->write_buffer); + + str_vector_free (&self->data); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +/// Reinitialize the interface so that you can reconnect anew +static void +mpd_client_reset (struct mpd_client *self) +{ + if (self->state == MPD_CONNECTING) + mpd_client_destroy_connector (self); + + if (self->socket != -1) + xclose (self->socket); + self->socket = -1; + + self->socket_event.closed = true; + poller_fd_reset (&self->socket_event); + poller_timer_reset (&self->timeout_timer); + + str_reset (&self->read_buffer); + str_reset (&self->write_buffer); + + str_vector_reset (&self->data); + + self->got_hello = false; + self->idling = false; + self->idling_subsystems = 0; + self->in_list = false; + + LIST_FOR_EACH (struct mpd_client_task, iter, self->tasks) + free (iter); + self->tasks = self->tasks_tail = NULL; + + self->state = MPD_DISCONNECTED; +} + +static void +mpd_client_fail (struct mpd_client *self) +{ + mpd_client_reset (self); + if (self->on_failure) + self->on_failure (self->user_data); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +mpd_client_parse_response (const char *p, struct mpd_response *response) +{ + if (!strcmp (p, "OK")) + return response->success = true; + if (!strcmp (p, "list_OK")) + // TODO: either implement this or fail the connection properly + hard_assert (!"command_list_ok_begin not implemented"); + + char *end = NULL; + if (*p++ != 'A' || *p++ != 'C' || *p++ != 'K' || *p++ != ' ' || *p++ != '[') + return false; + + errno = 0; + response->error = strtoul (p, &end, 10); + if (errno != 0 || end == p) + return false; + p = end; + if (*p++ != '@') + return false; + + errno = 0; + response->list_offset = strtoul (p, &end, 10); + if (errno != 0 || end == p) + return false; + p = end; + if (*p++ != ']' || *p++ != ' ' || *p++ != '{' || !(end = strchr (p, '}'))) + return false; + + response->current_command = xstrndup (p, end - p); + p = end + 1; + + if (*p++ != ' ') + return false; + + response->message_text = xstrdup (p); + response->success = false; + return true; +} + +static void +mpd_client_dispatch (struct mpd_client *self, struct mpd_response *response) +{ + struct mpd_client_task *task; + if (!(task = self->tasks)) + return; + + if (task->callback) + task->callback (response, &self->data, task->user_data); + str_vector_reset (&self->data); + + LIST_UNLINK_WITH_TAIL (self->tasks, self->tasks_tail, task); + free (task); +} + +static bool +mpd_client_parse_hello (struct mpd_client *self, const char *line) +{ + const char hello[] = "OK MPD "; + if (strncmp (line, hello, sizeof hello - 1)) + { + print_debug ("invalid MPD hello message"); + return false; + } + + // TODO: call "on_connected" now. We should however also set up a timer + // so that we don't wait on this message forever. + return self->got_hello = true; +} + +static bool +mpd_client_parse_line (struct mpd_client *self, const char *line) +{ + print_debug ("MPD >> %s", line); + + if (!self->got_hello) + return mpd_client_parse_hello (self, line); + + struct mpd_response response; + memset (&response, 0, sizeof response); + if (mpd_client_parse_response (line, &response)) + { + mpd_client_dispatch (self, &response); + free (response.current_command); + free (response.message_text); + } + else + str_vector_add (&self->data, line); + return true; +} + +/// All output from MPD commands seems to be in a trivial "key: value" format +static char * +mpd_client_parse_kv (char *line, char **value) +{ + char *sep; + if (!(sep = strstr (line, ": "))) + return NULL; + + *sep = 0; + *value = sep + 2; + return line; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +mpd_client_update_poller (struct mpd_client *self) +{ + poller_fd_set (&self->socket_event, + self->write_buffer.len ? (POLLIN | POLLOUT) : POLLIN); +} + +static bool +mpd_client_process_input (struct mpd_client *self) +{ + // Split socket input at newlines and process them separately + struct str *rb = &self->read_buffer; + char *start = rb->str, *end = start + rb->len; + for (char *p = start; p < end; p++) + { + if (*p != '\n') + continue; + + *p = 0; + if (!mpd_client_parse_line (self, start)) + return false; + start = p + 1; + } + + str_remove_slice (rb, 0, start - rb->str); + return true; +} + +static void +mpd_client_on_ready (const struct pollfd *pfd, void *user_data) +{ + (void) pfd; + + struct mpd_client *self = user_data; + if (socket_io_try_read (self->socket, &self->read_buffer) != SOCKET_IO_OK + || !mpd_client_process_input (self) + || socket_io_try_write (self->socket, &self->write_buffer) != SOCKET_IO_OK) + mpd_client_fail (self); + else + mpd_client_update_poller (self); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +mpd_client_must_quote_char (char c) +{ + return (unsigned char) c <= ' ' || c == '"' || c == '\''; +} + +static bool +mpd_client_must_quote (const char *s) +{ + if (!*s) + return true; + for (; *s; s++) + if (mpd_client_must_quote_char (*s)) + return true; + return false; +} + +static void +mpd_client_quote (const char *s, struct str *output) +{ + str_append_c (output, '"'); + for (; *s; s++) + { + if (mpd_client_must_quote_char (*s)) + str_append_c (output, '\\'); + str_append_c (output, *s); + } + str_append_c (output, '"'); +} + +/// Beware that delivery of the event isn't deferred and you musn't make +/// changes to the interface while processing the event! +static void +mpd_client_add_task + (struct mpd_client *self, mpd_client_task_cb cb, void *user_data) +{ + // This only has meaning with command_list_ok_begin, and then it requires + // special handling (all in-list tasks need to be specially marked and + // later flushed if an early ACK or OK arrives). + hard_assert (!self->in_list); + + struct mpd_client_task *task = xcalloc (1, sizeof *self); + task->callback = cb; + task->user_data = user_data; + LIST_APPEND_WITH_TAIL (self->tasks, self->tasks_tail, task); +} + +/// Send a command. Remember to call mpd_client_add_task() to handle responses, +/// unless the command is being sent in a list. +static void mpd_client_send_command + (struct mpd_client *self, const char *command, ...) ATTRIBUTE_SENTINEL; + +static void +mpd_client_send_commandv (struct mpd_client *self, char **commands) +{ + // Automatically interrupt idle mode + if (self->idling) + { + poller_timer_reset (&self->timeout_timer); + + self->idling = false; + self->idling_subsystems = 0; + mpd_client_send_command (self, "noidle", NULL); + } + + struct str line; + str_init (&line); + + for (; *commands; commands++) + { + if (line.len) + str_append_c (&line, ' '); + + if (mpd_client_must_quote (*commands)) + mpd_client_quote (*commands, &line); + else + str_append (&line, *commands); + } + + print_debug ("MPD << %s", line.str); + str_append_c (&line, '\n'); + str_append_str (&self->write_buffer, &line); + str_free (&line); + + mpd_client_update_poller (self); +} + +static void +mpd_client_send_command (struct mpd_client *self, const char *command, ...) +{ + struct str_vector v; + str_vector_init (&v); + + va_list ap; + va_start (ap, command); + for (; command; command = va_arg (ap, const char *)) + str_vector_add (&v, command); + va_end (ap); + + mpd_client_send_commandv (self, v.vector); + str_vector_free (&v); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +mpd_client_list_begin (struct mpd_client *self) +{ + hard_assert (!self->in_list); + mpd_client_send_command (self, "command_list_begin", NULL); + self->in_list = true; +} + +/// End a list of commands. Remember to call mpd_client_add_task() +/// to handle the summary response. +static void +mpd_client_list_end (struct mpd_client *self) +{ + hard_assert (self->in_list); + mpd_client_send_command (self, "command_list_end", NULL); + self->in_list = false; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static bool +mpd_resolve_subsystem (const char *name, unsigned *output) +{ + for (size_t i = 0; i < N_ELEMENTS (mpd_subsystem_names); i++) + if (!strcasecmp_ascii (name, mpd_subsystem_names[i])) + { + *output |= 1 << i; + return true; + } + return false; +} + +static void +mpd_client_on_idle_return (const struct mpd_response *response, + const struct str_vector *data, void *user_data) +{ + (void) response; + + struct mpd_client *self = user_data; + unsigned subsystems = 0; + for (size_t i = 0; i < data->len; i++) + { + char *value, *key; + if (!(key = mpd_client_parse_kv (data->vector[i], &value))) + print_debug ("%s: %s", "erroneous MPD output", data->vector[i]); + else if (strcasecmp_ascii (key, "changed")) + print_debug ("%s: %s", "unexpected idle key", key); + else if (!mpd_resolve_subsystem (value, &subsystems)) + print_debug ("%s: %s", "unknown subsystem", value); + } + + // Not resetting "idling" here, we may send an extra "noidle" no problem + if (self->on_event && subsystems) + self->on_event (subsystems, self->user_data); +} + +static void mpd_client_idle (struct mpd_client *self, unsigned subsystems); + +static void +mpd_client_on_timeout (void *user_data) +{ + struct mpd_client *self = user_data; + unsigned subsystems = self->idling_subsystems; + + // Just sending this out should bring a dead connection down over TCP + // TODO: set another timer to make sure the ping reply arrives + mpd_client_send_command (self, "ping", NULL); + mpd_client_add_task (self, NULL, NULL); + + // Restore the incriminating idle immediately + mpd_client_idle (self, subsystems); +} + +/// When not expecting to send any further commands, you should call this +/// in order to keep the connection alive. Or to receive updates. +static void +mpd_client_idle (struct mpd_client *self, unsigned subsystems) +{ + hard_assert (!self->in_list); + + struct str_vector v; + str_vector_init (&v); + + str_vector_add (&v, "idle"); + for (size_t i = 0; i < N_ELEMENTS (mpd_subsystem_names); i++) + if (subsystems & (1 << i)) + str_vector_add (&v, mpd_subsystem_names[i]); + + mpd_client_send_commandv (self, v.vector); + str_vector_free (&v); + + self->timeout_timer.dispatcher = mpd_client_on_timeout; + self->timeout_timer.user_data = self; + poller_timer_set (&self->timeout_timer, 5 * 60 * 1000); + + mpd_client_add_task (self, mpd_client_on_idle_return, self); + self->idling = true; + self->idling_subsystems = subsystems; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +mpd_client_finish_connection (struct mpd_client *self, int socket) +{ + set_blocking (socket, false); + self->socket = socket; + self->state = MPD_CONNECTED; + + poller_fd_init (&self->socket_event, self->poller, self->socket); + self->socket_event.dispatcher = mpd_client_on_ready; + self->socket_event.user_data = self; + + mpd_client_update_poller (self); + + if (self->on_connected) + self->on_connected (self->user_data); +} + +static void +mpd_client_destroy_connector (struct mpd_client *self) +{ + if (self->connector) + connector_free (self->connector); + free (self->connector); + self->connector = NULL; + + // Not connecting anymore + self->state = MPD_DISCONNECTED; +} + +static void +mpd_client_on_connector_failure (void *user_data) +{ + struct mpd_client *self = user_data; + mpd_client_destroy_connector (self); + mpd_client_fail (self); +} + +static void +mpd_client_on_connector_connected + (void *user_data, int socket, const char *host) +{ + (void) host; + + struct mpd_client *self = user_data; + mpd_client_destroy_connector (self); + mpd_client_finish_connection (self, socket); +} + +static bool +mpd_client_connect_unix (struct mpd_client *self, const char *address, + struct error **e) +{ + int fd = socket (AF_UNIX, SOCK_STREAM, 0); + if (fd == -1) + { + error_set (e, "%s: %s", "socket", strerror (errno)); + return false; + } + + // Expand tilde if needed + char *expanded = resolve_filename (address, xstrdup); + + struct sockaddr_un sun; + sun.sun_family = AF_UNIX; + strncpy (sun.sun_path, expanded, sizeof sun.sun_path); + sun.sun_path[sizeof sun.sun_path - 1] = 0; + + free (expanded); + + if (connect (fd, (struct sockaddr *) &sun, sizeof sun)) + { + error_set (e, "%s: %s", "connect", strerror (errno)); + return false; + } + + mpd_client_finish_connection (self, fd); + return true; +} + +static bool +mpd_client_connect (struct mpd_client *self, const char *address, + const char *service, struct error **e) +{ + hard_assert (self->state == MPD_DISCONNECTED); + + // If it looks like a path, assume it's a UNIX socket + if (strchr (address, '/')) + return mpd_client_connect_unix (self, address, e); + + struct connector *connector = xmalloc (sizeof *connector); + connector_init (connector, self->poller); + self->connector = connector; + + connector->user_data = self; + connector->on_connected = mpd_client_on_connector_connected; + connector->on_failure = mpd_client_on_connector_failure; + + connector_add_target (connector, address, service); + self->state = MPD_CONNECTING; + return true; +} 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 + * 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 +#include "mpd.c" + +#include +#include +#ifndef TIOCGWINSZ +#include +#endif // ! TIOCGWINSZ +#include + +// 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 +#include +#include + +#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; +} + diff --git a/termo b/termo new file mode 160000 index 0000000..4282f37 --- /dev/null +++ b/termo @@ -0,0 +1 @@ +Subproject commit 4282f3715c7d4307f57c27edf66874762bdee858