16 Commits

Author SHA1 Message Date
dcb2829e9b Separate the actions of aborting and quitting
The user should not be afraid of pressing Escape too many times.
2023-03-25 11:39:34 +01:00
349c907cbf X11: act on DestroyNotify rather than UnmapNotify
This makes the program survive i3 restarts, which cause a sequence
of: UnmapNotify, ReparentNotify, MapNotify.
2023-03-25 11:10:26 +01:00
0b62b2a788 Update NEWS 2023-03-07 01:56:26 +01:00
d58856571d Improve display of files lacking proper metadata 2023-03-07 01:53:50 +01:00
61fac878ad X11: fix rendering of overflowing last list items 2022-10-30 18:49:10 +01:00
da83dbee1f Bump liberty 2022-10-09 01:10:50 +02:00
41fda4e317 Bump liberty, improve fallback manual page output 2022-09-30 18:22:28 +02:00
d4d2259825 Bump liberty, make use of its new asciiman.awk 2022-09-25 21:14:36 +02:00
568abc896c 10-azlyrics.pl: fix "the" stripping 2022-09-20 12:45:45 +02:00
8aac4ae0a8 Update documentation 2022-09-20 12:24:00 +02:00
e72ed71f53 X11: support italic fonts as well
The bold + italic combination isn't supported thus far,
because it seems unnecessary.
2022-09-20 11:15:20 +02:00
28ed7a85a8 Implement lyrics lookup
There is now a generic mechanism for loading lyrics,
or any other arbitrary content related to songs.
2022-09-20 11:04:39 +02:00
b6dd940720 Implement M-u, M-l, M-c from Readline 2022-09-18 04:24:58 +02:00
d8e0d1b2fe Make M-f behave like it does in Readline 2022-09-18 01:07:47 +02:00
5cda848f94 Don't depend on a standalone C preprocessor
And get rid of the sed insanity.
2022-09-13 01:01:35 +02:00
a167ae40b3 Document configuration file key binding 2022-09-12 20:02:57 +02:00
12 changed files with 824 additions and 98 deletions

View File

@@ -10,6 +10,14 @@ endif ()
# For custom modules
set (CMAKE_MODULE_PATH ${PROJECT_SOURCE_DIR}/liberty/cmake)
# Collect important build toggles for our simple preprocessor
# (cpp(1) isn't part of POSIX, otherwise we could reuse config.h)
set (options)
macro (add_option variable help value)
option (${ARGV})
list (APPEND options "${variable}=$<BOOL:${${variable}}>")
endmacro ()
# Dependencies
find_package (Ncursesw REQUIRED)
find_package (PkgConfig REQUIRED)
@@ -19,7 +27,7 @@ pkg_check_modules (curl REQUIRED libcurl)
include (AddThreads)
find_package (Termo QUIET NO_MODULE)
option (USE_SYSTEM_TERMO
add_option (USE_SYSTEM_TERMO
"Don't compile our own termo library, use the system one" ${Termo_FOUND})
if (USE_SYSTEM_TERMO)
if (NOT Termo_FOUND)
@@ -41,7 +49,7 @@ else ()
endif ()
pkg_check_modules (fftw fftw3 fftw3f)
option (WITH_FFTW "Use FFTW to enable spectrum visualisation" ${fftw_FOUND})
add_option (WITH_FFTW "Use FFTW to enable spectrum visualisation" ${fftw_FOUND})
if (WITH_FFTW)
if (NOT fftw_FOUND)
message (FATAL_ERROR "FFTW not found")
@@ -50,7 +58,8 @@ if (WITH_FFTW)
endif ()
pkg_check_modules (libpulse libpulse)
option (WITH_PULSE "Enable control of PulseAudio sink volume" ${libpulse_FOUND})
add_option (WITH_PULSE
"Enable PulseAudio sink volume control" ${libpulse_FOUND})
if (WITH_PULSE)
if (NOT libpulse_FOUND)
message (FATAL_ERROR "libpulse not found")
@@ -59,7 +68,7 @@ if (WITH_PULSE)
endif ()
pkg_check_modules (x11 x11 xrender xft fontconfig)
option (WITH_X11 "Use FFTW to enable spectrum visualisation" ${x11_FOUND})
add_option (WITH_X11 "Use FFTW to enable spectrum visualisation" ${x11_FOUND})
if (WITH_X11)
if (NOT x11_FOUND)
message (FATAL_ERROR "Some X11 libraries were not found")
@@ -95,24 +104,18 @@ foreach (extra m)
endforeach ()
# Generate a configuration file
include (GNUInstallDirs)
configure_file (${PROJECT_SOURCE_DIR}/config.h.in
${PROJECT_BINARY_DIR}/config.h)
include_directories (${PROJECT_SOURCE_DIR} ${PROJECT_BINARY_DIR})
# Assuming a Unix-compatible system with a standalone preprocessor
set (actions_list ${PROJECT_SOURCE_DIR}/nncmpp.actions)
set (actions_awk ${PROJECT_SOURCE_DIR}/nncmpp.actions.awk)
set (actions ${PROJECT_BINARY_DIR}/nncmpp-actions.h)
add_custom_command (OUTPUT ${actions}
COMMAND cpp -I${PROJECT_BINARY_DIR} -P ${actions_list}
| grep . | tr [[\n]] ^ | sed -ne [[h; s/,[^^]*/,/g]] -e [[s/$/COUNT/]]
-e [[s/[^^]*/\tACTION_&/g]] -e [[s/.*/enum action {\n&\n};\n/p]]
-e [[g; s/,[^^]*//g; y/_/-/]] -e [[s/[^^]\{1,\}/\t"&",/g]]
-e [[s/.*/static const char *g_action_names[] = {\n&};\n/p]]
-e [[g; s/[^^]*, *//g;]] -e [[s/[^^]\{1,\}/\t"&",/g]]
-e [[s/.*/static const char *g_action_descriptions[] = {\n&};/p]]
| tr ^ [[\n]] > ${actions}
COMMAND test -s ${actions}
DEPENDS ${actions_list} ${PROJECT_BINARY_DIR}/config.h VERBATIM)
COMMAND env LC_ALL=C ${options}
awk -f ${actions_awk} ${actions_list} > ${actions}
DEPENDS ${actions_awk} ${actions_list} VERBATIM)
# Build the main executable and link it
add_executable (${PROJECT_NAME} ${PROJECT_NAME}.c ${actions})
@@ -121,16 +124,17 @@ target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES}
add_threads (${PROJECT_NAME})
# Installation
include (GNUInstallDirs)
install (TARGETS ${PROJECT_NAME} DESTINATION ${CMAKE_INSTALL_BINDIR})
install (FILES LICENSE DESTINATION ${CMAKE_INSTALL_DOCDIR})
install (DIRECTORY contrib DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
install (DIRECTORY info DESTINATION ${CMAKE_INSTALL_DATADIR}/${PROJECT_NAME})
# Generate documentation from text markup
find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor)
find_program (A2X_EXECUTABLE a2x)
if (NOT ASCIIDOCTOR_EXECUTABLE AND NOT A2X_EXECUTABLE)
message (FATAL_ERROR "Neither asciidoctor nor a2x were found")
message (WARNING "Neither asciidoctor nor a2x were found, "
"falling back to a substandard manual page generator")
endif ()
foreach (page ${PROJECT_NAME})
@@ -152,6 +156,14 @@ foreach (page ${PROJECT_NAME})
"${PROJECT_SOURCE_DIR}/${page}.adoc"
DEPENDS ${page}.adoc
COMMENT "Generating man page for ${page}" VERBATIM)
else ()
set (ASCIIMAN ${PROJECT_SOURCE_DIR}/liberty/tools/asciiman.awk)
add_custom_command (OUTPUT ${page_output}
COMMAND env LC_ALL=C asciidoc-release-version=${PROJECT_VERSION}
awk -f ${ASCIIMAN} "${PROJECT_SOURCE_DIR}/${page}.adoc"
> ${page_output}
DEPENDS ${page}.adoc ${ASCIIMAN}
COMMENT "Generating man page for ${page}" VERBATIM)
endif ()
endforeach ()

View File

@@ -1,4 +1,4 @@
Copyright (c) 2016 - 2022, 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.

16
NEWS
View File

@@ -1,3 +1,19 @@
Unreleased
* Added ability to look up song lyrics,
using a new scriptable extension interface for the Info tab
* Improved song information shown in the window header
* Escape no longer quits the program
* X11: added italic font support
* X11: fixed rendering of overflowing, partially visible list items
* Added Readline-like M-u, M-l, M-c editor bindings
2.0.0 (2022-09-03)
* Added an optional X11 user interface

View File

@@ -18,6 +18,7 @@ Features
Most stuff is there. I've been using the program exclusively for many years.
Among other things, it can display and change PulseAudio volume directly
to cover the use case of remote control, it has a fast spectrum visualiser,
it can be extended with plugins to fetch lyrics or other song-related info,
and both its appearance and key bindings can be customized.
Note that currently only the filesystem browsing mode is implemented,
@@ -37,10 +38,10 @@ The rest of this README will concern itself with externalities.
Building
--------
Build dependencies: CMake, pkg-config, asciidoctor or asciidoc,
liberty (included), termo (included) +
Build dependencies: CMake, pkg-config, awk, liberty (included),
termo (included), asciidoctor or asciidoc (recommended but optional) +
Runtime dependencies: ncursesw, libunistring, cURL +
Optional runtime dependencies: fftw3, libpulse, x11, xft
Optional runtime dependencies: fftw3, libpulse, x11, xft, Perl + cURL (lyrics)
$ git clone --recursive https://git.janouch.name/p/nncmpp.git
$ mkdir nncmpp/build

View File

@@ -4,6 +4,9 @@
#define PROGRAM_NAME "${PROJECT_NAME}"
#define PROGRAM_VERSION "${PROJECT_VERSION}"
// We use the XDG Base Directory Specification, but may be installed anywhere.
#define PROJECT_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}"
#cmakedefine HAVE_RESIZETERM
#cmakedefine WITH_FFTW
#cmakedefine WITH_PULSE

43
info/10-azlyrics.pl Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env perl
# 10-azlyrics.pl: nncmpp info plugin to fetch song lyrics on AZLyrics
#
# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
# SPDX-License-Identifier: 0BSD
#
# Inspired by a similar ncmpc plugin.
use warnings;
use strict;
use utf8;
use open ':std', ':utf8';
unless (@ARGV) {
print "Look up on AZLyrics\n";
exit;
}
use Encode;
my ($title, $artist, $album) = map {decode_utf8($_)} @ARGV;
# TODO: An upgrade would be transliteration with, e.g., Text::Unidecode.
use Unicode::Normalize;
$artist = lc(NFD($artist)) =~ s/^the\s+//ir =~ s/[^a-z0-9]//gr;
$title = lc(NFD($title)) =~ s/\(.*?\)//gr =~ s/[^a-z0-9]//gr;
# TODO: Consider caching the results in a location like
# $XDG_CACHE_HOME/nncmpp/info/azlyrics/$artist-$title
my $found = 0;
if ($title ne '') {
open(my $curl, '-|', 'curl', '-sA', 'nncmpp/2.0',
"https://www.azlyrics.com/lyrics/$artist/$title.html") or die $!;
while (<$curl>) {
next unless /^<div>/ .. /^<\/div>/; s/<!--.*?-->//g; s/\s+$//gs;
$found = 1;
s/<\/?b>/\x01/g; s/<\/?i>/\x02/g; s/<br>/\n/; s/<.+?>//g;
s/&lt;/</g; s/&gt;/>/g; s/&quot;/"/g; s/&apos;/'/g; s/&amp;/&/g;
print;
}
close($curl) or die $?;
}
print "No lyrics have been found.\n" unless $found;

Submodule liberty updated: 63aed8f0fd...0e86ffe7c3

View File

@@ -1,7 +1,7 @@
/*
* line-editor.c: a line editor component for the TUI part of liberty
*
* Copyright (c) 2017 - 2018, Přemysl Eric Janouch <p@janouch.name>
* Copyright (c) 2017 - 2022, 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.
@@ -48,6 +48,10 @@ enum line_editor_action
LINE_EDITOR_HOME, ///< Go to start of line
LINE_EDITOR_END, ///< Go to end of line
LINE_EDITOR_UPCASE_WORD, ///< Convert word to uppercase
LINE_EDITOR_DOWNCASE_WORD, ///< Convert word to lowercase
LINE_EDITOR_CAPITALIZE_WORD, ///< Capitalize word
LINE_EDITOR_B_DELETE, ///< Delete last character
LINE_EDITOR_F_DELETE, ///< Delete next character
LINE_EDITOR_B_KILL_WORD, ///< Delete last word
@@ -185,8 +189,8 @@ line_editor_action (struct line_editor *self, enum line_editor_action action)
if (self->point + 1 > (int) self->len)
return false;
int i = self->point;
while (i < (int) self->len && self->line[i] != ' ') i++;
while (i < (int) self->len && self->line[i] == ' ') i++;
while (i < (int) self->len && self->line[i] != ' ') i++;
self->point = i;
return true;
}
@@ -197,6 +201,41 @@ line_editor_action (struct line_editor *self, enum line_editor_action action)
self->point = self->len;
return true;
case LINE_EDITOR_UPCASE_WORD:
{
int i = self->point;
for (; i < (int) self->len && self->line[i] == ' '; i++);
for (; i < (int) self->len && self->line[i] != ' '; i++)
self->line[i] = uc_toupper (self->line[i]);
self->point = i;
line_editor_changed (self);
return true;
}
case LINE_EDITOR_DOWNCASE_WORD:
{
int i = self->point;
for (; i < (int) self->len && self->line[i] == ' '; i++);
for (; i < (int) self->len && self->line[i] != ' '; i++)
self->line[i] = uc_tolower (self->line[i]);
self->point = i;
line_editor_changed (self);
return true;
}
case LINE_EDITOR_CAPITALIZE_WORD:
{
int i = self->point;
ucs4_t (*converter) (ucs4_t) = uc_totitle;
for (; i < (int) self->len && self->line[i] == ' '; i++);
for (; i < (int) self->len && self->line[i] != ' '; i++)
{
self->line[i] = converter (self->line[i]);
converter = uc_tolower;
}
self->point = i;
line_editor_changed (self);
return true;
}
case LINE_EDITOR_B_DELETE:
{
if (self->point < 1)

View File

@@ -1,9 +1,8 @@
#include "config.h"
NONE, Do nothing
QUIT, Quit
REDRAW, Redraw screen
ABORT, Abort
TAB_HELP, Switch to help tab
TAB_LAST, Switch to last tab
TAB_PREVIOUS, Switch to previous tab
@@ -28,11 +27,11 @@ MPD_CONSUME, Toggle consume
MPD_UPDATE_DB, Update MPD database
MPD_COMMAND, Send raw command to MPD
#ifdef WITH_PULSE
.ifdef WITH_PULSE
PULSE_VOLUME_UP, Increase PulseAudio volume
PULSE_VOLUME_DOWN, Decrease PulseAudio volume
PULSE_MUTE, Toggle PulseAudio sink mute
#endif
.endif
CHOOSE, Choose item
DELETE, Delete item
@@ -67,6 +66,10 @@ EDITOR_F_WORD, Go forward a word
EDITOR_HOME, Go to start of line
EDITOR_END, Go to end of line
EDITOR_UPCASE_WORD, Convert word to uppercase
EDITOR_DOWNCASE_WORD, Convert word to lowercase
EDITOR_CAPITALIZE_WORD, Capitalize word
EDITOR_B_DELETE, Delete last character
EDITOR_F_DELETE, Delete next character
EDITOR_B_KILL_WORD, Delete last word

106
nncmpp.actions.awk Normal file
View File

@@ -0,0 +1,106 @@
# nncmpp.actions.awk: produce C code for a list of user actions
#
# Copyright (c) 2022, Přemysl Eric Janouch <p@janouch.name>
# SPDX-License-Identifier: 0BSD
#
# Usage: env LC_ALL=C A=0 B=1 awk -f nncmpp.actions.awk \
# nncmpp.actions > nncmpp-actions.h
# --- Preprocessor -------------------------------------------------------------
function fatal(message) {
print "// " FILENAME ":" FNR ": fatal error: " message
print FILENAME ":" FNR ": fatal error: " message > "/dev/stderr"
exit 1
}
function condition(pass, passing, a, i) {
split(substr($0, RSTART + RLENGTH), a, /[[:space:]]+/)
if (!(1 in a))
fatal("missing condition")
passing = 0
for (i in a)
if (a[i] && !pass == !ENVIRON[a[i]])
passing = 1
while (getline > 0) {
if (match($0, /^[[:space:]]*[.]endif[[:space:]]*$/))
return 1
if (match($0, /^[[:space:]]*[.]else[[:space:]]*$/))
passing = !passing
else if (!directive() && passing)
process()
}
fatal("unterminated condition body")
}
# Multiple arguments mean logical OR, multiple directives mean logical AND.
# Similar syntax is also used by Exim, BSD make, or various assemblers.
#
# Looking at what others have picked for their preprocessor syntax:
# {OpenGL, FreeBASIC} reuse #ifdef, which would be confusing with C code around,
# {Mental Ray, RapidQ and UniVerse BASIC} use $ifdef, NSIS has !ifdef,
# and Verilog went for `ifdef. Not much more can be easily found.
function directive() {
sub(/#.*/, "")
if (match($0, /^[[:space:]]*[.]ifdef[[:space:]]+/))
return condition(1)
if (match($0, /^[[:space:]]*[.]ifndef[[:space:]]+/))
return condition(0)
if (/^[[:space:]]*[.]/)
fatal("unexpected or unsupported directive")
return 0
}
!directive() {
process()
}
# --- Postprocessor ------------------------------------------------------------
function strip(string) {
gsub(/^[[:space:]]*|[[:space:]]*$/, "", string)
return string
}
function process( constant, name, description) {
if (match($0, /,/)) {
constant = name = strip(substr($0, 1, RSTART - 1))
description = strip(substr($0, RSTART + RLENGTH))
gsub(/_/, "-", name)
N++
Constants[N] = constant
Names[N] = tolower(name)
Descriptions[N] = description
} else if (/[^[:space:]]/) {
fatal("invalid action definition syntax")
}
}
function tocstring(string) {
gsub(/\\/, "\\\\", string)
gsub(/"/, "\\\"", string)
return "\"" string "\""
}
END {
print "enum action {"
for (i in Constants)
print "\t" "ACTION_" Constants[i] ","
print "\t" "ACTION_COUNT"
print "};"
print ""
print "static const char *g_action_names[] = {"
for (i in Names)
print "\t" tocstring(Names[i]) ","
print "};"
print ""
print "static const char *g_action_descriptions[] = {"
for (i in Descriptions)
print "\t" tocstring(Descriptions[i]) ","
print "};"
}

View File

@@ -81,6 +81,10 @@ The distribution contains example colour schemes in the _contrib_ directory.
// TODO: it seems like liberty should contain an includable snippet about
// the format, which could form a part of nncmpp.conf(5).
To adjust key bindings, put them within a *normal* or *editor* object.
Run *nncmpp* with the *--debug* option to find out key combinations names.
Press *?* in the help tab to learn the action identifiers to use.
Spectrum visualiser
-------------------
When built against the FFTW library, *nncmpp* can make use of MPD's "fifo"
@@ -124,6 +128,19 @@ For this to work, *nncmpp* needs to access the right PulseAudio daemon--in case
your setup is unusual, consult the list of environment variables in
*pulseaudio*(1). MPD-compatibles are currently unsupported.
Info plugins
------------
You can invoke various plugins from the Info tab, for example to look up
song lyrics.
Plugins can be arbitrary scripts or binaries. When run without command line
arguments, a plugin outputs a user interface description of what it does.
When invoked by a user, it receives the following self-explanatory arguments:
_TITLE_ _ARTIST_ [_ALBUM_], and anything it writes to its standard output
or standard error stream is presented back to the user. Here, bold and italic
formatting can be toggled with ASCII control characters 1 (SOH) and 2 (STX),
respectively. Otherwise, all input and output makes use of the UTF-8 encoding.
Files
-----
*nncmpp* follows the XDG Base Directory Specification.
@@ -131,6 +148,14 @@ Files
_~/.config/nncmpp/nncmpp.conf_::
The configuration file.
_~/.local/share/nncmpp/info/_::
_/usr/local/share/nncmpp/info/_::
_/usr/share/nncmpp/info/_::
Info plugins are loaded from these directories, in order,
then listed lexicographically.
Only the first occurence of a particular filename is used,
and empty files act as silent disablers.
Reporting bugs
--------------
Use https://git.janouch.name/p/nncmpp to report bugs, request features,

618
nncmpp.c
View File

@@ -1,7 +1,7 @@
/*
* nncmpp -- the MPD client you never knew you needed
*
* Copyright (c) 2016 - 2022, 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.
@@ -75,10 +75,11 @@ enum
#define HAVE_LIBERTY
#include "line-editor.c"
#include <math.h>
#include <dirent.h>
#include <locale.h>
#include <termios.h>
#include <math.h>
#include <sys/ioctl.h>
#include <termios.h>
// ncurses is notoriously retarded for input handling, we need something
// different if only to receive mouse events reliably.
@@ -130,6 +131,20 @@ clock_msec (clockid_t clock)
return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000;
}
static void
shell_quote (const char *str, struct str *output)
{
// See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes
str_append_c (output, '"');
for (const char *p = str; *p; p++)
{
if (strchr ("`$\"\\", *p))
str_append_c (output, '\\');
str_append_c (output, *p);
}
str_append_c (output, '"');
}
static bool
xstrtoul_map (const struct str_map *map, const char *key, unsigned long *out)
{
@@ -164,6 +179,18 @@ latin1_to_utf8 (const char *latin1)
return str_steal (&converted);
}
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)
{
@@ -318,6 +345,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);
@@ -1368,6 +1397,7 @@ static struct app_context
XftDraw *xft_draw; ///< Xft rendering context
XftFont *xft_regular; ///< Regular font
XftFont *xft_bold; ///< Bold font
XftFont *xft_italic; ///< Italic font
char *x11_selection; ///< CLIPBOARD selection
XRenderColor x_fg[ATTRIBUTE_COUNT]; ///< Foreground per attribute
@@ -1808,10 +1838,24 @@ app_layout_song_info (void)
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 layout l = {};
app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
@@ -1821,23 +1865,38 @@ app_layout_song_info (void)
app_flush_layout (&l);
}
char *artist = compact_map_find (map, "artist");
char *album = compact_map_find (map, "album");
if (!artist && !album)
return;
// 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));
if (artist)
char *artist = compact_map_find (map, "artist");
char *album = compact_map_find (map, "album");
if (artist || album)
{
app_push (&l, g.ui->label (attrs[0], "by "));
app_push (&l, g.ui->label (attrs[1], artist));
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));
}
}
if (album)
else if (subroot_basename && subroot_basename != file)
{
app_push (&l, g.ui->label (attrs[0], &" from "[!artist]));
app_push (&l, g.ui->label (attrs[1], album));
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));
}
app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
@@ -2603,6 +2662,14 @@ app_process_action (enum action action)
case ACTION_NONE:
return true;
case ACTION_QUIT:
app_quit ();
return true;
case ACTION_REDRAW:
clear ();
app_invalidate ();
return true;
case ACTION_ABORT:
// It is a pseudomode, avoid surprising the user
if (tab->item_mark > -1)
{
@@ -2610,13 +2677,7 @@ app_process_action (enum action action)
app_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_mpd_command_editor_end;
@@ -2748,7 +2809,7 @@ app_editor_process_action (enum action action)
app_invalidate ();
switch (action)
{
case ACTION_QUIT:
case ACTION_ABORT:
line_editor_abort (&g.editor, false);
g.editor.on_end = NULL;
return true;
@@ -2774,6 +2835,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:
@@ -2941,9 +3009,9 @@ 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 },
@@ -3009,6 +3077,10 @@ g_normal_defaults[] =
},
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 },
@@ -3020,6 +3092,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 },
@@ -3028,10 +3104,6 @@ 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
@@ -4078,68 +4150,456 @@ streams_tab_init (void)
// --- 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':
return A_ITALIC;
default:
return 0;
}
}
static void
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)
{
const char *key = g_info_tab.keys.vector[item_index];
const char *value = g_info_tab.values.vector[item_index];
struct info_tab_item *item = &g_info_tab.items[item_index];
struct layout l = {};
if (item->prefix)
{
char *prefix = xstrdup_printf ("%s:", item->prefix);
app_push (&l, g.ui->label (A_BOLD, prefix))
->width = 8 * g.ui_hunit;
app_push (&l, g.ui->padding (0, 0.5, 1));
}
char *prefix = xstrdup_printf ("%s:", key);
app_push (&l, g.ui->label (A_BOLD, prefix))
->width = 8 * g.ui_hunit;
app_push (&l, g.ui->padding (0, 0.5, 1));
app_push_fill (&l, g.ui->label (0, value));
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));
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 ();
app_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 ();
app_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_action = info_tab_on_action;
super->on_item_layout = info_tab_on_item_layout;
return super;
}
@@ -4168,9 +4628,8 @@ help_tab_on_action (enum action action)
if (action == ACTION_DESCRIBE)
{
char *name = xstrdup (g_action_names[a]);
cstr_transform (name, tolower_ascii);
app_show_message (xstrdup ("Configuration name: "), name);
app_show_message (xstrdup ("Configuration name: "),
xstrdup (g_action_names[a]));
return true;
}
if (action != ACTION_CHOOSE || a == ACTION_CHOOSE /* avoid recursion */)
@@ -5367,7 +5826,7 @@ tui_on_tty_readable (const struct pollfd *fd, void *user_data)
poller_timer_reset (&g.tk_timer);
termo_advisereadable (g.tk);
termo_key_t event;
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)
@@ -5438,7 +5897,11 @@ static XErrorHandler x11_default_error_handler;
static XftFont *
x11_font (struct widget *self)
{
return (self->attrs & A_BOLD) ? g.xft_bold : g.xft_regular;
if (self->attrs & A_BOLD)
return g.xft_bold;
if (self->attrs & A_ITALIC)
return g.xft_italic;
return g.xft_regular;
}
static XRenderColor *
@@ -5816,7 +6279,8 @@ static void
x11_render_list (struct widget *self)
{
// We could do that for all widgets, but it would be kind-of pointless.
XRenderSetPictureClipRectangles (g.dpy, g.x11_pixmap_picture, 0, 0,
// We need to go through Xft, or XftTextRenderUtf8() might skip glyphs.
XftDrawSetClipRectangles (g.xft_draw, 0, 0,
&(XRectangle) { self->x, self->y, self->width, self->height }, 1);
x11_render_padding (self);
@@ -5826,8 +6290,7 @@ x11_render_list (struct widget *self)
free (w);
}
XRenderChangePicture (g.dpy, g.x11_pixmap_picture, CPClipMask,
&(XRenderPictureAttributes) { .clip_mask = None });
XftDrawSetClip (g.xft_draw, None);
}
static struct widget *
@@ -5919,6 +6382,7 @@ x11_destroy (void)
XftDrawDestroy (g.xft_draw);
XftFontClose (g.dpy, g.xft_regular);
XftFontClose (g.dpy, g.xft_bold);
XftFontClose (g.dpy, g.xft_italic);
cstr_set (&g.x11_selection, NULL);
poller_fd_reset (&g.x11_event);
@@ -6286,7 +6750,10 @@ on_x11_event (XEvent *ev)
case SelectionClear:
cstr_set (&g.x11_selection, NULL);
break;
case UnmapNotify:
// UnmapNotify can be received when restarting the window manager.
// Should this turn out to be unreliable (window not destroyed by WM
// upon closing), opt for the WM_DELETE_WINDOW protocol as well.
case DestroyNotify:
app_quit ();
break;
case FocusIn:
@@ -6420,8 +6887,11 @@ x11_init_fonts (void)
FcPattern *query_regular = FcNameParse ((const FcChar8 *) name);
FcPattern *query_bold = FcPatternDuplicate (query_regular);
FcPatternAdd (query_bold, FC_STYLE,
(FcValue) { .type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse);
FcPatternAdd (query_bold, FC_STYLE, (FcValue) {
.type = FcTypeString, .u.s = (FcChar8 *) "Bold" }, FcFalse);
FcPattern *query_italic = FcPatternDuplicate (query_regular);
FcPatternAdd (query_italic, FC_STYLE, (FcValue) {
.type = FcTypeString, .u.s = (FcChar8 *) "Italic" }, FcFalse);
FcPattern *regular = XftFontMatch (g.dpy, screen, query_regular, &result);
FcPatternDestroy (query_regular);
@@ -6439,6 +6909,13 @@ x11_init_fonts (void)
FcPatternDestroy (bold);
if (!g.xft_bold)
g.xft_bold = XftFontCopy (g.dpy, g.xft_regular);
FcPattern *italic = XftFontMatch (g.dpy, screen, query_italic, &result);
FcPatternDestroy (query_italic);
if (italic && !(g.xft_italic = XftFontOpenPattern (g.dpy, italic)))
FcPatternDestroy (italic);
if (!g.xft_italic)
g.xft_italic = XftFontCopy (g.dpy, g.xft_regular);
}
static void
@@ -6665,6 +7142,7 @@ app_log_handler (void *user_data, const char *quote, const char *fmt,
str_append_vprintf (&message, fmt, ap);
// 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));