diff options
Diffstat (limited to 'nncmpp.c')
-rw-r--r-- | nncmpp.c | 4199 |
1 files changed, 3197 insertions, 1002 deletions
@@ -1,7 +1,7 @@ /* * nncmpp -- the MPD client you never knew you needed * - * Copyright (c) 2016 - 2020, 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. @@ -21,30 +21,32 @@ // We "need" to have an enum for attributes before including liberty. // Avoiding colours in the defaults here in order to support dumb terminals. #define ATTRIBUTE_TABLE(XX) \ - XX( NORMAL, "normal", -1, -1, 0 ) \ - XX( HIGHLIGHT, "highlight", -1, -1, A_BOLD ) \ + XX( NORMAL, normal, -1, -1, 0 ) \ + XX( HIGHLIGHT, highlight, -1, -1, A_BOLD ) \ /* Gauge */ \ - XX( ELAPSED, "elapsed", -1, -1, A_REVERSE ) \ - XX( REMAINS, "remains", -1, -1, A_UNDERLINE ) \ + XX( ELAPSED, elapsed, -1, -1, A_REVERSE ) \ + XX( REMAINS, remains, -1, -1, A_UNDERLINE ) \ /* Tab bar */ \ - XX( TAB_BAR, "tab_bar", -1, -1, A_REVERSE ) \ - XX( TAB_ACTIVE, "tab_active", -1, -1, A_UNDERLINE ) \ + XX( TAB_BAR, tab_bar, -1, -1, A_REVERSE ) \ + XX( TAB_ACTIVE, tab_active, -1, -1, A_BOLD ) \ /* Listview */ \ - XX( HEADER, "header", -1, -1, A_UNDERLINE ) \ - XX( EVEN, "even", -1, -1, 0 ) \ - XX( ODD, "odd", -1, -1, 0 ) \ - XX( DIRECTORY, "directory", -1, -1, 0 ) \ - XX( SELECTION, "selection", -1, -1, A_REVERSE ) \ + XX( HEADER, header, -1, -1, A_UNDERLINE ) \ + XX( EVEN, even, -1, -1, 0 ) \ + XX( ODD, odd, -1, -1, 0 ) \ + XX( DIRECTORY, directory, -1, -1, 0 ) \ + XX( SELECTION, selection, -1, -1, A_REVERSE ) \ /* Cyan is good with both black and white. * Can't use A_REVERSE because bold'd be bright. * Unfortunately ran out of B&W attributes. */ \ - XX( MULTISELECT, "multiselect",-1, 6, 0 ) \ - XX( SCROLLBAR, "scrollbar", -1, -1, 0 ) \ + XX( MULTISELECT, multiselect, -1, 6, 0 ) \ + /* This ought to be indicative enough. */ \ + XX( DEFOCUSED, defocused, -1, -1, A_UNDERLINE ) \ + XX( SCROLLBAR, scrollbar, -1, -1, 0 ) \ /* These are for debugging only */ \ - XX( WARNING, "warning", 3, -1, 0 ) \ - XX( ERROR, "error", 1, -1, 0 ) \ - XX( INCOMING, "incoming", 2, -1, 0 ) \ - XX( OUTGOING, "outgoing", 4, -1, 0 ) + XX( WARNING, warning, 3, -1, 0 ) \ + XX( ERROR, error, 1, -1, 0 ) \ + XX( INCOMING, incoming, 2, -1, 0 ) \ + XX( OUTGOING, outgoing, 4, -1, 0 ) enum { @@ -68,59 +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. - -#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 @@ -137,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) { @@ -156,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])) @@ -174,7 +185,7 @@ print_curl_debug (CURL *easy, curl_infotype type, char *data, size_t len, for (size_t i = 0; i < len; i++) { uint8_t c = data[i]; - copy[i] = c >= 32 || c == '\n' ? c : '.'; + copy[i] = !iscntrl_ascii (c) || c == '\n' ? c : '.'; } copy[len] = '\0'; @@ -206,6 +217,35 @@ mpd_parse_kv (char *line, char **value) return key; } +static void +mpd_read_time (const char *value, int *sec, int *optional_msec) +{ + if (!value) + return; + + char *end = NULL; + long n = strtol (value, &end, 10); + if (n < 0 || (*end && *end != '.')) + return; + + int msec = 0; + if (*end == '.') + { + // In practice, MPD always uses three decimal digits + size_t digits = strspn (++end, "0123456789"); + if (end[digits]) + return; + + if (digits--) msec += (*end++ - '0') * 100; + if (digits--) msec += (*end++ - '0') * 10; + if (digits--) msec += *end++ - '0'; + } + + *sec = MIN (INT_MAX, n); + if (optional_msec) + *optional_msec = msec; +} + // --- cURL async wrapper ------------------------------------------------------ // You are meant to subclass this structure, no user_data pointers needed @@ -234,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 @@ -276,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); @@ -342,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)); } @@ -402,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; } @@ -411,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; } @@ -526,45 +575,624 @@ item_list_resize (struct item_list *self, size_t len) self->len = len; } +// --- Spectrum analyzer ------------------------------------------------------- + +// See http://www.zytrax.com/tech/audio/equalization.html +// for a good write-up about this problem domain + +#ifdef WITH_FFTW + +struct spectrum +{ + int sampling_rate; ///< Number of samples per seconds + int channels; ///< Number of sampled channels + int bits; ///< Number of bits per sample + int bars; ///< Number of output vertical bars + + int bins; ///< Number of DFT bins + int useful_bins; ///< Bins up to the Nyquist frequency + 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 *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 + size_t buffer_size; ///< Input buffer size + + /// Decode the respective part of the buffer into the second half of data + void (*decode) (struct spectrum *, int sample); + + float *data; ///< Normalized audio data + float *window; ///< Sampled window function + float *windowed; ///< data * window + fftwf_complex *out; ///< DFT output + fftwf_plan p; ///< DFT plan/FFTW configuration + float *accumulator; ///< Accumulated powers of samples +}; + +// - - Windows - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Out: float[n] of 0..1 +static void +window_hann (float *coefficients, size_t n) +{ + for (size_t i = 0; i < n; i++) + { + float sine = sin (M_PI * i / n); + coefficients[i] = sine * sine; + } +} + +// In: float[n] of -1..1, float[n] of 0..1; out: float[n] of -1..1 +static void +window_apply (const float *in, const float *coefficients, float *out, size_t n) +{ + for (size_t i = 0; i < n; i++) + out[i] = in[i] * coefficients[i]; +} + +// In: float[n] of 0..1; out: float 0..n, describing the coherent gain +static float +window_coherent_gain (const float *in, size_t n) +{ + float sum = 0; + for (size_t i = 0; i < n; i++) + sum += in[i]; + return sum; +} + +// - - Decoding - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static void +spectrum_decode_8 (struct spectrum *s, int sample) +{ + size_t n = s->useful_bins; + float *data = s->data + n; + for (int8_t *p = (int8_t *) s->buffer + sample * n * s->channels; + n--; p += s->channels) + { + int32_t acc = 0; + for (int ch = 0; ch < s->channels; ch++) + acc += p[ch]; + *data++ = (float) acc / s->channels / -INT8_MIN; + } +} + +static void +spectrum_decode_16 (struct spectrum *s, int sample) +{ + size_t n = s->useful_bins; + float *data = s->data + n; + for (int16_t *p = (int16_t *) s->buffer + sample * n * s->channels; + n--; p += s->channels) + { + int32_t acc = 0; + for (int ch = 0; ch < s->channels; ch++) + acc += p[ch]; + *data++ = (float) acc / s->channels / -INT16_MIN; + } +} + +static void +spectrum_decode_16_2 (struct spectrum *s, int sample) +{ + size_t n = s->useful_bins; + float *data = s->data + n; + for (int16_t *p = (int16_t *) s->buffer + sample * n * 2; n--; p += 2) + *data++ = ((int32_t) p[0] + p[1]) / 2. / -INT16_MIN; +} + +// - - Spectrum analysis - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +static const char *spectrum_bars[] = + { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }; + +/// Assuming the input buffer is full, updates the rendered spectrum +static void +spectrum_sample (struct spectrum *s) +{ + memset (s->accumulator, 0, sizeof *s->accumulator * s->useful_bins); + + // Credit for the algorithm goes to Audacity's /src/SpectrumAnalyst.cpp, + // apparently Welch's method + for (int sample = 0; sample < s->samples; sample++) + { + // We use 50% overlap and start with data from the last run (if any) + memmove (s->data, s->data + s->useful_bins, + sizeof *s->data * s->useful_bins); + s->decode (s, sample); + + window_apply (s->data, s->window, s->windowed, s->bins); + fftwf_execute (s->p); + + for (int bin = 0; bin < s->useful_bins; bin++) + { + // out[0][0] is the DC component, not useful to us + float re = s->out[bin + 1][0]; + float im = s->out[bin + 1][1]; + s->accumulator[bin] += re * re + im * im; + } + } + + int last_bin = 0; + char *p = s->rendered; + for (int bar = 0; bar < s->bars; bar++) + { + int top_bin = s->top_bins[bar]; + + // Think of this as accumulating energies within bands, + // so that it matches our non-linear hearing--there's no averaging. + // For more precision, we could employ an "equal loudness contour". + float acc = 0; + for (int bin = last_bin; bin < top_bin; bin++) + acc += s->accumulator[bin]; + + last_bin = top_bin; + float db = 10 * log10f (acc * s->accumulator_scale); + if (db > 0) + db = 0; + + // Assuming decibels are always negative (i.e., properly normalized). + // 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, int fps, + struct error **e) +{ + errno = 0; + + long sampling_rate, bits, channels; + if (!format + || (sampling_rate = strtol (format, &format, 10), *format++ != ':') + || (bits = strtol (format, &format, 10), *format++ != ':') + || (channels = strtol (format, &format, 10), *format) + || errno != 0) + return error_set (e, "invalid format, expected RATE:BITS:CHANNELS"); + + if (sampling_rate < 20000 || sampling_rate > INT_MAX) + return error_set (e, "unsupported sampling rate (%ld)", sampling_rate); + if (bits != 8 && bits != 16) + return error_set (e, "unsupported bit count (%ld)", bits); + if (channels < 1 || channels > INT_MAX) + return error_set (e, "no channels to sample (%ld)", channels); + if (bars < 1 || bars > 12) + return error_set (e, "requested too few or too many bars (%d)", bars); + + // All that can fail henceforth is memory allocation + *s = (struct spectrum) + { + .sampling_rate = sampling_rate, + .bits = bits, + .channels = channels, + .bars = bars, + }; + + // The number of bars is always smaller than that of the samples (bins). + // Let's start with the equation of the top FFT bin to use for a given bar: + // top_bin = (num_bins + 1) ^ (bar / num_bars) - 1 + // N.b. if we didn't subtract, the power function would make this ≥ 1. + // N.b. we then also need to extend the range by the same amount. + // + // We need the amount of bins for the first bar to be at least one: + // 1 ≤ (num_bins + 1) ^ (1 / num_bars) - 1 + // + // Solving with Wolfram Alpha gives us: + // num_bins ≥ (2 ^ num_bars) - 1 [for y > 0] + // + // And we need to remember that half of the FFT bins are useless/missing-- + // FFTW skips useless points past the Nyquist frequency. + int necessary_bins = 2 << s->bars; + + // Discard frequencies above 20 kHz, which take up a constant ratio + // of all bins, given by the sampling rate. A more practical/efficient + // solution would be to just handle 96/192/... kHz rates as bitshifts. + // + // Filtering out sub-20 Hz frequencies would be even more wasteful than + // this wild DFT size, so we don't even try. While we may just shift + // the lowest used bin easily within the extra range provided by this + // extension (the Nyquist is usually above 22 kHz, and it hardly matters + // if we go a bit beyond 20 kHz in the last bin), for a small number of bars + // the first bin already includes audible frequencies, and even for larger + // numbers it wouldn't be too accurate. An exact solution would require + // 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, + // 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->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++) + { + int top_bin = floor (pow (used_bins + 1, (bar + 1.) / s->bars)) - 1; + s->top_bins[bar] = MIN (top_bin, used_bins); + } + + s->samples = s->sampling_rate / s->bins * 2 / MAX (fps, 1); + if (s->samples < 1) + s->samples = 1; + + // XXX: we average the channels but might want to average the DFT results + if (s->bits == 8) s->decode = spectrum_decode_8; + if (s->bits == 16) s->decode = spectrum_decode_16; + + // Micro-optimize to achieve some piece of mind; it's weak but measurable + if (s->bits == 16 && s->channels == 2) + s->decode = spectrum_decode_16_2; + + s->buffer_size = s->samples * s->useful_bins * s->bits / 8 * s->channels; + s->buffer = xcalloc (1, s->buffer_size); + + // Prepare the window + s->window = xcalloc (sizeof *s->window, s->bins); + window_hann (s->window, s->bins); + + // Multiply by 2 for only using half of the DFT's result, then adjust to + // the total energy of the window. Both squared, because the accumulator + // contains squared values. Compute the average, and convert to decibels. + // See also the mildly confusing https://dsp.stackexchange.com/a/14945. + float coherent_gain = window_coherent_gain (s->window, s->bins); + s->accumulator_scale = 2 * 2 / coherent_gain / coherent_gain / s->samples; + + s->data = xcalloc (sizeof *s->data, s->bins); + s->windowed = fftw_malloc (sizeof *s->windowed * s->bins); + s->out = fftw_malloc (sizeof *s->out * (s->useful_bins + 1)); + s->p = fftwf_plan_dft_r2c_1d (s->bins, s->windowed, s->out, FFTW_MEASURE); + s->accumulator = xcalloc (sizeof *s->accumulator, s->useful_bins); + return true; +} + +static void +spectrum_free (struct spectrum *s) +{ + free (s->accumulator); + fftwf_destroy_plan (s->p); + fftw_free (s->out); + 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); + + memset (s, 0, sizeof *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: @@ -591,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 @@ -599,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: @@ -609,9 +1239,9 @@ static struct app_context struct str_map playback_info; ///< Current song info struct poller_timer elapsed_event; ///< Seconds elapsed event - int64_t elapsed_since; ///< Time of the next tick + 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 @@ -625,29 +1255,33 @@ 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: + // User interface: - int header_height; ///< Height of the header + struct app_ui *ui; ///< User interface interface + int ui_dragging; ///< ID of any dragged widget - 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 +#ifdef WITH_FFTW + struct spectrum spectrum; ///< Spectrum analyser + int spectrum_fd; ///< FIFO file descriptor (non-blocking) + 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 struct attrs attrs[ATTRIBUTE_COUNT]; @@ -666,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; } @@ -692,6 +1323,20 @@ tab_selection_range (struct tab *self) // --- Configuration ----------------------------------------------------------- +static void +on_poll_elapsed_time_changed (struct config_item *item) +{ + // This is only set once, on application startup + 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", @@ -701,16 +1346,65 @@ static struct config_schema g_config_settings[] = { .name = "password", .comment = "Password to use for MPD authentication", .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 }, + +#ifdef WITH_FFTW + { .name = "spectrum_path", + .comment = "Visualizer feed path to a FIFO audio output", + .type = CONFIG_ITEM_STRING }, + // MPD's "outputs" command doesn't include this information + { .name = "spectrum_format", + .comment = "Visualizer feed data format", + .type = CONFIG_ITEM_STRING, + .default_ = "\"44100:16:2\"" }, + // 10 is about the useful limit, then it gets too computationally expensive + { .name = "spectrum_bars", + .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 + // is currently playing, we do not reset g.song_elapsed (we could ask + // for a response which feels racy, or rethink the mechanism there) + { .name = "poll_elapsed_time", + .comment = "Whether to actively poll MPD for the elapsed time", + .type = CONFIG_ITEM_BOOLEAN, + .on_change = on_poll_elapsed_time_changed, + .default_ = "on" }, {} }; static struct config_schema g_config_colors[] = { #define XX(name_, config, fg_, bg_, attrs_) \ - { .name = config, .type = CONFIG_ITEM_STRING }, + { .name = #config, .type = CONFIG_ITEM_STRING }, ATTRIBUTE_TABLE (XX) #undef XX {} @@ -745,7 +1439,7 @@ load_config_colors (struct config_item *subtree, void *user_data) // For simplicity, we should reload the entire table on each change anyway. const char *value; #define XX(name, config, fg_, bg_, attrs_) \ - if ((value = get_config_string (subtree, config))) \ + if ((value = get_config_string (subtree, #config))) \ g.attrs[ATTRIBUTE_ ## name] = attrs_decode (value); ATTRIBUTE_TABLE (XX) #undef XX @@ -836,76 +1530,68 @@ 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; - // 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"); +#ifdef WITH_FFTW + g.spectrum_fd = -1; +#endif // WITH_FFTW - // It doesn't work 100% (e.g. incompatible with undelining in urxvt) - // TODO: make this configurable - g.use_partial_boxes = g.locale_is_utf8; +#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 + spectrum_free (&g.spectrum); + if (g.spectrum_fd != -1) + { + poller_fd_reset (&g.spectrum_event); + xclose (g.spectrum_fd); + } +#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 @@ -918,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 * @@ -1023,164 +1796,147 @@ app_time_string (int seconds) } static void -app_write_time (struct row_buffer *buf, int seconds, chtype attrs) -{ - char *s = app_time_string (seconds); - row_buffer_append (buf, s, attrs); - free (s); -} - -static void -app_write_gauge (struct row_buffer *buf, float ratio, int width) +app_layout_status (struct layout *out) { - 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; + + // XXX: attrs[0]? + app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.5, 1)) + ->id = WIDGET_TAB; - g.tabs_offset = g.header_height; + int i = 0; LIST_FOR_EACH (struct tab, iter, g.tabs) - row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]); - app_flush_header (&buf, attrs[false]); + { + struct widget *w = app_push (&l, + g.ui->label (attrs[iter == g.active_tab], iter->name)); + w->id = WIDGET_TAB; + w->userdata = ++i; + } - const char *header = g.active_tab->header; - if (header) + 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) { - buf = row_buffer_make (); - row_buffer_append (&buf, header, APP_ATTR (HEADER)); - app_flush_header (&buf, APP_ATTR (HEADER)); + app_push (&l, g.ui->spectrum (attrs[0], g.spectrum.bars)) + ->id = WIDGET_SPECTRUM; } +#endif // WITH_FFTW + + 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 @@ -1188,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) @@ -1198,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); @@ -1206,213 +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 (); + int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN); - hard_assert (tab->item_count != 0); - if (!g.use_partial_boxes) - { - struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1); - for (int row = 0; row < visible_items; row++) - { - move (g.header_height + row, COLS - 1); - if (row < bar.start || row >= bar.start + bar.length) - addch (' ' | APP_ATTR (SCROLLBAR)); - else - addch (' ' | APP_ATTR (SCROLLBAR) | A_REVERSE); - } - return; - } - - struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 8); - bar.length += bar.start; - - int start_part = bar.start % 8; bar.start /= 8; - int end_part = bar.length % 8; bar.length /= 8; + 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 (app_fitting_items (), (int) tab->item_count - tab->item_top); + int to_show = MIN ((int) tab->item_count - tab->item_top, + ceil ((double) list->height / g_xui.vunit)); + + 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 = APP_ATTR (SELECTION); - else if (tab->item_mark > -1 && - ((item_index >= tab->item_mark && item_index <= tab->item_selected) - || (item_index >= tab->item_selected && item_index <= tab->item_mark))) - row_attrs = APP_ATTR (MULTISELECT); - else - override_colors = false; - - struct row_buffer buf = row_buffer_make (); - tab->on_item_draw (item_index, &buf, view_width); - - // Combine attributes used by the handler with the defaults. - // Avoiding attrset() because of row_buffer_flush(). - for (size_t i = 0; i < buf.chars_len; i++) - { - chtype *attrs = &buf.chars[i].attrs; - if (override_colors) - *attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs; - else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR)) - *attrs |= (row_attrs & ~A_COLOR); - else - *attrs |= row_attrs; - } - - move (g.header_height + row, 0); - app_flush_buffer (&buf, view_width, row_attrs); + 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) @@ -1438,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 ----------------------------------------------------------------- @@ -1458,7 +2258,7 @@ static bool app_scroll (int n) { g.active_tab->item_top += n; - app_invalidate (); + xui_invalidate (); return app_fix_view_range (); } @@ -1480,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; @@ -1489,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 @@ -1512,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 @@ -1530,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; } @@ -1689,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) @@ -1704,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 @@ -1721,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; } @@ -1730,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: @@ -1757,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) @@ -1804,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); + +#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 should rather be parametrized + // 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: @@ -1825,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; @@ -1855,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; @@ -1867,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: @@ -1882,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: @@ -1897,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 (); + } + + 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; - 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); + 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; } @@ -2005,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 }, @@ -2035,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 }, @@ -2044,8 +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 }, @@ -2062,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 }, @@ -2078,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 }, @@ -2086,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; } @@ -2107,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) @@ -2136,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++ }; } @@ -2158,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]; } @@ -2166,20 +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) { + char *formatted = app_strfkey (event); + print_debug ("%s", formatted); + free (formatted); + + bool handled = false; + if ((handled = event->type == TERMO_TYPE_FOCUS)) + { + 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, @@ -2195,43 +3140,42 @@ 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; -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 - int length_len = 1 /*separator */ + 2 /* h */ + 3 /* m */+ 3 /* s */; - compact_map_t map = item_list_get (&g.playlist, item_index); const char *artist = compact_map_find (map, "artist"); const char *title = compact_map_find (map, "title"); chtype attrs = (int) item_index == g.song ? A_BOLD : 0; + 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); + app_push_fill (&l, g.ui->label (attrs, compact_map_find (map, "file"))); - row_buffer_align (buffer, width - length_len, attrs); + 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 = NULL; - unsigned long n; - const char *time = compact_map_find (map, "time"); - if (!time || !xstrtoul (&n, time, 10) || !(s = app_time_string (n))) - s = xstrdup ("?"); - - char *right_aligned = xstrdup_printf ("%*s", length_len, s); - row_buffer_append (buffer, right_aligned, attrs); - free (right_aligned); + char *s = duration < 0 ? xstrdup ("-") : app_time_string (duration); + app_push (&l, g.ui->padding (attrs, 1, 1)); + app_push (&l, g.ui->label (attrs, s)); free (s); + + return l; } static void @@ -2298,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: @@ -2306,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; @@ -2336,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 * @@ -2346,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; } @@ -2361,73 +3318,87 @@ struct library_level char path[]; ///< Path of the level }; -static struct -{ - struct tab super; ///< Parent class - struct str path; ///< Current path - struct strv items; ///< Current items (type, name, path) - struct library_level *above; ///< Upper levels - - bool searching; ///< Search mode is active -} -g_library_tab; - enum { // This list is also ordered by ASCII and important for sorting - LIBRARY_ROOT = '/', ///< Root entry - LIBRARY_UP = '^', ///< Upper directory - LIBRARY_DIR = 'd', ///< Directory - LIBRARY_FILE = 'f' ///< File + LIBRARY_ROOT = '/', ///< Root entry + LIBRARY_UP = '^', ///< Upper directory + LIBRARY_DIR = 'd', ///< Directory + LIBRARY_FILE = 'f', ///< File + LIBRARY_PLAYLIST = 'p', ///< Playlist (unsupported) }; struct library_tab_item { int type; ///< Type of the item - const char *name; ///< Visible name - const char *path; ///< MPD path + int duration; ///< Duration or -1 if N/A or unknown + char *name; ///< Visible name + const char *path; ///< MPD path (follows the name) }; -static void -library_tab_add (int type, const char *name, const char *path) +static struct { - strv_append_owned (&g_library_tab.items, - xstrdup_printf ("%c%s%c%s", type, name, 0, path)); + struct tab super; ///< Parent class + struct str path; ///< Current path + struct library_level *above; ///< Upper levels + + /// Current items + ARRAY (struct library_tab_item, items) + + bool searching; ///< Search mode is active } +g_library_tab; -static struct library_tab_item -library_tab_resolve (const char *raw) +static void +library_tab_add (int type, int duration, const char *name, const char *path) { - struct library_tab_item item; - item.type = *raw++; - item.name = raw; - item.path = strchr (raw, '\0') + 1; - return item; + // Slightly reduce memory overhead while retaining friendly access + size_t name_len = strlen (name), path_len = strlen (path); + char *combined = xmalloc (++name_len + ++path_len); + + ARRAY_RESERVE (g_library_tab.items, 1); + g_library_tab.items[g_library_tab.items_len++] = (struct library_tab_item) + { + .type = type, + .duration = duration, + .name = memcpy (combined, name, name_len), + .path = memcpy (combined + name_len, path, path_len), + }; } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -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); + hard_assert (item_index < g_library_tab.items_len); - struct library_tab_item x = - library_tab_resolve (g_library_tab.items.vector[item_index]); + struct library_tab_item *x = &g_library_tab.items[item_index]; const char *prefix, *name; - switch (x.type) + switch (x->type) { - case LIBRARY_ROOT: prefix = "/"; name = ""; break; - case LIBRARY_UP: prefix = "/"; name = ".."; break; - case LIBRARY_DIR: prefix = "/"; name = x.name; break; - case LIBRARY_FILE: prefix = " "; name = x.name; break; + case LIBRARY_ROOT: prefix = "/"; name = ""; break; + case LIBRARY_UP: prefix = "/"; name = ".."; break; + case LIBRARY_DIR: prefix = "/"; name = x->name; break; + 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); + + chtype attrs = x->type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0; + struct layout l = {}; + + 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 @@ -2435,31 +3406,38 @@ library_tab_header_type (const char *key) { if (!strcasecmp_ascii (key, "file")) return LIBRARY_FILE; if (!strcasecmp_ascii (key, "directory")) return LIBRARY_DIR; + if (!strcasecmp_ascii (key, "playlist")) return LIBRARY_PLAYLIST; return 0; } static void library_tab_chunk (char type, const char *path, struct str_map *map) { + // CUE files appear once as a directory and another time as a playlist, + // just skip them entirely + if (type == LIBRARY_PLAYLIST) + return; + const char *artist = str_map_find (map, "artist"); const char *title = str_map_find (map, "title"); char *name = (artist && title) ? xstrdup_printf ("%s - %s", artist, title) : xstrdup (xbasename (path)); - library_tab_add (type, name, path); + + int duration = -1; + mpd_read_time (str_map_find (map, "duration"), &duration, NULL); + mpd_read_time (str_map_find (map, "time"), &duration, NULL); + library_tab_add (type, duration, name, path); free (name); } static int -library_tab_compare (char **a, char **b) +library_tab_compare (struct library_tab_item *a, struct library_tab_item *b) { - struct library_tab_item xa = library_tab_resolve (*a); - struct library_tab_item xb = library_tab_resolve (*b); - - if (xa.type != xb.type) - return xa.type - xb.type; + if (a->type != b->type) + return a->type - b->type; - return app_casecmp ((uint8_t *) xa.path, (uint8_t *) xb.path); + return app_casecmp ((uint8_t *) a->path, (uint8_t *) b->path); } static char * @@ -2520,8 +3498,7 @@ library_tab_change_level (const char *new_path) str_reset (path); str_append (path, new_path); - free (g_library_tab.super.header); - g_library_tab.super.header = NULL; + cstr_set (&g_library_tab.super.header, NULL); g_library_tab.super.item_mark = -1; if (path->len) @@ -2529,15 +3506,24 @@ library_tab_change_level (const char *new_path) } static void +library_tab_reset (void) +{ + for (size_t i = 0; i < g_library_tab.items_len; i++) + free (g_library_tab.items[i].name); + free (g_library_tab.items); + ARRAY_INIT (g_library_tab.items); +} + +static void library_tab_load_data (const struct strv *data) { - strv_reset (&g_library_tab.items); + library_tab_reset (); char *parent = library_tab_parent (); if (parent) { - library_tab_add (LIBRARY_ROOT, "", ""); - library_tab_add (LIBRARY_UP, "", parent); + library_tab_add (LIBRARY_ROOT, -1, "", ""); + library_tab_add (LIBRARY_UP, -1, "", parent); free (parent); } @@ -2557,19 +3543,19 @@ library_tab_load_data (const struct strv *data) } str_map_free (&map); - struct strv *items = &g_library_tab.items; - qsort (items->vector, items->len, sizeof *items->vector, + struct library_tab_item *items = g_library_tab.items; + size_t len = g_library_tab.super.item_count = g_library_tab.items_len; + qsort (items, len, sizeof *items, (int (*) (const void *, const void *)) library_tab_compare); - g_library_tab.super.item_count = items->len; // XXX: this unmarks even if just the database updates g_library_tab.super.item_mark = -1; // Don't force the selection visible when there's no need to touch it - if (g_library_tab.super.item_selected >= (int) items->len) + if (g_library_tab.super.item_selected >= (int) len) app_move_selection (0); - app_invalidate (); + xui_invalidate (); } static void @@ -2654,9 +3640,8 @@ library_tab_is_range_playable (struct tab_range range) { for (int i = range.from; i <= range.upto; i++) { - struct library_tab_item x = - library_tab_resolve (g_library_tab.items.vector[i]); - if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE) + struct library_tab_item *x = &g_library_tab.items[i]; + if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE) return true; } return false; @@ -2674,9 +3659,7 @@ library_tab_on_action (enum action action) if (range.from < 0) return false; - struct library_tab_item x = - library_tab_resolve (g_library_tab.items.vector[range.from]); - + struct library_tab_item *x = &g_library_tab.items[range.from]; switch (action) { case ACTION_CHOOSE: @@ -2684,16 +3667,22 @@ library_tab_on_action (enum action action) if (range.from != range.upto) break; - switch (x.type) + switch (x->type) { case LIBRARY_ROOT: case LIBRARY_UP: - case LIBRARY_DIR: library_tab_reload (x.path); break; - case LIBRARY_FILE: MPD_SIMPLE ("add", x.path); break; + case LIBRARY_DIR: library_tab_reload (x->path); break; + case LIBRARY_FILE: MPD_SIMPLE ("add", x->path); break; default: hard_assert (!"invalid item type"); } 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 (); @@ -2719,8 +3708,7 @@ library_tab_on_action (enum action action) free (fake_subdir); } - free (tab->header); - tab->header = xstrdup_printf ("Global search"); + cstr_set (&tab->header, xstrdup_printf ("Global search")); g_library_tab.searching = true; // Since we've already changed the header, empty the list, @@ -2730,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: @@ -2739,10 +3727,9 @@ library_tab_on_action (enum action action) for (int i = range.from; i <= range.upto; i++) { - struct library_tab_item x = - library_tab_resolve (g_library_tab.items.vector[i]); - if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE) - MPD_SIMPLE ("add", x.path); + struct library_tab_item *x = &g_library_tab.items[i]; + if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE) + MPD_SIMPLE ("add", x->path); } tab->item_mark = -1; return true; @@ -2758,10 +3745,9 @@ library_tab_on_action (enum action action) mpd_client_send_command (c, "clear", NULL); for (int i = range.from; i <= range.upto; i++) { - struct library_tab_item x = - library_tab_resolve (g_library_tab.items.vector[i]); - if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE) - mpd_client_send_command (c, "add", x.path, NULL); + struct library_tab_item *x = &g_library_tab.items[i]; + if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE) + mpd_client_send_command (c, "add", x->path, NULL); } if (g.state == PLAYER_PLAYING) mpd_client_send_command (c, "play", NULL); @@ -2781,13 +3767,13 @@ static struct tab * library_tab_init (void) { g_library_tab.path = str_make (); - g_library_tab.items = strv_make (); + // g_library_tab.items is fine with zero initialisation struct tab *super = &g_library_tab.super; 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; } @@ -2799,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 @@ -2857,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); @@ -2876,12 +3867,28 @@ streams_tab_extract_links (struct str *data, const char *content_type, for (size_t i = 0; i < data->len; i++) { uint8_t c = data->str[i]; - if ((c < 32) & (c != '\t') & (c != '\r') & (c != '\n')) + if (iscntrl_ascii (c) & (c != '\t') & (c != '\r') & (c != '\n')) return false; } 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 @@ -2889,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; @@ -2914,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); @@ -2939,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 @@ -2957,56 +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); + // 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; + } - 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)); + struct stream_tab_task *task = xcalloc (1, sizeof *task); + hard_assert (poller_curl_spawn (&task->curl, NULL)); - CURL *easy = task.curl.easy; - task.data = str_make (); - task.replace = replace; - bool result = false; + 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, 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); - 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 @@ -3029,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; } @@ -3040,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 * @@ -3054,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) +{ + 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) { - (void) width; + 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) { - 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"); + (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)) + { + 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; } @@ -3146,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 @@ -3192,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; @@ -3214,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 * @@ -3261,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; } @@ -3283,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]; @@ -3296,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 @@ -3316,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 * @@ -3327,32 +4718,216 @@ 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; } -// --- MPD interface ----------------------------------------------------------- +// --- Spectrum analyser ------------------------------------------------------- + +#ifdef WITH_FFTW static void -mpd_read_time (const char *value, int *sec, int *optional_msec) +spectrum_redraw (void) { - if (!value) + // A full refresh would be too computationally expensive, + // let's hack around it in this case + 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 +static void +spectrum_discard_fifo (void) +{ + if (g.spectrum_fd != -1) + { + poller_fd_reset (&g.spectrum_event); + xclose (g.spectrum_fd); + g.spectrum_fd = -1; + + spectrum_free (&g.spectrum); + xui_invalidate (); + } +} + +static void +spectrum_on_fifo_readable (const struct pollfd *pfd, void *user_data) +{ + (void) user_data; + struct spectrum *s = &g.spectrum; + + bool update = false; + ssize_t n; +restart: + while ((n = read (pfd->fd, + s->buffer + s->buffer_len, s->buffer_size - s->buffer_len)) > 0) + if ((s->buffer_len += n) == s->buffer_size) + { + update = true; + spectrum_sample (s); + s->buffer_len = 0; + } + + if (!n) + spectrum_discard_fifo (); + else if (errno == EINTR) + goto restart; + else if (errno != EAGAIN) + { + print_error ("spectrum: %s", strerror (errno)); + spectrum_discard_fifo (); + } + else if (update) + spectrum_redraw (); +} + +// When playback is stopped, we need to feed the analyser some zeroes ourselves. +// We could also just hide it. Hard to say which is simpler or better. +static void +spectrum_clear (void) +{ + if (g.spectrum_fd != -1) + { + struct spectrum *s = &g.spectrum; + memset (s->buffer, 0, s->buffer_size); + spectrum_sample (s); + spectrum_sample (s); + s->buffer_len = 0; + + spectrum_redraw (); + } +} + +static void +spectrum_setup_fifo (void) +{ + const char *spectrum_path = + get_config_string (g.config.root, "settings.spectrum_path"); + const char *spectrum_format = + 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; - char *end, *period = strchr (value, '.'); - if (optional_msec && period) + struct error *e = NULL; + char *path = resolve_filename + (spectrum_path, resolve_relative_config_filename); + + if (!path) + print_error ("spectrum: %s", "FIFO path could not be resolved"); + 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, spectrum_fps->value.integer, &e)) { - unsigned long n = strtoul (period + 1, &end, 10); - if (*end) - return; - *optional_msec = MIN (INT_MAX, n); + print_error ("spectrum: %s", e->message); + error_free (e); + } + else if ((g.spectrum_fd = open (path, O_RDONLY | O_NONBLOCK)) == -1) + { + print_error ("spectrum: %s: %s", path, strerror (errno)); + spectrum_free (&g.spectrum); + } + else + { + g.spectrum_event = poller_fd_make (&g.poller, g.spectrum_fd); + g.spectrum_event.dispatcher = spectrum_on_fifo_readable; + poller_fd_set (&g.spectrum_event, POLLIN); + } + + free (path); +} + +#else // ! WITH_FFTW +#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; } - unsigned long n = strtoul (value, &end, 10); - if (end == period || !*end) - *sec = MIN (INT_MAX, n); + + 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 mpd_update_playlist_time (void) { g.playlist_time = 0; @@ -3369,6 +4944,33 @@ mpd_update_playlist_time (void) } static void +mpd_set_elapsed_timer (int msec_past_second) +{ + int delay_msec = 1000 - msec_past_second; // Until the next round second + if (!g.elapsed_poll) + { + poller_timer_set (&g.elapsed_event, delay_msec); + // Remember when the last round second was, relative to monotonic time + g.elapsed_since = clock_msec (CLOCK_BEST) - msec_past_second; + return; + } + + // We may receive an earlier time, this seems to compensate for it well + // (I haven't seen it trigger more than 50ms too early) + delay_msec += 100; + + // When playback stalls, avoid busy looping with the server + int elapsed_msec = g.song_elapsed * 1000 + msec_past_second; + if (elapsed_msec == g.elapsed_since) + delay_msec = MAX (delay_msec, 500); + + // In polling mode, we're interested in progress rather than stability. + // We can reuse both the poller_timer struct and the timestamp field. + poller_timer_set (&g.elapsed_event, delay_msec); + g.elapsed_since = elapsed_msec; +} + +static void mpd_update_playback_state (void) { struct str_map *map = &g.playback_info; @@ -3383,6 +4985,10 @@ mpd_update_playback_state (void) if (!strcmp (state, "play")) g.state = PLAYER_PLAYING; if (!strcmp (state, "pause")) g.state = PLAYER_PAUSED; } + if (g.state == PLAYER_STOPPED) + { + spectrum_clear (); + } // Values in "time" are always rounded. "elapsed", introduced in MPD 0.16, // is in millisecond precision and "duration" as well, starting with 0.20. @@ -3404,13 +5010,11 @@ mpd_update_playback_state (void) mpd_read_time (duration, &g.song_duration, NULL); strv_free (&fields); - // We could also just poll the server each half a second but let's not poller_timer_reset (&g.elapsed_event); if (g.state == PLAYER_PLAYING) - { - poller_timer_set (&g.elapsed_event, 1000 - msec_past_second); - g.elapsed_since = clock_msec (CLOCK_BEST) - msec_past_second; - } + mpd_set_elapsed_timer (msec_past_second); + else + g.elapsed_since = -1; // The server sends -1 when nothing is being played right now unsigned long n; @@ -3422,7 +5026,7 @@ mpd_update_playback_state (void) if (g.playlist_version != last_playlist_version) mpd_update_playlist_time (); - app_invalidate (); + xui_invalidate (); } // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -3478,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); @@ -3488,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 @@ -3535,18 +5149,22 @@ mpd_on_info_response (const struct mpd_response *response, } static void -mpd_on_tick (void *user_data) +mpd_on_elapsed_time_tick (void *user_data) { (void) user_data; + + // Compute how much time has elapsed since the last round second int64_t diff_msec = clock_msec (CLOCK_BEST) - g.elapsed_since; int elapsed_sec = diff_msec / 1000; int elapsed_msec = diff_msec % 1000; g.song_elapsed += elapsed_sec; g.elapsed_since += elapsed_sec * 1000; + + // 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 @@ -3565,6 +5183,15 @@ mpd_request_info (void) } static void +mpd_on_elapsed_time_tick_poll (void *user_data) +{ + (void) user_data; + + // As soon as the reply arrives, we (may) set the timer again + mpd_request_info (); +} + +static void mpd_on_events (unsigned subsystems, void *user_data) { (void) user_data; @@ -3572,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)) @@ -3588,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) @@ -3597,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", @@ -3623,10 +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); - } + mpd_on_ready (); } static void @@ -3643,6 +5318,9 @@ mpd_on_failure (void *user_data) mpd_update_playback_state (); current_tab_update (); info_tab_update (); + + spectrum_discard_fifo (); + pulse_disable (); } static void @@ -3696,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 @@ -3765,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) @@ -3843,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 (); } } @@ -3856,9 +6007,7 @@ app_on_message_timer (void *user_data) { (void) user_data; - free (g.message); - g.message = NULL; - app_invalidate (); + app_hide_message (); } static void @@ -3874,22 +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 - { - free (g.message); - g.message = xstrdup (message.str); - app_invalidate (); - poller_timer_set (&g.message_timer, 5000); - } str_free (&message); in_processing = false; @@ -3902,25 +6049,67 @@ 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); g.elapsed_event = poller_timer_make (&g.poller); - g.elapsed_event.dispatcher = mpd_on_tick; + g.elapsed_event.dispatcher = g.elapsed_poll + ? mpd_on_elapsed_time_tick_poll + : mpd_on_elapsed_time_tick; +} + +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; +} - g.refresh_event = poller_idle_make (&g.poller); - g.refresh_event.dispatcher = app_on_refresh; +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 @@ -3929,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, "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) @@ -3944,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); @@ -3958,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 @@ -3971,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 ()); @@ -3994,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; |