diff options
Diffstat (limited to 'nncmpp.c')
-rw-r--r-- | nncmpp.c | 3484 |
1 files changed, 2539 insertions, 945 deletions
@@ -1,7 +1,7 @@ /* * nncmpp -- the MPD client you never knew you needed * - * Copyright (c) 2016 - 2021, Přemysl Eric Janouch <p@janouch.name> + * Copyright (c) 2016 - 2023, 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. @@ -28,7 +28,7 @@ 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 ) \ + XX( TAB_ACTIVE, tab_active, -1, -1, A_BOLD ) \ /* Listview */ \ XX( HEADER, header, -1, -1, A_UNDERLINE ) \ XX( EVEN, even, -1, -1, 0 ) \ @@ -70,69 +70,54 @@ enum #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" +#ifdef WITH_X11 +#define LIBERTY_XUI_WANT_X11 +#endif // WITH_X11 +#include "liberty/liberty-xui.c" -#include <math.h> +#include <dirent.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. -// -// 2021 update: ncurses is mostly reliable now, though rxvt-unicode only -// supports the 1006 mode that ncurses also supports mode starting with 9.25. - -#include "termo.h" +#include <math.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> // The spectrum analyser requires a DFT transform. The FFTW library is fairly // efficient, and doesn't have a requirement on the number of bins. - #ifdef WITH_FFTW #include <fftw3.h> #endif // WITH_FFTW +// Remote MPD control needs appropriate volume controls. +#ifdef WITH_PULSE +#include "liberty/liberty-pulse.c" +#include <pulse/context.h> +#include <pulse/error.h> +#include <pulse/introspect.h> +#include <pulse/subscribe.h> +#include <pulse/sample.h> +#endif // WITH_PULSE + #define APP_TITLE PROGRAM_NAME ///< Left top corner +#include "nncmpp-actions.h" + // --- Utilities --------------------------------------------------------------- -// The standard endwin/refresh sequence makes the terminal flicker static void -update_curses_terminal_size (void) +shell_quote (const char *str, struct str *output) { -#if defined (HAVE_RESIZETERM) && defined (TIOCGWINSZ) - struct winsize size; - if (!ioctl (STDOUT_FILENO, TIOCGWINSZ, (char *) &size)) + // See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes + str_append_c (output, '"'); + for (const char *p = str; *p; p++) { - 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); + if (strchr ("`$\"\\", *p)) + str_append_c (output, '\\'); + str_append_c (output, *p); } -#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; + str_append_c (output, '"'); } static bool @@ -149,6 +134,8 @@ xbasename (const char *path) return last_slash ? last_slash + 1 : path; } +static char *xstrdup0 (const char *s) { return s ? xstrdup (s) : NULL; } + static char * latin1_to_utf8 (const char *latin1) { @@ -168,6 +155,18 @@ latin1_to_utf8 (const char *latin1) } static void +str_enforce_utf8 (struct str *self) +{ + if (!utf8_validate (self->str, self->len)) + { + char *sanitized = latin1_to_utf8 (self->str); + str_reset (self); + str_append (self, sanitized); + free (sanitized); + } +} + +static void cstr_uncapitalize (char *s) { if (isupper (s[0]) && islower (s[1])) @@ -275,6 +274,10 @@ struct poller_curl struct poller_timer timer; ///< cURL timer CURLM *multi; ///< cURL multi interface handle struct poller_curl_fd *fds; ///< List of all FDs + + // TODO: also make sure to dispose of them at the end of the program + + int registered; ///< Number of attached easy handles }; static void @@ -317,6 +320,8 @@ poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what, struct poller_curl_fd *fd; if (!(fd = socket_data)) { + set_cloexec (s); + fd = xmalloc (sizeof *fd); LIST_PREPEND (self->fds, fd); @@ -383,6 +388,7 @@ poller_curl_init (struct poller_curl *self, struct poller *poller, || (mres = curl_multi_setopt (self->multi, CURLMOPT_TIMERDATA, self))) { curl_multi_cleanup (self->multi); + self->multi = NULL; return error_set (e, "%s: %s", "cURL setup failed", curl_multi_strerror (mres)); } @@ -443,6 +449,7 @@ poller_curl_add (struct poller_curl *self, CURL *easy, struct error **e) // "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)); + self->registered++; return true; } @@ -452,6 +459,7 @@ 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)); + self->registered--; return true; } @@ -586,7 +594,8 @@ struct spectrum int samples; ///< Number of windows to average float accumulator_scale; ///< Scaling factor for accum. values int *top_bins; ///< Top DFT bin index for each bar - char *spectrum; ///< String buffer for the "render" + char *rendered; ///< String buffer for the "render" + float *spectrum; ///< The "render" as normalized floats void *buffer; ///< Input buffer size_t buffer_len; ///< Input buffer fill level @@ -708,7 +717,7 @@ spectrum_sample (struct spectrum *s) } int last_bin = 0; - char *p = s->spectrum; + char *p = s->rendered; for (int bar = 0; bar < s->bars; bar++) { int top_bin = s->top_bins[bar]; @@ -726,14 +735,19 @@ spectrum_sample (struct spectrum *s) db = 0; // Assuming decibels are always negative (i.e., properly normalized). - // The division defines the cutoff: 9 * 7 = 63 dB of range. + // The division defines the cutoff: 8 * 7 = 56 dB of range. int height = N_ELEMENTS (spectrum_bars) - 1 + (int) (db / 7); p += strlen (strcpy (p, spectrum_bars[MAX (height, 0)])); + + // Even with slightly the higher display resolutions provided by X11, + // 60 dB roughly covers the useful range. + s->spectrum[bar] = MAX (0, 1 + db / 60); } } static bool -spectrum_init (struct spectrum *s, char *format, int bars, struct error **e) +spectrum_init (struct spectrum *s, char *format, int bars, int fps, + struct error **e) { errno = 0; @@ -793,13 +807,15 @@ spectrum_init (struct spectrum *s, char *format, int bars, struct error **e) // having the amount of bins be strictly a factor of Nyquist / 20 (stemming // from the equation 20 = Nyquist / bins). Since log2(44100 / 2 / 20) > 10, // it would be fairly expensive, and somewhat slowly updating. Always. - // (Note that you can increase window overlap to get smoother framerates.) + // (Note that you can increase window overlap to get smoother framerates, + // but it would remain laggy.) double audible_ratio = s->sampling_rate / 2. / 20000; s->bins = ceil (necessary_bins * MAX (audible_ratio, 1)); s->useful_bins = s->bins / 2; int used_bins = necessary_bins / 2; - s->spectrum = xcalloc (sizeof *s->spectrum, s->bars * 3 + 1); + s->rendered = xcalloc (sizeof *s->rendered, s->bars * 3 + 1); + s->spectrum = xcalloc (sizeof *s->spectrum, s->bars); s->top_bins = xcalloc (sizeof *s->top_bins, s->bars); for (int bar = 0; bar < s->bars; bar++) { @@ -807,8 +823,7 @@ spectrum_init (struct spectrum *s, char *format, int bars, struct error **e) s->top_bins[bar] = MIN (top_bin, used_bins); } - // Limit updates to 30 times per second to limit CPU load - s->samples = s->sampling_rate / s->bins * 2 / 30; + s->samples = s->sampling_rate / s->bins * 2 / MAX (fps, 1); if (s->samples < 1) s->samples = 1; @@ -851,7 +866,12 @@ spectrum_free (struct spectrum *s) fftw_free (s->windowed); free (s->data); free (s->window); +#if 0 + // We don't particularly want to discard wisdom. + fftwf_cleanup (); +#endif + free (s->rendered); free (s->spectrum); free (s->top_bins); free (s->buffer); @@ -861,45 +881,318 @@ spectrum_free (struct spectrum *s) #endif // WITH_FFTW +// --- PulseAudio -------------------------------------------------------------- + +#ifdef WITH_PULSE + +struct pulse +{ + struct poller_timer make_context; ///< Event to establish connection + pa_mainloop_api *api; ///< PulseAudio event loop proxy + pa_context *context; ///< PulseAudio connection context + uint32_t sink_candidate; ///< Used while searching for MPD + uint32_t sink; ///< The relevant sink or -1 + pa_cvolume sink_volume; ///< Current volume + bool sink_muted; ///< Currently muted? + + void (*on_update) (void); ///< Update callback +}; + +static void +pulse_on_sink_info (pa_context *context, const pa_sink_info *info, int eol, + void *userdata) +{ + (void) context; + (void) eol; + + struct pulse *self = userdata; + if (info) + { + self->sink_volume = info->volume; + self->sink_muted = !!info->mute; + self->on_update (); + } +} + +static void +pulse_update_from_sink (struct pulse *self) +{ + if (self->sink == PA_INVALID_INDEX) + return; + + pa_operation_unref (pa_context_get_sink_info_by_index + (self->context, self->sink, pulse_on_sink_info, self)); +} + +static void +pulse_on_sink_input_info (pa_context *context, + const struct pa_sink_input_info *info, int eol, void *userdata) +{ + (void) context; + (void) eol; + + struct pulse *self = userdata; + if (!info) + { + if ((self->sink = self->sink_candidate) != PA_INVALID_INDEX) + pulse_update_from_sink (self); + else + self->on_update (); + return; + } + + // TODO: also save info->mute as a different mute level, + // and perhaps info->index (they can appear and disappear) + const char *name = + pa_proplist_gets (info->proplist, PA_PROP_APPLICATION_NAME); + if (name && !strcmp (name, "Music Player Daemon")) + self->sink_candidate = info->sink; +} + +static void +pulse_read_sink_inputs (struct pulse *self) +{ + self->sink_candidate = PA_INVALID_INDEX; + pa_operation_unref (pa_context_get_sink_input_info_list + (self->context, pulse_on_sink_input_info, self)); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +pulse_on_event (pa_context *context, pa_subscription_event_type_t event, + uint32_t index, void *userdata) +{ + (void) context; + + struct pulse *self = userdata; + switch (event & PA_SUBSCRIPTION_EVENT_FACILITY_MASK) + { + case PA_SUBSCRIPTION_EVENT_SINK_INPUT: + pulse_read_sink_inputs (self); + break; + case PA_SUBSCRIPTION_EVENT_SINK: + if (index == self->sink) + pulse_update_from_sink (self); + } +} + +static void +pulse_on_subscribe_finish (pa_context *context, int success, void *userdata) +{ + (void) context; + + struct pulse *self = userdata; + if (success) + pulse_read_sink_inputs (self); + else + { + print_debug ("PulseAudio failed to subscribe for events"); + self->on_update (); + pa_context_disconnect (context); + } +} + +static void +pulse_on_context_state_change (pa_context *context, void *userdata) +{ + struct pulse *self = userdata; + switch (pa_context_get_state (context)) + { + case PA_CONTEXT_FAILED: + case PA_CONTEXT_TERMINATED: + print_debug ("PulseAudio context failed or has been terminated"); + + pa_context_unref (context); + self->context = NULL; + self->sink = PA_INVALID_INDEX; + self->on_update (); + + // Retry after an arbitrary delay of 5 seconds + poller_timer_set (&self->make_context, 5000); + break; + case PA_CONTEXT_READY: + pa_context_set_subscribe_callback (context, pulse_on_event, userdata); + pa_operation_unref (pa_context_subscribe (context, + PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SINK_INPUT, + pulse_on_subscribe_finish, userdata)); + default: + break; + } +} + +static void +pulse_make_context (void *user_data) +{ + struct pulse *self = user_data; + self->context = pa_context_new (self->api, PROGRAM_NAME); + pa_context_set_state_callback (self->context, + pulse_on_context_state_change, self); + pa_context_connect (self->context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL); +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +pulse_on_finish (pa_context *context, int success, void *userdata) +{ + (void) context; + (void) success; + (void) userdata; + + // Just like... whatever, man +} + +static bool +pulse_volume_mute (struct pulse *self) +{ + if (!self->context || self->sink == PA_INVALID_INDEX) + return false; + + pa_operation_unref (pa_context_set_sink_mute_by_index (self->context, + self->sink, !self->sink_muted, pulse_on_finish, self)); + return true; +} + +static bool +pulse_volume_set (struct pulse *self, int arg) +{ + if (!self->context || self->sink == PA_INVALID_INDEX) + return false; + + pa_cvolume volume = self->sink_volume; + if (arg > 0) + pa_cvolume_inc (&volume, (pa_volume_t) arg * PA_VOLUME_NORM / 100); + else + pa_cvolume_dec (&volume, (pa_volume_t) -arg * PA_VOLUME_NORM / 100); + pa_operation_unref (pa_context_set_sink_volume_by_index (self->context, + self->sink, &volume, pulse_on_finish, self)); + return true; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +pulse_init (struct pulse *self, struct poller *poller) +{ + memset (self, 0, sizeof *self); + self->sink = PA_INVALID_INDEX; + if (!poller) + return; + + self->api = poller_pa_new (poller); + + self->make_context = poller_timer_make (poller); + self->make_context.dispatcher = pulse_make_context; + self->make_context.user_data = self; + poller_timer_set (&self->make_context, 0); +} + +static void +pulse_free (struct pulse *self) +{ + if (self->context) + pa_context_unref (self->context); + if (self->api) + { + poller_pa_destroy (self->api); + poller_timer_reset (&self->make_context); + } + + pulse_init (self, NULL); +} + +#define VOLUME_PERCENT(x) (((x) * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM) + +static bool +pulse_volume_status (struct pulse *self, struct str *s) +{ + if (!self->context || self->sink == PA_INVALID_INDEX + || !self->sink_volume.channels) + return false; + + if (self->sink_muted) + { + str_append (s, "Muted"); + return true; + } + + str_append_printf (s, + "%u%%", VOLUME_PERCENT (self->sink_volume.values[0])); + if (!pa_cvolume_channels_equal_to (&self->sink_volume, + self->sink_volume.values[0])) + { + for (size_t i = 1; i < self->sink_volume.channels; i++) + str_append_printf (s, " / %u%%", + VOLUME_PERCENT (self->sink_volume.values[i])); + } + return true; +} + +#endif // WITH_PULSE + // --- Application ------------------------------------------------------------- -// Function names are prefixed mostly because of curses which clutters the +// 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. +// since we use a custom toolkit, so code would get bloated rather fast-- +// especially given our TUI/GUI duality. // // 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. +// Widget identification, mostly for mouse events. +enum +{ + WIDGET_NONE = 0, WIDGET_BUTTON, WIDGET_GAUGE, WIDGET_VOLUME, + WIDGET_TAB, WIDGET_SPECTRUM, WIDGET_LIST, WIDGET_SCROLLBAR, WIDGET_MESSAGE, +}; + +struct layout +{ + struct widget *head; + struct widget *tail; +}; + +struct app_ui +{ + struct widget *(*padding) (chtype attrs, float width, float height); + struct widget *(*label) (chtype attrs, const char *label); + struct widget *(*button) (chtype attrs, const char *label, enum action a); + struct widget *(*gauge) (chtype attrs); + struct widget *(*spectrum) (chtype attrs, int width); + struct widget *(*scrollbar) (chtype attrs); + struct widget *(*list) (void); + struct widget *(*editor) (chtype attrs); + + bool have_icons; +}; + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + 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); +/// Return a line of widgets for the row +typedef struct layout (*tab_item_layout_fn) (size_t item_index); 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 + tab_item_layout_fn on_item_layout; ///< Item layout callback // Provided by tab owner: @@ -926,6 +1219,7 @@ static struct app_context // Event loop: struct poller poller; ///< Poller + struct poller_curl poller_curl; ///< cURL abstractor bool quitting; ///< Quit signal for the event loop bool polling; ///< The event loop is running @@ -934,6 +1228,7 @@ static struct app_context struct poller_timer message_timer; ///< Message timeout char *message; ///< Message to show in the statusbar + char *message_detail; ///< Non-emphasized part // Connection: @@ -947,7 +1242,6 @@ static struct app_context int64_t elapsed_since; ///< Last tick ts or last elapsed time bool elapsed_poll; ///< Poll MPD for the elapsed time? - // TODO: initialize these to -1 int song; ///< Current song index int song_elapsed; ///< Song elapsed in seconds int song_duration; ///< Song duration in seconds @@ -961,38 +1255,34 @@ static struct app_context struct config config; ///< Program configuration struct strv streams; ///< List of "name NUL URI NUL" + struct strv enqueue; ///< Items to enqueue once connected 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 + // User interface: - 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 app_ui *ui; ///< User interface interface + int ui_dragging; ///< ID of any dragged widget #ifdef WITH_FFTW struct spectrum spectrum; ///< Spectrum analyser int spectrum_fd; ///< FIFO file descriptor (non-blocking) - int spectrum_column, spectrum_row; ///< Position for fast refresh struct poller_fd spectrum_event; ///< FIFO watcher #endif // WITH_FFTW +#ifdef WITH_PULSE + struct pulse pulse; ///< PulseAudio control +#endif // WITH_PULSE + bool pulse_control_requested; ///< PulseAudio control desired by user + 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 - bool focused; ///< Whether the terminal has focus struct attrs attrs[ATTRIBUTE_COUNT]; } @@ -1010,9 +1300,6 @@ tab_init (struct tab *self, const char *name) // 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; } @@ -1043,6 +1330,13 @@ on_poll_elapsed_time_changed (struct config_item *item) g.elapsed_poll = item->value.boolean; } +static void +on_pulseaudio_changed (struct config_item *item) +{ + // This is only set once, on application startup + g.pulse_control_requested = item->value.boolean; +} + static struct config_schema g_config_settings[] = { { .name = "address", @@ -1054,6 +1348,7 @@ static struct config_schema g_config_settings[] = .type = CONFIG_ITEM_STRING }, // NOTE: this is unused--in theory we could allow manual metadata adjustment + // NOTE: the "config" command may return "music_directory" for local clients { .name = "root", .comment = "Where all the files MPD is playing are located", .type = CONFIG_ITEM_STRING }, @@ -1072,8 +1367,27 @@ static struct config_schema g_config_settings[] = .comment = "Number of computed audio spectrum bars", .type = CONFIG_ITEM_INTEGER, .default_ = "8" }, + { .name = "spectrum_fps", + .comment = "Maximum frames per second, affects CPU usage", + .type = CONFIG_ITEM_INTEGER, + .default_ = "30" }, #endif // WITH_FFTW +#ifdef WITH_PULSE + { .name = "pulseaudio", + .comment = "Look up MPD in PulseAudio for improved volume controls", + .type = CONFIG_ITEM_BOOLEAN, + .on_change = on_pulseaudio_changed, + .default_ = "off" }, +#endif // WITH_PULSE + +#ifdef WITH_X11 + { .name = "x11_font", + .comment = "Fontconfig name/pattern for the X11 font to use", + .type = CONFIG_ITEM_STRING, + .default_ = "`sans\\-serif-11`" }, +#endif // WITH_X11 + // Disabling this minimises MPD traffic and has the following caveats: // - when MPD stalls on retrieving audio data, we keep ticking // - when the "play" succeeds in ACTION_MPD_REPLACE for the same item as @@ -1216,74 +1530,46 @@ app_init_attributes (void) #undef XX } +static bool +app_on_insufficient_color (void) +{ + app_init_attributes (); + return true; +} + static void app_init_context (void) { poller_init (&g.poller); + hard_assert (poller_curl_init (&g.poller_curl, &g.poller, NULL)); g.client = mpd_client_make (&g.poller); + g.song_elapsed = g.song_duration = g.volume = g.song = -1; + g.playlist = item_list_make (); g.config = config_make (); g.streams = strv_make (); - g.playlist = item_list_make (); + g.enqueue = strv_make (); g.playback_info = str_map_make (free); g.playback_info.key_xfrm = tolower_ascii_strxfrm; #ifdef WITH_FFTW g.spectrum_fd = -1; - g.spectrum_row = g.spectrum_column = -1; #endif // WITH_FFTW - // 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; - - // Presumably, although not necessarily; unsure if queryable at all - g.focused = true; +#ifdef WITH_PULSE + pulse_init (&g.pulse, NULL); +#endif // WITH_PULSE 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); + strv_free (&g.enqueue); item_list_free (&g.playlist); #ifdef WITH_FFTW @@ -1295,14 +1581,17 @@ app_free_context (void) } #endif // WITH_FFTW +#ifdef WITH_PULSE + pulse_free (&g.pulse); +#endif // WITH_PULSE + line_editor_free (&g.editor); config_free (&g.config); + poller_curl_free (&g.poller_curl); poller_free (&g.poller); free (g.message); - - if (g.tk) - termo_destroy (g.tk); + free (g.message_detail); } static void @@ -1315,92 +1604,179 @@ app_quit (void) g.polling = false; } -static bool -app_is_character_in_locale (ucs4_t ch) +// --- Layouting --------------------------------------------------------------- + +static void +app_append_layout (struct layout *l, struct layout *dest) { - // Avoid the overhead joined with calling iconv() for all characters. - if (g.locale_is_utf8) - return true; + struct widget *last = dest->tail; + if (!last) + *dest = *l; + else if (l->head) + { + // Assuming there is no unclaimed vertical space. + LIST_FOR_EACH (struct widget, w, l->head) + widget_move (w, 0, last->y + last->height); - // 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; -} + last->next = l->head; + l->head->prev = last; + dest->tail = l->tail; + } -// --- Rendering --------------------------------------------------------------- + *l = (struct layout) {}; +} +/// Replaces negative widths amongst widgets in the layout by redistributing +/// any width remaining after all positive claims are satisfied from "width". +/// Also unifies heights to the maximum value of the run. +/// Then the widths are taken as final, and used to initialize X coordinates. static void -app_invalidate (void) +app_flush_layout_full (struct layout *l, int width, struct layout *dest) { - poller_idle_set (&g.refresh_event); + hard_assert (l != NULL && l->head != NULL); + + int parts = 0, max_height = 0; + LIST_FOR_EACH (struct widget, w, l->head) + { + max_height = MAX (max_height, w->height); + if (w->width < 0) + parts -= w->width; + else + width -= w->width; + } + + int remaining = MAX (width, 0), part_width = parts ? remaining / parts : 0; + struct widget *last = NULL; + LIST_FOR_EACH (struct widget, w, l->head) + { + w->height = max_height; + if (w->width < 0) + { + remaining -= (w->width *= -part_width); + last = w; + } + } + if (last) + last->width += remaining; + + int x = 0; + LIST_FOR_EACH (struct widget, w, l->head) + { + widget_move (w, x - w->x, 0); + x += w->width; + } + + app_append_layout (l, dest); } static void -app_flush_buffer (struct row_buffer *buf, int width, chtype attrs) +app_flush_layout (struct layout *l, struct layout *out) { - row_buffer_align (buf, width, attrs); - row_buffer_flush (buf); - row_buffer_free (buf); + app_flush_layout_full (l, g_xui.width, out); } -/// 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) +static struct widget * +app_push (struct layout *l, struct widget *w) { - struct row_buffer buf = row_buffer_make (); - row_buffer_append (&buf, str, attrs); - app_flush_buffer (&buf, COLS, attrs); + LIST_APPEND_WITH_TAIL (l->head, l->tail, w); + return w; } -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +static struct widget * +app_push_fill (struct layout *l, struct widget *w) +{ + w->width = -1; + LIST_APPEND_WITH_TAIL (l->head, l->tail, w); + return w; +} +/// Write the given UTF-8 string padded with spaces. +/// @param[in] attrs Text attributes for the text, including padding. static void -app_flush_header (struct row_buffer *buf, chtype attrs) +app_layout_text (const char *str, chtype attrs, struct layout *out) { - move (g.header_height++, 0); - app_flush_buffer (buf, COLS, attrs); + struct layout l = {}; + app_push (&l, g.ui->padding (attrs, 0.25, 1)); + app_push_fill (&l, g.ui->label (attrs, str)); + app_push (&l, g.ui->padding (attrs, 0.25, 1)); + app_flush_layout (&l, out); } +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + static void -app_draw_song_info (void) +app_layout_song_info (struct layout *out) { 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); + chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) }; - char *title; + // Split the path for files lying within MPD's "music_directory". + const char *file = compact_map_find (map, "file"); + const char *subroot_basename = NULL; + if (file && *file != '/' && !strstr (file, "://")) + { + const char *last_slash = strrchr (file, '/'); + if (last_slash) + subroot_basename = last_slash + 1; + else + subroot_basename = file; + } + + const char *title = NULL; + const char *name = compact_map_find (map, "name"); if ((title = compact_map_find (map, "title")) - || (title = compact_map_find (map, "name")) - || (title = compact_map_find (map, "file"))) + || (title = name) + || (title = subroot_basename) + || (title = file)) { - struct row_buffer buf = row_buffer_make (); - row_buffer_append (&buf, title, attr_highlight); - app_flush_header (&buf, attr_normal); + struct layout l = {}; + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_push (&l, g.ui->label (attrs[1], title)); + app_push_fill (&l, g.ui->padding (attrs[0], 0, 1)); + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_flush_layout (&l, out); } + // Showing a blank line is better than having the controls jump around + // while switching between files that we do and don't have enough data for. + struct layout l = {}; + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + char *artist = compact_map_find (map, "artist"); char *album = compact_map_find (map, "album"); - if (!artist && !album) - return; + if (artist || album) + { + if (artist) + { + app_push (&l, g.ui->label (attrs[0], "by ")); + app_push (&l, g.ui->label (attrs[1], artist)); + } + if (album) + { + app_push (&l, g.ui->label (attrs[0], &" from "[!artist])); + app_push (&l, g.ui->label (attrs[1], album)); + } + } + else if (subroot_basename && subroot_basename != file) + { + char *parent = xstrndup (file, subroot_basename - file - 1); + app_push (&l, g.ui->label (attrs[0], "in ")); + app_push (&l, g.ui->label (attrs[1], parent)); + free (parent); + } + else if (file && *file != '/' && strstr (file, "://") + && name && name != title) + { + // This is likely to contain the name of an Internet radio. + app_push (&l, g.ui->label (attrs[1], name)); + } - 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); + app_push_fill (&l, g.ui->padding (attrs[0], 0, 1)); + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_flush_layout (&l, out); } static char * @@ -1420,179 +1796,147 @@ app_time_string (int seconds) } static void -app_write_time (struct row_buffer *buf, int seconds, chtype attrs) +app_layout_status (struct layout *out) { - 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; + if (!stopped) + app_layout_song_info (out); + chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) }; + struct layout l = {}; + + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_push (&l, g.ui->button (attrs[!stopped], "<<", ACTION_MPD_PREVIOUS)); + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); 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); + app_push (&l, g.ui->button (attrs[1], toggle, ACTION_MPD_TOGGLE)); + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, g.ui->button (attrs[!stopped], "[]", ACTION_MPD_STOP)); + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, g.ui->button (attrs[!stopped], ">>", ACTION_MPD_NEXT)); + app_push (&l, g.ui->padding (attrs[0], 1, 1)); if (stopped) - row_buffer_append (&buf, "Stopped", attr_normal); + app_push_fill (&l, g.ui->label (attrs[0], "Stopped")); else { if (g.song_elapsed >= 0) { - app_write_time (&buf, g.song_elapsed, attr_normal); - row_buffer_append (&buf, " ", attr_normal); + char *s = app_time_string (g.song_elapsed); + app_push (&l, g.ui->label (attrs[0], s)); + free (s); } 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); + char *s = app_time_string (g.song_duration); + app_push (&l, g.ui->label (attrs[0], " / ")); + app_push (&l, g.ui->label (attrs[0], s)); + free (s); } - 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); + app_push (&l, g.ui->padding (attrs[0], 1, 1)); } - if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1 - && remaining > 0) + struct str volume = str_make (); +#ifdef WITH_PULSE + if (g.pulse_control_requested) { - g.gauge_offset = buf.total_width; - g.gauge_width = remaining; - app_write_gauge (&buf, - (float) g.song_elapsed / g.song_duration, remaining); + if (pulse_volume_status (&g.pulse, &volume)) + { + if (g.volume >= 0 && g.volume != 100) + str_append_printf (&volume, " (%d%%)", g.volume); + } + else + { + if (g.volume >= 0) + str_append_printf (&volume, "(%d%%)", g.volume); + } } else - row_buffer_space (&buf, remaining, attr_normal); +#endif // WITH_PULSE + if (g.volume >= 0) + str_append_printf (&volume, "%3d%%", g.volume); + + if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1) + app_push (&l, g.ui->gauge (attrs[0])) + ->id = WIDGET_GAUGE; + else + app_push_fill (&l, g.ui->padding (attrs[0], 0, 1)); - if (volume) + if (volume.len) { - row_buffer_append (&buf, volume, attr_normal); - free (volume); + app_push (&l, g.ui->padding (attrs[0], 1, 1)); + app_push (&l, g.ui->label (attrs[0], volume.str)) + ->id = WIDGET_VOLUME; } - g.controls_offset = g.header_height; - app_flush_header (&buf, attr_normal); + str_free (&volume); + + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_flush_layout (&l, out); } static void -app_draw_header (void) +app_layout_tabs (struct layout *out) { - 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) }; + struct layout l = {}; // 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]); + app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.25, 1)) + ->id = WIDGET_TAB; + app_push (&l, g.ui->label (attrs[g.active_tab == g.help_tab], APP_TITLE)) + ->id = WIDGET_TAB; - g.tabs_offset = g.header_height; + // XXX: attrs[0]? + app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.5, 1)) + ->id = WIDGET_TAB; + + int i = 0; LIST_FOR_EACH (struct tab, iter, g.tabs) - row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]); + { + struct widget *w = app_push (&l, + g.ui->label (attrs[iter == g.active_tab], iter->name)); + w->id = WIDGET_TAB; + w->userdata = ++i; + } + + app_push_fill (&l, g.ui->padding (attrs[0], 1, 1)); #ifdef WITH_FFTW // This seems like the most reasonable, otherwise unoccupied space if (g.spectrum_fd != -1) { - // Find some space and remember where it was, for fast refreshes - row_buffer_ellipsis (&buf, COLS - g.spectrum.bars - 1); - row_buffer_align (&buf, COLS - g.spectrum.bars, attrs[false]); - g.spectrum_row = g.header_height; - g.spectrum_column = buf.total_width; - - row_buffer_append (&buf, g.spectrum.spectrum, attrs[false]); + app_push (&l, g.ui->spectrum (attrs[0], g.spectrum.bars)) + ->id = WIDGET_SPECTRUM; } #endif // WITH_FFTW - 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)); - } + app_flush_layout (&l, out); } -static int -app_fitting_items (void) +static void +app_layout_padding (chtype attrs, struct layout *out) { - // The raw number of items that would have fit on the terminal - return LINES - g.header_height - 1 /* status bar */; + struct layout l = {}; + app_push_fill (&l, g.ui->padding (attrs, 0, 0.125)); + app_flush_layout (&l, out); } -static int -app_visible_items (void) +static void +app_layout_header (struct layout *out) { - return MAX (0, app_fitting_items ()); + if (g.client.state == MPD_CONNECTED) + { + app_layout_padding (APP_ATTR (NORMAL), out); + app_layout_status (out); + app_layout_padding (APP_ATTR (NORMAL), out); + } + + app_layout_tabs (out); + + const char *header = g.active_tab->header; + if (header) + app_layout_text (header, APP_ATTR (HEADER), out); } /// Figure out scrollbar appearance. @a s is the minimal slider length as well @@ -1600,7 +1944,7 @@ app_visible_items (void) struct scrollbar { long length, start; } app_compute_scrollbar (struct tab *tab, long visible, long s) { - long top = tab->item_top, total = tab->item_count; + long top = s * tab->item_top, total = s * tab->item_count; if (total < visible) return (struct scrollbar) { 0, 0 }; if (visible == 1) @@ -1610,7 +1954,7 @@ app_compute_scrollbar (struct tab *tab, long visible, long s) // 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 available_length = visible - 2 - s + 1; double lenf = s + available_length * visible / total, length = 0.; long offset = 1 + available_length * top / total + modf (lenf, &length); @@ -1618,215 +1962,247 @@ app_compute_scrollbar (struct tab *tab, long visible, long s) if (top == 0) return (struct scrollbar) { length, 0 }; if (top + visible >= total) - return (struct scrollbar) { length, s * visible - length }; + return (struct scrollbar) { length, visible - length }; return (struct scrollbar) { length, offset }; } -static void -app_draw_scrollbar (void) +static struct layout +app_layout_row (struct tab *tab, int item_index) { - // 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 row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN); - int start_part = bar.start % 8; bar.start /= 8; - int end_part = bar.length % 8; bar.length /= 8; + bool override_colors = true; + if (item_index == tab->item_selected) + row_attrs = g_xui.focused + ? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED); + 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 = g_xui.focused + ? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED); + else + override_colors = false; - // Even with this, the solid part must be at least one character high - static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" }; + // The padding must be added before the recoloring below. + struct layout l = tab->on_item_layout (item_index); + struct widget *w = g.ui->padding (0, 0.25, 1); + LIST_PREPEND (l.head, w); + app_push (&l, g.ui->padding (0, 0.25, 1)); - for (int row = 0; row < visible_items; row++) + // Combine attributes used by the handler with the defaults. + LIST_FOR_EACH (struct widget, w, l.head) { - 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); + chtype *attrs = &w->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; } + return l; } static void -app_draw_view (void) +app_layout_view (struct layout *out, int height) { - move (g.header_height, 0); - clrtobot (); + struct layout l = {}; + struct widget *list = app_push_fill (&l, g.ui->list ()); + list->id = WIDGET_LIST; + list->height = height; + list->width = g_xui.width; struct tab *tab = g.active_tab; - bool want_scrollbar = (int) tab->item_count > app_visible_items (); - int view_width = COLS - want_scrollbar; + if ((int) tab->item_count * g_xui.vunit > list->height) + { + struct widget *scrollbar = g.ui->scrollbar (APP_ATTR (SCROLLBAR)); + list->width -= scrollbar->width; + app_push (&l, scrollbar)->id = WIDGET_SCROLLBAR; + } + + int to_show = MIN ((int) tab->item_count - tab->item_top, + ceil ((double) list->height / g_xui.vunit)); - int to_show = - MIN (app_fitting_items (), (int) tab->item_count - tab->item_top); + struct layout children = {}; 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 = g.focused - ? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED); - 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 = g.focused - ? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED); - 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); + struct layout subl = app_layout_row (tab, item_index); + // TODO: Change layouting so that we don't need to know list->width. + app_flush_layout_full (&subl, list->width, &children); } + list->children = children.head; - if (want_scrollbar) - app_draw_scrollbar (); + app_flush_layout (&l, out); } static void -app_write_mpd_status_playlist (struct row_buffer *buf) +app_layout_mpd_status_playlist (struct layout *l, chtype attrs) { - 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); + char *songs = (g.playlist.len == 1) + ? xstrdup_printf ("1 song") + : xstrdup_printf ("%zu songs", g.playlist.len); + app_push (l, g.ui->label (attrs, songs)); + free (songs); int hours = g.playlist_time / 3600; int minutes = g.playlist_time % 3600 / 60; if (hours || minutes) { - str_append_c (&stats, ' '); - + struct str length = str_make (); if (hours == 1) - str_append_printf (&stats, " 1 hour"); + str_append_printf (&length, " 1 hour"); else if (hours) - str_append_printf (&stats, " %d hours", hours); + str_append_printf (&length, " %d hours", hours); if (minutes == 1) - str_append_printf (&stats, " 1 minute"); + str_append_printf (&length, " 1 minute"); else if (minutes) - str_append_printf (&stats, " %d minutes", minutes); + str_append_printf (&length, " %d minutes", minutes); + + app_push (l, g.ui->padding (attrs, 1, 1)); + app_push (l, g.ui->label (attrs, length.str + 1)); + str_free (&length); + } + + const char *task = NULL; + if (g.poller_curl.registered) + task = "Downloading..."; + else if (str_map_find (&g.playback_info, "updating_db")) + task = "Updating database..."; + + if (task) + { + app_push (l, g.ui->padding (attrs, 1, 1)); + app_push (l, g.ui->label (attrs, task)); } - row_buffer_append (buf, stats.str, APP_ATTR (NORMAL)); - str_free (&stats); } static void -app_write_mpd_status (struct row_buffer *buf) +app_layout_mpd_status (struct layout *out) { - struct str_map *map = &g.playback_info; + struct layout l = {}; + chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) }; + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + 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)); + app_push_fill (&l, g.ui->label (attrs[0], msg)); 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); + { + app_layout_mpd_status_playlist (&l, attrs[0]); + l.tail->width = -1; + } - const char *s; + const char *s = NULL; + struct str_map *map = &g.playback_info; 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); + if (g.ui->have_icons || repeat) + { + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, + g.ui->button (attrs[repeat], "repeat", ACTION_MPD_REPEAT)); + } + if (g.ui->have_icons || random) + { + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, + g.ui->button (attrs[random], "random", ACTION_MPD_RANDOM)); + } + if (g.ui->have_icons || single) + { + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, + g.ui->button (attrs[single], "single", ACTION_MPD_SINGLE)); + } + if (g.ui->have_icons || consume) + { + app_push (&l, g.ui->padding (attrs[0], 0.5, 1)); + app_push (&l, + g.ui->button (attrs[consume], "consume", ACTION_MPD_CONSUME)); + } - 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); + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_flush_layout (&l, out); } static void -app_draw_statusbar (void) +app_layout_statusbar (struct layout *out) { - int caret = -1; + chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) }; + app_layout_padding (attrs[0], out); - struct row_buffer buf = row_buffer_make (); + struct layout l = {}; 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)); + { + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + if (!g.message_detail) + app_push_fill (&l, g.ui->label (attrs[1], g.message)); + else + { + app_push (&l, g.ui->label (attrs[1], g.message)); + app_push_fill (&l, g.ui->label (attrs[0], g.message_detail)); + } + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); - curs_set (0); - if (caret != -1) + app_flush_layout (&l, out); + LIST_FOR_EACH (struct widget, w, l.head) + w->id = WIDGET_MESSAGE; + } + else if (g.editor.line) { - move (LINES - 1, caret); - curs_set (1); + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_push (&l, g.ui->editor (attrs[1])); + app_push (&l, g.ui->padding (attrs[0], 0.25, 1)); + app_flush_layout (&l, out); } + else if (g.client.state == MPD_CONNECTED) + app_layout_mpd_status (out); + else if (g.client.state == MPD_CONNECTING) + app_layout_text ("Connecting to MPD...", attrs[0], out); + else if (g.client.state == MPD_DISCONNECTED) + app_layout_text ("Disconnected", attrs[0], out); + + app_layout_padding (attrs[0], out); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +static struct widget * +app_widget_by_id (int id) +{ + LIST_FOR_EACH (struct widget, w, g_xui.widgets) + if (w->id == id) + return w; + return NULL; +} + +static int +app_visible_items_height (void) +{ + struct widget *list = app_widget_by_id (WIDGET_LIST); + hard_assert (list != NULL); + + // The raw number of items that would have fit on the terminal + return MAX (0, list->height); +} + +static int +app_visible_items (void) +{ + return app_visible_items_height () / g_xui.vunit; +} + /// Checks what items are visible and returns if the range was alright static bool app_fix_view_range (void) @@ -1852,17 +2228,27 @@ app_fix_view_range (void) } static void -app_on_refresh (void *user_data) +app_layout (void) { - (void) user_data; - poller_idle_reset (&g.refresh_event); + struct layout top = {}, bottom = {}; + app_layout_header (&top); + app_layout_statusbar (&bottom); + + int available_height = g_xui.height; + if (top.tail) + available_height -= top.tail->y + top.tail->height; + if (bottom.tail) + available_height -= bottom.tail->y + bottom.tail->height; + + struct layout widgets = {}; + app_append_layout (&top, &widgets); + app_layout_view (&widgets, available_height); + app_append_layout (&bottom, &widgets); + g_xui.widgets = widgets.head; - app_draw_header (); app_fix_view_range(); - app_draw_view (); - app_draw_statusbar (); - refresh (); + curs_set (0); } // --- Actions ----------------------------------------------------------------- @@ -1872,7 +2258,7 @@ static bool app_scroll (int n) { g.active_tab->item_top += n; - app_invalidate (); + xui_invalidate (); return app_fix_view_range (); } @@ -1894,6 +2280,19 @@ app_ensure_selection_visible (void) } static bool +app_center_cursor (void) +{ + struct tab *tab = g.active_tab; + if (tab->item_selected < 0 || !tab->item_count) + return false; + + int offset = tab->item_selected - tab->item_top; + int target = app_visible_items () / 2; + app_scroll (offset - target); + return true; +} + +static bool app_move_selection (int diff) { struct tab *tab = g.active_tab; @@ -1903,19 +2302,52 @@ app_move_selection (int diff) bool result = !diff || tab->item_selected != fixed; tab->item_selected = fixed; - app_invalidate (); + xui_invalidate (); app_ensure_selection_visible (); return result; } +static void +app_show_message (char *message, char *detail) +{ + cstr_set (&g.message, message); + cstr_set (&g.message_detail, detail); + poller_timer_set (&g.message_timer, 5000); + xui_invalidate (); +} + +static void +app_hide_message (void) +{ + if (!g.message) + return; + + cstr_set (&g.message, NULL); + cstr_set (&g.message_detail, NULL); + poller_timer_reset (&g.message_timer); + xui_invalidate (); +} + +static void +app_on_clipboard_copy (const char *text) +{ + app_show_message (xstrdup ("Text copied to clipboard: "), xstrdup (text)); +} + +static struct widget * +app_make_label (chtype attrs, const char *label) +{ + return g_xui.ui->label (attrs, 0, label); +} + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - static void app_prepend_tab (struct tab *tab) { LIST_PREPEND (g.tabs, tab); - app_invalidate (); + xui_invalidate (); } static void @@ -1926,7 +2358,7 @@ app_switch_tab (struct tab *tab) g.last_tab = g.active_tab; g.active_tab = tab; - app_invalidate (); + xui_invalidate (); } static bool @@ -1944,105 +2376,12 @@ app_goto_tab (int tab_index) // --- 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; - } + if (!strcasecmp_ascii (g_action_names[i], name)) + return i; return -1; } @@ -2103,7 +2442,7 @@ app_setvol (int value) } static void -app_on_editor_end (bool confirmed) +app_on_mpd_command_editor_end (bool confirmed) { struct mpd_client *c = &g.client; if (!confirmed) @@ -2118,6 +2457,63 @@ app_on_editor_end (bool confirmed) mpd_client_idle (c, 0); } +static size_t +incremental_search_match (const ucs4_t *needle, size_t len, + const ucs4_t *chars, size_t chars_len) +{ + // XXX: this is slow and simplistic, but unistring is awkward to use + size_t best = 0; + for (size_t start = 0; start < chars_len; start++) + { + size_t i = 0; + for (; i < len && start + i < chars_len; i++) + if (uc_tolower (needle[i]) != uc_tolower (chars[start + i])) + break; + best = MAX (best, i); + } + return best; +} + +static void +incremental_search_on_changed (void) +{ + struct tab *tab = g.active_tab; + if (!tab->item_count) + return; + + size_t best = 0, current = 0, index = MAX (tab->item_selected, 0), i = 0; + while (i++ < tab->item_count) + { + struct str s = str_make (); + LIST_FOR_EACH (struct widget, w, tab->on_item_layout (index).head) + { + str_append (&s, w->text); + widget_destroy (w); + } + + size_t len; + ucs4_t *text = u8_to_u32 ((const uint8_t *) s.str, s.len, NULL, &len); + str_free (&s); + current = incremental_search_match + (g.editor.line, g.editor.len, text, len); + free (text); + if (best < current) + { + best = current; + tab->item_selected = index; + app_move_selection (0); + } + index = (index + 1) % tab->item_count; + } +} + +static void +incremental_search_on_end (bool confirmed) +{ + (void) confirmed; + // Required callback, nothing to do here. +} + // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - static bool @@ -2135,7 +2531,7 @@ app_process_action (enum action action) struct tab *tab = g.active_tab; if (tab->on_action && tab->on_action (action)) { - app_invalidate (); + xui_invalidate (); return true; } @@ -2144,26 +2540,31 @@ app_process_action (enum action action) case ACTION_NONE: return true; case ACTION_QUIT: + app_quit (); + return true; + case ACTION_REDRAW: + clear (); + xui_invalidate (); + return true; + + case ACTION_ABORT: // It is a pseudomode, avoid surprising the user if (tab->item_mark > -1) { tab->item_mark = -1; - app_invalidate (); + xui_invalidate (); return true; } - - app_quit (); - return true; - case ACTION_REDRAW: - clear (); - app_invalidate (); - return true; + return false; case ACTION_MPD_COMMAND: line_editor_start (&g.editor, ':'); - g.editor.on_end = app_on_editor_end; - app_invalidate (); + g.editor.on_end = app_on_mpd_command_editor_end; + xui_invalidate (); + app_hide_message (); return true; default: + print_error ("\"%s\" is not allowed here", + g_action_descriptions[action]); return false; case ACTION_MULTISELECT: @@ -2171,12 +2572,19 @@ app_process_action (enum action action) || !tab->item_count || tab->item_selected < 0) return false; - app_invalidate (); + xui_invalidate (); if (tab->item_mark > -1) tab->item_mark = -1; else tab->item_mark = tab->item_selected; return true; + case ACTION_INCREMENTAL_SEARCH: + line_editor_start (&g.editor, '/'); + g.editor.on_changed = incremental_search_on_changed; + g.editor.on_end = incremental_search_on_end; + xui_invalidate (); + app_hide_message (); + return true; case ACTION_TAB_LAST: if (!g.last_tab) @@ -2218,19 +2626,26 @@ app_process_action (enum action action) 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); + case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 5); + case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 5); - // XXX: these should rather be parametrized +#ifdef WITH_PULSE + case ACTION_PULSE_VOLUME_UP: return pulse_volume_set (&g.pulse, +5); + case ACTION_PULSE_VOLUME_DOWN: return pulse_volume_set (&g.pulse, -5); + case ACTION_PULSE_MUTE: return pulse_volume_mute (&g.pulse); +#endif // WITH_PULSE + + // XXX: these two should rather be parametrized case ACTION_SCROLL_UP: return app_scroll (-3); - case ACTION_SCROLL_DOWN: return app_scroll (3); + case ACTION_SCROLL_DOWN: return app_scroll (+3); + case ACTION_CENTER_CURSOR: return app_center_cursor (); case ACTION_GOTO_TOP: if (tab->item_count) { g.active_tab->item_selected = 0; app_ensure_selection_visible (); - app_invalidate (); + xui_invalidate (); } return true; case ACTION_GOTO_BOTTOM: @@ -2239,7 +2654,7 @@ app_process_action (enum action action) g.active_tab->item_selected = MAX (0, (int) g.active_tab->item_count - 1); app_ensure_selection_visible (); - app_invalidate (); + xui_invalidate (); } return true; @@ -2269,10 +2684,10 @@ app_process_action (enum action action) static bool app_editor_process_action (enum action action) { - app_invalidate (); + xui_invalidate (); switch (action) { - case ACTION_QUIT: + case ACTION_ABORT: line_editor_abort (&g.editor, false); g.editor.on_end = NULL; return true; @@ -2281,6 +2696,8 @@ app_editor_process_action (enum action action) g.editor.on_end = NULL; return true; default: + print_error ("\"%s\" is not allowed here", + g_action_descriptions[action]); return false; case ACTION_EDITOR_B_CHAR: @@ -2296,6 +2713,13 @@ app_editor_process_action (enum action action) case ACTION_EDITOR_END: return line_editor_action (&g.editor, LINE_EDITOR_END); + case ACTION_EDITOR_UPCASE_WORD: + return line_editor_action (&g.editor, LINE_EDITOR_UPCASE_WORD); + case ACTION_EDITOR_DOWNCASE_WORD: + return line_editor_action (&g.editor, LINE_EDITOR_DOWNCASE_WORD); + case ACTION_EDITOR_CAPITALIZE_WORD: + return line_editor_action (&g.editor, LINE_EDITOR_CAPITALIZE_WORD); + case ACTION_EDITOR_B_DELETE: return line_editor_action (&g.editor, LINE_EDITOR_B_DELETE); case ACTION_EDITOR_F_DELETE: @@ -2311,93 +2735,156 @@ app_editor_process_action (enum action action) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Carefully chosen to limit the possibility of ever hitting termo keymods. +enum { APP_KEYMOD_DOUBLE_CLICK = 1 << 15 }; + static bool -app_process_left_mouse_click (int line, int column, bool double_click) +app_process_left_mouse_click (struct widget *w, int x, int y, int modifiers) { - if (line == g.controls_offset) + switch (w->id) { - // 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; + case WIDGET_BUTTON: + app_process_action (w->userdata); + break; + case WIDGET_GAUGE: + { + // TODO: We should avoid queuing up too many. + float position = (float) x / w->width; if (g.song_duration >= 1) { char *where = xstrdup_printf ("%f", position * g.song_duration); MPD_SIMPLE ("seekcur", where); free (where); } + break; } - else if (line == g.tabs_offset) + case WIDGET_TAB: { - 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; + struct tab *tab = g.help_tab; + int i = 0; + LIST_FOR_EACH (struct tab, iter, g.tabs) + if (++i == w->userdata) + tab = iter; - app_switch_tab (winner); + app_switch_tab (tab); + break; } - else if (line >= g.header_height) + case WIDGET_LIST: { struct tab *tab = g.active_tab; - int row_index = line - g.header_height; + int row_index = y / g_xui.vunit; 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 (!(modifiers & TERMO_KEYMOD_SHIFT)) + tab->item_mark = -1; + else if (!tab->can_multiselect || tab->item_selected < 0) + return false; + else if (tab->item_mark < 0) + tab->item_mark = tab->item_selected; + + tab->item_selected = row_index + tab->item_top; + app_ensure_selection_visible (); + xui_invalidate (); - if (double_click) + if (modifiers & APP_KEYMOD_DOUBLE_CLICK) app_process_action (ACTION_CHOOSE); + break; + } + case WIDGET_SCROLLBAR: + { + struct tab *tab = g.active_tab; + int visible_items = app_visible_items (); + tab->item_top = (double) y / w->height + * (int) tab->item_count - visible_items / 2; + xui_invalidate (); + app_fix_view_range (); + break; + } + case WIDGET_MESSAGE: + app_hide_message (); } return true; } static bool -app_process_mouse (termo_mouse_event_t type, int line, int column, int button, - bool double_click) +app_process_mouse (termo_mouse_event_t type, int x, int y, int button, + int modifiers) { - if (type != TERMO_MOUSE_PRESS) + // XXX: Terminals don't let us know which button has been released, + // so we can't press buttons at that point. We'd need a special "click" + // event handler that could be handled better under X11. + if (type == TERMO_MOUSE_RELEASE) + { + g.ui_dragging = WIDGET_NONE; return true; + } + + if (type == TERMO_MOUSE_DRAG) + { + if (g.ui_dragging != WIDGET_GAUGE + && g.ui_dragging != WIDGET_SCROLLBAR) + return true; + + struct widget *target = app_widget_by_id (g.ui_dragging); + x -= target->x; + y -= target->y; + return app_process_left_mouse_click (target, x, y, modifiers); + } if (g.editor.line) + { line_editor_abort (&g.editor, false); + xui_invalidate (); + } - 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); + struct widget *target = NULL; + LIST_FOR_EACH (struct widget, w, g_xui.widgets) + if (x >= w->x && x < w->x + w->width + && y >= w->y && y < w->y + w->height) + target = w; + if (!target) + return false; + + x -= target->x; + y -= target->y; + switch (button) + { + case 1: + g.ui_dragging = target->id; + return app_process_left_mouse_click (target, x, y, modifiers); + case 4: + switch (target->id) + { + case WIDGET_LIST: + return app_process_action (ACTION_SCROLL_UP); + case WIDGET_VOLUME: + return app_process_action ( +#ifdef WITH_PULSE + g.pulse_control_requested ? ACTION_PULSE_VOLUME_UP : +#endif // WITH_PULSE + ACTION_MPD_VOLUME_UP); + case WIDGET_GAUGE: + return app_process_action (ACTION_MPD_FORWARD); + } + break; + case 5: + switch (target->id) + { + case WIDGET_LIST: + return app_process_action (ACTION_SCROLL_DOWN); + case WIDGET_VOLUME: + return app_process_action ( +#ifdef WITH_PULSE + g.pulse_control_requested ? ACTION_PULSE_VOLUME_DOWN : +#endif // WITH_PULSE + ACTION_MPD_VOLUME_DOWN); + case WIDGET_GAUGE: + return app_process_action (ACTION_MPD_BACKWARD); + } + break; + } return false; } @@ -2419,16 +2906,19 @@ static struct binding_default } g_normal_defaults[] = { - { "Escape", ACTION_QUIT }, { "q", ACTION_QUIT }, { "C-l", ACTION_REDRAW }, + { "Escape", ACTION_ABORT }, { "M-Tab", ACTION_TAB_LAST }, { "F1", ACTION_TAB_HELP }, + { "S-Tab", ACTION_TAB_PREVIOUS }, + { "Tab", ACTION_TAB_NEXT }, { "C-Left", ACTION_TAB_PREVIOUS }, { "C-Right", ACTION_TAB_NEXT }, { "C-PageUp", ACTION_TAB_PREVIOUS }, { "C-PageDown", ACTION_TAB_NEXT }, + { "o", ACTION_GOTO_PLAYING }, { "Home", ACTION_GOTO_TOP }, { "End", ACTION_GOTO_BOTTOM }, { "M-<", ACTION_GOTO_TOP }, @@ -2449,6 +2939,7 @@ g_normal_defaults[] = { "C-f", ACTION_GOTO_PAGE_NEXT }, { "C-y", ACTION_SCROLL_UP }, { "C-e", ACTION_SCROLL_DOWN }, + { "z", ACTION_CENTER_CURSOR }, { "H", ACTION_GOTO_VIEW_TOP }, { "M", ACTION_GOTO_VIEW_CENTER }, @@ -2458,9 +2949,11 @@ g_normal_defaults[] = { "Enter", ACTION_CHOOSE }, { "Delete", ACTION_DELETE }, { "d", ACTION_DELETE }, + { "?", ACTION_DESCRIBE }, { "M-Up", ACTION_UP }, { "Backspace", ACTION_UP }, { "v", ACTION_MULTISELECT }, + { "C-s", ACTION_INCREMENTAL_SEARCH }, { "/", ACTION_MPD_SEARCH }, { "a", ACTION_MPD_ADD }, { "r", ACTION_MPD_REPLACE }, @@ -2477,11 +2970,15 @@ g_normal_defaults[] = { "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 }, + { "+", ACTION_MPD_VOLUME_UP }, + { "-", ACTION_MPD_VOLUME_DOWN }, }, g_editor_defaults[] = { + { "C-g", ACTION_ABORT }, + { "Escape", ACTION_ABORT }, + { "Enter", ACTION_EDITOR_CONFIRM }, + { "Left", ACTION_EDITOR_B_CHAR }, { "Right", ACTION_EDITOR_F_CHAR }, { "C-b", ACTION_EDITOR_B_CHAR }, @@ -2493,6 +2990,10 @@ g_editor_defaults[] = { "C-a", ACTION_EDITOR_HOME }, { "C-e", ACTION_EDITOR_END }, + { "M-u", ACTION_EDITOR_UPCASE_WORD }, + { "M-l", ACTION_EDITOR_DOWNCASE_WORD }, + { "M-c", ACTION_EDITOR_CAPITALIZE_WORD }, + { "C-h", ACTION_EDITOR_B_DELETE }, { "DEL", ACTION_EDITOR_B_DELETE }, { "Backspace", ACTION_EDITOR_B_DELETE }, @@ -2501,17 +3002,13 @@ g_editor_defaults[] = { "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); + int cmp = termo_keycmp (g_xui.tk, &aa->decoded, &bb->decoded); return cmp ? cmp : bb->order - aa->order; } @@ -2522,7 +3019,7 @@ app_next_binding (struct str_map_iter *iter, termo_key_t *key, int *action) while ((v = str_map_iter_next (iter))) { *action = ACTION_NONE; - if (*termo_strpkey_utf8 (g.tk, + if (*termo_strpkey_utf8 (g_xui.tk, iter->link->key, key, TERMO_FORMAT_ALTISMETA)) print_error ("%s: invalid binding", iter->link->key); else if (v->type == CONFIG_ITEM_NULL) @@ -2551,7 +3048,7 @@ app_init_bindings (const char *keymap, termo_key_t decoded; for (size_t i = 0; i < defaults_len; i++) { - hard_assert (!*termo_strpkey_utf8 (g.tk, + hard_assert (!*termo_strpkey_utf8 (g_xui.tk, defaults[i].key, &decoded, TERMO_FORMAT_ALTISMETA)); a[a_len++] = (struct binding) { decoded, defaults[i].action, order++ }; } @@ -2573,7 +3070,8 @@ app_init_bindings (const char *keymap, 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)) + if (!out + || termo_keycmp (g_xui.tk, &a[in].decoded, &a[out - 1].decoded)) a[out++] = a[in]; } @@ -2581,26 +3079,52 @@ app_init_bindings (const char *keymap, return a; } +static char * +app_strfkey (const termo_key_t *key) +{ + // For display purposes, this is highly desirable + int flags = termo_get_flags (g_xui.tk); + termo_set_flags (g_xui.tk, flags | TERMO_FLAG_SPACESYMBOL); + termo_key_t fixed = *key; + termo_canonicalise (g_xui.tk, &fixed); + termo_set_flags (g_xui.tk, flags); + + char buf[16] = ""; + termo_strfkey_utf8 (g_xui.tk, + buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA); + return xstrdup (buf); +} + static bool app_process_termo_event (termo_key_t *event) { - if (event->type == TERMO_TYPE_FOCUS) + char *formatted = app_strfkey (event); + print_debug ("%s", formatted); + free (formatted); + + bool handled = false; + if ((handled = event->type == TERMO_TYPE_FOCUS)) { - g.focused = !!event->code.focused; - app_invalidate (); + xui_invalidate (); + // Senseless fall-through } struct binding dummy = { *event, 0, 0 }, *binding; if (g.editor.line) { + if (event->type == TERMO_TYPE_KEY + || event->type == TERMO_TYPE_FUNCTION + || event->type == TERMO_TYPE_KEYSYM) + app_hide_message (); + 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; + return handled; line_editor_insert (&g.editor, event->code.codepoint); - app_invalidate (); + xui_invalidate (); return true; } if ((binding = bsearch (&dummy, g_normal_keys, g_normal_keys_len, @@ -2616,18 +3140,15 @@ app_process_termo_event (termo_key_t *event) if (app_goto_tab ((n == 0 ? 10 : n) - 1)) return true; } - return false; + return handled; } // --- Current tab ------------------------------------------------------------- static struct tab g_current_tab; -#define DURATION_MAX_LEN (1 /*separator */ + 2 /* h */ + 3 /* m */+ 3 /* s */) - -static void -current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, - int width) +static struct layout +current_tab_on_item_layout (size_t item_index) { // TODO: configurable output, maybe dynamically sized columns compact_map_t map = item_list_get (&g.playlist, item_index); @@ -2635,23 +3156,26 @@ current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, const char *title = compact_map_find (map, "title"); chtype attrs = (int) item_index == g.song ? A_BOLD : 0; + struct layout l = {}; if (artist && title) - row_buffer_append_args (buffer, - artist, attrs, " - ", attrs, title, attrs, NULL); + { + char *joined = xstrdup_printf ("%s - %s", artist, title); + app_push_fill (&l, g.ui->label (attrs, joined)); + free (joined); + } else - row_buffer_append (buffer, compact_map_find (map, "file"), attrs); - - row_buffer_align (buffer, width - DURATION_MAX_LEN, attrs); + app_push_fill (&l, g.ui->label (attrs, compact_map_find (map, "file"))); int duration = -1; mpd_read_time (compact_map_find (map, "duration"), &duration, NULL); mpd_read_time (compact_map_find (map, "time"), &duration, NULL); char *s = duration < 0 ? xstrdup ("-") : app_time_string (duration); - char *right_aligned = xstrdup_printf ("%*s", DURATION_MAX_LEN, s); - row_buffer_append (buffer, right_aligned, attrs); - free (right_aligned); + app_push (&l, g.ui->padding (attrs, 1, 1)); + app_push (&l, g.ui->label (attrs, s)); free (s); + + return l; } static void @@ -2718,6 +3242,13 @@ current_tab_on_action (enum action action) switch (action) { const char *id; + case ACTION_GOTO_PLAYING: + if (g.song < 0 || (size_t) g.song >= tab->item_count) + return false; + + tab->item_selected = g.song; + app_ensure_selection_visible (); + return true; case ACTION_MOVE_UP: return current_tab_move_selection (-1); case ACTION_MOVE_DOWN: @@ -2726,6 +3257,12 @@ current_tab_on_action (enum action action) tab->item_mark = -1; return map && (id = compact_map_find (map, "id")) && MPD_SIMPLE ("playid", id); + case ACTION_DESCRIBE: + if (!map || !(id = compact_map_find (map, "file"))) + return false; + + app_show_message (xstrdup ("Path: "), xstrdup (id)); + return true; case ACTION_DELETE: { struct mpd_client *c = &g.client; @@ -2756,7 +3293,7 @@ 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 (); + xui_invalidate (); } static struct tab * @@ -2766,7 +3303,7 @@ current_tab_init (void) tab_init (super, "Current"); super->can_multiselect = true; super->on_action = current_tab_on_action; - super->on_item_draw = current_tab_on_item_draw; + super->on_item_layout = current_tab_on_item_layout; return super; } @@ -2832,11 +3369,9 @@ library_tab_add (int type, int duration, const char *name, const char *path) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -static void -library_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, - int width) +static struct layout +library_tab_on_item_layout (size_t item_index) { - (void) width; hard_assert (item_index < g_library_tab.items_len); struct library_tab_item *x = &g_library_tab.items[item_index]; @@ -2849,15 +3384,21 @@ library_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, case LIBRARY_FILE: prefix = " "; name = x->name; break; default: hard_assert (!"invalid item type"); } + chtype attrs = x->type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0; - row_buffer_append_args (buffer, prefix, attrs, name, attrs, NULL); - if (x->duration < 0) - return; + struct layout l = {}; - char *s = app_time_string (x->duration); - row_buffer_align (buffer, width - 2 /* gap */ - strlen (s), 0); - row_buffer_append_args (buffer, " " /* gap */, 0, s, 0, NULL); - free (s); + app_push (&l, g.ui->label (attrs, prefix)); + app_push_fill (&l, g.ui->label (attrs, name)); + + if (x->duration >= 0) + { + char *s = app_time_string (x->duration); + app_push (&l, g.ui->padding (0, 1, 1)); + app_push (&l, g.ui->label (attrs, s)); + free (s); + } + return l; } static char @@ -3014,7 +3555,7 @@ library_tab_load_data (const struct strv *data) if (g_library_tab.super.item_selected >= (int) len) app_move_selection (0); - app_invalidate (); + xui_invalidate (); } static void @@ -3136,6 +3677,12 @@ library_tab_on_action (enum action action) } tab->item_mark = -1; return true; + case ACTION_DESCRIBE: + if (!*x->path) + break; + + app_show_message (xstrdup ("Path: "), xstrdup (x->path)); + return true; case ACTION_UP: { char *parent = library_tab_parent (); @@ -3171,7 +3718,7 @@ library_tab_on_action (enum action action) library_tab_load_data (&empty); strv_free (&empty); - app_invalidate (); + xui_invalidate (); return true; } case ACTION_MPD_ADD: @@ -3226,7 +3773,7 @@ library_tab_init (void) tab_init (super, "Library"); super->can_multiselect = true; super->on_action = library_tab_on_action; - super->on_item_draw = library_tab_on_item_draw; + super->on_item_layout = library_tab_on_item_layout; return super; } @@ -3238,8 +3785,8 @@ struct stream_tab_task { struct poller_curl_task curl; ///< Superclass struct str data; ///< Downloaded data - bool polling; ///< Still downloading bool replace; ///< Should playlist be replaced? + struct curl_slist *alias_ok; }; static bool @@ -3296,8 +3843,13 @@ streams_tab_parse_playlist (const char *playlist, const char *content_type, || (content_type && is_content_type (content_type, "audio", "x-scpls"))) extract_re = "^File[^=]*=(.+)"; else if ((lines.len && !strcasecmp_ascii (lines.vector[0], "#EXTM3U")) + || (content_type && is_content_type (content_type, "audio", "mpegurl")) || (content_type && is_content_type (content_type, "audio", "x-mpegurl"))) - extract_re = "^([^#].*)"; + // This could be "^([^#].*)", however 1. we would need to resolve + // relative URIs, and 2. relative URIs probably mean a Media Playlist, + // which must be passed to MPD. The better thing to do here would be to + // reject anything with EXT-X-TARGETDURATION, and to resolve the URIs. + extract_re = "^(https?://.+)"; regex_t *re = regex_compile (extract_re, REG_EXTENDED, NULL); hard_assert (re != NULL); @@ -3320,7 +3872,23 @@ streams_tab_extract_links (struct str *data, const char *content_type, } streams_tab_parse_playlist (data->str, content_type, out); - return true; + return out->len != 0; +} + +static void +streams_tab_task_finalize (struct stream_tab_task *self) +{ + curl_easy_cleanup (self->curl.easy); + curl_slist_free_all (self->alias_ok); + str_free (&self->data); + free (self); +} + +static void +streams_tab_task_dispose (struct stream_tab_task *self) +{ + hard_assert (poller_curl_remove (&g.poller_curl, self->curl.easy, NULL)); + streams_tab_task_finalize (self); } static void @@ -3328,19 +3896,18 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task) { struct stream_tab_task *self = CONTAINER_OF (task, struct stream_tab_task, curl); - self->polling = false; if (msg->data.result && msg->data.result != CURLE_WRITE_ERROR) { cstr_uncapitalize (self->curl.curl_error); print_error ("%s", self->curl.curl_error); - return; + goto dispose; } struct mpd_client *c = &g.client; if (c->state != MPD_CONNECTED) - return; + goto dispose; CURL *easy = msg->easy_handle; CURLcode res; @@ -3353,13 +3920,13 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task) { print_error ("%s: %s", "cURL info retrieval failed", curl_easy_strerror (res)); - return; + goto dispose; } // cURL is not willing to parse the ICY header, the code is zero then if (code && code != 200) { print_error ("%s: %ld", "unexpected HTTP response", code); - return; + goto dispose; } mpd_client_list_begin (c); @@ -3378,6 +3945,9 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task) mpd_client_list_end (c); mpd_client_add_task (c, mpd_on_simple_response, NULL); mpd_client_idle (c, 0); + +dispose: + streams_tab_task_dispose (self); } static size_t @@ -3396,60 +3966,44 @@ write_callback (char *ptr, size_t size, size_t nmemb, void *user_data) static bool streams_tab_process (const char *uri, bool replace, struct error **e) { - struct poller poller; - poller_init (&poller); - - struct poller_curl pc; - hard_assert (poller_curl_init (&pc, &poller, NULL)); - struct stream_tab_task task; - hard_assert (poller_curl_spawn (&task.curl, NULL)); + // TODO: streams_tab_task_dispose() on that running task + if (g.poller_curl.registered) + { + print_error ("waiting for the last stream to time out"); + return false; + } - CURL *easy = task.curl.easy; - task.data = str_make (); - task.replace = replace; - bool result = false; + struct stream_tab_task *task = xcalloc (1, sizeof *task); + hard_assert (poller_curl_spawn (&task->curl, NULL)); - struct curl_slist *ok_headers = curl_slist_append (NULL, "ICY 200 OK"); + CURL *easy = task->curl.easy; + task->data = str_make (); + task->replace = replace; + task->alias_ok = curl_slist_append (NULL, "ICY 200 OK"); CURLcode res; if ((res = curl_easy_setopt (easy, CURLOPT_FOLLOWLOCATION, 1L)) || (res = curl_easy_setopt (easy, CURLOPT_NOPROGRESS, 1L)) - // TODO: make the timeout a bit larger once we're asynchronous - || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 5L)) + || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 10L)) // Not checking anything, we just want some data, any data || (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYPEER, 0L)) || (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYHOST, 0L)) || (res = curl_easy_setopt (easy, CURLOPT_URL, uri)) - || (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, ok_headers)) + || (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, task->alias_ok)) || (res = curl_easy_setopt (easy, CURLOPT_VERBOSE, (long) g_debug_mode)) || (res = curl_easy_setopt (easy, CURLOPT_DEBUGFUNCTION, print_curl_debug)) - || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task.data)) + || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task->data)) || (res = curl_easy_setopt (easy, CURLOPT_WRITEFUNCTION, write_callback))) { error_set (e, "%s: %s", "cURL setup failed", curl_easy_strerror (res)); - goto error; + streams_tab_task_finalize (task); + return false; } - task.curl.on_done = streams_tab_on_downloaded; - hard_assert (poller_curl_add (&pc, task.curl.easy, NULL)); - - // TODO: don't run a subloop, run the task fully asynchronously - task.polling = true; - while (task.polling) - poller_run (&poller); - - hard_assert (poller_curl_remove (&pc, task.curl.easy, NULL)); - result = true; - -error: - curl_easy_cleanup (task.curl.easy); - curl_slist_free_all (ok_headers); - str_free (&task.data); - poller_curl_free (&pc); - - poller_free (&poller); - return result; + task->curl.on_done = streams_tab_on_downloaded; + hard_assert (poller_curl_add (&g.poller_curl, task->curl.easy, NULL)); + return true; } static bool @@ -3472,6 +4026,9 @@ streams_tab_on_action (enum action action) case ACTION_MPD_ADD: streams_tab_process (uri, false, &e); break; + case ACTION_DESCRIBE: + app_show_message (xstrdup (uri), NULL); + break; default: return false; } @@ -3483,12 +4040,12 @@ streams_tab_on_action (enum action action) return true; } -static void -streams_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, - int width) +static struct layout +streams_tab_on_item_layout (size_t item_index) { - (void) width; - row_buffer_append (buffer, g.streams.vector[item_index], 0); + struct layout l = {}; + app_push_fill (&l, g.ui->label (0, g.streams.vector[item_index])); + return l; } static struct tab * @@ -3497,80 +4054,468 @@ streams_tab_init (void) static struct tab super; tab_init (&super, "Streams"); super.on_action = streams_tab_on_action; - super.on_item_draw = streams_tab_on_item_draw; + super.on_item_layout = streams_tab_on_item_layout; super.item_count = g.streams.len; return &super; } // --- Info tab ---------------------------------------------------------------- +struct info_tab_plugin +{ + LIST_HEADER (struct info_tab_plugin) + + char *path; ///< Filesystem path to plugin + char *description; ///< What the plugin does +}; + +static struct info_tab_plugin * +info_tab_plugin_load (const char *path) +{ + // Shell quoting is less annoying than process management. + struct str escaped = str_make (); + shell_quote (path, &escaped); + FILE *fp = popen (escaped.str, "r"); + str_free (&escaped); + if (!fp) + { + print_error ("%s: %s", path, strerror (errno)); + return NULL; + } + + struct str description = str_make (); + char buf[BUFSIZ]; + size_t len; + while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf) + str_append_data (&description, buf, len); + str_append_data (&description, buf, len); + if (pclose (fp)) + { + str_free (&description); + print_error ("%s: %s", path, strerror (errno)); + return NULL; + } + + char *newline = strpbrk (description.str, "\r\n"); + if (newline) + { + description.len = newline - description.str; + *newline = '\0'; + } + str_enforce_utf8 (&description); + if (!description.len) + { + str_free (&description); + print_error ("%s: %s", path, "missing description"); + return NULL; + } + + struct info_tab_plugin *plugin = xcalloc (1, sizeof *plugin); + plugin->path = xstrdup (path); + plugin->description = str_steal (&description); + return plugin; +} + +static void +info_tab_plugin_load_dir (struct str_map *basename_to_path, const char *dirname) +{ + DIR *dir = opendir (dirname); + if (!dir) + { + print_debug ("opendir: %s: %s", dirname, strerror (errno)); + return; + } + + struct dirent *entry = NULL; + while ((entry = readdir (dir))) + { + struct stat st = {}; + char *path = xstrdup_printf ("%s/%s", dirname, entry->d_name); + if (stat (path, &st) || !S_ISREG (st.st_mode)) + { + free (path); + continue; + } + + // Empty files silently erase formerly found basenames. + if (!st.st_size) + cstr_set (&path, NULL); + + str_map_set (basename_to_path, entry->d_name, path); + } + closedir (dir); +} + +static int +strv_sort_cb (const void *a, const void *b) +{ + return strcmp (*(const char **) a, *(const char **) b); +} + +static struct info_tab_plugin * +info_tab_plugin_load_all (void) +{ + struct str_map basename_to_path = str_map_make (free); + struct strv paths = strv_make (); + get_xdg_data_dirs (&paths); + strv_append (&paths, PROJECT_DATADIR); + for (size_t i = paths.len; i--; ) + { + char *dirname = + xstrdup_printf ("%s/" PROGRAM_NAME "/info", paths.vector[i]); + info_tab_plugin_load_dir (&basename_to_path, dirname); + free (dirname); + } + strv_free (&paths); + + struct strv sorted = strv_make (); + struct str_map_iter iter = str_map_iter_make (&basename_to_path); + while (str_map_iter_next (&iter)) + strv_append (&sorted, iter.link->key); + qsort (sorted.vector, sorted.len, sizeof *sorted.vector, strv_sort_cb); + + struct info_tab_plugin *result = NULL; + for (size_t i = sorted.len; i--; ) + { + const char *path = str_map_find (&basename_to_path, sorted.vector[i]); + struct info_tab_plugin *plugin = info_tab_plugin_load (path); + if (plugin) + LIST_PREPEND (result, plugin); + } + str_map_free (&basename_to_path); + strv_free (&sorted); + return result; +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +struct info_tab_item +{ + char *prefix; ///< Fixed-width prefix column or NULL + char *text; ///< Text or NULL + bool formatted; ///< Interpret inline formatting marks? + struct info_tab_plugin *plugin; ///< Activatable plugin +}; + +static void +info_tab_item_free (struct info_tab_item *self) +{ + cstr_set (&self->prefix, NULL); + cstr_set (&self->text, NULL); +} + static struct { struct tab super; ///< Parent class - struct strv keys; ///< Data keys - struct strv values; ///< Data values + struct info_tab_item *items; ///< Items array + size_t items_alloc; ///< How many items are allocated + + struct info_tab_plugin *plugins; ///< Plugins + + int plugin_songid; ///< Song ID or -1 + pid_t plugin_pid; ///< Running plugin's process ID or -1 + int plugin_stdout; ///< pid != -1: read end of stdout + struct poller_fd plugin_event; ///< pid != -1: stdout is readable + struct str plugin_output; ///< pid != -1: buffer, otherwise result } g_info_tab; +static chtype +info_tab_format_decode_toggle (char c) +{ + switch (c) + { + case '\x01': + return A_BOLD; + case '\x02': +#ifdef A_ITALIC + return A_ITALIC; +#else + return A_UNDERLINE; +#endif + default: + return 0; + } +} + static void -info_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width) +info_tab_format (struct layout *l, const char *text) { - (void) width; + chtype attrs = 0; + for (const char *p = text; *p; p++) + { + chtype toggled = info_tab_format_decode_toggle (*p); + if (!toggled) + continue; + + if (p != text) + { + char *slice = xstrndup (text, p - text); + app_push (l, g.ui->label (attrs, slice)); + free (slice); + } + + attrs ^= toggled; + text = p + 1; + } + if (*text) + app_push (l, g.ui->label (attrs, text)); +} + +static struct layout +info_tab_on_item_layout (size_t item_index) +{ + struct info_tab_item *item = &g_info_tab.items[item_index]; + struct layout l = {}; + if (item->prefix) + { + char *prefix = xstrdup_printf ("%s:", item->prefix); + app_push (&l, g.ui->label (A_BOLD, prefix)) + ->width = 8 * g_xui.hunit; + app_push (&l, g.ui->padding (0, 0.5, 1)); + } - // It looks like we could do with a generic list structure that just - // stores formatted row_buffers. Let's see for other tabs: - // - Current -- unusable, has dynamic column alignment - // - Library -- could work for the "icons" - // - Streams -- useless - // - Debug -- it'd take up considerably more space - // However so far we're only showing show key-value pairs. + if (item->plugin) + app_push (&l, g.ui->label (A_BOLD, item->plugin->description)); + else if (!item->text || !*item->text) + app_push (&l, g.ui->padding (0, 1, 1)); + else if (item->formatted) + info_tab_format (&l, item->text); + else + app_push (&l, g.ui->label (0, item->text)); - row_buffer_append_args (buffer, - g_info_tab.keys.vector[item_index], A_BOLD, ":", A_BOLD, NULL); - row_buffer_space (buffer, 8 - buffer->total_width, 0); - row_buffer_append (buffer, g_info_tab.values.vector[item_index], 0); + if (l.tail) + l.tail->width = -1; + return l; +} + +static struct info_tab_item * +info_tab_prepare (void) +{ + if (g_info_tab.super.item_count == g_info_tab.items_alloc) + g_info_tab.items = xreallocarray (g_info_tab.items, + sizeof *g_info_tab.items, (g_info_tab.items_alloc <<= 1)); + + struct info_tab_item *item = + &g_info_tab.items[g_info_tab.super.item_count++]; + memset (item, 0, sizeof *item); + return item; } static void info_tab_add (compact_map_t data, const char *field) { - const char *value = compact_map_find (data, field); - if (!value) value = ""; - - strv_append (&g_info_tab.keys, field); - strv_append (&g_info_tab.values, value); - g_info_tab.super.item_count++; + struct info_tab_item *item = info_tab_prepare (); + item->prefix = xstrdup (field); + item->text = xstrdup0 (compact_map_find (data, field)); } static void info_tab_update (void) { - strv_reset (&g_info_tab.keys); - strv_reset (&g_info_tab.values); - g_info_tab.super.item_count = 0; + while (g_info_tab.super.item_count) + info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]); - compact_map_t map; - if ((map = item_list_get (&g.playlist, g.song))) + compact_map_t map = item_list_get (&g.playlist, g.song); + if (!map) + return; + + info_tab_add (map, "Title"); + info_tab_add (map, "Artist"); + info_tab_add (map, "Album"); + info_tab_add (map, "Track"); + info_tab_add (map, "Genre"); + // We actually receive it as "file", but the key is also used for display + info_tab_add (map, "File"); + + if (g_info_tab.plugins) + { + (void) info_tab_prepare (); + LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins) + info_tab_prepare ()->plugin = plugin; + } + + if (g_info_tab.plugin_pid != -1) + { + (void) info_tab_prepare (); + info_tab_prepare ()->text = xstrdup ("Processing..."); + return; + } + + const char *songid = compact_map_find (map, "Id"); + if (songid && atoi (songid) == g_info_tab.plugin_songid + && g_info_tab.plugin_output.len) + { + struct strv lines = strv_make (); + cstr_split (g_info_tab.plugin_output.str, "\r\n", false, &lines); + + (void) info_tab_prepare (); + for (size_t i = 0; i < lines.len; i++) + { + struct info_tab_item *item = info_tab_prepare (); + item->formatted = true; + item->text = lines.vector[i]; + } + free (lines.vector); + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +info_tab_plugin_abort (void) +{ + if (g_info_tab.plugin_pid == -1) + return; + + // XXX: our methods of killing are very crude, we hope to improve; + // at least install a SIGCHLD handler to collect zombies + (void) kill (-g_info_tab.plugin_pid, SIGTERM); + + int status = 0; + while (waitpid (g_info_tab.plugin_pid, &status, WNOHANG) == -1 + && errno == EINTR) + ; + if (WIFEXITED (status) && WEXITSTATUS (status) != EXIT_SUCCESS) + print_error ("plugin reported failure"); + + g_info_tab.plugin_pid = -1; + poller_fd_reset (&g_info_tab.plugin_event); + xclose (g_info_tab.plugin_stdout); + g_info_tab.plugin_stdout = -1; +} + +static void +info_tab_on_plugin_stdout (const struct pollfd *fd, void *user_data) +{ + (void) user_data; + + struct str *buf = &g_info_tab.plugin_output; + switch (socket_io_try_read (fd->fd, buf)) { - info_tab_add (map, "Title"); - info_tab_add (map, "Artist"); - info_tab_add (map, "Album"); - info_tab_add (map, "Track"); - info_tab_add (map, "Genre"); - // Yes, it is "file", but this is also for display - info_tab_add (map, "File"); + case SOCKET_IO_OK: + str_enforce_utf8 (buf); + return; + case SOCKET_IO_ERROR: + print_error ("error reading from plugin: %s", strerror (errno)); + // Fall-through + case SOCKET_IO_EOF: + info_tab_plugin_abort (); + info_tab_update (); + xui_invalidate (); + } +} + +static void +info_tab_plugin_run (struct info_tab_plugin *plugin, compact_map_t map) +{ + info_tab_plugin_abort (); + if (!map) + return; + + const char *songid = compact_map_find (map, "Id"); + const char *title = compact_map_find (map, "Title"); + const char *artist = compact_map_find (map, "Artist"); + const char *album = compact_map_find (map, "Album"); + if (!songid || !title || !artist) + { + print_error ("unknown song title or artist"); + return; + } + + int stdout_pipe[2]; + if (pipe (stdout_pipe)) + { + print_error ("%s: %s", "pipe", strerror (errno)); + return; + } + + enum { READ, WRITE }; + set_cloexec (stdout_pipe[READ]); + set_cloexec (stdout_pipe[WRITE]); + + const char *argv[] = + { xbasename (plugin->path), title, artist, album, NULL }; + + pid_t child = fork (); + switch (child) + { + case -1: + print_error ("%s: %s", "fork", strerror (errno)); + xclose (stdout_pipe[READ]); + xclose (stdout_pipe[WRITE]); + return; + case 0: + if (setpgid (0, 0) == -1 || !freopen ("/dev/null", "r", stdin) + || dup2 (stdout_pipe[WRITE], STDOUT_FILENO) == -1 + || dup2 (stdout_pipe[WRITE], STDERR_FILENO) == -1) + _exit (EXIT_FAILURE); + + signal (SIGPIPE, SIG_DFL); + + (void) execv (plugin->path, (char **) argv); + fprintf (stderr, "%s\n", strerror (errno)); + _exit (EXIT_FAILURE); + default: + // Resolve the race, even though it isn't critical for us + (void) setpgid (child, child); + + g_info_tab.plugin_songid = atoi (songid); + g_info_tab.plugin_pid = child; + set_blocking ((g_info_tab.plugin_stdout = stdout_pipe[READ]), false); + xclose (stdout_pipe[WRITE]); + + struct poller_fd *event = &g_info_tab.plugin_event; + *event = poller_fd_make (&g.poller, g_info_tab.plugin_stdout); + event->dispatcher = info_tab_on_plugin_stdout; + str_reset (&g_info_tab.plugin_output); + poller_fd_set (&g_info_tab.plugin_event, POLLIN); + } +} + +static bool +info_tab_on_action (enum action action) +{ + struct tab *tab = g.active_tab; + if (tab->item_selected < 0 + || tab->item_selected >= (int) tab->item_count) + return false; + + struct info_tab_item *item = &g_info_tab.items[tab->item_selected]; + if (!item->plugin) + return false; + + switch (action) + { + case ACTION_DESCRIBE: + app_show_message (xstrdup ("Path: "), xstrdup (item->plugin->path)); + return true; + case ACTION_CHOOSE: + info_tab_plugin_run (item->plugin, item_list_get (&g.playlist, g.song)); + info_tab_update (); + xui_invalidate (); + return true; + default: + return false; } } static struct tab * info_tab_init (void) { - g_info_tab.keys = strv_make (); - g_info_tab.values = strv_make (); + g_info_tab.items = + xcalloc ((g_info_tab.items_alloc = 16), sizeof *g_info_tab.items); + + g_info_tab.plugins = info_tab_plugin_load_all (); + g_info_tab.plugin_songid = -1; + g_info_tab.plugin_pid = -1; + g_info_tab.plugin_stdout = -1; + g_info_tab.plugin_output = str_make (); struct tab *super = &g_info_tab.super; tab_init (super, "Info"); - super->on_item_draw = info_tab_on_item_draw; + super->on_action = info_tab_on_action; + super->on_item_layout = info_tab_on_item_layout; return super; } @@ -3589,29 +4534,25 @@ help_tab_on_action (enum action action) { struct tab *tab = &g_help_tab.super; if (tab->item_selected < 0 - || tab->item_selected >= (int) g_help_tab.actions_len - || action != ACTION_CHOOSE) + || tab->item_selected >= (int) g_help_tab.actions_len) return false; - action = g_help_tab.actions[tab->item_selected]; - return action != ACTION_NONE - && action != ACTION_CHOOSE // avoid recursion - && app_process_action (action); -} + enum action a = g_help_tab.actions[tab->item_selected]; + if (!a) + return false; -static void -help_tab_strfkey (const termo_key_t *key, struct strv *out) -{ - // For display purposes, this is highly desirable - int flags = termo_get_flags (g.tk); - termo_set_flags (g.tk, flags | TERMO_FLAG_SPACESYMBOL); - termo_key_t fixed = *key; - termo_canonicalise (g.tk, &fixed); - termo_set_flags (g.tk, flags); + if (action == ACTION_DESCRIBE) + { + app_show_message (xstrdup ("Configuration name: "), + xstrdup (g_action_names[a])); + return true; + } + if (action != ACTION_CHOOSE || a == ACTION_CHOOSE /* avoid recursion */) + return false; - char buf[16]; - termo_strfkey_utf8 (g.tk, buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA); - strv_append (out, buf); + // XXX: We can't propagate failure to ring the terminal/X11 bell, but we + // don't want to let our caller show a bad "can't do that" message either. + return app_process_action (a), true; } static void @@ -3635,12 +4576,12 @@ help_tab_group (struct binding *keys, size_t len, struct strv *out, struct strv ass = strv_make (); for (size_t k = 0; k < len; k++) if (keys[k].action == i) - help_tab_strfkey (&keys[k].decoded, &ass); + strv_append_owned (&ass, app_strfkey (&keys[k].decoded)); if (ass.len) { char *joined = strv_join (&ass, ", "); strv_append_owned (out, xstrdup_printf - (" %-30s %s", g_actions[i].description, joined)); + (" %s%c%s", g_action_descriptions[i], 0, joined)); free (joined); bound[i] = true; @@ -3657,19 +4598,27 @@ help_tab_unbound (struct strv *out, bool bound[ACTION_COUNT]) if (!bound[i]) { strv_append_owned (out, - xstrdup_printf (" %-30s", g_actions[i].description)); + xstrdup_printf (" %s%c", g_action_descriptions[i], 0)); help_tab_assign_action (i); } } -static void -help_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width) +static struct layout +help_tab_on_item_layout (size_t item_index) { - (void) width; - hard_assert (item_index < g_help_tab.lines.len); const char *line = g_help_tab.lines.vector[item_index]; - row_buffer_append (buffer, line, *line == ' ' ? 0 : A_BOLD); + + struct layout l = {}; + app_push_fill (&l, g.ui->label (*line == ' ' ? 0 : A_BOLD, line)); + + const char *definition = strchr (line, 0) + 1; + if (*line == ' ' && *definition) + { + app_push (&l, g.ui->padding (0, 0.5, 1)); + app_push_fill (&l, g.ui->label (0, definition)); + } + return l; } static struct tab * @@ -3704,7 +4653,7 @@ help_tab_init (void) struct tab *super = &g_help_tab.super; tab_init (super, "Help"); super->on_action = help_tab_on_action; - super->on_item_draw = help_tab_on_item_draw; + super->on_item_layout = help_tab_on_item_layout; super->item_count = lines->len; return super; } @@ -3726,8 +4675,8 @@ static struct } g_debug_tab; -static void -debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width) +static struct layout +debug_tab_on_item_layout (size_t item_index) { hard_assert (item_index < g_debug_tab.items_len); struct debug_item *item = &g_debug_tab.items[item_index]; @@ -3739,14 +4688,13 @@ debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width) char *prefix = xstrdup_printf ("%s.%03d", buf, (int) (item->timestamp % 1000)); - row_buffer_append (buffer, prefix, 0); - free (prefix); - - row_buffer_append (buffer, " ", item->attrs); - row_buffer_append (buffer, item->text, item->attrs); - // We override the formatting including colors -- do it for the whole line - row_buffer_align (buffer, width, item->attrs); + struct layout l = {}; + app_push (&l, g.ui->label (0, prefix)); + app_push (&l, g.ui->padding (item->attrs, 0.5, 1)); + app_push_fill (&l, g.ui->label (item->attrs, item->text)); + free (prefix); + return l; } static void @@ -3759,7 +4707,7 @@ debug_tab_push (char *message, chtype attrs) item->attrs = attrs; item->timestamp = clock_msec (CLOCK_REALTIME); - app_invalidate (); + xui_invalidate (); } static struct tab * @@ -3770,7 +4718,7 @@ debug_tab_init (void) struct tab *super = &g_debug_tab.super; tab_init (super, "Debug"); - super->on_item_draw = debug_tab_on_item_draw; + super->on_item_layout = debug_tab_on_item_layout; return super; } @@ -3783,15 +4731,11 @@ spectrum_redraw (void) { // A full refresh would be too computationally expensive, // let's hack around it in this case - if (g.spectrum_row != -1) - { - attrset (APP_ATTR (TAB_BAR)); - mvaddstr (g.spectrum_row, g.spectrum_column, g.spectrum.spectrum); - attrset (0); - refresh (); - } - else - app_invalidate (); + struct widget *spectrum = app_widget_by_id (WIDGET_SPECTRUM); + if (spectrum) + spectrum->on_render (spectrum); + + poller_idle_set (&g_xui.flip_event); } // When any problem occurs with the FIFO, we'll just give up on it completely @@ -3805,8 +4749,7 @@ spectrum_discard_fifo (void) g.spectrum_fd = -1; spectrum_free (&g.spectrum); - g.spectrum_row = g.spectrum_column = -1; - app_invalidate (); + xui_invalidate (); } } @@ -3867,6 +4810,8 @@ spectrum_setup_fifo (void) get_config_string (g.config.root, "settings.spectrum_format"); struct config_item *spectrum_bars = config_item_get (g.config.root, "settings.spectrum_bars", NULL); + struct config_item *spectrum_fps = + config_item_get (g.config.root, "settings.spectrum_fps", NULL); if (!spectrum_path) return; @@ -3876,10 +4821,10 @@ spectrum_setup_fifo (void) if (!path) print_error ("spectrum: %s", "FIFO path could not be resolved"); - else if (!g.locale_is_utf8) + else if (!g_xui.locale_is_utf8) print_error ("spectrum: %s", "UTF-8 locale required"); - else if (!spectrum_init (&g.spectrum, - (char *) spectrum_format, spectrum_bars->value.integer, &e)) + else if (!spectrum_init (&g.spectrum, (char *) spectrum_format, + spectrum_bars->value.integer, spectrum_fps->value.integer, &e)) { print_error ("spectrum: %s", e->message); error_free (e); @@ -3900,11 +4845,86 @@ spectrum_setup_fifo (void) } #else // ! WITH_FFTW -#define spectrum_setup_fifo() -#define spectrum_clear() -#define spectrum_discard_fifo() +#define spectrum_setup_fifo() BLOCK_START BLOCK_END +#define spectrum_clear() BLOCK_START BLOCK_END +#define spectrum_discard_fifo() BLOCK_START BLOCK_END #endif // ! WITH_FFTW +// --- PulseAudio -------------------------------------------------------------- + +#ifdef WITH_PULSE + +static bool +mpd_find_output (const struct strv *data, const char *wanted) +{ + // The plugin field is new in MPD 0.21, by default take any output + unsigned long n, accept = 1; + for (size_t i = data->len; i--; ) + { + char *key, *value; + if (!(key = mpd_parse_kv (data->vector[i], &value))) + continue; + + if (!strcasecmp_ascii (key, "outputid")) + { + if (accept) + return true; + + accept = 1; + } + else if (!strcasecmp_ascii (key, "plugin")) + accept &= !strcmp (value, wanted); + else if (!strcasecmp_ascii (key, "outputenabled") + && xstrtoul (&n, value, 10)) + accept &= n == 1; + } + return false; +} + +static void +mpd_on_outputs_response (const struct mpd_response *response, + const struct strv *data, void *user_data) +{ + (void) user_data; + + // TODO: check whether an action is actually necessary + pulse_free (&g.pulse); + if (response->success && !mpd_find_output (data, "pulse")) + print_debug ("MPD has no PulseAudio output to control"); + else + { + pulse_init (&g.pulse, &g.poller); + g.pulse.on_update = xui_invalidate; + } + + xui_invalidate (); +} + +static void +pulse_update (void) +{ + struct mpd_client *c = &g.client; + if (!g.pulse_control_requested) + return; + + // The read permission is sufficient for this command + mpd_client_send_command (c, "outputs", NULL); + mpd_client_add_task (c, mpd_on_outputs_response, NULL); + mpd_client_idle (c, 0); +} + +static void +pulse_disable (void) +{ + pulse_free (&g.pulse); + xui_invalidate (); +} + +#else // ! WITH_PULSE +#define pulse_update() BLOCK_START BLOCK_END +#define pulse_disable() BLOCK_START BLOCK_END +#endif // ! WITH_PULSE + // --- MPD interface ----------------------------------------------------------- static void @@ -4006,7 +5026,7 @@ mpd_update_playback_state (void) if (g.playlist_version != last_playlist_version) mpd_update_playlist_time (); - app_invalidate (); + xui_invalidate (); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4062,7 +5082,7 @@ mpd_find_pos_of_id (const char *desired_id) return -1; } -static char * +static const char * mpd_id_of_pos (int pos) { compact_map_t map = item_list_get (&g.playlist, pos); @@ -4072,29 +5092,39 @@ mpd_id_of_pos (int pos) static void mpd_process_info (const struct strv *data) { - int *selected = &g_current_tab.item_selected; - int *marked = &g_current_tab.item_mark; - char *prev_sel_id = mpd_id_of_pos (*selected); - char *prev_mark_id = mpd_id_of_pos (*marked); - if (prev_sel_id) prev_sel_id = xstrdup (prev_sel_id); - if (prev_mark_id) prev_mark_id = xstrdup (prev_mark_id); + struct tab *tab = &g_current_tab; + char *prev_sel_id = xstrdup0 (mpd_id_of_pos (tab->item_selected)); + char *prev_mark_id = xstrdup0 (mpd_id_of_pos (tab->item_mark)); + char *fallback_id = NULL; + + struct tab_range r = tab_selection_range (g.active_tab); + if (r.upto >= 0) + { + if (!(fallback_id = xstrdup0 (mpd_id_of_pos (r.upto + 1)))) + fallback_id = xstrdup0 (mpd_id_of_pos (r.from - 1)); + } mpd_process_info_data (data); - const char *sel_id = mpd_id_of_pos (*selected); - const char *mark_id = mpd_id_of_pos (*marked); + const char *sel_id = mpd_id_of_pos (tab->item_selected); + const char *mark_id = mpd_id_of_pos (tab->item_mark); if (prev_mark_id && (!mark_id || strcmp (prev_mark_id, mark_id))) - *marked = mpd_find_pos_of_id (prev_mark_id); + tab->item_mark = mpd_find_pos_of_id (prev_mark_id); if (prev_sel_id && (!sel_id || strcmp (prev_sel_id, sel_id))) { - if ((*selected = mpd_find_pos_of_id (prev_sel_id)) < 0) - *marked = -1; + if ((tab->item_selected = mpd_find_pos_of_id (prev_sel_id)) < 0) + { + tab->item_mark = -1; + if (fallback_id) + tab->item_selected = mpd_find_pos_of_id (fallback_id); + } app_move_selection (0); } free (prev_sel_id); free (prev_mark_id); + free (fallback_id); } static void @@ -4134,7 +5164,7 @@ mpd_on_elapsed_time_tick (void *user_data) // Try to get called on the next round second of playback poller_timer_set (&g.elapsed_event, 1000 - elapsed_msec); - app_invalidate (); + xui_invalidate (); } static void @@ -4169,6 +5199,8 @@ mpd_on_events (unsigned subsystems, void *user_data) if (subsystems & MPD_SUBSYSTEM_DATABASE) library_tab_reload (NULL); + if (subsystems & MPD_SUBSYSTEM_OUTPUT) + pulse_update (); if (subsystems & (MPD_SUBSYSTEM_PLAYER | MPD_SUBSYSTEM_OPTIONS | MPD_SUBSYSTEM_PLAYLIST | MPD_SUBSYSTEM_MIXER | MPD_SUBSYSTEM_UPDATE)) @@ -4185,6 +5217,58 @@ mpd_queue_reconnect (void) poller_timer_set (&g.connect_event, 5 * 1000); } +// On an error, MPD discards the rest of our enqueuing commands--work it around +static void mpd_enqueue_step (size_t start_offset); + +static void +mpd_on_enqueue_response (const struct mpd_response *response, + const struct strv *data, void *user_data) +{ + (void) data; + intptr_t start_offset = (intptr_t) user_data; + + if (response->success) + strv_reset (&g.enqueue); + else + { + // Their addition may also overflow, but YOLO + hard_assert (start_offset >= 0 && response->list_offset >= 0); + + print_error ("%s: %s", response->message_text, + g.enqueue.vector[start_offset + response->list_offset]); + mpd_enqueue_step (start_offset + response->list_offset + 1); + } +} + +static void +mpd_enqueue_step (size_t start_offset) +{ + struct mpd_client *c = &g.client; + if (start_offset >= g.enqueue.len) + { + strv_reset (&g.enqueue); + return; + } + + // TODO: might want to consider using addid and autoplaying + mpd_client_list_begin (c); + for (size_t i = start_offset; i < g.enqueue.len; i++) + mpd_client_send_command (c, "add", g.enqueue.vector[i], NULL); + mpd_client_list_end (c); + mpd_client_add_task (c, mpd_on_enqueue_response, (void *) start_offset); + mpd_client_idle (c, 0); +} + +static void +mpd_on_ready (void) +{ + mpd_request_info (); + library_tab_reload (NULL); + spectrum_setup_fifo (); + pulse_update (); + mpd_enqueue_step (0); +} + static void mpd_on_password_response (const struct mpd_response *response, const struct strv *data, void *user_data) @@ -4194,10 +5278,7 @@ mpd_on_password_response (const struct mpd_response *response, struct mpd_client *c = &g.client; if (response->success) - { - mpd_request_info (); - library_tab_reload (NULL); - } + mpd_on_ready (); else { print_error ("%s: %s", @@ -4220,12 +5301,7 @@ mpd_on_connected (void *user_data) mpd_client_add_task (c, mpd_on_password_response, NULL); } else - { - mpd_request_info (); - library_tab_reload (NULL); - } - - spectrum_setup_fifo (); + mpd_on_ready (); } static void @@ -4244,6 +5320,7 @@ mpd_on_failure (void *user_data) info_tab_update (); spectrum_discard_fifo (); + pulse_disable (); } static void @@ -4297,8 +5374,544 @@ app_on_reconnect (void *user_data) mpd_queue_reconnect (); } free (address); + xui_invalidate (); +} + +// --- TUI --------------------------------------------------------------------- + +static struct widget * +tui_make_button (chtype attrs, const char *label, enum action a) +{ + struct widget *w = tui_make_label (attrs, 0, label); + w->id = WIDGET_BUTTON; + w->userdata = a; + return w; +} + +static void +tui_render_gauge (struct widget *self) +{ + struct row_buffer buf = row_buffer_make (); + if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1) + goto out; + + float ratio = (float) g.song_elapsed / g.song_duration; + 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 * self->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 = self->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)); + +out: + tui_flush_buffer (self, &buf); +} + +// TODO: Perhaps it should save the number within. +static struct widget * +tui_make_gauge (chtype attrs) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = tui_render_gauge; + w->attrs = attrs; + w->width = -1; + w->height = 1; + return w; +} + +static void +tui_render_spectrum (struct widget *self) +{ + // Don't mess up the line editor caret, when it's shown + int last_x, last_y; + getyx (stdscr, last_y, last_x); + + struct row_buffer buf = row_buffer_make (); +#ifdef WITH_FFTW + row_buffer_append (&buf, g.spectrum.rendered, self->attrs); +#endif // WITH_FFTW + tui_flush_buffer (self, &buf); + + move (last_y, last_x); +} + +static struct widget * +tui_make_spectrum (chtype attrs, int width) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = tui_render_spectrum; + w->attrs = attrs; + w->width = width; + w->height = 1; + return w; +} + +static void +tui_render_scrollbar (struct widget *self) +{ + // 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). + 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 (self->y + row, self->x); + if (row < bar.start || row >= bar.start + bar.length) + addch (' ' | self->attrs); + else + addch (' ' | self->attrs | A_REVERSE); + } + return; + } + + struct scrollbar bar = app_compute_scrollbar (tab, visible_items * 8, 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 = self->attrs; + 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 (self->y + row, self->x); + + struct row_buffer buf = row_buffer_make (); + row_buffer_append (&buf, c, attrs); + row_buffer_flush (&buf); + row_buffer_free (&buf); + } +} + +static struct widget * +tui_make_scrollbar (chtype attrs) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = tui_render_scrollbar; + w->attrs = attrs; + w->width = 1; + return w; +} + +static struct widget * +tui_make_list (void) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->width = -1; + w->height = g.active_tab->item_count; + return w; +} + +static void +tui_render_editor (struct widget *self) +{ + struct row_buffer buf = row_buffer_make (); + const struct line_editor *e = &g.editor; + int width = self->width; + if (e->prompt) + { + hard_assert (e->prompt < 127); + row_buffer_append_c (&buf, e->prompt, self->attrs); + width--; + } + + int following = 0; + for (size_t i = e->point; i < e->len; i++) + following += e->w[i]; + + int preceding = 0; + size_t start = e->point; + while (start && preceding < width / 2) + preceding += e->w[--start]; + + // There can be one extra space at the end of the line but this way we + // don't need to care about non-spacing marks following full-width chars + while (start && width - preceding - following > 2 /* widest char */) + preceding += e->w[--start]; + + // XXX: we should also show < > indicators for overflow but it'd probably + // considerably complicate this algorithm + for (; start < e->len; start++) + row_buffer_append_c (&buf, e->line[start], self->attrs); + tui_flush_buffer (self, &buf); + + // FIXME: This should be at the end of of tui_render(). + int caret = !!e->prompt + preceding; + move (self->y, self->x + caret); + curs_set (1); +} + +static struct widget * +tui_make_editor (chtype attrs) +{ + // TODO: This should ideally measure the text, and copy it to w->text. + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = tui_render_editor; + w->attrs = attrs; + w->width = -1; + w->height = 1; + return w; +} + +static struct app_ui app_tui_ui = +{ + .padding = tui_make_padding, + .label = app_make_label, + .button = tui_make_button, + .gauge = tui_make_gauge, + .spectrum = tui_make_spectrum, + .scrollbar = tui_make_scrollbar, + .list = tui_make_list, + .editor = tui_make_editor, +}; + +// --- X11 --------------------------------------------------------------------- + +#ifdef WITH_X11 + +// On a 20x20 raster to make it feasible to design on paper. +#define X11_STOP {INFINITY, INFINITY} +static const XPointDouble + x11_icon_previous[] = + { + {10, 0}, {0, 10}, {10, 20}, X11_STOP, + {20, 0}, {10, 10}, {20, 20}, X11_STOP, X11_STOP, + }, + x11_icon_pause[] = + { + {1, 0}, {7, 0}, {7, 20}, {1, 20}, X11_STOP, + {13, 0}, {19, 0}, {19, 20}, {13, 20}, X11_STOP, X11_STOP, + }, + x11_icon_play[] = + { + {0, 0}, {20, 10}, {0, 20}, X11_STOP, X11_STOP, + }, + x11_icon_stop[] = + { + {0, 0}, {20, 0}, {20, 20}, {0, 20}, X11_STOP, X11_STOP, + }, + x11_icon_next[] = + { + {0, 0}, {10, 10}, {0, 20}, X11_STOP, + {10, 0}, {20, 10}, {10, 20}, X11_STOP, X11_STOP, + }, + x11_icon_repeat[] = + { + {0, 12}, {0, 6}, {3, 3}, {13, 3}, {13, 0}, {20, 4.5}, + {13, 9}, {13, 6}, {3, 6}, {3, 10}, X11_STOP, + {0, 15.5}, {7, 11}, {7, 14}, {17, 14}, {17, 10}, {20, 8}, + {20, 14}, {17, 17}, {7, 17}, {7, 20}, X11_STOP, X11_STOP, + }, + x11_icon_random[] = + { + {0, 6}, {0, 3}, {5, 3}, {6, 4.5}, {4, 7.5}, {3, 6}, X11_STOP, + {9, 15.5}, {11, 12.5}, {12, 14}, {13, 14}, {13, 11}, {20, 15.5}, + {13, 20}, {13, 17}, {10, 17}, X11_STOP, + {0, 17}, {0, 14}, {3, 14}, {10, 3}, {13, 3}, {13, 0}, {20, 4.5}, + {13, 9}, {13, 6}, {12, 6}, {5, 17}, X11_STOP, X11_STOP, + }, + x11_icon_single[] = + { + {7, 6}, {7, 4}, {9, 2}, {12, 2}, {12, 15}, {14, 15}, {14, 18}, + {7, 18}, {7, 15}, {9, 15}, {9, 6}, X11_STOP, X11_STOP, + }, + x11_icon_consume[] = + { + {0, 13}, {0, 7}, {4, 3}, {10, 3}, {14, 7}, {5, 10}, {14, 13}, + {10, 17}, {4, 17}, X11_STOP, + {16, 12}, {16, 8}, {20, 8}, {20, 12}, X11_STOP, X11_STOP, + }; + +static const XPointDouble * +x11_icon_for_action (enum action action) +{ + switch (action) + { + case ACTION_MPD_PREVIOUS: + return x11_icon_previous; + case ACTION_MPD_TOGGLE: + return g.state == PLAYER_PLAYING ? x11_icon_pause : x11_icon_play; + case ACTION_MPD_STOP: + return x11_icon_stop; + case ACTION_MPD_NEXT: + return x11_icon_next; + case ACTION_MPD_REPEAT: + return x11_icon_repeat; + case ACTION_MPD_RANDOM: + return x11_icon_random; + case ACTION_MPD_SINGLE: + return x11_icon_single; + case ACTION_MPD_CONSUME: + return x11_icon_consume; + default: + return NULL; + } +} + +static void +x11_render_button (struct widget *self) +{ + x11_render_padding (self); + + const XPointDouble *icon = x11_icon_for_action (self->userdata); + if (!icon) + { + x11_render_label (self); + return; + } + + size_t total = 0; + for (size_t i = 0; icon[i].x != INFINITY || icon[i - 1].x != INFINITY; i++) + total++; + + // TODO: There should be an attribute for buttons, to handle this better. + XRenderColor color = *x11_fg (self); + if (!(self->attrs & A_BOLD)) + { + color.alpha /= 2; + color.red /= 2; + color.green /= 2; + color.blue /= 2; + } + + Picture source = XRenderCreateSolidFill (g_xui.dpy, &color); + const XRenderPictFormat *format + = XRenderFindStandardFormat (g_xui.dpy, PictStandardA8); + + int x = self->x, y = self->y + (self->height - self->width) / 2; + XPointDouble buffer[total], *p = buffer; + for (size_t i = 0; i < total; i++) + if (icon[i].x != INFINITY) + { + p->x = x + icon[i].x / 20.0 * self->width; + p->y = y + icon[i].y / 20.0 * self->width; + p++; + } + else if (p != buffer) + { + XRenderCompositeDoublePoly (g_xui.dpy, PictOpOver, + source, g_xui.x11_pixmap_picture, format, + 0, 0, 0, 0, buffer, p - buffer, EvenOddRule); + p = buffer; + } + XRenderFreePicture (g_xui.dpy, source); +} + +static struct widget * +x11_make_button (chtype attrs, const char *label, enum action a) +{ + struct widget *w = x11_make_label (attrs, 0, label); + w->id = WIDGET_BUTTON; + w->userdata = a; + + if (x11_icon_for_action (a)) + { + w->on_render = x11_render_button; + + // It should be padded by the caller horizontally. + w->height = g_xui.vunit; + w->width = w->height * 3 / 4; + } + return w; +} + +static void +x11_render_gauge (struct widget *self) +{ + x11_render_padding (self); + if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1) + return; + + int part = (float) g.song_elapsed / g.song_duration * self->width; + XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture, + x11_bg_attrs (APP_ATTR (ELAPSED)), + self->x, + self->y + self->height / 8, + part, + self->height * 3 / 4); + XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture, + x11_bg_attrs (APP_ATTR (REMAINS)), + self->x + part, + self->y + self->height / 8, + self->width - part, + self->height * 3 / 4); +} + +// TODO: Perhaps it should save the number within. +static struct widget * +x11_make_gauge (chtype attrs) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = x11_render_gauge; + w->attrs = attrs; + w->width = -1; + w->height = g_xui.vunit; + return w; +} + +static void +x11_render_spectrum (struct widget *self) +{ + x11_render_padding (self); + +#ifdef WITH_FFTW + XRectangle rectangles[g.spectrum.bars]; + int step = self->width / N_ELEMENTS (rectangles); + for (int i = 0; i < g.spectrum.bars; i++) + { + int height = round ((self->height - 2) * g.spectrum.spectrum[i]); + rectangles[i] = (XRectangle) + { + self->x + i * step, + self->y + self->height - 1 - height, + step, + height, + }; + } + + XRenderFillRectangles (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture, + x11_fg (self), rectangles, N_ELEMENTS (rectangles)); +#endif // WITH_FFTW + + // Enable the spectrum_redraw() hack. + XRectangle r = { self->x, self->y, self->width, self->height }; + XUnionRectWithRegion (&r, g_xui.x11_clip, g_xui.x11_clip); +} + +static struct widget * +x11_make_spectrum (chtype attrs, int width) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = x11_render_spectrum; + w->attrs = attrs; + w->width = width * g_xui.vunit / 2; + w->height = g_xui.vunit; + return w; +} + +static void +x11_render_scrollbar (struct widget *self) +{ + x11_render_padding (self); + + struct tab *tab = g.active_tab; + struct scrollbar bar = + app_compute_scrollbar (tab, app_visible_items_height (), g_xui.vunit); + + XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture, + x11_fg_attrs (self->attrs), + self->x, + self->y + bar.start, + self->width, + bar.length); +} + +static struct widget * +x11_make_scrollbar (chtype attrs) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = x11_render_scrollbar; + w->attrs = attrs; + w->width = g_xui.vunit / 2; + return w; +} + +static struct widget * +x11_make_list (void) +{ + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = x11_render_padding; + return w; +} + +static void +x11_render_editor (struct widget *self) +{ + x11_render_padding (self); + + XftFont *font = x11_widget_font (self)->list->font; + XftColor color = { .color = *x11_fg (self) }; + + // A simplistic adaptation of line_editor_write() follows. + int x = self->x, y = self->y + font->ascent; + XGlyphInfo extents = {}; + if (g.editor.prompt) + { + FT_UInt i = XftCharIndex (g_xui.dpy, font, g.editor.prompt); + XftDrawGlyphs (g_xui.xft_draw, &color, font, x, y, &i, 1); + XftGlyphExtents (g_xui.dpy, font, &i, 1, &extents); + x += extents.xOff + g_xui.vunit / 4; + } + + // TODO: Adapt x11_font_{hadvance,draw}(). + // TODO: Make this scroll around the caret, and fade like labels. + XftDrawString32 (g_xui.xft_draw, &color, font, x, y, + g.editor.line, g.editor.len); + + XftTextExtents32 (g_xui.dpy, font, g.editor.line, g.editor.point, &extents); + XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture, + &color.color, x + extents.xOff, self->y, 2, self->height); +} + +static struct widget * +x11_make_editor (chtype attrs) +{ + // TODO: This should ideally measure the text, and copy it to w->text. + struct widget *w = xcalloc (1, sizeof *w + 1); + w->on_render = x11_render_editor; + w->attrs = attrs; + w->width = -1; + w->height = g_xui.vunit; + return w; } +static struct app_ui app_x11_ui = +{ + .padding = x11_make_padding, + .label = app_make_label, + .button = x11_make_button, + .gauge = x11_make_gauge, + .spectrum = x11_make_spectrum, + .scrollbar = x11_make_scrollbar, + .list = x11_make_list, + .editor = x11_make_editor, + + .have_icons = true, +}; + +#endif // WITH_X11 + // --- Signals ----------------------------------------------------------------- static int g_signal_pipe[2]; ///< A pipe used to signal... signals @@ -4366,72 +5979,7 @@ signals_setup_handlers (void) // --- Initialisation, event handling ------------------------------------------ -static void -app_on_tty_event (termo_key_t *event, int64_t event_ts) -{ - // Simple double click detection via release--press delay, only a bit - // complicated by the fact that we don't know what's being released - static termo_key_t last_event; - static int64_t last_event_ts; - static int last_button; - - int y, x, button, y_last, x_last; - termo_mouse_event_t type, type_last; - if (termo_interpret_mouse (g.tk, event, &type, &button, &y, &x)) - { - bool double_click = termo_interpret_mouse - (g.tk, &last_event, &type_last, NULL, &y_last, &x_last) - && event_ts - last_event_ts < 500 - && type_last == TERMO_MOUSE_RELEASE && type == TERMO_MOUSE_PRESS - && y_last == y && x_last == x && last_button == button; - if (!app_process_mouse (type, y, x, button, double_click)) - beep (); - - // Prevent interpreting triple clicks as two double clicks - if (double_click) - last_button = 0; - else if (type == TERMO_MOUSE_PRESS) - last_button = button; - } - else if (!app_process_termo_event (event)) - beep (); - - last_event = *event; - last_event_ts = event_ts; -} - -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.tk_timer); - termo_advisereadable (g.tk); - - termo_key_t event; - int64_t event_ts = clock_msec (CLOCK_BEST); - termo_result_t res; - while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY) - app_on_tty_event (&event, event_ts); - - if (res == TERMO_RES_AGAIN) - poller_timer_set (&g.tk_timer, termo_get_waittime (g.tk)); - else if (res == TERMO_RES_ERROR || res == TERMO_RES_EOF) - app_quit (); -} - -static void -app_on_key_timer (void *user_data) -{ - (void) user_data; - - termo_key_t event; - if (termo_getkey_force (g.tk, &event) == TERMO_RES_KEY) - if (!app_process_termo_event (&event)) - beep (); -} +static bool g_verbose_mode = false; static void app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data) @@ -4444,11 +5992,13 @@ app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data) if (g_termination_requested && !g.quitting) app_quit (); + // It would be awkward to set up SIGWINCH conditionally, + // so have it as a handler within UIs. if (g_winch_received) { g_winch_received = false; - update_curses_terminal_size (); - app_invalidate (); + if (g_xui.ui->winch) + g_xui.ui->winch (); } } @@ -4457,8 +6007,7 @@ app_on_message_timer (void *user_data) { (void) user_data; - cstr_set (&g.message, NULL); - app_invalidate (); + app_hide_message (); } static void @@ -4474,21 +6023,20 @@ app_log_handler (void *user_data, const char *quote, const char *fmt, struct str message = str_make (); str_append (&message, quote); + size_t quote_len = message.len; str_append_vprintf (&message, fmt, ap); - // If the standard error output isn't redirected, try our best at showing - // the message to the user - if (!isatty (STDERR_FILENO)) + // Show it prettified to the user, then maybe log it elsewhere as well. + // TODO: Review locale encoding vs UTF-8 in the entire program. + message.str[0] = toupper_ascii (message.str[0]); + app_show_message (xstrndup (message.str, quote_len), + xstrdup (message.str + quote_len)); + + if (g_verbose_mode && (g_xui.ui != &tui_ui || !isatty (STDERR_FILENO))) fprintf (stderr, "%s\n", message.str); - else if (g_debug_tab.active) + if (g_debug_tab.active) debug_tab_push (str_steal (&message), user_data == NULL ? 0 : g.attrs[(intptr_t) user_data].attrs); - else - { - cstr_set (&g.message, xstrdup (message.str)); - app_invalidate (); - poller_timer_set (&g.message_timer, 5000); - } str_free (&message); in_processing = false; @@ -4501,16 +6049,9 @@ app_init_poller_events (void) g.signal_event.dispatcher = app_on_signal_pipe_readable; poller_fd_set (&g.signal_event, POLLIN); - g.tty_event = poller_fd_make (&g.poller, STDIN_FILENO); - g.tty_event.dispatcher = app_on_tty_readable; - poller_fd_set (&g.tty_event, POLLIN); - g.message_timer = poller_timer_make (&g.poller); g.message_timer.dispatcher = app_on_message_timer; - g.tk_timer = poller_timer_make (&g.poller); - g.tk_timer.dispatcher = app_on_key_timer; - g.connect_event = poller_timer_make (&g.poller); g.connect_event.dispatcher = app_on_reconnect; poller_timer_set (&g.connect_event, 0); @@ -4519,9 +6060,56 @@ app_init_poller_events (void) g.elapsed_event.dispatcher = g.elapsed_poll ? mpd_on_elapsed_time_tick_poll : mpd_on_elapsed_time_tick; +} - g.refresh_event = poller_idle_make (&g.poller); - g.refresh_event.dispatcher = app_on_refresh; +static void +app_init_ui (bool requested_x11) +{ + xui_preinit (); + + g_normal_keys = app_init_bindings ("normal", + g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len); + g_editor_keys = app_init_bindings ("editor", + g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len); + + // It doesn't work 100% (e.g. incompatible with undelining in urxvt) + // TODO: make this configurable + g.use_partial_boxes = g_xui.locale_is_utf8; + +#ifdef WITH_X11 + g_xui.x11_fontname = get_config_string (g.config.root, "settings.x11_font"); +#endif // WITH_X11 + + xui_start (&g.poller, requested_x11, g.attrs, N_ELEMENTS (g.attrs)); + +#ifdef WITH_X11 + if (g_xui.ui == &x11_ui) + g.ui = &app_x11_ui; + else +#endif // WITH_X11 + g.ui = &app_tui_ui; +} + +static void +app_init_enqueue (char *argv[], int argc) +{ + // TODO: MPD is unwilling to play directories, so perhaps recurse ourselves + char cwd[4096] = ""; + for (int i = 0; i < argc; i++) + { + // This is a super-trivial method of URL detection, however anything + // contaning the scheme and authority delimiters in a sequence is most + // certainly not a filesystem path, and thus it will work as expected. + // Error handling may be done by MPD. + const char *path_or_URL = argv[i]; + if (*path_or_URL == '/' || strstr (path_or_URL, "://")) + strv_append (&g.enqueue, path_or_URL); + else if (!*cwd && !getcwd (cwd, sizeof cwd)) + exit_fatal ("getcwd: %s", strerror (errno)); + else + strv_append_owned (&g.enqueue, + xstrdup_printf ("%s/%s", cwd, path_or_URL)); + } } int @@ -4530,13 +6118,18 @@ main (int argc, char *argv[]) static const struct opt opts[] = { { 'd', "debug", NULL, 0, "run in debug mode" }, +#ifdef WITH_X11 + { 'x', "x11", NULL, 0, "use X11 even when run from a terminal" }, +#endif // WITH_X11 { 'h', "help", NULL, 0, "display this help and exit" }, + { 'v', "verbose", NULL, 0, "log messages on standard error" }, { 'V', "version", NULL, 0, "output version information and exit" }, { 0, NULL, NULL, 0, NULL } }; - struct opt_handler oh = - opt_handler_make (argc, argv, opts, NULL, "Terminal-based MPD client."); + bool requested_x11 = false; + struct opt_handler oh + = opt_handler_make (argc, argv, opts, "[URL | PATH]...", "MPD client."); int c; while ((c = opt_handler_get (&oh)) != -1) @@ -4545,6 +6138,12 @@ main (int argc, char *argv[]) case 'd': g_debug_mode = true; break; + case 'x': + requested_x11 = true; + break; + case 'v': + g_verbose_mode = true; + break; case 'h': opt_handler_usage (&oh, stdout); exit (EXIT_SUCCESS); @@ -4559,12 +6158,6 @@ main (int argc, char *argv[]) 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 @@ -4572,15 +6165,11 @@ main (int argc, char *argv[]) print_warning ("failed to set the locale"); app_init_context (); + app_init_enqueue (argv, argc); app_load_configuration (); - app_init_terminal (); signals_setup_handlers (); app_init_poller_events (); - - g_normal_keys = app_init_bindings ("normal", - g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len); - g_editor_keys = app_init_bindings ("editor", - g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len); + app_init_ui (requested_x11); if (g_debug_mode) app_prepend_tab (debug_tab_init ()); @@ -4595,11 +6184,16 @@ main (int argc, char *argv[]) app_prepend_tab (current_tab_init ()); app_switch_tab ((g.help_tab = help_tab_init ())); + // TODO: the help tab should be the default for new users only, + // so provide a configuration option to flip this + if (argc) + app_switch_tab (&g_current_tab); + g.polling = true; while (g.polling) poller_run (&g.poller); - endwin (); + xui_stop (); g_log_message_real = log_message_stdio; app_free_context (); return 0; |