aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.clang-format32
-rw-r--r--.gitignore2
-rw-r--r--CMakeLists.txt176
-rw-r--r--LICENSE2
-rw-r--r--NEWS78
-rw-r--r--README.adoc51
-rw-r--r--config.h.in10
-rwxr-xr-xinfo/10-azlyrics.pl43
m---------liberty0
-rw-r--r--line-editor.c288
-rw-r--r--nncmpp.actions79
-rw-r--r--nncmpp.actions.awk106
-rw-r--r--nncmpp.adoc123
-rw-r--r--nncmpp.c3484
-rw-r--r--nncmpp.desktop9
-rw-r--r--nncmpp.pngbin4719 -> 36087 bytes
-rw-r--r--nncmpp.svg9
m---------termo0
18 files changed, 3172 insertions, 1320 deletions
diff --git a/.clang-format b/.clang-format
new file mode 100644
index 0000000..27838ac
--- /dev/null
+++ b/.clang-format
@@ -0,0 +1,32 @@
+# clang-format is fairly limited, and these rules are approximate:
+# - array initializers can get terribly mangled with clang-format 12.0,
+# - sometimes it still aligns with space characters,
+# - struct name NL { NL ... NL } NL name; is unachievable.
+BasedOnStyle: GNU
+ColumnLimit: 80
+IndentWidth: 4
+TabWidth: 4
+UseTab: ForContinuationAndIndentation
+BreakBeforeBraces: Allman
+SpaceAfterCStyleCast: true
+AlignAfterOpenBracket: DontAlign
+AlignOperands: DontAlign
+AlignConsecutiveMacros: Consecutive
+AllowAllArgumentsOnNextLine: false
+AllowAllParametersOfDeclarationOnNextLine: false
+IndentGotoLabels: false
+
+# IncludeCategories has some potential, but it may also break the build.
+# Note that the documentation says the value should be "Never".
+SortIncludes: false
+
+# This is a compromise, it generally works out aesthetically better.
+BinPackArguments: false
+
+# Unfortunately, this can't be told to align to column 40 or so.
+SpacesBeforeTrailingComments: 2
+
+# liberty-specific macro body wrappers.
+MacroBlockBegin: "BLOCK_START"
+MacroBlockEnd: "BLOCK_END"
+ForEachMacros: ["LIST_FOR_EACH"]
diff --git a/.gitignore b/.gitignore
index 6954c64..6835b43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@
/nncmpp.files
/nncmpp.creator*
/nncmpp.includes
+/nncmpp.cflags
+/nncmpp.cxxflags
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 97763e7..93df5e8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,5 @@
-cmake_minimum_required (VERSION 3.0)
-project (nncmpp VERSION 1.0.0 LANGUAGES C)
+cmake_minimum_required (VERSION 3.0...3.27)
+project (nncmpp VERSION 2.1.1 LANGUAGES C)
# Moar warnings
if ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC)
@@ -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,48 +27,75 @@ pkg_check_modules (curl REQUIRED libcurl)
include (AddThreads)
find_package (Termo QUIET NO_MODULE)
-option (USE_SYSTEM_TERMO
- "Don't compile our own termo library, use the system one" ${Termo_FOUND})
+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)
message (FATAL_ERROR "System termo library not found")
- endif (NOT Termo_FOUND)
+ endif ()
else ()
+ # We don't want the library to install, but EXCLUDE_FROM_ALL ignores tests
add_subdirectory (termo EXCLUDE_FROM_ALL)
- # We don't have many good choices when we don't want to install it and want
- # to support older versions of CMake; this is a relatively clean approach
- # (other possibilities: setting a variable in the parent scope, using a
- # cache variable, writing a special config file with build paths in it and
- # including it here, or setting a custom property on the targets).
+ file (WRITE ${PROJECT_BINARY_DIR}/CTestCustom.cmake
+ "execute_process (COMMAND ${CMAKE_COMMAND} --build termo)")
+
+ # We don't have many good choices; this is a relatively clean approach
+ # (other possibilities: setting a variable in the parent scope, using
+ # a cache variable, writing a special config file with build paths in it
+ # and including it here, or setting a custom property on the targets)
get_directory_property (Termo_INCLUDE_DIRS
DIRECTORY termo INCLUDE_DIRECTORIES)
set (Termo_LIBRARIES termo-static)
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")
- endif()
+ endif ()
+ list (APPEND extra_libraries ${fftw_LIBRARIES})
+endif ()
+
+pkg_check_modules (libpulse libpulse)
+add_option (WITH_PULSE
+ "Enable PulseAudio sink volume control" "${libpulse_FOUND}")
+if (WITH_PULSE)
+ if (NOT libpulse_FOUND)
+ message (FATAL_ERROR "libpulse not found")
+ endif ()
+ list (APPEND extra_libraries ${libpulse_LIBRARIES})
+endif ()
+
+pkg_check_modules (x11 x11 xrender xft fontconfig libpng)
+add_option (WITH_X11 "Build with X11 support" "${x11_FOUND}")
+if (WITH_X11)
+ if (NOT x11_FOUND)
+ message (FATAL_ERROR "Some X11 libraries were not found")
+ endif ()
+ list (APPEND extra_libraries ${x11_LIBRARIES})
endif ()
include_directories (${Unistring_INCLUDE_DIRS}
${Ncursesw_INCLUDE_DIRS} ${Termo_INCLUDE_DIRS} ${curl_INCLUDE_DIRS}
- ${fftw_INCLUDE_DIRS})
-link_directories (${curl_LIBRARY_DIRS} ${fftw_LIBRARY_DIRS})
+ ${fftw_INCLUDE_DIRS} ${libpulse_INCLUDE_DIRS} ${x11_INCLUDE_DIRS})
+link_directories (${curl_LIBRARY_DIRS}
+ ${fftw_LIBRARY_DIRS} ${libpulse_LIBRARY_DIRS} ${x11_LIBRARY_DIRS})
# Configuration
-include (CheckFunctionExists)
-set (CMAKE_REQUIRED_LIBRARIES ${Ncursesw_LIBRARIES})
-CHECK_FUNCTION_EXISTS ("resizeterm" HAVE_RESIZETERM)
-
if ("${CMAKE_SYSTEM_NAME}" MATCHES "BSD")
# Need this for SIGWINCH in FreeBSD and OpenBSD respectively;
# our POSIX version macros make it undefined
add_definitions (-D__BSD_VISIBLE=1 -D_BSD_SOURCE=1)
+elseif (APPLE)
+ add_definitions (-D_DARWIN_C_SOURCE)
endif ()
+include (CheckFunctionExists)
+set (CMAKE_REQUIRED_LIBRARIES ${Ncursesw_LIBRARIES})
+CHECK_FUNCTION_EXISTS ("resizeterm" HAVE_RESIZETERM)
+
# -lm may or may not be a part of libc
foreach (extra m)
find_library (extra_lib_${extra} ${extra})
@@ -70,39 +105,89 @@ 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})
+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 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)
+add_executable (${PROJECT_NAME} ${PROJECT_NAME}.c ${actions})
target_link_libraries (${PROJECT_NAME} ${Unistring_LIBRARIES}
- ${Ncursesw_LIBRARIES} termo-static ${curl_LIBRARIES}
- ${fftw_LIBRARIES} ${extra_libraries})
+ ${Ncursesw_LIBRARIES} ${Termo_LIBRARIES} ${curl_LIBRARIES}
+ ${extra_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}
+ USE_SOURCE_PERMISSIONS)
+if (WITH_X11)
+ include (IconUtils)
+
+ set (icon_base ${PROJECT_BINARY_DIR}/icons)
+ set (icon_png_list)
+ foreach (icon_size 16 32 48)
+ icon_to_png (${PROJECT_NAME} ${PROJECT_SOURCE_DIR}/${PROJECT_NAME}.svg
+ ${icon_size} ${icon_base} icon_png)
+ list (APPEND icon_png_list ${icon_png})
+ endforeach ()
+
+ add_custom_target (icons ALL DEPENDS ${icon_png_list})
+
+ install (FILES ${PROJECT_NAME}.svg
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps)
+ install (DIRECTORY ${icon_base}
+ DESTINATION ${CMAKE_INSTALL_DATADIR})
+ install (FILES ${PROJECT_NAME}.desktop
+ DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
+endif ()
# Generate documentation from text markup
find_program (ASCIIDOCTOR_EXECUTABLE asciidoctor)
-if (NOT ASCIIDOCTOR_EXECUTABLE)
- message (FATAL_ERROR "asciidoctor not found")
+find_program (A2X_EXECUTABLE a2x)
+if (NOT ASCIIDOCTOR_EXECUTABLE AND NOT A2X_EXECUTABLE)
+ message (WARNING "Neither asciidoctor nor a2x were found, "
+ "falling back to a substandard manual page generator")
endif ()
foreach (page ${PROJECT_NAME})
set (page_output "${PROJECT_BINARY_DIR}/${page}.1")
list (APPEND project_MAN_PAGES "${page_output}")
- add_custom_command (OUTPUT ${page_output}
- COMMAND ${ASCIIDOCTOR_EXECUTABLE} -b manpage
- -a release-version=${PROJECT_VERSION}
- "${PROJECT_SOURCE_DIR}/${page}.adoc"
- -o "${page_output}"
- DEPENDS ${page}.adoc
- COMMENT "Generating man page for ${page}" VERBATIM)
+ if (ASCIIDOCTOR_EXECUTABLE)
+ add_custom_command (OUTPUT ${page_output}
+ COMMAND ${ASCIIDOCTOR_EXECUTABLE} -b manpage
+ -a release-version=${PROJECT_VERSION}
+ -o "${page_output}"
+ "${PROJECT_SOURCE_DIR}/${page}.adoc"
+ DEPENDS ${page}.adoc
+ COMMENT "Generating man page for ${page}" VERBATIM)
+ elseif (A2X_EXECUTABLE)
+ add_custom_command (OUTPUT ${page_output}
+ COMMAND ${A2X_EXECUTABLE} --doctype manpage --format manpage
+ -a release-version=${PROJECT_VERSION}
+ -D "${PROJECT_BINARY_DIR}"
+ "${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 ()
add_custom_target (docs ALL DEPENDS ${project_MAN_PAGES})
@@ -113,8 +198,35 @@ foreach (page ${project_MAN_PAGES})
DESTINATION "${CMAKE_INSTALL_MANDIR}/man${CMAKE_MATCH_1}")
endforeach ()
+# Testing
+option (BUILD_TESTING "Build tests" OFF)
+if (BUILD_TESTING)
+ enable_testing ()
+
+ find_program (xmlwf_EXECUTABLE xmlwf)
+ find_program (xmllint_EXECUTABLE xmllint)
+ foreach (xml ${PROJECT_NAME}.svg)
+ if (xmlwf_EXECUTABLE)
+ add_test (test-xmlwf-${xml} ${xmlwf_EXECUTABLE}
+ ${PROJECT_SOURCE_DIR}/${xml})
+ endif ()
+ if (xmllint_EXECUTABLE)
+ add_test (test-xmllint-${xml} ${xmllint_EXECUTABLE} --noout
+ ${PROJECT_SOURCE_DIR}/${xml})
+ endif ()
+ endforeach ()
+
+ find_program (dfv_EXECUTABLE desktop-file-validate)
+ if (dfv_EXECUTABLE)
+ foreach (df ${PROJECT_NAME}.desktop)
+ add_test (test-dfv-${df} ${dfv_EXECUTABLE}
+ ${PROJECT_SOURCE_DIR}/${df})
+ endforeach ()
+ endif ()
+endif ()
+
# CPack
-set (CPACK_PACKAGE_DESCRIPTION_SUMMARY "MPD client")
+set (CPACK_PACKAGE_DESCRIPTION_SUMMARY "Terminal/X11 MPD client")
set (CPACK_PACKAGE_VENDOR "Premysl Eric Janouch")
set (CPACK_PACKAGE_CONTACT "Přemysl Eric Janouch <p@janouch.name>")
set (CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
diff --git a/LICENSE b/LICENSE
index acb5eaa..7b6617a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2016 - 2021, Přemysl Eric Janouch <p@janouch.name>
+Copyright (c) 2016 - 2023, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
diff --git a/NEWS b/NEWS
index 59432ea..ffe2ecc 100644
--- a/NEWS
+++ b/NEWS
@@ -1,10 +1,86 @@
-1.1.0 (20xx-xx-xx)
+2.1.1 (2024-02-27)
+
+ * Fixed installation of Info tab plugins
+
+ * Fixed display of playback mode toggles in the terminal user interface
+
+ * Fixed a dead link in the manual page
+
+
+2.1.0 (2024-02-11)
+
+ * 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 an icon and a desktop entry file
+
+ * X11: added support for font fallbacks and italic fonts
+
+ * X11: fixed rendering of overflowing, partially visible list items
+
+ * X11: fixed a crash when resizing the window to zero dimensions
+
+ * Added a "o" binding to select the currently playing song
+
+ * Added Readline-like M-u, M-l, M-c editor bindings
+
+ * Made the scroll wheel work on the elapsed time gauge and the volume display
+
+ * Changed volume adjustment bindings to use +/- keys
+
+ * Changed volume adjustment to go in steps of 5 rather than 10 %
+
+
+2.0.0 (2022-09-03)
+
+ * Added an optional X11 user interface
+
+ * Implemented mouse drags on the elapsed time gauge and the scrollbar
+
+ * Added Tab and S-Tab bindings to iterate tabs
+
+ * Added a "z" binding to center the view on the selected item
+
+ * Added a "?" binding to describe items in various tabs
+
+ * Made it possible to adjust the spectrum analyzer's FPS limit
+
+ * Moved "Disconnected" and "Connecting..." messages to the status bar
+
+ * Fixed possibility of connection timeouts with PulseAudio integration
+
+
+1.2.0 (2021-12-21)
+
+ * Added ability to control the volume of MPD's current PulseAudio sink
+
+ * Now fetching Internet stream information asynchronously
+
+ * Added basic incremental search, normally bound to C-s, in all tabs
+
+ * Fixed jumping to the beginning of the queue after deleting items
+
+
+1.1.1 (2021-11-04)
+
+ * Terminal focus in/out events no longer ring the terminall bell
+
+ * Made mouse work in non-rxvt terminals with recent xterm terminfo
+
+
+1.1.0 (2021-10-21)
* Now requesting and processing terminal de/focus events,
using a new "defocused" attribute for selected rows
* Made it possible to show a spectrum visualiser when built against FFTW
+ * Any program arguments are now added to MPD's current playlist
+
1.0.0 (2020-11-05)
diff --git a/README.adoc b/README.adoc
index ce6e8b3..1a0f199 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,26 +1,37 @@
nncmpp
======
-'nncmpp' is yet another MPD client. It is in effect a simplified TUI version
-of Sonata. I had already written a lot of the required code before, so I had
-the perfect opportunity to get rid of the unmaintained Python application and
-make the first TUI client that doesn't feel awkward to use.
+'nncmpp' is yet another MPD client. Its specialty is running equally well in
+the terminal, or as an X11 client--it will provide the same keyboard- and
+mouse-friendly interface.
+
+This project began its life as a simplified TUI version of Sonata. I had
+already written a lot of the required code before, so I had the perfect
+opportunity to get rid of the unmaintained Python application, and to make
+the first TUI client that doesn't feel awkward to use.
If it's not obvious enough, the name is a pun on all those ridiculous client
names, and should be pronounced as "nincompoop".
Features
--------
-Most things are there. Enough for me to use it exclusively. Note that since I
-only use the filesystem browsing mode, that's also the only thing I care to
-implement for the time being.
+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,
+and the search feature is known to be clumsy.
image::nncmpp.png[align="center"]
Packages
--------
-Regular releases are sporadic. git master should be stable enough. You can get
-a package with the latest development version from Archlinux's AUR.
+Regular releases are sporadic. git master should be stable enough.
+You can get a package with the latest development version using Arch Linux's
+https://aur.archlinux.org/packages/nncmpp-git[AUR],
+or as a https://git.janouch.name/p/nixexprs[Nix derivation].
Documentation
-------------
@@ -29,9 +40,12 @@ The rest of this README will concern itself with externalities.
Building
--------
-Build dependencies: CMake, pkg-config, asciidoctor, liberty (included),
- termo (included) +
-Runtime dependencies: ncursesw, libunistring, cURL
+Build-only dependencies: CMake, pkg-config, awk, liberty (included),
+ termo (included), asciidoctor or asciidoc (recommended but optional),
+ rsvg-convert (X11) +
+Runtime dependencies: ncursesw, libunistring, cURL +
+Optional runtime dependencies: fftw3, libpulse, x11 + xft + libpng (X11),
+ Perl + cURL (lyrics)
$ git clone --recursive https://git.janouch.name/p/nncmpp.git
$ mkdir nncmpp/build
@@ -48,16 +62,19 @@ Or you can try telling CMake to make a package for you. For Debian it is:
$ cpack -G DEB
# dpkg -i nncmpp-*.deb
-Terminal caveats
-----------------
-This application aspires to be as close to a GUI as possible. It expects you
-to use the mouse (though it's not required). Terminals are, however, somewhat
-tricky to get consistent results on, so be aware of the following:
+User interface caveats
+----------------------
+The ncurses interface aspires to be as close to a GUI as possible. Don't shy
+away from using your mouse (though keyboard is also fine). Terminals are,
+however, tricky to get consistent results on, so be aware of the following:
- use a UTF-8 locale to get finer resolution progress bars and scrollbars
- Xterm needs `XTerm*metaSendsEscape: true` for the default bindings to work
- urxvt's 'vtwheel' plugin sabotages scrolling
+The X11 graphical interface is a second-class citizen, so some limitations of
+terminals carry over, such as the plain default theme.
+
Contributing and Support
------------------------
Use https://git.janouch.name/p/nncmpp to report any bugs, request features,
diff --git a/config.h.in b/config.h.in
index 67fd7cd..77296dd 100644
--- a/config.h.in
+++ b/config.h.in
@@ -1,11 +1,15 @@
#ifndef CONFIG_H
#define CONFIG_H
-#define PROGRAM_NAME "${CMAKE_PROJECT_NAME}"
+#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
+#cmakedefine WITH_X11
-#endif // ! CONFIG_H
-
+#endif /* ! CONFIG_H */
diff --git a/info/10-azlyrics.pl b/info/10-azlyrics.pl
new file mode 100755
index 0000000..3cc0b92
--- /dev/null
+++ b/info/10-azlyrics.pl
@@ -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;
diff --git a/liberty b/liberty
-Subproject d71c47f8ce7aecdc4856630e9d73a48912be68c
+Subproject 969a4cfc3ea1c4d7c0327907385fc64906ed5d4
diff --git a/line-editor.c b/line-editor.c
deleted file mode 100644
index 0ae26f9..0000000
--- a/line-editor.c
+++ /dev/null
@@ -1,288 +0,0 @@
-/*
- * line-editor.c: a line editor component for the TUI part of liberty
- *
- * Copyright (c) 2017 - 2018, Přemysl Eric Janouch <p@janouch.name>
- *
- * Permission to use, copy, modify, and/or distribute this software for any
- * purpose with or without fee is hereby granted.
- *
- * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
- * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
- * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
- * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
- * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
- * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
- * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
- *
- */
-
-// This is here just for IDE code model reasons
-#ifndef HAVE_LIBERTY
-#include "liberty/liberty.c"
-#include "liberty/liberty-tui.c"
-#endif
-
-static void
-row_buffer_append_c (struct row_buffer *self, ucs4_t c, chtype attrs)
-{
- struct row_char current = { .attrs = attrs, .c = c };
- struct row_char invalid = { .attrs = attrs, .c = '?', .width = 1 };
-
- current.width = uc_width (current.c, locale_charset ());
- if (current.width < 0 || !app_is_character_in_locale (current.c))
- current = invalid;
-
- ARRAY_RESERVE (self->chars, 1);
- self->chars[self->chars_len++] = current;
- self->total_width += current.width;
-}
-
-// --- Line editor -------------------------------------------------------------
-
-enum line_editor_action
-{
- LINE_EDITOR_B_CHAR, ///< Go back a character
- LINE_EDITOR_F_CHAR, ///< Go forward a character
- LINE_EDITOR_B_WORD, ///< Go back a word
- LINE_EDITOR_F_WORD, ///< Go forward a word
- LINE_EDITOR_HOME, ///< Go to start of line
- LINE_EDITOR_END, ///< Go to end of line
-
- LINE_EDITOR_B_DELETE, ///< Delete last character
- LINE_EDITOR_F_DELETE, ///< Delete next character
- LINE_EDITOR_B_KILL_WORD, ///< Delete last word
- LINE_EDITOR_B_KILL_LINE, ///< Delete everything up to BOL
- LINE_EDITOR_F_KILL_LINE, ///< Delete everything up to EOL
-};
-
-struct line_editor
-{
- int point; ///< Caret index into line data
- ucs4_t *line; ///< Line data, 0-terminated
- int *w; ///< Codepoint widths, 0-terminated
- size_t len; ///< Editor length
- size_t alloc; ///< Editor allocated
- char prompt; ///< Prompt character
-
- void (*on_changed) (void); ///< Callback on text change
- void (*on_end) (bool); ///< Callback on abort
-};
-
-static void
-line_editor_free (struct line_editor *self)
-{
- free (self->line);
- free (self->w);
-}
-
-/// Notify whomever invoked the editor that it's been either confirmed or
-/// cancelled and clean up editor state
-static void
-line_editor_abort (struct line_editor *self, bool status)
-{
- self->on_end (status);
- self->on_changed = NULL;
-
- free (self->line);
- self->line = NULL;
- free (self->w);
- self->w = NULL;
- self->alloc = 0;
- self->len = 0;
- self->point = 0;
- self->prompt = 0;
-}
-
-/// Start the line editor; remember to fill in "change" and "end" callbacks
-static void
-line_editor_start (struct line_editor *self, char prompt)
-{
- self->alloc = 16;
- self->line = xcalloc (sizeof *self->line, self->alloc);
- self->w = xcalloc (sizeof *self->w, self->alloc);
- self->len = 0;
- self->point = 0;
- self->prompt = prompt;
-}
-
-static void
-line_editor_changed (struct line_editor *self)
-{
- self->line[self->len] = 0;
- self->w[self->len] = 0;
-
- if (self->on_changed)
- self->on_changed ();
-}
-
-static void
-line_editor_move (struct line_editor *self, int to, int from, int len)
-{
- memmove (self->line + to, self->line + from,
- sizeof *self->line * len);
- memmove (self->w + to, self->w + from,
- sizeof *self->w * len);
-}
-
-static void
-line_editor_insert (struct line_editor *self, ucs4_t codepoint)
-{
- while (self->alloc - self->len < 2 /* inserted + sentinel */)
- {
- self->alloc <<= 1;
- self->line = xreallocarray
- (self->line, sizeof *self->line, self->alloc);
- self->w = xreallocarray
- (self->w, sizeof *self->w, self->alloc);
- }
-
- line_editor_move (self, self->point + 1, self->point,
- self->len - self->point);
- self->line[self->point] = codepoint;
- self->w[self->point] = app_is_character_in_locale (codepoint)
- ? uc_width (codepoint, locale_charset ())
- : 1 /* the replacement question mark */;
-
- self->point++;
- self->len++;
- line_editor_changed (self);
-}
-
-static bool
-line_editor_action (struct line_editor *self, enum line_editor_action action)
-{
- switch (action)
- {
- default:
- return soft_assert (!"unknown line editor action");
-
- case LINE_EDITOR_B_CHAR:
- if (self->point < 1)
- return false;
- do self->point--;
- while (self->point > 0
- && !self->w[self->point]);
- return true;
- case LINE_EDITOR_F_CHAR:
- if (self->point + 1 > (int) self->len)
- return false;
- do self->point++;
- while (self->point < (int) self->len
- && !self->w[self->point]);
- return true;
- case LINE_EDITOR_B_WORD:
- {
- if (self->point < 1)
- return false;
- int i = self->point;
- while (i && self->line[--i] == ' ');
- while (i-- && self->line[i] != ' ');
- self->point = ++i;
- return true;
- }
- case LINE_EDITOR_F_WORD:
- {
- 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++;
- self->point = i;
- return true;
- }
- case LINE_EDITOR_HOME:
- self->point = 0;
- return true;
- case LINE_EDITOR_END:
- self->point = self->len;
- return true;
-
- case LINE_EDITOR_B_DELETE:
- {
- if (self->point < 1)
- return false;
- int len = 1;
- while (self->point - len > 0
- && !self->w[self->point - len])
- len++;
- line_editor_move (self, self->point - len, self->point,
- self->len - self->point);
- self->len -= len;
- self->point -= len;
- line_editor_changed (self);
- return true;
- }
- case LINE_EDITOR_F_DELETE:
- {
- if (self->point + 1 > (int) self->len)
- return false;
- int len = 1;
- while (self->point + len < (int) self->len
- && !self->w[self->point + len])
- len++;
- self->len -= len;
- line_editor_move (self, self->point, self->point + len,
- self->len - self->point);
- line_editor_changed (self);
- return true;
- }
- case LINE_EDITOR_B_KILL_WORD:
- {
- if (self->point < 1)
- return false;
-
- int i = self->point;
- while (i && self->line[--i] == ' ');
- while (i-- && self->line[i] != ' ');
- i++;
-
- line_editor_move (self, i, self->point, (self->len - self->point));
- self->len -= self->point - i;
- self->point = i;
- line_editor_changed (self);
- return true;
- }
- case LINE_EDITOR_B_KILL_LINE:
- self->len -= self->point;
- line_editor_move (self, 0, self->point, self->len);
- self->point = 0;
- line_editor_changed (self);
- return true;
- case LINE_EDITOR_F_KILL_LINE:
- self->len = self->point;
- line_editor_changed (self);
- return true;
- }
-}
-
-static int
-line_editor_write (const struct line_editor *self, struct row_buffer *row,
- int width, chtype attrs)
-{
- if (self->prompt)
- {
- hard_assert (self->prompt < 127);
- row_buffer_append_c (row, self->prompt, attrs);
- width--;
- }
-
- int following = 0;
- for (size_t i = self->point; i < self->len; i++)
- following += self->w[i];
-
- int preceding = 0;
- size_t start = self->point;
- while (start && preceding < width / 2)
- preceding += self->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 += self->w[--start];
-
- // XXX: we should also show < > indicators for overflow but it'd probably
- // considerably complicate this algorithm
- for (; start < self->len; start++)
- row_buffer_append_c (row, self->line[start], attrs);
- return !!self->prompt + preceding;
-}
diff --git a/nncmpp.actions b/nncmpp.actions
new file mode 100644
index 0000000..1eed3b7
--- /dev/null
+++ b/nncmpp.actions
@@ -0,0 +1,79 @@
+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
+TAB_NEXT, Switch to next tab
+
+MPD_TOGGLE, Toggle play/pause
+MPD_STOP, Stop playback
+MPD_PREVIOUS, Previous song
+MPD_NEXT, Next song
+MPD_BACKWARD, Seek backwards
+MPD_FORWARD, Seek forwards
+MPD_VOLUME_UP, Increase MPD volume
+MPD_VOLUME_DOWN, Decrease MPD volume
+
+MPD_SEARCH, Global search
+MPD_ADD, Add selection to playlist
+MPD_REPLACE, Replace playlist
+MPD_REPEAT, Toggle repeat
+MPD_RANDOM, Toggle random playback
+MPD_SINGLE, Toggle single song playback
+MPD_CONSUME, Toggle consume
+MPD_UPDATE_DB, Update MPD database
+MPD_COMMAND, Send raw command to MPD
+
+.ifdef WITH_PULSE
+PULSE_VOLUME_UP, Increase PulseAudio volume
+PULSE_VOLUME_DOWN, Decrease PulseAudio volume
+PULSE_MUTE, Toggle PulseAudio sink mute
+.endif
+
+CHOOSE, Choose item
+DELETE, Delete item
+DESCRIBE, Describe item
+UP, Go up a level
+MULTISELECT, Toggle multiselect
+INCREMENTAL_SEARCH, Incremental search
+
+SCROLL_UP, Scroll up
+SCROLL_DOWN, Scroll down
+CENTER_CURSOR, Center the cursor
+MOVE_UP, Move selection up
+MOVE_DOWN, Move selection down
+
+GOTO_PLAYING, Go to playing song
+
+GOTO_TOP, Go to top
+GOTO_BOTTOM, Go to bottom
+GOTO_ITEM_PREVIOUS, Go to previous item
+GOTO_ITEM_NEXT, Go to next item
+GOTO_PAGE_PREVIOUS, Go to previous page
+GOTO_PAGE_NEXT, Go to next page
+
+GOTO_VIEW_TOP, Select top item
+GOTO_VIEW_CENTER, Select center item
+GOTO_VIEW_BOTTOM, Select bottom item
+
+EDITOR_CONFIRM, Confirm input
+
+EDITOR_B_CHAR, Go back a character
+EDITOR_F_CHAR, Go forward a character
+EDITOR_B_WORD, Go back a word
+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
+EDITOR_B_KILL_LINE, Delete everything up to BOL
+EDITOR_F_KILL_LINE, Delete everything up to EOL
diff --git a/nncmpp.actions.awk b/nncmpp.actions.awk
new file mode 100644
index 0000000..b4d7eaf
--- /dev/null
+++ b/nncmpp.actions.awk
@@ -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 "};"
+}
diff --git a/nncmpp.adoc b/nncmpp.adoc
index 0909dd3..33e2834 100644
--- a/nncmpp.adoc
+++ b/nncmpp.adoc
@@ -6,25 +6,33 @@ nncmpp(1)
Name
----
-nncmpp - terminal-based MPD client
+nncmpp - MPD client
Synopsis
--------
-*nncmpp* [_OPTION_]...
+*nncmpp* [_OPTION_]... [_URL_ | _PATH_]...
Description
-----------
-*nncmpp* is a terminal-based GUI-like MPD client. On start up it will welcome
+*nncmpp* is a hybrid terminal/X11 MPD client. On start up it will welcome
you with an overview of all key bindings and the actions they're assigned to.
Individual tabs can be switched to either using the mouse or by pressing *M-1*
through *M-9*, corresponding to the order they appear in.
+As a convenience utility, any program arguments are added to the MPD queue.
+Note that to add files outside the database, you need to connect to MPD using
+a socket file.
+
Options
-------
*-d*, *--debug*::
Adds a "Debug" tab showing all MPD communication and other information
that help debug various issues.
+*-x*, *--x11*::
+ Use an X11 interface even when run from a terminal.
+ Note that the application may be built with this feature disabled.
+
*-h*, *--help*::
Display a help message and exit.
@@ -33,44 +41,50 @@ Options
Configuration
-------------
-Unless you run MPD on a remote machine, on an unusual port, or protected by
-a password, the client doesn't need a configuration file to work. It is,
-however, likely that you'll want to customize the looks or add some streams.
-You can start off with the following snippet:
+Unless you run MPD on a remote machine, on an unusual port, protected by
+a password, or only accessible through a Unix socket, the client doesn't need
+a configuration file to work. It is, however, likely that you'll want to
+customize the looks or add some streams. You can start off with the following
+snippet:
....
settings = {
- address = "localhost:6600"
- password = "<your password>"
- root = "~/Music"
+ address = "~/.mpd/mpd.socket"
+ password = "<your password>"
+ pulseaudio = on
+ x11_font = "sans\\-serif-11"
}
colors = {
- normal = ""
- highlight = "bold"
- elapsed = "reverse"
- remains = "ul"
- tab_bar = "reverse"
- tab_active = "ul"
- even = ""
- odd = ""
- selection = "reverse"
- multiselect = "-1 6"
- defocused = "ul"
- scrollbar = ""
+ normal = ""
+ highlight = "bold"
+ elapsed = "reverse"
+ remains = "ul"
+ tab_bar = "reverse"
+ tab_active = "ul"
+ even = ""
+ odd = ""
+ selection = "reverse"
+ multiselect = "-1 6"
+ defocused = "ul"
+ scrollbar = ""
}
streams = {
- "dnbradio.com" = "http://www.dnbradio.com/hi.m3u"
- "BassDrive.com" = "http://bassdrive.com/v2/streams/BassDrive.pls"
+ "dnbradio.com" = "https://dnbradio.com/hi.pls"
+ "BassDrive.com" = "http://bassdrive.com/v2/streams/BassDrive.pls"
}
....
-Terminal attributes are accepted in a format similar to that of *git-config*(1),
-only named colours aren't supported. The distribution contains example colour
-schemes in the _contrib_ directory.
+Terminal attributes also apply to the GUI, and are accepted in a format similar
+to that of *git-config*(1), only named colours aren't supported.
+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"
@@ -81,17 +95,52 @@ need to set it up manually to match your MPD configuration, e.g.:
....
settings = {
- ...
- spectrum_path = "~/.mpd/mpd.fifo" # "path"
- spectrum_format = "44100:16:2" # "format" (samplerate:bits:channels)
- spectrum_bars = 8 # beware of exponential complexity
- ...
+ ...
+ spectrum_path = "~/.mpd/mpd.fifo" # "path"
+ spectrum_format = "44100:16:2" # "format" (samplerate:bits:channels)
+ spectrum_bars = 8 # beware of exponential complexity
+ ...
}
....
The sample rate should be greater than 40 kHz, the number of bits 8 or 16,
and the number of channels doesn't matter, as they're simply averaged together.
+PulseAudio
+----------
+If you find standard MPD volume control useless, you may instead configure
+*nncmpp* to show and control the volume of any PulseAudio sink MPD is currently
+connected to.
+
+This feature may be enabled with the *settings.pulseaudio* configuration option,
+as in the snippet above. To replace the default volume control bindings, use:
+
+....
+normal = {
+ "+" = "pulse-volume-up"
+ "-" = "pulse-volume-down"
+}
+....
+
+The respective actions may also be invoked from the help tab directly.
+
+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.
@@ -99,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,
@@ -106,4 +163,4 @@ or submit pull requests.
See also
--------
-*mpd*(1)
+*mpd*(1), *pulseaudio*(1)
diff --git a/nncmpp.c b/nncmpp.c
index 8677635..0ee6796 100644
--- a/nncmpp.c
+++ b/nncmpp.c
@@ -1,7 +1,7 @@
/*
* nncmpp -- the MPD client you never knew you needed
*
- * Copyright (c) 2016 - 2021, Přemysl Eric Janouch <p@janouch.name>
+ * Copyright (c) 2016 - 2023, Přemysl Eric Janouch <p@janouch.name>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
@@ -28,7 +28,7 @@
XX( REMAINS, remains, -1, -1, A_UNDERLINE ) \
/* Tab bar */ \
XX( TAB_BAR, tab_bar, -1, -1, A_REVERSE ) \
- XX( TAB_ACTIVE, tab_active, -1, -1, A_UNDERLINE ) \
+ XX( TAB_ACTIVE, tab_active, -1, -1, A_BOLD ) \
/* Listview */ \
XX( HEADER, header, -1, -1, A_UNDERLINE ) \
XX( EVEN, even, -1, -1, 0 ) \
@@ -70,69 +70,54 @@ enum
#define LIBERTY_WANT_PROTO_HTTP
#define LIBERTY_WANT_PROTO_MPD
#include "liberty/liberty.c"
-#include "liberty/liberty-tui.c"
-#define HAVE_LIBERTY
-#include "line-editor.c"
+#ifdef WITH_X11
+#define LIBERTY_XUI_WANT_X11
+#endif // WITH_X11
+#include "liberty/liberty-xui.c"
-#include <math.h>
+#include <dirent.h>
#include <locale.h>
-#include <termios.h>
-#ifndef TIOCGWINSZ
-#include <sys/ioctl.h>
-#endif // ! TIOCGWINSZ
-
-// ncurses is notoriously retarded for input handling, we need something
-// different if only to receive mouse events reliably.
-//
-// 2021 update: ncurses is mostly reliable now, though rxvt-unicode only
-// supports the 1006 mode that ncurses also supports mode starting with 9.25.
-
-#include "termo.h"
+#include <math.h>
// We need cURL to extract links from Internet stream playlists. It'd be way
// too much code to do this all by ourselves, and there's nothing better around.
-
#include <curl/curl.h>
// The spectrum analyser requires a DFT transform. The FFTW library is fairly
// efficient, and doesn't have a requirement on the number of bins.
-
#ifdef WITH_FFTW
#include <fftw3.h>
#endif // WITH_FFTW
+// Remote MPD control needs appropriate volume controls.
+#ifdef WITH_PULSE
+#include "liberty/liberty-pulse.c"
+#include <pulse/context.h>
+#include <pulse/error.h>
+#include <pulse/introspect.h>
+#include <pulse/subscribe.h>
+#include <pulse/sample.h>
+#endif // WITH_PULSE
+
#define APP_TITLE PROGRAM_NAME ///< Left top corner
+#include "nncmpp-actions.h"
+
// --- Utilities ---------------------------------------------------------------
-// The standard endwin/refresh sequence makes the terminal flicker
static void
-update_curses_terminal_size (void)
+shell_quote (const char *str, struct str *output)
{
-#if defined (HAVE_RESIZETERM) && defined (TIOCGWINSZ)
- struct winsize size;
- if (!ioctl (STDOUT_FILENO, TIOCGWINSZ, (char *) &size))
+ // See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes
+ str_append_c (output, '"');
+ for (const char *p = str; *p; p++)
{
- char *row = getenv ("LINES");
- char *col = getenv ("COLUMNS");
- unsigned long tmp;
- resizeterm (
- (row && xstrtoul (&tmp, row, 10)) ? tmp : size.ws_row,
- (col && xstrtoul (&tmp, col, 10)) ? tmp : size.ws_col);
+ if (strchr ("`$\"\\", *p))
+ str_append_c (output, '\\');
+ str_append_c (output, *p);
}
-#else // HAVE_RESIZETERM && TIOCGWINSZ
- endwin ();
- refresh ();
-#endif // HAVE_RESIZETERM && TIOCGWINSZ
-}
-
-static int64_t
-clock_msec (clockid_t clock)
-{
- struct timespec tp;
- hard_assert (clock_gettime (clock, &tp) != -1);
- return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000;
+ str_append_c (output, '"');
}
static bool
@@ -149,6 +134,8 @@ xbasename (const char *path)
return last_slash ? last_slash + 1 : path;
}
+static char *xstrdup0 (const char *s) { return s ? xstrdup (s) : NULL; }
+
static char *
latin1_to_utf8 (const char *latin1)
{
@@ -168,6 +155,18 @@ latin1_to_utf8 (const char *latin1)
}
static void
+str_enforce_utf8 (struct str *self)
+{
+ if (!utf8_validate (self->str, self->len))
+ {
+ char *sanitized = latin1_to_utf8 (self->str);
+ str_reset (self);
+ str_append (self, sanitized);
+ free (sanitized);
+ }
+}
+
+static void
cstr_uncapitalize (char *s)
{
if (isupper (s[0]) && islower (s[1]))
@@ -275,6 +274,10 @@ struct poller_curl
struct poller_timer timer; ///< cURL timer
CURLM *multi; ///< cURL multi interface handle
struct poller_curl_fd *fds; ///< List of all FDs
+
+ // TODO: also make sure to dispose of them at the end of the program
+
+ int registered; ///< Number of attached easy handles
};
static void
@@ -317,6 +320,8 @@ poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what,
struct poller_curl_fd *fd;
if (!(fd = socket_data))
{
+ set_cloexec (s);
+
fd = xmalloc (sizeof *fd);
LIST_PREPEND (self->fds, fd);
@@ -383,6 +388,7 @@ poller_curl_init (struct poller_curl *self, struct poller *poller,
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_TIMERDATA, self)))
{
curl_multi_cleanup (self->multi);
+ self->multi = NULL;
return error_set (e, "%s: %s",
"cURL setup failed", curl_multi_strerror (mres));
}
@@ -443,6 +449,7 @@ poller_curl_add (struct poller_curl *self, CURL *easy, struct error **e)
// "CURLMOPT_TIMERFUNCTION [...] will be called from within this function"
if ((mres = curl_multi_add_handle (self->multi, easy)))
return error_set (e, "%s", curl_multi_strerror (mres));
+ self->registered++;
return true;
}
@@ -452,6 +459,7 @@ poller_curl_remove (struct poller_curl *self, CURL *easy, struct error **e)
CURLMcode mres;
if ((mres = curl_multi_remove_handle (self->multi, easy)))
return error_set (e, "%s", curl_multi_strerror (mres));
+ self->registered--;
return true;
}
@@ -586,7 +594,8 @@ struct spectrum
int samples; ///< Number of windows to average
float accumulator_scale; ///< Scaling factor for accum. values
int *top_bins; ///< Top DFT bin index for each bar
- char *spectrum; ///< String buffer for the "render"
+ char *rendered; ///< String buffer for the "render"
+ float *spectrum; ///< The "render" as normalized floats
void *buffer; ///< Input buffer
size_t buffer_len; ///< Input buffer fill level
@@ -708,7 +717,7 @@ spectrum_sample (struct spectrum *s)
}
int last_bin = 0;
- char *p = s->spectrum;
+ char *p = s->rendered;
for (int bar = 0; bar < s->bars; bar++)
{
int top_bin = s->top_bins[bar];
@@ -726,14 +735,19 @@ spectrum_sample (struct spectrum *s)
db = 0;
// Assuming decibels are always negative (i.e., properly normalized).
- // The division defines the cutoff: 9 * 7 = 63 dB of range.
+ // The division defines the cutoff: 8 * 7 = 56 dB of range.
int height = N_ELEMENTS (spectrum_bars) - 1 + (int) (db / 7);
p += strlen (strcpy (p, spectrum_bars[MAX (height, 0)]));
+
+ // Even with slightly the higher display resolutions provided by X11,
+ // 60 dB roughly covers the useful range.
+ s->spectrum[bar] = MAX (0, 1 + db / 60);
}
}
static bool
-spectrum_init (struct spectrum *s, char *format, int bars, struct error **e)
+spectrum_init (struct spectrum *s, char *format, int bars, int fps,
+ struct error **e)
{
errno = 0;
@@ -793,13 +807,15 @@ spectrum_init (struct spectrum *s, char *format, int bars, struct error **e)
// having the amount of bins be strictly a factor of Nyquist / 20 (stemming
// from the equation 20 = Nyquist / bins). Since log2(44100 / 2 / 20) > 10,
// it would be fairly expensive, and somewhat slowly updating. Always.
- // (Note that you can increase window overlap to get smoother framerates.)
+ // (Note that you can increase window overlap to get smoother framerates,
+ // but it would remain laggy.)
double audible_ratio = s->sampling_rate / 2. / 20000;
s->bins = ceil (necessary_bins * MAX (audible_ratio, 1));
s->useful_bins = s->bins / 2;
int used_bins = necessary_bins / 2;
- s->spectrum = xcalloc (sizeof *s->spectrum, s->bars * 3 + 1);
+ s->rendered = xcalloc (sizeof *s->rendered, s->bars * 3 + 1);
+ s->spectrum = xcalloc (sizeof *s->spectrum, s->bars);
s->top_bins = xcalloc (sizeof *s->top_bins, s->bars);
for (int bar = 0; bar < s->bars; bar++)
{
@@ -807,8 +823,7 @@ spectrum_init (struct spectrum *s, char *format, int bars, struct error **e)
s->top_bins[bar] = MIN (top_bin, used_bins);
}
- // Limit updates to 30 times per second to limit CPU load
- s->samples = s->sampling_rate / s->bins * 2 / 30;
+ s->samples = s->sampling_rate / s->bins * 2 / MAX (fps, 1);
if (s->samples < 1)
s->samples = 1;
@@ -851,7 +866,12 @@ spectrum_free (struct spectrum *s)
fftw_free (s->windowed);
free (s->data);
free (s->window);
+#if 0
+ // We don't particularly want to discard wisdom.
+ fftwf_cleanup ();
+#endif
+ free (s->rendered);
free (s->spectrum);
free (s->top_bins);
free (s->buffer);
@@ -861,45 +881,318 @@ spectrum_free (struct spectrum *s)
#endif // WITH_FFTW
+// --- PulseAudio --------------------------------------------------------------
+
+#ifdef WITH_PULSE
+
+struct pulse
+{
+ struct poller_timer make_context; ///< Event to establish connection
+ pa_mainloop_api *api; ///< PulseAudio event loop proxy
+ pa_context *context; ///< PulseAudio connection context
+ uint32_t sink_candidate; ///< Used while searching for MPD
+ uint32_t sink; ///< The relevant sink or -1
+ pa_cvolume sink_volume; ///< Current volume
+ bool sink_muted; ///< Currently muted?
+
+ void (*on_update) (void); ///< Update callback
+};
+
+static void
+pulse_on_sink_info (pa_context *context, const pa_sink_info *info, int eol,
+ void *userdata)
+{
+ (void) context;
+ (void) eol;
+
+ struct pulse *self = userdata;
+ if (info)
+ {
+ self->sink_volume = info->volume;
+ self->sink_muted = !!info->mute;
+ self->on_update ();
+ }
+}
+
+static void
+pulse_update_from_sink (struct pulse *self)
+{
+ if (self->sink == PA_INVALID_INDEX)
+ return;
+
+ pa_operation_unref (pa_context_get_sink_info_by_index
+ (self->context, self->sink, pulse_on_sink_info, self));
+}
+
+static void
+pulse_on_sink_input_info (pa_context *context,
+ const struct pa_sink_input_info *info, int eol, void *userdata)
+{
+ (void) context;
+ (void) eol;
+
+ struct pulse *self = userdata;
+ if (!info)
+ {
+ if ((self->sink = self->sink_candidate) != PA_INVALID_INDEX)
+ pulse_update_from_sink (self);
+ else
+ self->on_update ();
+ return;
+ }
+
+ // TODO: also save info->mute as a different mute level,
+ // and perhaps info->index (they can appear and disappear)
+ const char *name =
+ pa_proplist_gets (info->proplist, PA_PROP_APPLICATION_NAME);
+ if (name && !strcmp (name, "Music Player Daemon"))
+ self->sink_candidate = info->sink;
+}
+
+static void
+pulse_read_sink_inputs (struct pulse *self)
+{
+ self->sink_candidate = PA_INVALID_INDEX;
+ pa_operation_unref (pa_context_get_sink_input_info_list
+ (self->context, pulse_on_sink_input_info, self));
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_on_event (pa_context *context, pa_subscription_event_type_t event,
+ uint32_t index, void *userdata)
+{
+ (void) context;
+
+ struct pulse *self = userdata;
+ switch (event & PA_SUBSCRIPTION_EVENT_FACILITY_MASK)
+ {
+ case PA_SUBSCRIPTION_EVENT_SINK_INPUT:
+ pulse_read_sink_inputs (self);
+ break;
+ case PA_SUBSCRIPTION_EVENT_SINK:
+ if (index == self->sink)
+ pulse_update_from_sink (self);
+ }
+}
+
+static void
+pulse_on_subscribe_finish (pa_context *context, int success, void *userdata)
+{
+ (void) context;
+
+ struct pulse *self = userdata;
+ if (success)
+ pulse_read_sink_inputs (self);
+ else
+ {
+ print_debug ("PulseAudio failed to subscribe for events");
+ self->on_update ();
+ pa_context_disconnect (context);
+ }
+}
+
+static void
+pulse_on_context_state_change (pa_context *context, void *userdata)
+{
+ struct pulse *self = userdata;
+ switch (pa_context_get_state (context))
+ {
+ case PA_CONTEXT_FAILED:
+ case PA_CONTEXT_TERMINATED:
+ print_debug ("PulseAudio context failed or has been terminated");
+
+ pa_context_unref (context);
+ self->context = NULL;
+ self->sink = PA_INVALID_INDEX;
+ self->on_update ();
+
+ // Retry after an arbitrary delay of 5 seconds
+ poller_timer_set (&self->make_context, 5000);
+ break;
+ case PA_CONTEXT_READY:
+ pa_context_set_subscribe_callback (context, pulse_on_event, userdata);
+ pa_operation_unref (pa_context_subscribe (context,
+ PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SINK_INPUT,
+ pulse_on_subscribe_finish, userdata));
+ default:
+ break;
+ }
+}
+
+static void
+pulse_make_context (void *user_data)
+{
+ struct pulse *self = user_data;
+ self->context = pa_context_new (self->api, PROGRAM_NAME);
+ pa_context_set_state_callback (self->context,
+ pulse_on_context_state_change, self);
+ pa_context_connect (self->context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_on_finish (pa_context *context, int success, void *userdata)
+{
+ (void) context;
+ (void) success;
+ (void) userdata;
+
+ // Just like... whatever, man
+}
+
+static bool
+pulse_volume_mute (struct pulse *self)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX)
+ return false;
+
+ pa_operation_unref (pa_context_set_sink_mute_by_index (self->context,
+ self->sink, !self->sink_muted, pulse_on_finish, self));
+ return true;
+}
+
+static bool
+pulse_volume_set (struct pulse *self, int arg)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX)
+ return false;
+
+ pa_cvolume volume = self->sink_volume;
+ if (arg > 0)
+ pa_cvolume_inc (&volume, (pa_volume_t) arg * PA_VOLUME_NORM / 100);
+ else
+ pa_cvolume_dec (&volume, (pa_volume_t) -arg * PA_VOLUME_NORM / 100);
+ pa_operation_unref (pa_context_set_sink_volume_by_index (self->context,
+ self->sink, &volume, pulse_on_finish, self));
+ return true;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_init (struct pulse *self, struct poller *poller)
+{
+ memset (self, 0, sizeof *self);
+ self->sink = PA_INVALID_INDEX;
+ if (!poller)
+ return;
+
+ self->api = poller_pa_new (poller);
+
+ self->make_context = poller_timer_make (poller);
+ self->make_context.dispatcher = pulse_make_context;
+ self->make_context.user_data = self;
+ poller_timer_set (&self->make_context, 0);
+}
+
+static void
+pulse_free (struct pulse *self)
+{
+ if (self->context)
+ pa_context_unref (self->context);
+ if (self->api)
+ {
+ poller_pa_destroy (self->api);
+ poller_timer_reset (&self->make_context);
+ }
+
+ pulse_init (self, NULL);
+}
+
+#define VOLUME_PERCENT(x) (((x) * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM)
+
+static bool
+pulse_volume_status (struct pulse *self, struct str *s)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX
+ || !self->sink_volume.channels)
+ return false;
+
+ if (self->sink_muted)
+ {
+ str_append (s, "Muted");
+ return true;
+ }
+
+ str_append_printf (s,
+ "%u%%", VOLUME_PERCENT (self->sink_volume.values[0]));
+ if (!pa_cvolume_channels_equal_to (&self->sink_volume,
+ self->sink_volume.values[0]))
+ {
+ for (size_t i = 1; i < self->sink_volume.channels; i++)
+ str_append_printf (s, " / %u%%",
+ VOLUME_PERCENT (self->sink_volume.values[i]));
+ }
+ return true;
+}
+
+#endif // WITH_PULSE
+
// --- Application -------------------------------------------------------------
-// Function names are prefixed mostly because of curses which clutters the
+// Function names are prefixed mostly because of curses, which clutters the
// global namespace and makes it harder to distinguish what functions relate to.
// The user interface is focused on conceptual simplicity. That is important
-// since we're not using any TUI framework (which are mostly a lost cause to me
-// in the post-Unicode era and not worth pursuing), and the code would get
-// bloated and incomprehensible fast. We mostly rely on "row_buffer" to write
-// text from left to right row after row while keeping track of cells.
+// since we use a custom toolkit, so code would get bloated rather fast--
+// especially given our TUI/GUI duality.
//
// There is an independent top pane displaying general status information,
// followed by a tab bar and a listview served by a per-tab event handler.
//
// For simplicity, the listview can only work with items that are one row high.
+// Widget identification, mostly for mouse events.
+enum
+{
+ WIDGET_NONE = 0, WIDGET_BUTTON, WIDGET_GAUGE, WIDGET_VOLUME,
+ WIDGET_TAB, WIDGET_SPECTRUM, WIDGET_LIST, WIDGET_SCROLLBAR, WIDGET_MESSAGE,
+};
+
+struct layout
+{
+ struct widget *head;
+ struct widget *tail;
+};
+
+struct app_ui
+{
+ struct widget *(*padding) (chtype attrs, float width, float height);
+ struct widget *(*label) (chtype attrs, const char *label);
+ struct widget *(*button) (chtype attrs, const char *label, enum action a);
+ struct widget *(*gauge) (chtype attrs);
+ struct widget *(*spectrum) (chtype attrs, int width);
+ struct widget *(*scrollbar) (chtype attrs);
+ struct widget *(*list) (void);
+ struct widget *(*editor) (chtype attrs);
+
+ bool have_icons;
+};
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
struct tab;
-enum action;
/// Try to handle an action in the tab
typedef bool (*tab_action_fn) (enum action action);
-/// Draw an item to the screen using the row buffer API
-typedef void (*tab_item_draw_fn)
- (size_t item_index, struct row_buffer *buffer, int width);
+/// Return a line of widgets for the row
+typedef struct layout (*tab_item_layout_fn) (size_t item_index);
struct tab
{
LIST_HEADER (struct tab)
char *name; ///< Visible identifier
- size_t name_width; ///< Visible width of the name
-
char *header; ///< The header, should there be any
// Implementation:
tab_action_fn on_action; ///< User action handler callback
- tab_item_draw_fn on_item_draw; ///< Item draw callback
+ tab_item_layout_fn on_item_layout; ///< Item layout callback
// Provided by tab owner:
@@ -926,6 +1219,7 @@ static struct app_context
// Event loop:
struct poller poller; ///< Poller
+ struct poller_curl poller_curl; ///< cURL abstractor
bool quitting; ///< Quit signal for the event loop
bool polling; ///< The event loop is running
@@ -934,6 +1228,7 @@ static struct app_context
struct poller_timer message_timer; ///< Message timeout
char *message; ///< Message to show in the statusbar
+ char *message_detail; ///< Non-emphasized part
// Connection:
@@ -947,7 +1242,6 @@ static struct app_context
int64_t elapsed_since; ///< Last tick ts or last elapsed time
bool elapsed_poll; ///< Poll MPD for the elapsed time?
- // TODO: initialize these to -1
int song; ///< Current song index
int song_elapsed; ///< Song elapsed in seconds
int song_duration; ///< Song duration in seconds
@@ -961,38 +1255,34 @@ static struct app_context
struct config config; ///< Program configuration
struct strv streams; ///< List of "name NUL URI NUL"
+ struct strv enqueue; ///< Items to enqueue once connected
struct tab *help_tab; ///< Special help tab
struct tab *tabs; ///< All other tabs
struct tab *active_tab; ///< Active tab
struct tab *last_tab; ///< Previous tab
- // Emulated widgets:
-
- int header_height; ///< Height of the header
+ // User interface:
- int tabs_offset; ///< Offset to tabs or -1
- int controls_offset; ///< Offset to player controls or -1
- int gauge_offset; ///< Offset to the gauge or -1
- int gauge_width; ///< Width of the gauge, if present
+ struct app_ui *ui; ///< User interface interface
+ int ui_dragging; ///< ID of any dragged widget
#ifdef WITH_FFTW
struct spectrum spectrum; ///< Spectrum analyser
int spectrum_fd; ///< FIFO file descriptor (non-blocking)
- int spectrum_column, spectrum_row; ///< Position for fast refresh
struct poller_fd spectrum_event; ///< FIFO watcher
#endif // WITH_FFTW
+#ifdef WITH_PULSE
+ struct pulse pulse; ///< PulseAudio control
+#endif // WITH_PULSE
+ bool pulse_control_requested; ///< PulseAudio control desired by user
+
struct line_editor editor; ///< Line editor
- struct poller_idle refresh_event; ///< Refresh the screen
// Terminal:
- termo_t *tk; ///< termo handle
- struct poller_timer tk_timer; ///< termo timeout timer
- bool locale_is_utf8; ///< The locale is Unicode
bool use_partial_boxes; ///< Use Unicode box drawing chars
- bool focused; ///< Whether the terminal has focus
struct attrs attrs[ATTRIBUTE_COUNT];
}
@@ -1010,9 +1300,6 @@ tab_init (struct tab *self, const char *name)
// Add some padding for decorative purposes
self->name = xstrdup_printf (" %s ", name);
- // Assuming tab names are pure ASCII, otherwise this would be inaccurate
- // and we'd need to filter it first to replace invalid chars with '?'
- self->name_width = u8_strwidth ((uint8_t *) self->name, locale_charset ());
self->item_selected = 0;
self->item_mark = -1;
}
@@ -1043,6 +1330,13 @@ on_poll_elapsed_time_changed (struct config_item *item)
g.elapsed_poll = item->value.boolean;
}
+static void
+on_pulseaudio_changed (struct config_item *item)
+{
+ // This is only set once, on application startup
+ g.pulse_control_requested = item->value.boolean;
+}
+
static struct config_schema g_config_settings[] =
{
{ .name = "address",
@@ -1054,6 +1348,7 @@ static struct config_schema g_config_settings[] =
.type = CONFIG_ITEM_STRING },
// NOTE: this is unused--in theory we could allow manual metadata adjustment
+ // NOTE: the "config" command may return "music_directory" for local clients
{ .name = "root",
.comment = "Where all the files MPD is playing are located",
.type = CONFIG_ITEM_STRING },
@@ -1072,8 +1367,27 @@ static struct config_schema g_config_settings[] =
.comment = "Number of computed audio spectrum bars",
.type = CONFIG_ITEM_INTEGER,
.default_ = "8" },
+ { .name = "spectrum_fps",
+ .comment = "Maximum frames per second, affects CPU usage",
+ .type = CONFIG_ITEM_INTEGER,
+ .default_ = "30" },
#endif // WITH_FFTW
+#ifdef WITH_PULSE
+ { .name = "pulseaudio",
+ .comment = "Look up MPD in PulseAudio for improved volume controls",
+ .type = CONFIG_ITEM_BOOLEAN,
+ .on_change = on_pulseaudio_changed,
+ .default_ = "off" },
+#endif // WITH_PULSE
+
+#ifdef WITH_X11
+ { .name = "x11_font",
+ .comment = "Fontconfig name/pattern for the X11 font to use",
+ .type = CONFIG_ITEM_STRING,
+ .default_ = "`sans\\-serif-11`" },
+#endif // WITH_X11
+
// Disabling this minimises MPD traffic and has the following caveats:
// - when MPD stalls on retrieving audio data, we keep ticking
// - when the "play" succeeds in ACTION_MPD_REPLACE for the same item as
@@ -1216,74 +1530,46 @@ app_init_attributes (void)
#undef XX
}
+static bool
+app_on_insufficient_color (void)
+{
+ app_init_attributes ();
+ return true;
+}
+
static void
app_init_context (void)
{
poller_init (&g.poller);
+ hard_assert (poller_curl_init (&g.poller_curl, &g.poller, NULL));
g.client = mpd_client_make (&g.poller);
+ g.song_elapsed = g.song_duration = g.volume = g.song = -1;
+ g.playlist = item_list_make ();
g.config = config_make ();
g.streams = strv_make ();
- g.playlist = item_list_make ();
+ g.enqueue = strv_make ();
g.playback_info = str_map_make (free);
g.playback_info.key_xfrm = tolower_ascii_strxfrm;
#ifdef WITH_FFTW
g.spectrum_fd = -1;
- g.spectrum_row = g.spectrum_column = -1;
#endif // WITH_FFTW
- // This is also approximately what libunistring does internally,
- // since the locale name is canonicalized by locale_charset().
- // Note that non-Unicode locales are handled pretty inefficiently.
- g.locale_is_utf8 = !strcasecmp_ascii (locale_charset (), "UTF-8");
-
- // It doesn't work 100% (e.g. incompatible with undelining in urxvt)
- // TODO: make this configurable
- g.use_partial_boxes = g.locale_is_utf8;
-
- // Presumably, although not necessarily; unsure if queryable at all
- g.focused = true;
+#ifdef WITH_PULSE
+ pulse_init (&g.pulse, NULL);
+#endif // WITH_PULSE
app_init_attributes ();
}
static void
-app_init_terminal (void)
-{
- TERMO_CHECK_VERSION;
- if (!(g.tk = termo_new (STDIN_FILENO, NULL, 0)))
- abort ();
- if (!initscr () || nonl () == ERR)
- abort ();
-
- // By default we don't use any colors so they're not required...
- if (start_color () == ERR
- || use_default_colors () == ERR
- || COLOR_PAIRS <= ATTRIBUTE_COUNT)
- return;
-
- for (int a = 0; a < ATTRIBUTE_COUNT; a++)
- {
- // ...thus we can reset back to defaults even after initializing some
- if (g.attrs[a].fg >= COLORS || g.attrs[a].fg < -1
- || g.attrs[a].bg >= COLORS || g.attrs[a].bg < -1)
- {
- app_init_attributes ();
- return;
- }
-
- init_pair (a + 1, g.attrs[a].fg, g.attrs[a].bg);
- g.attrs[a].attrs |= COLOR_PAIR (a + 1);
- }
-}
-
-static void
app_free_context (void)
{
mpd_client_free (&g.client);
str_map_free (&g.playback_info);
strv_free (&g.streams);
+ strv_free (&g.enqueue);
item_list_free (&g.playlist);
#ifdef WITH_FFTW
@@ -1295,14 +1581,17 @@ app_free_context (void)
}
#endif // WITH_FFTW
+#ifdef WITH_PULSE
+ pulse_free (&g.pulse);
+#endif // WITH_PULSE
+
line_editor_free (&g.editor);
config_free (&g.config);
+ poller_curl_free (&g.poller_curl);
poller_free (&g.poller);
free (g.message);
-
- if (g.tk)
- termo_destroy (g.tk);
+ free (g.message_detail);
}
static void
@@ -1315,92 +1604,179 @@ app_quit (void)
g.polling = false;
}
-static bool
-app_is_character_in_locale (ucs4_t ch)
+// --- Layouting ---------------------------------------------------------------
+
+static void
+app_append_layout (struct layout *l, struct layout *dest)
{
- // Avoid the overhead joined with calling iconv() for all characters.
- if (g.locale_is_utf8)
- return true;
+ struct widget *last = dest->tail;
+ if (!last)
+ *dest = *l;
+ else if (l->head)
+ {
+ // Assuming there is no unclaimed vertical space.
+ LIST_FOR_EACH (struct widget, w, l->head)
+ widget_move (w, 0, last->y + last->height);
- // The library really creates a new conversion object every single time
- // and doesn't provide any smarter APIs. Luckily, most users use UTF-8.
- size_t len;
- char *tmp = u32_conv_to_encoding (locale_charset (), iconveh_error,
- &ch, 1, NULL, NULL, &len);
- if (!tmp)
- return false;
- free (tmp);
- return true;
-}
+ last->next = l->head;
+ l->head->prev = last;
+ dest->tail = l->tail;
+ }
-// --- Rendering ---------------------------------------------------------------
+ *l = (struct layout) {};
+}
+/// Replaces negative widths amongst widgets in the layout by redistributing
+/// any width remaining after all positive claims are satisfied from "width".
+/// Also unifies heights to the maximum value of the run.
+/// Then the widths are taken as final, and used to initialize X coordinates.
static void
-app_invalidate (void)
+app_flush_layout_full (struct layout *l, int width, struct layout *dest)
{
- poller_idle_set (&g.refresh_event);
+ hard_assert (l != NULL && l->head != NULL);
+
+ int parts = 0, max_height = 0;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ max_height = MAX (max_height, w->height);
+ if (w->width < 0)
+ parts -= w->width;
+ else
+ width -= w->width;
+ }
+
+ int remaining = MAX (width, 0), part_width = parts ? remaining / parts : 0;
+ struct widget *last = NULL;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ w->height = max_height;
+ if (w->width < 0)
+ {
+ remaining -= (w->width *= -part_width);
+ last = w;
+ }
+ }
+ if (last)
+ last->width += remaining;
+
+ int x = 0;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ widget_move (w, x - w->x, 0);
+ x += w->width;
+ }
+
+ app_append_layout (l, dest);
}
static void
-app_flush_buffer (struct row_buffer *buf, int width, chtype attrs)
+app_flush_layout (struct layout *l, struct layout *out)
{
- row_buffer_align (buf, width, attrs);
- row_buffer_flush (buf);
- row_buffer_free (buf);
+ app_flush_layout_full (l, g_xui.width, out);
}
-/// Write the given UTF-8 string padded with spaces.
-/// @param[in] attrs Text attributes for the text, including padding.
-static void
-app_write_line (const char *str, chtype attrs)
+static struct widget *
+app_push (struct layout *l, struct widget *w)
{
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, str, attrs);
- app_flush_buffer (&buf, COLS, attrs);
+ LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
+ return w;
}
-// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+static struct widget *
+app_push_fill (struct layout *l, struct widget *w)
+{
+ w->width = -1;
+ LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
+ return w;
+}
+/// Write the given UTF-8 string padded with spaces.
+/// @param[in] attrs Text attributes for the text, including padding.
static void
-app_flush_header (struct row_buffer *buf, chtype attrs)
+app_layout_text (const char *str, chtype attrs, struct layout *out)
{
- move (g.header_height++, 0);
- app_flush_buffer (buf, COLS, attrs);
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs, 0.25, 1));
+ app_push_fill (&l, g.ui->label (attrs, str));
+ app_push (&l, g.ui->padding (attrs, 0.25, 1));
+ app_flush_layout (&l, out);
}
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
static void
-app_draw_song_info (void)
+app_layout_song_info (struct layout *out)
{
compact_map_t map;
if (!(map = item_list_get (&g.playlist, g.song)))
return;
- chtype attr_normal = APP_ATTR (NORMAL);
- chtype attr_highlight = APP_ATTR (HIGHLIGHT);
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
- char *title;
+ // Split the path for files lying within MPD's "music_directory".
+ const char *file = compact_map_find (map, "file");
+ const char *subroot_basename = NULL;
+ if (file && *file != '/' && !strstr (file, "://"))
+ {
+ const char *last_slash = strrchr (file, '/');
+ if (last_slash)
+ subroot_basename = last_slash + 1;
+ else
+ subroot_basename = file;
+ }
+
+ const char *title = NULL;
+ const char *name = compact_map_find (map, "name");
if ((title = compact_map_find (map, "title"))
- || (title = compact_map_find (map, "name"))
- || (title = compact_map_find (map, "file")))
+ || (title = name)
+ || (title = subroot_basename)
+ || (title = file))
{
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, title, attr_highlight);
- app_flush_header (&buf, attr_normal);
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->label (attrs[1], title));
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
+ // Showing a blank line is better than having the controls jump around
+ // while switching between files that we do and don't have enough data for.
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+
char *artist = compact_map_find (map, "artist");
char *album = compact_map_find (map, "album");
- if (!artist && !album)
- return;
+ if (artist || album)
+ {
+ if (artist)
+ {
+ app_push (&l, g.ui->label (attrs[0], "by "));
+ app_push (&l, g.ui->label (attrs[1], artist));
+ }
+ if (album)
+ {
+ app_push (&l, g.ui->label (attrs[0], &" from "[!artist]));
+ app_push (&l, g.ui->label (attrs[1], album));
+ }
+ }
+ else if (subroot_basename && subroot_basename != file)
+ {
+ char *parent = xstrndup (file, subroot_basename - file - 1);
+ app_push (&l, g.ui->label (attrs[0], "in "));
+ app_push (&l, g.ui->label (attrs[1], parent));
+ free (parent);
+ }
+ else if (file && *file != '/' && strstr (file, "://")
+ && name && name != title)
+ {
+ // This is likely to contain the name of an Internet radio.
+ app_push (&l, g.ui->label (attrs[1], name));
+ }
- struct row_buffer buf = row_buffer_make ();
- if (artist)
- row_buffer_append_args (&buf, " by " + !buf.total_width, attr_normal,
- artist, attr_highlight, NULL);
- if (album)
- row_buffer_append_args (&buf, " from " + !buf.total_width, attr_normal,
- album, attr_highlight, NULL);
- app_flush_header (&buf, attr_normal);
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static char *
@@ -1420,179 +1796,147 @@ app_time_string (int seconds)
}
static void
-app_write_time (struct row_buffer *buf, int seconds, chtype attrs)
+app_layout_status (struct layout *out)
{
- char *s = app_time_string (seconds);
- row_buffer_append (buf, s, attrs);
- free (s);
-}
-
-static void
-app_write_gauge (struct row_buffer *buf, float ratio, int width)
-{
- if (ratio < 0) ratio = 0;
- if (ratio > 1) ratio = 1;
-
- // Always compute it in exactly eight times the resolution,
- // because sometimes Unicode is even useful
- int len_left = ratio * width * 8 + 0.5;
-
- static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" };
- int remainder = len_left % 8;
- len_left /= 8;
-
- const char *partial = NULL;
- if (g.use_partial_boxes)
- partial = partials[remainder];
- else
- len_left += remainder >= (int) 4;
-
- int len_right = width - len_left;
- row_buffer_space (buf, len_left, APP_ATTR (ELAPSED));
- if (partial && len_right-- > 0)
- row_buffer_append (buf, partial, APP_ATTR (REMAINS));
- row_buffer_space (buf, len_right, APP_ATTR (REMAINS));
-}
-
-static void
-app_draw_status (void)
-{
- if (g.state != PLAYER_STOPPED)
- app_draw_song_info ();
-
- chtype attr_normal = APP_ATTR (NORMAL);
- chtype attr_highlight = APP_ATTR (HIGHLIGHT);
-
- struct row_buffer buf = row_buffer_make ();
bool stopped = g.state == PLAYER_STOPPED;
- chtype attr_song_action = stopped ? attr_normal : attr_highlight;
+ if (!stopped)
+ app_layout_song_info (out);
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ struct layout l = {};
+
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], "<<", ACTION_MPD_PREVIOUS));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
const char *toggle = g.state == PLAYER_PLAYING ? "||" : "|>";
- row_buffer_append_args (&buf,
- "<<", attr_song_action, " ", attr_normal,
- toggle, attr_highlight, " ", attr_normal,
- "[]", attr_song_action, " ", attr_normal,
- ">>", attr_song_action, " ", attr_normal,
- NULL);
+ app_push (&l, g.ui->button (attrs[1], toggle, ACTION_MPD_TOGGLE));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], "[]", ACTION_MPD_STOP));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], ">>", ACTION_MPD_NEXT));
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
if (stopped)
- row_buffer_append (&buf, "Stopped", attr_normal);
+ app_push_fill (&l, g.ui->label (attrs[0], "Stopped"));
else
{
if (g.song_elapsed >= 0)
{
- app_write_time (&buf, g.song_elapsed, attr_normal);
- row_buffer_append (&buf, " ", attr_normal);
+ char *s = app_time_string (g.song_elapsed);
+ app_push (&l, g.ui->label (attrs[0], s));
+ free (s);
}
if (g.song_duration >= 1)
{
- row_buffer_append (&buf, "/ ", attr_normal);
- app_write_time (&buf, g.song_duration, attr_normal);
- row_buffer_append (&buf, " ", attr_normal);
+ char *s = app_time_string (g.song_duration);
+ app_push (&l, g.ui->label (attrs[0], " / "));
+ app_push (&l, g.ui->label (attrs[0], s));
+ free (s);
}
- row_buffer_append (&buf, " ", attr_normal);
- }
- // It gets a bit complicated due to the only right-aligned item on the row
- char *volume = NULL;
- int remaining = COLS - buf.total_width;
- if (g.volume >= 0)
- {
- volume = xstrdup_printf (" %3d%%", g.volume);
- remaining -= strlen (volume);
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
}
- if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1
- && remaining > 0)
+ struct str volume = str_make ();
+#ifdef WITH_PULSE
+ if (g.pulse_control_requested)
{
- g.gauge_offset = buf.total_width;
- g.gauge_width = remaining;
- app_write_gauge (&buf,
- (float) g.song_elapsed / g.song_duration, remaining);
+ if (pulse_volume_status (&g.pulse, &volume))
+ {
+ if (g.volume >= 0 && g.volume != 100)
+ str_append_printf (&volume, " (%d%%)", g.volume);
+ }
+ else
+ {
+ if (g.volume >= 0)
+ str_append_printf (&volume, "(%d%%)", g.volume);
+ }
}
else
- row_buffer_space (&buf, remaining, attr_normal);
+#endif // WITH_PULSE
+ if (g.volume >= 0)
+ str_append_printf (&volume, "%3d%%", g.volume);
+
+ if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1)
+ app_push (&l, g.ui->gauge (attrs[0]))
+ ->id = WIDGET_GAUGE;
+ else
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
- if (volume)
+ if (volume.len)
{
- row_buffer_append (&buf, volume, attr_normal);
- free (volume);
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
+ app_push (&l, g.ui->label (attrs[0], volume.str))
+ ->id = WIDGET_VOLUME;
}
- g.controls_offset = g.header_height;
- app_flush_header (&buf, attr_normal);
+ str_free (&volume);
+
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static void
-app_draw_header (void)
+app_layout_tabs (struct layout *out)
{
- g.header_height = 0;
-
- g.tabs_offset = -1;
- g.controls_offset = -1;
- g.gauge_offset = -1;
- g.gauge_width = 0;
-
- switch (g.client.state)
- {
- case MPD_CONNECTED:
- app_draw_status ();
- break;
- case MPD_CONNECTING:
- move (g.header_height++, 0);
- app_write_line ("Connecting to MPD...", APP_ATTR (NORMAL));
- break;
- case MPD_DISCONNECTED:
- move (g.header_height++, 0);
- app_write_line ("Disconnected", APP_ATTR (NORMAL));
- }
-
chtype attrs[2] = { APP_ATTR (TAB_BAR), APP_ATTR (TAB_ACTIVE) };
+ struct layout l = {};
// The help tab is disguised so that it's not too intruding
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, APP_TITLE, attrs[g.active_tab == g.help_tab]);
- row_buffer_append (&buf, " ", attrs[false]);
+ app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.25, 1))
+ ->id = WIDGET_TAB;
+ app_push (&l, g.ui->label (attrs[g.active_tab == g.help_tab], APP_TITLE))
+ ->id = WIDGET_TAB;
- g.tabs_offset = g.header_height;
+ // XXX: attrs[0]?
+ app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.5, 1))
+ ->id = WIDGET_TAB;
+
+ int i = 0;
LIST_FOR_EACH (struct tab, iter, g.tabs)
- row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]);
+ {
+ struct widget *w = app_push (&l,
+ g.ui->label (attrs[iter == g.active_tab], iter->name));
+ w->id = WIDGET_TAB;
+ w->userdata = ++i;
+ }
+
+ app_push_fill (&l, g.ui->padding (attrs[0], 1, 1));
#ifdef WITH_FFTW
// This seems like the most reasonable, otherwise unoccupied space
if (g.spectrum_fd != -1)
{
- // Find some space and remember where it was, for fast refreshes
- row_buffer_ellipsis (&buf, COLS - g.spectrum.bars - 1);
- row_buffer_align (&buf, COLS - g.spectrum.bars, attrs[false]);
- g.spectrum_row = g.header_height;
- g.spectrum_column = buf.total_width;
-
- row_buffer_append (&buf, g.spectrum.spectrum, attrs[false]);
+ app_push (&l, g.ui->spectrum (attrs[0], g.spectrum.bars))
+ ->id = WIDGET_SPECTRUM;
}
#endif // WITH_FFTW
- app_flush_header (&buf, attrs[false]);
-
- const char *header = g.active_tab->header;
- if (header)
- {
- buf = row_buffer_make ();
- row_buffer_append (&buf, header, APP_ATTR (HEADER));
- app_flush_header (&buf, APP_ATTR (HEADER));
- }
+ app_flush_layout (&l, out);
}
-static int
-app_fitting_items (void)
+static void
+app_layout_padding (chtype attrs, struct layout *out)
{
- // The raw number of items that would have fit on the terminal
- return LINES - g.header_height - 1 /* status bar */;
+ struct layout l = {};
+ app_push_fill (&l, g.ui->padding (attrs, 0, 0.125));
+ app_flush_layout (&l, out);
}
-static int
-app_visible_items (void)
+static void
+app_layout_header (struct layout *out)
{
- return MAX (0, app_fitting_items ());
+ if (g.client.state == MPD_CONNECTED)
+ {
+ app_layout_padding (APP_ATTR (NORMAL), out);
+ app_layout_status (out);
+ app_layout_padding (APP_ATTR (NORMAL), out);
+ }
+
+ app_layout_tabs (out);
+
+ const char *header = g.active_tab->header;
+ if (header)
+ app_layout_text (header, APP_ATTR (HEADER), out);
}
/// Figure out scrollbar appearance. @a s is the minimal slider length as well
@@ -1600,7 +1944,7 @@ app_visible_items (void)
struct scrollbar { long length, start; }
app_compute_scrollbar (struct tab *tab, long visible, long s)
{
- long top = tab->item_top, total = tab->item_count;
+ long top = s * tab->item_top, total = s * tab->item_count;
if (total < visible)
return (struct scrollbar) { 0, 0 };
if (visible == 1)
@@ -1610,7 +1954,7 @@ app_compute_scrollbar (struct tab *tab, long visible, long s)
// Only be at the top or bottom when the top or bottom item can be seen.
// The algorithm isn't optimal but it's a bitch to get right.
- double available_length = s * visible - 2 - s + 1;
+ double available_length = visible - 2 - s + 1;
double lenf = s + available_length * visible / total, length = 0.;
long offset = 1 + available_length * top / total + modf (lenf, &length);
@@ -1618,215 +1962,247 @@ app_compute_scrollbar (struct tab *tab, long visible, long s)
if (top == 0)
return (struct scrollbar) { length, 0 };
if (top + visible >= total)
- return (struct scrollbar) { length, s * visible - length };
+ return (struct scrollbar) { length, visible - length };
return (struct scrollbar) { length, offset };
}
-static void
-app_draw_scrollbar (void)
+static struct layout
+app_layout_row (struct tab *tab, int item_index)
{
- // This assumes that we can write to the one-before-last column,
- // i.e. that it's not covered by any double-wide character (and that
- // ncurses comes to the right results when counting characters).
- //
- // We could also precompute the scrollbar and append it to each row
- // as we render them, plus all the unoccupied rows.
- struct tab *tab = g.active_tab;
- int visible_items = app_visible_items ();
-
- hard_assert (tab->item_count != 0);
- if (!g.use_partial_boxes)
- {
- struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1);
- for (int row = 0; row < visible_items; row++)
- {
- move (g.header_height + row, COLS - 1);
- if (row < bar.start || row >= bar.start + bar.length)
- addch (' ' | APP_ATTR (SCROLLBAR));
- else
- addch (' ' | APP_ATTR (SCROLLBAR) | A_REVERSE);
- }
- return;
- }
-
- struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 8);
- bar.length += bar.start;
+ int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN);
- int start_part = bar.start % 8; bar.start /= 8;
- int end_part = bar.length % 8; bar.length /= 8;
+ bool override_colors = true;
+ if (item_index == tab->item_selected)
+ row_attrs = g_xui.focused
+ ? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED);
+ else if (tab->item_mark > -1 &&
+ ((item_index >= tab->item_mark && item_index <= tab->item_selected)
+ || (item_index >= tab->item_selected && item_index <= tab->item_mark)))
+ row_attrs = g_xui.focused
+ ? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED);
+ else
+ override_colors = false;
- // Even with this, the solid part must be at least one character high
- static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" };
+ // The padding must be added before the recoloring below.
+ struct layout l = tab->on_item_layout (item_index);
+ struct widget *w = g.ui->padding (0, 0.25, 1);
+ LIST_PREPEND (l.head, w);
+ app_push (&l, g.ui->padding (0, 0.25, 1));
- for (int row = 0; row < visible_items; row++)
+ // Combine attributes used by the handler with the defaults.
+ LIST_FOR_EACH (struct widget, w, l.head)
{
- chtype attrs = APP_ATTR (SCROLLBAR);
- if (row > bar.start && row <= bar.length)
- attrs ^= A_REVERSE;
-
- const char *c = " ";
- if (row == bar.start) c = partials[start_part];
- if (row == bar.length) c = partials[end_part];
-
- move (g.header_height + row, COLS - 1);
-
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, c, attrs);
- row_buffer_flush (&buf);
- row_buffer_free (&buf);
+ chtype *attrs = &w->attrs;
+ if (override_colors)
+ *attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs;
+ else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR))
+ *attrs |= (row_attrs & ~A_COLOR);
+ else
+ *attrs |= row_attrs;
}
+ return l;
}
static void
-app_draw_view (void)
+app_layout_view (struct layout *out, int height)
{
- move (g.header_height, 0);
- clrtobot ();
+ struct layout l = {};
+ struct widget *list = app_push_fill (&l, g.ui->list ());
+ list->id = WIDGET_LIST;
+ list->height = height;
+ list->width = g_xui.width;
struct tab *tab = g.active_tab;
- bool want_scrollbar = (int) tab->item_count > app_visible_items ();
- int view_width = COLS - want_scrollbar;
+ if ((int) tab->item_count * g_xui.vunit > list->height)
+ {
+ struct widget *scrollbar = g.ui->scrollbar (APP_ATTR (SCROLLBAR));
+ list->width -= scrollbar->width;
+ app_push (&l, scrollbar)->id = WIDGET_SCROLLBAR;
+ }
+
+ int to_show = MIN ((int) tab->item_count - tab->item_top,
+ ceil ((double) list->height / g_xui.vunit));
- int to_show =
- MIN (app_fitting_items (), (int) tab->item_count - tab->item_top);
+ struct layout children = {};
for (int row = 0; row < to_show; row++)
{
int item_index = tab->item_top + row;
- int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN);
-
- bool override_colors = true;
- if (item_index == tab->item_selected)
- row_attrs = g.focused
- ? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED);
- else if (tab->item_mark > -1 &&
- ((item_index >= tab->item_mark && item_index <= tab->item_selected)
- || (item_index >= tab->item_selected && item_index <= tab->item_mark)))
- row_attrs = g.focused
- ? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED);
- else
- override_colors = false;
-
- struct row_buffer buf = row_buffer_make ();
- tab->on_item_draw (item_index, &buf, view_width);
-
- // Combine attributes used by the handler with the defaults.
- // Avoiding attrset() because of row_buffer_flush().
- for (size_t i = 0; i < buf.chars_len; i++)
- {
- chtype *attrs = &buf.chars[i].attrs;
- if (override_colors)
- *attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs;
- else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR))
- *attrs |= (row_attrs & ~A_COLOR);
- else
- *attrs |= row_attrs;
- }
-
- move (g.header_height + row, 0);
- app_flush_buffer (&buf, view_width, row_attrs);
+ struct layout subl = app_layout_row (tab, item_index);
+ // TODO: Change layouting so that we don't need to know list->width.
+ app_flush_layout_full (&subl, list->width, &children);
}
+ list->children = children.head;
- if (want_scrollbar)
- app_draw_scrollbar ();
+ app_flush_layout (&l, out);
}
static void
-app_write_mpd_status_playlist (struct row_buffer *buf)
+app_layout_mpd_status_playlist (struct layout *l, chtype attrs)
{
- struct str stats = str_make ();
- if (g.playlist.len == 1)
- str_append_printf (&stats, "1 song ");
- else
- str_append_printf (&stats, "%zu songs ", g.playlist.len);
+ char *songs = (g.playlist.len == 1)
+ ? xstrdup_printf ("1 song")
+ : xstrdup_printf ("%zu songs", g.playlist.len);
+ app_push (l, g.ui->label (attrs, songs));
+ free (songs);
int hours = g.playlist_time / 3600;
int minutes = g.playlist_time % 3600 / 60;
if (hours || minutes)
{
- str_append_c (&stats, ' ');
-
+ struct str length = str_make ();
if (hours == 1)
- str_append_printf (&stats, " 1 hour");
+ str_append_printf (&length, " 1 hour");
else if (hours)
- str_append_printf (&stats, " %d hours", hours);
+ str_append_printf (&length, " %d hours", hours);
if (minutes == 1)
- str_append_printf (&stats, " 1 minute");
+ str_append_printf (&length, " 1 minute");
else if (minutes)
- str_append_printf (&stats, " %d minutes", minutes);
+ str_append_printf (&length, " %d minutes", minutes);
+
+ app_push (l, g.ui->padding (attrs, 1, 1));
+ app_push (l, g.ui->label (attrs, length.str + 1));
+ str_free (&length);
+ }
+
+ const char *task = NULL;
+ if (g.poller_curl.registered)
+ task = "Downloading...";
+ else if (str_map_find (&g.playback_info, "updating_db"))
+ task = "Updating database...";
+
+ if (task)
+ {
+ app_push (l, g.ui->padding (attrs, 1, 1));
+ app_push (l, g.ui->label (attrs, task));
}
- row_buffer_append (buf, stats.str, APP_ATTR (NORMAL));
- str_free (&stats);
}
static void
-app_write_mpd_status (struct row_buffer *buf)
+app_layout_mpd_status (struct layout *out)
{
- struct str_map *map = &g.playback_info;
+ struct layout l = {};
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+
if (g.active_tab->item_mark > -1)
{
struct tab_range r = tab_selection_range (g.active_tab);
char *msg = xstrdup_printf (r.from == r.upto
? "Selected %d item" : "Selected %d items", r.upto - r.from + 1);
- row_buffer_append (buf, msg, APP_ATTR (HIGHLIGHT));
+ app_push_fill (&l, g.ui->label (attrs[0], msg));
free (msg);
}
- else if (str_map_find (map, "updating_db"))
- row_buffer_append (buf, "Updating database...", APP_ATTR (NORMAL));
else
- app_write_mpd_status_playlist (buf);
+ {
+ app_layout_mpd_status_playlist (&l, attrs[0]);
+ l.tail->width = -1;
+ }
- const char *s;
+ const char *s = NULL;
+ struct str_map *map = &g.playback_info;
bool repeat = (s = str_map_find (map, "repeat")) && strcmp (s, "0");
bool random = (s = str_map_find (map, "random")) && strcmp (s, "0");
bool single = (s = str_map_find (map, "single")) && strcmp (s, "0");
bool consume = (s = str_map_find (map, "consume")) && strcmp (s, "0");
- struct row_buffer right = row_buffer_make ();
- chtype a[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
- if (repeat) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "repeat", a[repeat], NULL);
- if (random) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "random", a[random], NULL);
- if (single) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "single", a[single], NULL);
- if (consume) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "consume", a[consume], NULL);
+ if (g.ui->have_icons || repeat)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[repeat], "repeat", ACTION_MPD_REPEAT));
+ }
+ if (g.ui->have_icons || random)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[random], "random", ACTION_MPD_RANDOM));
+ }
+ if (g.ui->have_icons || single)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[single], "single", ACTION_MPD_SINGLE));
+ }
+ if (g.ui->have_icons || consume)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[consume], "consume", ACTION_MPD_CONSUME));
+ }
- row_buffer_space (buf,
- MAX (0, COLS - buf->total_width - right.total_width),
- APP_ATTR (NORMAL));
- row_buffer_append_buffer (buf, &right);
- row_buffer_free (&right);
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static void
-app_draw_statusbar (void)
+app_layout_statusbar (struct layout *out)
{
- int caret = -1;
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ app_layout_padding (attrs[0], out);
- struct row_buffer buf = row_buffer_make ();
+ struct layout l = {};
if (g.message)
- row_buffer_append (&buf, g.message, APP_ATTR (HIGHLIGHT));
- else if (g.editor.line)
- caret = line_editor_write (&g.editor, &buf, COLS, APP_ATTR (HIGHLIGHT));
- else if (g.client.state == MPD_CONNECTED)
- app_write_mpd_status (&buf);
-
- move (LINES - 1, 0);
- app_flush_buffer (&buf, COLS, APP_ATTR (NORMAL));
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ if (!g.message_detail)
+ app_push_fill (&l, g.ui->label (attrs[1], g.message));
+ else
+ {
+ app_push (&l, g.ui->label (attrs[1], g.message));
+ app_push_fill (&l, g.ui->label (attrs[0], g.message_detail));
+ }
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
- curs_set (0);
- if (caret != -1)
+ app_flush_layout (&l, out);
+ LIST_FOR_EACH (struct widget, w, l.head)
+ w->id = WIDGET_MESSAGE;
+ }
+ else if (g.editor.line)
{
- move (LINES - 1, caret);
- curs_set (1);
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->editor (attrs[1]));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
+ else if (g.client.state == MPD_CONNECTED)
+ app_layout_mpd_status (out);
+ else if (g.client.state == MPD_CONNECTING)
+ app_layout_text ("Connecting to MPD...", attrs[0], out);
+ else if (g.client.state == MPD_DISCONNECTED)
+ app_layout_text ("Disconnected", attrs[0], out);
+
+ app_layout_padding (attrs[0], out);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+static struct widget *
+app_widget_by_id (int id)
+{
+ LIST_FOR_EACH (struct widget, w, g_xui.widgets)
+ if (w->id == id)
+ return w;
+ return NULL;
+}
+
+static int
+app_visible_items_height (void)
+{
+ struct widget *list = app_widget_by_id (WIDGET_LIST);
+ hard_assert (list != NULL);
+
+ // The raw number of items that would have fit on the terminal
+ return MAX (0, list->height);
+}
+
+static int
+app_visible_items (void)
+{
+ return app_visible_items_height () / g_xui.vunit;
+}
+
/// Checks what items are visible and returns if the range was alright
static bool
app_fix_view_range (void)
@@ -1852,17 +2228,27 @@ app_fix_view_range (void)
}
static void
-app_on_refresh (void *user_data)
+app_layout (void)
{
- (void) user_data;
- poller_idle_reset (&g.refresh_event);
+ struct layout top = {}, bottom = {};
+ app_layout_header (&top);
+ app_layout_statusbar (&bottom);
+
+ int available_height = g_xui.height;
+ if (top.tail)
+ available_height -= top.tail->y + top.tail->height;
+ if (bottom.tail)
+ available_height -= bottom.tail->y + bottom.tail->height;
+
+ struct layout widgets = {};
+ app_append_layout (&top, &widgets);
+ app_layout_view (&widgets, available_height);
+ app_append_layout (&bottom, &widgets);
+ g_xui.widgets = widgets.head;
- app_draw_header ();
app_fix_view_range();
- app_draw_view ();
- app_draw_statusbar ();
- refresh ();
+ curs_set (0);
}
// --- Actions -----------------------------------------------------------------
@@ -1872,7 +2258,7 @@ static bool
app_scroll (int n)
{
g.active_tab->item_top += n;
- app_invalidate ();
+ xui_invalidate ();
return app_fix_view_range ();
}
@@ -1894,6 +2280,19 @@ app_ensure_selection_visible (void)
}
static bool
+app_center_cursor (void)
+{
+ struct tab *tab = g.active_tab;
+ if (tab->item_selected < 0 || !tab->item_count)
+ return false;
+
+ int offset = tab->item_selected - tab->item_top;
+ int target = app_visible_items () / 2;
+ app_scroll (offset - target);
+ return true;
+}
+
+static bool
app_move_selection (int diff)
{
struct tab *tab = g.active_tab;
@@ -1903,19 +2302,52 @@ app_move_selection (int diff)
bool result = !diff || tab->item_selected != fixed;
tab->item_selected = fixed;
- app_invalidate ();
+ xui_invalidate ();
app_ensure_selection_visible ();
return result;
}
+static void
+app_show_message (char *message, char *detail)
+{
+ cstr_set (&g.message, message);
+ cstr_set (&g.message_detail, detail);
+ poller_timer_set (&g.message_timer, 5000);
+ xui_invalidate ();
+}
+
+static void
+app_hide_message (void)
+{
+ if (!g.message)
+ return;
+
+ cstr_set (&g.message, NULL);
+ cstr_set (&g.message_detail, NULL);
+ poller_timer_reset (&g.message_timer);
+ xui_invalidate ();
+}
+
+static void
+app_on_clipboard_copy (const char *text)
+{
+ app_show_message (xstrdup ("Text copied to clipboard: "), xstrdup (text));
+}
+
+static struct widget *
+app_make_label (chtype attrs, const char *label)
+{
+ return g_xui.ui->label (attrs, 0, label);
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static void
app_prepend_tab (struct tab *tab)
{
LIST_PREPEND (g.tabs, tab);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -1926,7 +2358,7 @@ app_switch_tab (struct tab *tab)
g.last_tab = g.active_tab;
g.active_tab = tab;
- app_invalidate ();
+ xui_invalidate ();
}
static bool
@@ -1944,105 +2376,12 @@ app_goto_tab (int tab_index)
// --- Actions -----------------------------------------------------------------
-#define ACTIONS(XX) \
- XX( NONE, Do nothing ) \
- \
- XX( QUIT, Quit ) \
- XX( REDRAW, Redraw screen ) \
- XX( TAB_HELP, Switch to help tab ) \
- XX( TAB_LAST, Switch to last tab ) \
- XX( TAB_PREVIOUS, Switch to previous tab ) \
- XX( TAB_NEXT, Switch to next tab ) \
- \
- XX( MPD_TOGGLE, Toggle play/pause ) \
- XX( MPD_STOP, Stop playback ) \
- XX( MPD_PREVIOUS, Previous song ) \
- XX( MPD_NEXT, Next song ) \
- XX( MPD_BACKWARD, Seek backwards ) \
- XX( MPD_FORWARD, Seek forwards ) \
- XX( MPD_VOLUME_UP, Increase volume ) \
- XX( MPD_VOLUME_DOWN, Decrease volume ) \
- \
- XX( MPD_SEARCH, Global search ) \
- XX( MPD_ADD, Add selection to playlist ) \
- XX( MPD_REPLACE, Replace playlist ) \
- XX( MPD_REPEAT, Toggle repeat ) \
- XX( MPD_RANDOM, Toggle random playback ) \
- XX( MPD_SINGLE, Toggle single song playback ) \
- XX( MPD_CONSUME, Toggle consume ) \
- XX( MPD_UPDATE_DB, Update MPD database ) \
- XX( MPD_COMMAND, Send raw command to MPD ) \
- \
- XX( CHOOSE, Choose item ) \
- XX( DELETE, Delete item ) \
- XX( UP, Go up a level ) \
- XX( MULTISELECT, Toggle multiselect ) \
- \
- XX( SCROLL_UP, Scroll up ) \
- XX( SCROLL_DOWN, Scroll down ) \
- XX( MOVE_UP, Move selection up ) \
- XX( MOVE_DOWN, Move selection down ) \
- \
- XX( GOTO_TOP, Go to top ) \
- XX( GOTO_BOTTOM, Go to bottom ) \
- XX( GOTO_ITEM_PREVIOUS, Go to previous item ) \
- XX( GOTO_ITEM_NEXT, Go to next item ) \
- XX( GOTO_PAGE_PREVIOUS, Go to previous page ) \
- XX( GOTO_PAGE_NEXT, Go to next page ) \
- \
- XX( GOTO_VIEW_TOP, Select top item ) \
- XX( GOTO_VIEW_CENTER, Select center item ) \
- XX( GOTO_VIEW_BOTTOM, Select bottom item ) \
- \
- XX( EDITOR_CONFIRM, Confirm input ) \
- \
- XX( EDITOR_B_CHAR, Go back a character ) \
- XX( EDITOR_F_CHAR, Go forward a character ) \
- XX( EDITOR_B_WORD, Go back a word ) \
- XX( EDITOR_F_WORD, Go forward a word ) \
- XX( EDITOR_HOME, Go to start of line ) \
- XX( EDITOR_END, Go to end of line ) \
- \
- XX( EDITOR_B_DELETE, Delete last character ) \
- XX( EDITOR_F_DELETE, Delete next character ) \
- XX( EDITOR_B_KILL_WORD, Delete last word ) \
- XX( EDITOR_B_KILL_LINE, Delete everything up to BOL ) \
- XX( EDITOR_F_KILL_LINE, Delete everything up to EOL )
-
-enum action
-{
-#define XX(name, description) ACTION_ ## name,
- ACTIONS (XX)
-#undef XX
- ACTION_COUNT
-};
-
-static struct action_info
-{
- const char *name; ///< Name for user bindings
- const char *description; ///< Human-readable description
-}
-g_actions[] =
-{
-#define XX(name, description) { #name, #description },
- ACTIONS (XX)
-#undef XX
-};
-
-/// Accept a more human format of action-name instead of ACTION_NAME
-static int action_toupper (int c) { return c == '-' ? '_' : toupper_ascii (c); }
-
static int
action_resolve (const char *name)
{
- const unsigned char *s = (const unsigned char *) name;
for (int i = 0; i < ACTION_COUNT; i++)
- {
- const char *target = g_actions[i].name;
- for (size_t k = 0; action_toupper (s[k]) == target[k]; k++)
- if (!s[k] && !target[k])
- return i;
- }
+ if (!strcasecmp_ascii (g_action_names[i], name))
+ return i;
return -1;
}
@@ -2103,7 +2442,7 @@ app_setvol (int value)
}
static void
-app_on_editor_end (bool confirmed)
+app_on_mpd_command_editor_end (bool confirmed)
{
struct mpd_client *c = &g.client;
if (!confirmed)
@@ -2118,6 +2457,63 @@ app_on_editor_end (bool confirmed)
mpd_client_idle (c, 0);
}
+static size_t
+incremental_search_match (const ucs4_t *needle, size_t len,
+ const ucs4_t *chars, size_t chars_len)
+{
+ // XXX: this is slow and simplistic, but unistring is awkward to use
+ size_t best = 0;
+ for (size_t start = 0; start < chars_len; start++)
+ {
+ size_t i = 0;
+ for (; i < len && start + i < chars_len; i++)
+ if (uc_tolower (needle[i]) != uc_tolower (chars[start + i]))
+ break;
+ best = MAX (best, i);
+ }
+ return best;
+}
+
+static void
+incremental_search_on_changed (void)
+{
+ struct tab *tab = g.active_tab;
+ if (!tab->item_count)
+ return;
+
+ size_t best = 0, current = 0, index = MAX (tab->item_selected, 0), i = 0;
+ while (i++ < tab->item_count)
+ {
+ struct str s = str_make ();
+ LIST_FOR_EACH (struct widget, w, tab->on_item_layout (index).head)
+ {
+ str_append (&s, w->text);
+ widget_destroy (w);
+ }
+
+ size_t len;
+ ucs4_t *text = u8_to_u32 ((const uint8_t *) s.str, s.len, NULL, &len);
+ str_free (&s);
+ current = incremental_search_match
+ (g.editor.line, g.editor.len, text, len);
+ free (text);
+ if (best < current)
+ {
+ best = current;
+ tab->item_selected = index;
+ app_move_selection (0);
+ }
+ index = (index + 1) % tab->item_count;
+ }
+}
+
+static void
+incremental_search_on_end (bool confirmed)
+{
+ (void) confirmed;
+ // Required callback, nothing to do here.
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static bool
@@ -2135,7 +2531,7 @@ app_process_action (enum action action)
struct tab *tab = g.active_tab;
if (tab->on_action && tab->on_action (action))
{
- app_invalidate ();
+ xui_invalidate ();
return true;
}
@@ -2144,26 +2540,31 @@ app_process_action (enum action action)
case ACTION_NONE:
return true;
case ACTION_QUIT:
+ app_quit ();
+ return true;
+ case ACTION_REDRAW:
+ clear ();
+ xui_invalidate ();
+ return true;
+
+ case ACTION_ABORT:
// It is a pseudomode, avoid surprising the user
if (tab->item_mark > -1)
{
tab->item_mark = -1;
- app_invalidate ();
+ xui_invalidate ();
return true;
}
-
- app_quit ();
- return true;
- case ACTION_REDRAW:
- clear ();
- app_invalidate ();
- return true;
+ return false;
case ACTION_MPD_COMMAND:
line_editor_start (&g.editor, ':');
- g.editor.on_end = app_on_editor_end;
- app_invalidate ();
+ g.editor.on_end = app_on_mpd_command_editor_end;
+ xui_invalidate ();
+ app_hide_message ();
return true;
default:
+ print_error ("\"%s\" is not allowed here",
+ g_action_descriptions[action]);
return false;
case ACTION_MULTISELECT:
@@ -2171,12 +2572,19 @@ app_process_action (enum action action)
|| !tab->item_count || tab->item_selected < 0)
return false;
- app_invalidate ();
+ xui_invalidate ();
if (tab->item_mark > -1)
tab->item_mark = -1;
else
tab->item_mark = tab->item_selected;
return true;
+ case ACTION_INCREMENTAL_SEARCH:
+ line_editor_start (&g.editor, '/');
+ g.editor.on_changed = incremental_search_on_changed;
+ g.editor.on_end = incremental_search_on_end;
+ xui_invalidate ();
+ app_hide_message ();
+ return true;
case ACTION_TAB_LAST:
if (!g.last_tab)
@@ -2218,19 +2626,26 @@ app_process_action (enum action action)
case ACTION_MPD_CONSUME: return app_mpd_toggle ("consume");
case ACTION_MPD_UPDATE_DB: return MPD_SIMPLE ("update");
- case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 10);
- case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 10);
+ case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 5);
+ case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 5);
- // XXX: these should rather be parametrized
+#ifdef WITH_PULSE
+ case ACTION_PULSE_VOLUME_UP: return pulse_volume_set (&g.pulse, +5);
+ case ACTION_PULSE_VOLUME_DOWN: return pulse_volume_set (&g.pulse, -5);
+ case ACTION_PULSE_MUTE: return pulse_volume_mute (&g.pulse);
+#endif // WITH_PULSE
+
+ // XXX: these two should rather be parametrized
case ACTION_SCROLL_UP: return app_scroll (-3);
- case ACTION_SCROLL_DOWN: return app_scroll (3);
+ case ACTION_SCROLL_DOWN: return app_scroll (+3);
+ case ACTION_CENTER_CURSOR: return app_center_cursor ();
case ACTION_GOTO_TOP:
if (tab->item_count)
{
g.active_tab->item_selected = 0;
app_ensure_selection_visible ();
- app_invalidate ();
+ xui_invalidate ();
}
return true;
case ACTION_GOTO_BOTTOM:
@@ -2239,7 +2654,7 @@ app_process_action (enum action action)
g.active_tab->item_selected =
MAX (0, (int) g.active_tab->item_count - 1);
app_ensure_selection_visible ();
- app_invalidate ();
+ xui_invalidate ();
}
return true;
@@ -2269,10 +2684,10 @@ app_process_action (enum action action)
static bool
app_editor_process_action (enum action action)
{
- app_invalidate ();
+ xui_invalidate ();
switch (action)
{
- case ACTION_QUIT:
+ case ACTION_ABORT:
line_editor_abort (&g.editor, false);
g.editor.on_end = NULL;
return true;
@@ -2281,6 +2696,8 @@ app_editor_process_action (enum action action)
g.editor.on_end = NULL;
return true;
default:
+ print_error ("\"%s\" is not allowed here",
+ g_action_descriptions[action]);
return false;
case ACTION_EDITOR_B_CHAR:
@@ -2296,6 +2713,13 @@ app_editor_process_action (enum action action)
case ACTION_EDITOR_END:
return line_editor_action (&g.editor, LINE_EDITOR_END);
+ case ACTION_EDITOR_UPCASE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_UPCASE_WORD);
+ case ACTION_EDITOR_DOWNCASE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_DOWNCASE_WORD);
+ case ACTION_EDITOR_CAPITALIZE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_CAPITALIZE_WORD);
+
case ACTION_EDITOR_B_DELETE:
return line_editor_action (&g.editor, LINE_EDITOR_B_DELETE);
case ACTION_EDITOR_F_DELETE:
@@ -2311,93 +2735,156 @@ app_editor_process_action (enum action action)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Carefully chosen to limit the possibility of ever hitting termo keymods.
+enum { APP_KEYMOD_DOUBLE_CLICK = 1 << 15 };
+
static bool
-app_process_left_mouse_click (int line, int column, bool double_click)
+app_process_left_mouse_click (struct widget *w, int x, int y, int modifiers)
{
- if (line == g.controls_offset)
+ switch (w->id)
{
- // XXX: there could be a push_widget(buf, text, attrs, handler)
- // function to help with this but it might not be worth it
- enum action action = ACTION_NONE;
- if (column >= 0 && column <= 1) action = ACTION_MPD_PREVIOUS;
- if (column >= 3 && column <= 4) action = ACTION_MPD_TOGGLE;
- if (column >= 6 && column <= 7) action = ACTION_MPD_STOP;
- if (column >= 9 && column <= 10) action = ACTION_MPD_NEXT;
-
- if (action)
- return app_process_action (action);
-
- int gauge_offset = column - g.gauge_offset;
- if (g.gauge_offset < 0
- || gauge_offset < 0 || gauge_offset >= g.gauge_width)
- return false;
-
- float position = (float) gauge_offset / g.gauge_width;
+ case WIDGET_BUTTON:
+ app_process_action (w->userdata);
+ break;
+ case WIDGET_GAUGE:
+ {
+ // TODO: We should avoid queuing up too many.
+ float position = (float) x / w->width;
if (g.song_duration >= 1)
{
char *where = xstrdup_printf ("%f", position * g.song_duration);
MPD_SIMPLE ("seekcur", where);
free (where);
}
+ break;
}
- else if (line == g.tabs_offset)
+ case WIDGET_TAB:
{
- struct tab *winner = NULL;
- int indent = strlen (APP_TITLE);
- if (column < indent)
- {
- app_switch_tab (g.help_tab);
- return true;
- }
- for (struct tab *iter = g.tabs; !winner && iter; iter = iter->next)
- {
- if (column < (indent += iter->name_width))
- winner = iter;
- }
- if (!winner)
- return false;
+ struct tab *tab = g.help_tab;
+ int i = 0;
+ LIST_FOR_EACH (struct tab, iter, g.tabs)
+ if (++i == w->userdata)
+ tab = iter;
- app_switch_tab (winner);
+ app_switch_tab (tab);
+ break;
}
- else if (line >= g.header_height)
+ case WIDGET_LIST:
{
struct tab *tab = g.active_tab;
- int row_index = line - g.header_height;
+ int row_index = y / g_xui.vunit;
if (row_index < 0
|| row_index >= (int) tab->item_count - tab->item_top)
return false;
- // TODO: handle the scrollbar a bit better than this
- int visible_items = app_visible_items ();
- if ((int) tab->item_count > visible_items && column == COLS - 1)
- tab->item_top = (float) row_index / visible_items
- * (int) tab->item_count - visible_items / 2;
- else
- tab->item_selected = row_index + tab->item_top;
- app_invalidate ();
+ if (!(modifiers & TERMO_KEYMOD_SHIFT))
+ tab->item_mark = -1;
+ else if (!tab->can_multiselect || tab->item_selected < 0)
+ return false;
+ else if (tab->item_mark < 0)
+ tab->item_mark = tab->item_selected;
+
+ tab->item_selected = row_index + tab->item_top;
+ app_ensure_selection_visible ();
+ xui_invalidate ();
- if (double_click)
+ if (modifiers & APP_KEYMOD_DOUBLE_CLICK)
app_process_action (ACTION_CHOOSE);
+ break;
+ }
+ case WIDGET_SCROLLBAR:
+ {
+ struct tab *tab = g.active_tab;
+ int visible_items = app_visible_items ();
+ tab->item_top = (double) y / w->height
+ * (int) tab->item_count - visible_items / 2;
+ xui_invalidate ();
+ app_fix_view_range ();
+ break;
+ }
+ case WIDGET_MESSAGE:
+ app_hide_message ();
}
return true;
}
static bool
-app_process_mouse (termo_mouse_event_t type, int line, int column, int button,
- bool double_click)
+app_process_mouse (termo_mouse_event_t type, int x, int y, int button,
+ int modifiers)
{
- if (type != TERMO_MOUSE_PRESS)
+ // XXX: Terminals don't let us know which button has been released,
+ // so we can't press buttons at that point. We'd need a special "click"
+ // event handler that could be handled better under X11.
+ if (type == TERMO_MOUSE_RELEASE)
+ {
+ g.ui_dragging = WIDGET_NONE;
return true;
+ }
+
+ if (type == TERMO_MOUSE_DRAG)
+ {
+ if (g.ui_dragging != WIDGET_GAUGE
+ && g.ui_dragging != WIDGET_SCROLLBAR)
+ return true;
+
+ struct widget *target = app_widget_by_id (g.ui_dragging);
+ x -= target->x;
+ y -= target->y;
+ return app_process_left_mouse_click (target, x, y, modifiers);
+ }
if (g.editor.line)
+ {
line_editor_abort (&g.editor, false);
+ xui_invalidate ();
+ }
- if (button == 1)
- return app_process_left_mouse_click (line, column, double_click);
- else if (button == 4)
- return app_process_action (ACTION_SCROLL_UP);
- else if (button == 5)
- return app_process_action (ACTION_SCROLL_DOWN);
+ struct widget *target = NULL;
+ LIST_FOR_EACH (struct widget, w, g_xui.widgets)
+ if (x >= w->x && x < w->x + w->width
+ && y >= w->y && y < w->y + w->height)
+ target = w;
+ if (!target)
+ return false;
+
+ x -= target->x;
+ y -= target->y;
+ switch (button)
+ {
+ case 1:
+ g.ui_dragging = target->id;
+ return app_process_left_mouse_click (target, x, y, modifiers);
+ case 4:
+ switch (target->id)
+ {
+ case WIDGET_LIST:
+ return app_process_action (ACTION_SCROLL_UP);
+ case WIDGET_VOLUME:
+ return app_process_action (
+#ifdef WITH_PULSE
+ g.pulse_control_requested ? ACTION_PULSE_VOLUME_UP :
+#endif // WITH_PULSE
+ ACTION_MPD_VOLUME_UP);
+ case WIDGET_GAUGE:
+ return app_process_action (ACTION_MPD_FORWARD);
+ }
+ break;
+ case 5:
+ switch (target->id)
+ {
+ case WIDGET_LIST:
+ return app_process_action (ACTION_SCROLL_DOWN);
+ case WIDGET_VOLUME:
+ return app_process_action (
+#ifdef WITH_PULSE
+ g.pulse_control_requested ? ACTION_PULSE_VOLUME_DOWN :
+#endif // WITH_PULSE
+ ACTION_MPD_VOLUME_DOWN);
+ case WIDGET_GAUGE:
+ return app_process_action (ACTION_MPD_BACKWARD);
+ }
+ break;
+ }
return false;
}
@@ -2419,16 +2906,19 @@ static struct binding_default
}
g_normal_defaults[] =
{
- { "Escape", ACTION_QUIT },
{ "q", ACTION_QUIT },
{ "C-l", ACTION_REDRAW },
+ { "Escape", ACTION_ABORT },
{ "M-Tab", ACTION_TAB_LAST },
{ "F1", ACTION_TAB_HELP },
+ { "S-Tab", ACTION_TAB_PREVIOUS },
+ { "Tab", ACTION_TAB_NEXT },
{ "C-Left", ACTION_TAB_PREVIOUS },
{ "C-Right", ACTION_TAB_NEXT },
{ "C-PageUp", ACTION_TAB_PREVIOUS },
{ "C-PageDown", ACTION_TAB_NEXT },
+ { "o", ACTION_GOTO_PLAYING },
{ "Home", ACTION_GOTO_TOP },
{ "End", ACTION_GOTO_BOTTOM },
{ "M-<", ACTION_GOTO_TOP },
@@ -2449,6 +2939,7 @@ g_normal_defaults[] =
{ "C-f", ACTION_GOTO_PAGE_NEXT },
{ "C-y", ACTION_SCROLL_UP },
{ "C-e", ACTION_SCROLL_DOWN },
+ { "z", ACTION_CENTER_CURSOR },
{ "H", ACTION_GOTO_VIEW_TOP },
{ "M", ACTION_GOTO_VIEW_CENTER },
@@ -2458,9 +2949,11 @@ g_normal_defaults[] =
{ "Enter", ACTION_CHOOSE },
{ "Delete", ACTION_DELETE },
{ "d", ACTION_DELETE },
+ { "?", ACTION_DESCRIBE },
{ "M-Up", ACTION_UP },
{ "Backspace", ACTION_UP },
{ "v", ACTION_MULTISELECT },
+ { "C-s", ACTION_INCREMENTAL_SEARCH },
{ "/", ACTION_MPD_SEARCH },
{ "a", ACTION_MPD_ADD },
{ "r", ACTION_MPD_REPLACE },
@@ -2477,11 +2970,15 @@ g_normal_defaults[] =
{ "Space", ACTION_MPD_TOGGLE },
{ "C-Space", ACTION_MPD_STOP },
{ "u", ACTION_MPD_UPDATE_DB },
- { "M-PageUp", ACTION_MPD_VOLUME_UP },
- { "M-PageDown", ACTION_MPD_VOLUME_DOWN },
+ { "+", ACTION_MPD_VOLUME_UP },
+ { "-", ACTION_MPD_VOLUME_DOWN },
},
g_editor_defaults[] =
{
+ { "C-g", ACTION_ABORT },
+ { "Escape", ACTION_ABORT },
+ { "Enter", ACTION_EDITOR_CONFIRM },
+
{ "Left", ACTION_EDITOR_B_CHAR },
{ "Right", ACTION_EDITOR_F_CHAR },
{ "C-b", ACTION_EDITOR_B_CHAR },
@@ -2493,6 +2990,10 @@ g_editor_defaults[] =
{ "C-a", ACTION_EDITOR_HOME },
{ "C-e", ACTION_EDITOR_END },
+ { "M-u", ACTION_EDITOR_UPCASE_WORD },
+ { "M-l", ACTION_EDITOR_DOWNCASE_WORD },
+ { "M-c", ACTION_EDITOR_CAPITALIZE_WORD },
+
{ "C-h", ACTION_EDITOR_B_DELETE },
{ "DEL", ACTION_EDITOR_B_DELETE },
{ "Backspace", ACTION_EDITOR_B_DELETE },
@@ -2501,17 +3002,13 @@ g_editor_defaults[] =
{ "C-u", ACTION_EDITOR_B_KILL_LINE },
{ "C-k", ACTION_EDITOR_F_KILL_LINE },
{ "C-w", ACTION_EDITOR_B_KILL_WORD },
-
- { "C-g", ACTION_QUIT },
- { "Escape", ACTION_QUIT },
- { "Enter", ACTION_EDITOR_CONFIRM },
};
static int
app_binding_cmp (const void *a, const void *b)
{
const struct binding *aa = a, *bb = b;
- int cmp = termo_keycmp (g.tk, &aa->decoded, &bb->decoded);
+ int cmp = termo_keycmp (g_xui.tk, &aa->decoded, &bb->decoded);
return cmp ? cmp : bb->order - aa->order;
}
@@ -2522,7 +3019,7 @@ app_next_binding (struct str_map_iter *iter, termo_key_t *key, int *action)
while ((v = str_map_iter_next (iter)))
{
*action = ACTION_NONE;
- if (*termo_strpkey_utf8 (g.tk,
+ if (*termo_strpkey_utf8 (g_xui.tk,
iter->link->key, key, TERMO_FORMAT_ALTISMETA))
print_error ("%s: invalid binding", iter->link->key);
else if (v->type == CONFIG_ITEM_NULL)
@@ -2551,7 +3048,7 @@ app_init_bindings (const char *keymap,
termo_key_t decoded;
for (size_t i = 0; i < defaults_len; i++)
{
- hard_assert (!*termo_strpkey_utf8 (g.tk,
+ hard_assert (!*termo_strpkey_utf8 (g_xui.tk,
defaults[i].key, &decoded, TERMO_FORMAT_ALTISMETA));
a[a_len++] = (struct binding) { decoded, defaults[i].action, order++ };
}
@@ -2573,7 +3070,8 @@ app_init_bindings (const char *keymap,
for (size_t in = 0; in < a_len; in++)
{
a[in].order = 0;
- if (!out || termo_keycmp (g.tk, &a[in].decoded, &a[out - 1].decoded))
+ if (!out
+ || termo_keycmp (g_xui.tk, &a[in].decoded, &a[out - 1].decoded))
a[out++] = a[in];
}
@@ -2581,26 +3079,52 @@ app_init_bindings (const char *keymap,
return a;
}
+static char *
+app_strfkey (const termo_key_t *key)
+{
+ // For display purposes, this is highly desirable
+ int flags = termo_get_flags (g_xui.tk);
+ termo_set_flags (g_xui.tk, flags | TERMO_FLAG_SPACESYMBOL);
+ termo_key_t fixed = *key;
+ termo_canonicalise (g_xui.tk, &fixed);
+ termo_set_flags (g_xui.tk, flags);
+
+ char buf[16] = "";
+ termo_strfkey_utf8 (g_xui.tk,
+ buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA);
+ return xstrdup (buf);
+}
+
static bool
app_process_termo_event (termo_key_t *event)
{
- if (event->type == TERMO_TYPE_FOCUS)
+ char *formatted = app_strfkey (event);
+ print_debug ("%s", formatted);
+ free (formatted);
+
+ bool handled = false;
+ if ((handled = event->type == TERMO_TYPE_FOCUS))
{
- g.focused = !!event->code.focused;
- app_invalidate ();
+ xui_invalidate ();
+ // Senseless fall-through
}
struct binding dummy = { *event, 0, 0 }, *binding;
if (g.editor.line)
{
+ if (event->type == TERMO_TYPE_KEY
+ || event->type == TERMO_TYPE_FUNCTION
+ || event->type == TERMO_TYPE_KEYSYM)
+ app_hide_message ();
+
if ((binding = bsearch (&dummy, g_editor_keys, g_editor_keys_len,
sizeof *binding, app_binding_cmp)))
return app_editor_process_action (binding->action);
if (event->type != TERMO_TYPE_KEY || event->modifiers != 0)
- return false;
+ return handled;
line_editor_insert (&g.editor, event->code.codepoint);
- app_invalidate ();
+ xui_invalidate ();
return true;
}
if ((binding = bsearch (&dummy, g_normal_keys, g_normal_keys_len,
@@ -2616,18 +3140,15 @@ app_process_termo_event (termo_key_t *event)
if (app_goto_tab ((n == 0 ? 10 : n) - 1))
return true;
}
- return false;
+ return handled;
}
// --- Current tab -------------------------------------------------------------
static struct tab g_current_tab;
-#define DURATION_MAX_LEN (1 /*separator */ + 2 /* h */ + 3 /* m */+ 3 /* s */)
-
-static void
-current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+current_tab_on_item_layout (size_t item_index)
{
// TODO: configurable output, maybe dynamically sized columns
compact_map_t map = item_list_get (&g.playlist, item_index);
@@ -2635,23 +3156,26 @@ current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
const char *title = compact_map_find (map, "title");
chtype attrs = (int) item_index == g.song ? A_BOLD : 0;
+ struct layout l = {};
if (artist && title)
- row_buffer_append_args (buffer,
- artist, attrs, " - ", attrs, title, attrs, NULL);
+ {
+ char *joined = xstrdup_printf ("%s - %s", artist, title);
+ app_push_fill (&l, g.ui->label (attrs, joined));
+ free (joined);
+ }
else
- row_buffer_append (buffer, compact_map_find (map, "file"), attrs);
-
- row_buffer_align (buffer, width - DURATION_MAX_LEN, attrs);
+ app_push_fill (&l, g.ui->label (attrs, compact_map_find (map, "file")));
int duration = -1;
mpd_read_time (compact_map_find (map, "duration"), &duration, NULL);
mpd_read_time (compact_map_find (map, "time"), &duration, NULL);
char *s = duration < 0 ? xstrdup ("-") : app_time_string (duration);
- char *right_aligned = xstrdup_printf ("%*s", DURATION_MAX_LEN, s);
- row_buffer_append (buffer, right_aligned, attrs);
- free (right_aligned);
+ app_push (&l, g.ui->padding (attrs, 1, 1));
+ app_push (&l, g.ui->label (attrs, s));
free (s);
+
+ return l;
}
static void
@@ -2718,6 +3242,13 @@ current_tab_on_action (enum action action)
switch (action)
{
const char *id;
+ case ACTION_GOTO_PLAYING:
+ if (g.song < 0 || (size_t) g.song >= tab->item_count)
+ return false;
+
+ tab->item_selected = g.song;
+ app_ensure_selection_visible ();
+ return true;
case ACTION_MOVE_UP:
return current_tab_move_selection (-1);
case ACTION_MOVE_DOWN:
@@ -2726,6 +3257,12 @@ current_tab_on_action (enum action action)
tab->item_mark = -1;
return map && (id = compact_map_find (map, "id"))
&& MPD_SIMPLE ("playid", id);
+ case ACTION_DESCRIBE:
+ if (!map || !(id = compact_map_find (map, "file")))
+ return false;
+
+ app_show_message (xstrdup ("Path: "), xstrdup (id));
+ return true;
case ACTION_DELETE:
{
struct mpd_client *c = &g.client;
@@ -2756,7 +3293,7 @@ current_tab_update (void)
g_current_tab.item_count = g.playlist.len;
g_current_tab.item_mark =
MIN ((int) g.playlist.len - 1, g_current_tab.item_mark);
- app_invalidate ();
+ xui_invalidate ();
}
static struct tab *
@@ -2766,7 +3303,7 @@ current_tab_init (void)
tab_init (super, "Current");
super->can_multiselect = true;
super->on_action = current_tab_on_action;
- super->on_item_draw = current_tab_on_item_draw;
+ super->on_item_layout = current_tab_on_item_layout;
return super;
}
@@ -2832,11 +3369,9 @@ library_tab_add (int type, int duration, const char *name, const char *path)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-static void
-library_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+library_tab_on_item_layout (size_t item_index)
{
- (void) width;
hard_assert (item_index < g_library_tab.items_len);
struct library_tab_item *x = &g_library_tab.items[item_index];
@@ -2849,15 +3384,21 @@ library_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
case LIBRARY_FILE: prefix = " "; name = x->name; break;
default: hard_assert (!"invalid item type");
}
+
chtype attrs = x->type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0;
- row_buffer_append_args (buffer, prefix, attrs, name, attrs, NULL);
- if (x->duration < 0)
- return;
+ struct layout l = {};
- char *s = app_time_string (x->duration);
- row_buffer_align (buffer, width - 2 /* gap */ - strlen (s), 0);
- row_buffer_append_args (buffer, " " /* gap */, 0, s, 0, NULL);
- free (s);
+ app_push (&l, g.ui->label (attrs, prefix));
+ app_push_fill (&l, g.ui->label (attrs, name));
+
+ if (x->duration >= 0)
+ {
+ char *s = app_time_string (x->duration);
+ app_push (&l, g.ui->padding (0, 1, 1));
+ app_push (&l, g.ui->label (attrs, s));
+ free (s);
+ }
+ return l;
}
static char
@@ -3014,7 +3555,7 @@ library_tab_load_data (const struct strv *data)
if (g_library_tab.super.item_selected >= (int) len)
app_move_selection (0);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -3136,6 +3677,12 @@ library_tab_on_action (enum action action)
}
tab->item_mark = -1;
return true;
+ case ACTION_DESCRIBE:
+ if (!*x->path)
+ break;
+
+ app_show_message (xstrdup ("Path: "), xstrdup (x->path));
+ return true;
case ACTION_UP:
{
char *parent = library_tab_parent ();
@@ -3171,7 +3718,7 @@ library_tab_on_action (enum action action)
library_tab_load_data (&empty);
strv_free (&empty);
- app_invalidate ();
+ xui_invalidate ();
return true;
}
case ACTION_MPD_ADD:
@@ -3226,7 +3773,7 @@ library_tab_init (void)
tab_init (super, "Library");
super->can_multiselect = true;
super->on_action = library_tab_on_action;
- super->on_item_draw = library_tab_on_item_draw;
+ super->on_item_layout = library_tab_on_item_layout;
return super;
}
@@ -3238,8 +3785,8 @@ struct stream_tab_task
{
struct poller_curl_task curl; ///< Superclass
struct str data; ///< Downloaded data
- bool polling; ///< Still downloading
bool replace; ///< Should playlist be replaced?
+ struct curl_slist *alias_ok;
};
static bool
@@ -3296,8 +3843,13 @@ streams_tab_parse_playlist (const char *playlist, const char *content_type,
|| (content_type && is_content_type (content_type, "audio", "x-scpls")))
extract_re = "^File[^=]*=(.+)";
else if ((lines.len && !strcasecmp_ascii (lines.vector[0], "#EXTM3U"))
+ || (content_type && is_content_type (content_type, "audio", "mpegurl"))
|| (content_type && is_content_type (content_type, "audio", "x-mpegurl")))
- extract_re = "^([^#].*)";
+ // This could be "^([^#].*)", however 1. we would need to resolve
+ // relative URIs, and 2. relative URIs probably mean a Media Playlist,
+ // which must be passed to MPD. The better thing to do here would be to
+ // reject anything with EXT-X-TARGETDURATION, and to resolve the URIs.
+ extract_re = "^(https?://.+)";
regex_t *re = regex_compile (extract_re, REG_EXTENDED, NULL);
hard_assert (re != NULL);
@@ -3320,7 +3872,23 @@ streams_tab_extract_links (struct str *data, const char *content_type,
}
streams_tab_parse_playlist (data->str, content_type, out);
- return true;
+ return out->len != 0;
+}
+
+static void
+streams_tab_task_finalize (struct stream_tab_task *self)
+{
+ curl_easy_cleanup (self->curl.easy);
+ curl_slist_free_all (self->alias_ok);
+ str_free (&self->data);
+ free (self);
+}
+
+static void
+streams_tab_task_dispose (struct stream_tab_task *self)
+{
+ hard_assert (poller_curl_remove (&g.poller_curl, self->curl.easy, NULL));
+ streams_tab_task_finalize (self);
}
static void
@@ -3328,19 +3896,18 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
{
struct stream_tab_task *self =
CONTAINER_OF (task, struct stream_tab_task, curl);
- self->polling = false;
if (msg->data.result
&& msg->data.result != CURLE_WRITE_ERROR)
{
cstr_uncapitalize (self->curl.curl_error);
print_error ("%s", self->curl.curl_error);
- return;
+ goto dispose;
}
struct mpd_client *c = &g.client;
if (c->state != MPD_CONNECTED)
- return;
+ goto dispose;
CURL *easy = msg->easy_handle;
CURLcode res;
@@ -3353,13 +3920,13 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
{
print_error ("%s: %s",
"cURL info retrieval failed", curl_easy_strerror (res));
- return;
+ goto dispose;
}
// cURL is not willing to parse the ICY header, the code is zero then
if (code && code != 200)
{
print_error ("%s: %ld", "unexpected HTTP response", code);
- return;
+ goto dispose;
}
mpd_client_list_begin (c);
@@ -3378,6 +3945,9 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
mpd_client_list_end (c);
mpd_client_add_task (c, mpd_on_simple_response, NULL);
mpd_client_idle (c, 0);
+
+dispose:
+ streams_tab_task_dispose (self);
}
static size_t
@@ -3396,60 +3966,44 @@ write_callback (char *ptr, size_t size, size_t nmemb, void *user_data)
static bool
streams_tab_process (const char *uri, bool replace, struct error **e)
{
- struct poller poller;
- poller_init (&poller);
-
- struct poller_curl pc;
- hard_assert (poller_curl_init (&pc, &poller, NULL));
- struct stream_tab_task task;
- hard_assert (poller_curl_spawn (&task.curl, NULL));
+ // TODO: streams_tab_task_dispose() on that running task
+ if (g.poller_curl.registered)
+ {
+ print_error ("waiting for the last stream to time out");
+ return false;
+ }
- CURL *easy = task.curl.easy;
- task.data = str_make ();
- task.replace = replace;
- bool result = false;
+ struct stream_tab_task *task = xcalloc (1, sizeof *task);
+ hard_assert (poller_curl_spawn (&task->curl, NULL));
- struct curl_slist *ok_headers = curl_slist_append (NULL, "ICY 200 OK");
+ CURL *easy = task->curl.easy;
+ task->data = str_make ();
+ task->replace = replace;
+ task->alias_ok = curl_slist_append (NULL, "ICY 200 OK");
CURLcode res;
if ((res = curl_easy_setopt (easy, CURLOPT_FOLLOWLOCATION, 1L))
|| (res = curl_easy_setopt (easy, CURLOPT_NOPROGRESS, 1L))
- // TODO: make the timeout a bit larger once we're asynchronous
- || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 5L))
+ || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 10L))
// Not checking anything, we just want some data, any data
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYPEER, 0L))
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYHOST, 0L))
|| (res = curl_easy_setopt (easy, CURLOPT_URL, uri))
- || (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, ok_headers))
+ || (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, task->alias_ok))
|| (res = curl_easy_setopt (easy, CURLOPT_VERBOSE, (long) g_debug_mode))
|| (res = curl_easy_setopt (easy, CURLOPT_DEBUGFUNCTION, print_curl_debug))
- || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task.data))
+ || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task->data))
|| (res = curl_easy_setopt (easy, CURLOPT_WRITEFUNCTION, write_callback)))
{
error_set (e, "%s: %s", "cURL setup failed", curl_easy_strerror (res));
- goto error;
+ streams_tab_task_finalize (task);
+ return false;
}
- task.curl.on_done = streams_tab_on_downloaded;
- hard_assert (poller_curl_add (&pc, task.curl.easy, NULL));
-
- // TODO: don't run a subloop, run the task fully asynchronously
- task.polling = true;
- while (task.polling)
- poller_run (&poller);
-
- hard_assert (poller_curl_remove (&pc, task.curl.easy, NULL));
- result = true;
-
-error:
- curl_easy_cleanup (task.curl.easy);
- curl_slist_free_all (ok_headers);
- str_free (&task.data);
- poller_curl_free (&pc);
-
- poller_free (&poller);
- return result;
+ task->curl.on_done = streams_tab_on_downloaded;
+ hard_assert (poller_curl_add (&g.poller_curl, task->curl.easy, NULL));
+ return true;
}
static bool
@@ -3472,6 +4026,9 @@ streams_tab_on_action (enum action action)
case ACTION_MPD_ADD:
streams_tab_process (uri, false, &e);
break;
+ case ACTION_DESCRIBE:
+ app_show_message (xstrdup (uri), NULL);
+ break;
default:
return false;
}
@@ -3483,12 +4040,12 @@ streams_tab_on_action (enum action action)
return true;
}
-static void
-streams_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+streams_tab_on_item_layout (size_t item_index)
{
- (void) width;
- row_buffer_append (buffer, g.streams.vector[item_index], 0);
+ struct layout l = {};
+ app_push_fill (&l, g.ui->label (0, g.streams.vector[item_index]));
+ return l;
}
static struct tab *
@@ -3497,80 +4054,468 @@ streams_tab_init (void)
static struct tab super;
tab_init (&super, "Streams");
super.on_action = streams_tab_on_action;
- super.on_item_draw = streams_tab_on_item_draw;
+ super.on_item_layout = streams_tab_on_item_layout;
super.item_count = g.streams.len;
return &super;
}
// --- Info tab ----------------------------------------------------------------
+struct info_tab_plugin
+{
+ LIST_HEADER (struct info_tab_plugin)
+
+ char *path; ///< Filesystem path to plugin
+ char *description; ///< What the plugin does
+};
+
+static struct info_tab_plugin *
+info_tab_plugin_load (const char *path)
+{
+ // Shell quoting is less annoying than process management.
+ struct str escaped = str_make ();
+ shell_quote (path, &escaped);
+ FILE *fp = popen (escaped.str, "r");
+ str_free (&escaped);
+ if (!fp)
+ {
+ print_error ("%s: %s", path, strerror (errno));
+ return NULL;
+ }
+
+ struct str description = str_make ();
+ char buf[BUFSIZ];
+ size_t len;
+ while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf)
+ str_append_data (&description, buf, len);
+ str_append_data (&description, buf, len);
+ if (pclose (fp))
+ {
+ str_free (&description);
+ print_error ("%s: %s", path, strerror (errno));
+ return NULL;
+ }
+
+ char *newline = strpbrk (description.str, "\r\n");
+ if (newline)
+ {
+ description.len = newline - description.str;
+ *newline = '\0';
+ }
+ str_enforce_utf8 (&description);
+ if (!description.len)
+ {
+ str_free (&description);
+ print_error ("%s: %s", path, "missing description");
+ return NULL;
+ }
+
+ struct info_tab_plugin *plugin = xcalloc (1, sizeof *plugin);
+ plugin->path = xstrdup (path);
+ plugin->description = str_steal (&description);
+ return plugin;
+}
+
+static void
+info_tab_plugin_load_dir (struct str_map *basename_to_path, const char *dirname)
+{
+ DIR *dir = opendir (dirname);
+ if (!dir)
+ {
+ print_debug ("opendir: %s: %s", dirname, strerror (errno));
+ return;
+ }
+
+ struct dirent *entry = NULL;
+ while ((entry = readdir (dir)))
+ {
+ struct stat st = {};
+ char *path = xstrdup_printf ("%s/%s", dirname, entry->d_name);
+ if (stat (path, &st) || !S_ISREG (st.st_mode))
+ {
+ free (path);
+ continue;
+ }
+
+ // Empty files silently erase formerly found basenames.
+ if (!st.st_size)
+ cstr_set (&path, NULL);
+
+ str_map_set (basename_to_path, entry->d_name, path);
+ }
+ closedir (dir);
+}
+
+static int
+strv_sort_cb (const void *a, const void *b)
+{
+ return strcmp (*(const char **) a, *(const char **) b);
+}
+
+static struct info_tab_plugin *
+info_tab_plugin_load_all (void)
+{
+ struct str_map basename_to_path = str_map_make (free);
+ struct strv paths = strv_make ();
+ get_xdg_data_dirs (&paths);
+ strv_append (&paths, PROJECT_DATADIR);
+ for (size_t i = paths.len; i--; )
+ {
+ char *dirname =
+ xstrdup_printf ("%s/" PROGRAM_NAME "/info", paths.vector[i]);
+ info_tab_plugin_load_dir (&basename_to_path, dirname);
+ free (dirname);
+ }
+ strv_free (&paths);
+
+ struct strv sorted = strv_make ();
+ struct str_map_iter iter = str_map_iter_make (&basename_to_path);
+ while (str_map_iter_next (&iter))
+ strv_append (&sorted, iter.link->key);
+ qsort (sorted.vector, sorted.len, sizeof *sorted.vector, strv_sort_cb);
+
+ struct info_tab_plugin *result = NULL;
+ for (size_t i = sorted.len; i--; )
+ {
+ const char *path = str_map_find (&basename_to_path, sorted.vector[i]);
+ struct info_tab_plugin *plugin = info_tab_plugin_load (path);
+ if (plugin)
+ LIST_PREPEND (result, plugin);
+ }
+ str_map_free (&basename_to_path);
+ strv_free (&sorted);
+ return result;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+struct info_tab_item
+{
+ char *prefix; ///< Fixed-width prefix column or NULL
+ char *text; ///< Text or NULL
+ bool formatted; ///< Interpret inline formatting marks?
+ struct info_tab_plugin *plugin; ///< Activatable plugin
+};
+
+static void
+info_tab_item_free (struct info_tab_item *self)
+{
+ cstr_set (&self->prefix, NULL);
+ cstr_set (&self->text, NULL);
+}
+
static struct
{
struct tab super; ///< Parent class
- struct strv keys; ///< Data keys
- struct strv values; ///< Data values
+ struct info_tab_item *items; ///< Items array
+ size_t items_alloc; ///< How many items are allocated
+
+ struct info_tab_plugin *plugins; ///< Plugins
+
+ int plugin_songid; ///< Song ID or -1
+ pid_t plugin_pid; ///< Running plugin's process ID or -1
+ int plugin_stdout; ///< pid != -1: read end of stdout
+ struct poller_fd plugin_event; ///< pid != -1: stdout is readable
+ struct str plugin_output; ///< pid != -1: buffer, otherwise result
}
g_info_tab;
+static chtype
+info_tab_format_decode_toggle (char c)
+{
+ switch (c)
+ {
+ case '\x01':
+ return A_BOLD;
+ case '\x02':
+#ifdef A_ITALIC
+ return A_ITALIC;
+#else
+ return A_UNDERLINE;
+#endif
+ default:
+ return 0;
+ }
+}
+
static void
-info_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+info_tab_format (struct layout *l, const char *text)
{
- (void) width;
+ chtype attrs = 0;
+ for (const char *p = text; *p; p++)
+ {
+ chtype toggled = info_tab_format_decode_toggle (*p);
+ if (!toggled)
+ continue;
+
+ if (p != text)
+ {
+ char *slice = xstrndup (text, p - text);
+ app_push (l, g.ui->label (attrs, slice));
+ free (slice);
+ }
+
+ attrs ^= toggled;
+ text = p + 1;
+ }
+ if (*text)
+ app_push (l, g.ui->label (attrs, text));
+}
+
+static struct layout
+info_tab_on_item_layout (size_t item_index)
+{
+ struct info_tab_item *item = &g_info_tab.items[item_index];
+ struct layout l = {};
+ if (item->prefix)
+ {
+ char *prefix = xstrdup_printf ("%s:", item->prefix);
+ app_push (&l, g.ui->label (A_BOLD, prefix))
+ ->width = 8 * g_xui.hunit;
+ app_push (&l, g.ui->padding (0, 0.5, 1));
+ }
- // It looks like we could do with a generic list structure that just
- // stores formatted row_buffers. Let's see for other tabs:
- // - Current -- unusable, has dynamic column alignment
- // - Library -- could work for the "icons"
- // - Streams -- useless
- // - Debug -- it'd take up considerably more space
- // However so far we're only showing show key-value pairs.
+ if (item->plugin)
+ app_push (&l, g.ui->label (A_BOLD, item->plugin->description));
+ else if (!item->text || !*item->text)
+ app_push (&l, g.ui->padding (0, 1, 1));
+ else if (item->formatted)
+ info_tab_format (&l, item->text);
+ else
+ app_push (&l, g.ui->label (0, item->text));
- row_buffer_append_args (buffer,
- g_info_tab.keys.vector[item_index], A_BOLD, ":", A_BOLD, NULL);
- row_buffer_space (buffer, 8 - buffer->total_width, 0);
- row_buffer_append (buffer, g_info_tab.values.vector[item_index], 0);
+ if (l.tail)
+ l.tail->width = -1;
+ return l;
+}
+
+static struct info_tab_item *
+info_tab_prepare (void)
+{
+ if (g_info_tab.super.item_count == g_info_tab.items_alloc)
+ g_info_tab.items = xreallocarray (g_info_tab.items,
+ sizeof *g_info_tab.items, (g_info_tab.items_alloc <<= 1));
+
+ struct info_tab_item *item =
+ &g_info_tab.items[g_info_tab.super.item_count++];
+ memset (item, 0, sizeof *item);
+ return item;
}
static void
info_tab_add (compact_map_t data, const char *field)
{
- const char *value = compact_map_find (data, field);
- if (!value) value = "";
-
- strv_append (&g_info_tab.keys, field);
- strv_append (&g_info_tab.values, value);
- g_info_tab.super.item_count++;
+ struct info_tab_item *item = info_tab_prepare ();
+ item->prefix = xstrdup (field);
+ item->text = xstrdup0 (compact_map_find (data, field));
}
static void
info_tab_update (void)
{
- strv_reset (&g_info_tab.keys);
- strv_reset (&g_info_tab.values);
- g_info_tab.super.item_count = 0;
+ while (g_info_tab.super.item_count)
+ info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]);
- compact_map_t map;
- if ((map = item_list_get (&g.playlist, g.song)))
+ compact_map_t map = item_list_get (&g.playlist, g.song);
+ if (!map)
+ return;
+
+ info_tab_add (map, "Title");
+ info_tab_add (map, "Artist");
+ info_tab_add (map, "Album");
+ info_tab_add (map, "Track");
+ info_tab_add (map, "Genre");
+ // We actually receive it as "file", but the key is also used for display
+ info_tab_add (map, "File");
+
+ if (g_info_tab.plugins)
+ {
+ (void) info_tab_prepare ();
+ LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins)
+ info_tab_prepare ()->plugin = plugin;
+ }
+
+ if (g_info_tab.plugin_pid != -1)
+ {
+ (void) info_tab_prepare ();
+ info_tab_prepare ()->text = xstrdup ("Processing...");
+ return;
+ }
+
+ const char *songid = compact_map_find (map, "Id");
+ if (songid && atoi (songid) == g_info_tab.plugin_songid
+ && g_info_tab.plugin_output.len)
+ {
+ struct strv lines = strv_make ();
+ cstr_split (g_info_tab.plugin_output.str, "\r\n", false, &lines);
+
+ (void) info_tab_prepare ();
+ for (size_t i = 0; i < lines.len; i++)
+ {
+ struct info_tab_item *item = info_tab_prepare ();
+ item->formatted = true;
+ item->text = lines.vector[i];
+ }
+ free (lines.vector);
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+info_tab_plugin_abort (void)
+{
+ if (g_info_tab.plugin_pid == -1)
+ return;
+
+ // XXX: our methods of killing are very crude, we hope to improve;
+ // at least install a SIGCHLD handler to collect zombies
+ (void) kill (-g_info_tab.plugin_pid, SIGTERM);
+
+ int status = 0;
+ while (waitpid (g_info_tab.plugin_pid, &status, WNOHANG) == -1
+ && errno == EINTR)
+ ;
+ if (WIFEXITED (status) && WEXITSTATUS (status) != EXIT_SUCCESS)
+ print_error ("plugin reported failure");
+
+ g_info_tab.plugin_pid = -1;
+ poller_fd_reset (&g_info_tab.plugin_event);
+ xclose (g_info_tab.plugin_stdout);
+ g_info_tab.plugin_stdout = -1;
+}
+
+static void
+info_tab_on_plugin_stdout (const struct pollfd *fd, void *user_data)
+{
+ (void) user_data;
+
+ struct str *buf = &g_info_tab.plugin_output;
+ switch (socket_io_try_read (fd->fd, buf))
{
- info_tab_add (map, "Title");
- info_tab_add (map, "Artist");
- info_tab_add (map, "Album");
- info_tab_add (map, "Track");
- info_tab_add (map, "Genre");
- // Yes, it is "file", but this is also for display
- info_tab_add (map, "File");
+ case SOCKET_IO_OK:
+ str_enforce_utf8 (buf);
+ return;
+ case SOCKET_IO_ERROR:
+ print_error ("error reading from plugin: %s", strerror (errno));
+ // Fall-through
+ case SOCKET_IO_EOF:
+ info_tab_plugin_abort ();
+ info_tab_update ();
+ xui_invalidate ();
+ }
+}
+
+static void
+info_tab_plugin_run (struct info_tab_plugin *plugin, compact_map_t map)
+{
+ info_tab_plugin_abort ();
+ if (!map)
+ return;
+
+ const char *songid = compact_map_find (map, "Id");
+ const char *title = compact_map_find (map, "Title");
+ const char *artist = compact_map_find (map, "Artist");
+ const char *album = compact_map_find (map, "Album");
+ if (!songid || !title || !artist)
+ {
+ print_error ("unknown song title or artist");
+ return;
+ }
+
+ int stdout_pipe[2];
+ if (pipe (stdout_pipe))
+ {
+ print_error ("%s: %s", "pipe", strerror (errno));
+ return;
+ }
+
+ enum { READ, WRITE };
+ set_cloexec (stdout_pipe[READ]);
+ set_cloexec (stdout_pipe[WRITE]);
+
+ const char *argv[] =
+ { xbasename (plugin->path), title, artist, album, NULL };
+
+ pid_t child = fork ();
+ switch (child)
+ {
+ case -1:
+ print_error ("%s: %s", "fork", strerror (errno));
+ xclose (stdout_pipe[READ]);
+ xclose (stdout_pipe[WRITE]);
+ return;
+ case 0:
+ if (setpgid (0, 0) == -1 || !freopen ("/dev/null", "r", stdin)
+ || dup2 (stdout_pipe[WRITE], STDOUT_FILENO) == -1
+ || dup2 (stdout_pipe[WRITE], STDERR_FILENO) == -1)
+ _exit (EXIT_FAILURE);
+
+ signal (SIGPIPE, SIG_DFL);
+
+ (void) execv (plugin->path, (char **) argv);
+ fprintf (stderr, "%s\n", strerror (errno));
+ _exit (EXIT_FAILURE);
+ default:
+ // Resolve the race, even though it isn't critical for us
+ (void) setpgid (child, child);
+
+ g_info_tab.plugin_songid = atoi (songid);
+ g_info_tab.plugin_pid = child;
+ set_blocking ((g_info_tab.plugin_stdout = stdout_pipe[READ]), false);
+ xclose (stdout_pipe[WRITE]);
+
+ struct poller_fd *event = &g_info_tab.plugin_event;
+ *event = poller_fd_make (&g.poller, g_info_tab.plugin_stdout);
+ event->dispatcher = info_tab_on_plugin_stdout;
+ str_reset (&g_info_tab.plugin_output);
+ poller_fd_set (&g_info_tab.plugin_event, POLLIN);
+ }
+}
+
+static bool
+info_tab_on_action (enum action action)
+{
+ struct tab *tab = g.active_tab;
+ if (tab->item_selected < 0
+ || tab->item_selected >= (int) tab->item_count)
+ return false;
+
+ struct info_tab_item *item = &g_info_tab.items[tab->item_selected];
+ if (!item->plugin)
+ return false;
+
+ switch (action)
+ {
+ case ACTION_DESCRIBE:
+ app_show_message (xstrdup ("Path: "), xstrdup (item->plugin->path));
+ return true;
+ case ACTION_CHOOSE:
+ info_tab_plugin_run (item->plugin, item_list_get (&g.playlist, g.song));
+ info_tab_update ();
+ xui_invalidate ();
+ return true;
+ default:
+ return false;
}
}
static struct tab *
info_tab_init (void)
{
- g_info_tab.keys = strv_make ();
- g_info_tab.values = strv_make ();
+ g_info_tab.items =
+ xcalloc ((g_info_tab.items_alloc = 16), sizeof *g_info_tab.items);
+
+ g_info_tab.plugins = info_tab_plugin_load_all ();
+ g_info_tab.plugin_songid = -1;
+ g_info_tab.plugin_pid = -1;
+ g_info_tab.plugin_stdout = -1;
+ g_info_tab.plugin_output = str_make ();
struct tab *super = &g_info_tab.super;
tab_init (super, "Info");
- super->on_item_draw = info_tab_on_item_draw;
+ super->on_action = info_tab_on_action;
+ super->on_item_layout = info_tab_on_item_layout;
return super;
}
@@ -3589,29 +4534,25 @@ help_tab_on_action (enum action action)
{
struct tab *tab = &g_help_tab.super;
if (tab->item_selected < 0
- || tab->item_selected >= (int) g_help_tab.actions_len
- || action != ACTION_CHOOSE)
+ || tab->item_selected >= (int) g_help_tab.actions_len)
return false;
- action = g_help_tab.actions[tab->item_selected];
- return action != ACTION_NONE
- && action != ACTION_CHOOSE // avoid recursion
- && app_process_action (action);
-}
+ enum action a = g_help_tab.actions[tab->item_selected];
+ if (!a)
+ return false;
-static void
-help_tab_strfkey (const termo_key_t *key, struct strv *out)
-{
- // For display purposes, this is highly desirable
- int flags = termo_get_flags (g.tk);
- termo_set_flags (g.tk, flags | TERMO_FLAG_SPACESYMBOL);
- termo_key_t fixed = *key;
- termo_canonicalise (g.tk, &fixed);
- termo_set_flags (g.tk, flags);
+ if (action == ACTION_DESCRIBE)
+ {
+ app_show_message (xstrdup ("Configuration name: "),
+ xstrdup (g_action_names[a]));
+ return true;
+ }
+ if (action != ACTION_CHOOSE || a == ACTION_CHOOSE /* avoid recursion */)
+ return false;
- char buf[16];
- termo_strfkey_utf8 (g.tk, buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA);
- strv_append (out, buf);
+ // XXX: We can't propagate failure to ring the terminal/X11 bell, but we
+ // don't want to let our caller show a bad "can't do that" message either.
+ return app_process_action (a), true;
}
static void
@@ -3635,12 +4576,12 @@ help_tab_group (struct binding *keys, size_t len, struct strv *out,
struct strv ass = strv_make ();
for (size_t k = 0; k < len; k++)
if (keys[k].action == i)
- help_tab_strfkey (&keys[k].decoded, &ass);
+ strv_append_owned (&ass, app_strfkey (&keys[k].decoded));
if (ass.len)
{
char *joined = strv_join (&ass, ", ");
strv_append_owned (out, xstrdup_printf
- (" %-30s %s", g_actions[i].description, joined));
+ (" %s%c%s", g_action_descriptions[i], 0, joined));
free (joined);
bound[i] = true;
@@ -3657,19 +4598,27 @@ help_tab_unbound (struct strv *out, bool bound[ACTION_COUNT])
if (!bound[i])
{
strv_append_owned (out,
- xstrdup_printf (" %-30s", g_actions[i].description));
+ xstrdup_printf (" %s%c", g_action_descriptions[i], 0));
help_tab_assign_action (i);
}
}
-static void
-help_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+static struct layout
+help_tab_on_item_layout (size_t item_index)
{
- (void) width;
-
hard_assert (item_index < g_help_tab.lines.len);
const char *line = g_help_tab.lines.vector[item_index];
- row_buffer_append (buffer, line, *line == ' ' ? 0 : A_BOLD);
+
+ struct layout l = {};
+ app_push_fill (&l, g.ui->label (*line == ' ' ? 0 : A_BOLD, line));
+
+ const char *definition = strchr (line, 0) + 1;
+ if (*line == ' ' && *definition)
+ {
+ app_push (&l, g.ui->padding (0, 0.5, 1));
+ app_push_fill (&l, g.ui->label (0, definition));
+ }
+ return l;
}
static struct tab *
@@ -3704,7 +4653,7 @@ help_tab_init (void)
struct tab *super = &g_help_tab.super;
tab_init (super, "Help");
super->on_action = help_tab_on_action;
- super->on_item_draw = help_tab_on_item_draw;
+ super->on_item_layout = help_tab_on_item_layout;
super->item_count = lines->len;
return super;
}
@@ -3726,8 +4675,8 @@ static struct
}
g_debug_tab;
-static void
-debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+static struct layout
+debug_tab_on_item_layout (size_t item_index)
{
hard_assert (item_index < g_debug_tab.items_len);
struct debug_item *item = &g_debug_tab.items[item_index];
@@ -3739,14 +4688,13 @@ debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
char *prefix = xstrdup_printf
("%s.%03d", buf, (int) (item->timestamp % 1000));
- row_buffer_append (buffer, prefix, 0);
- free (prefix);
-
- row_buffer_append (buffer, " ", item->attrs);
- row_buffer_append (buffer, item->text, item->attrs);
- // We override the formatting including colors -- do it for the whole line
- row_buffer_align (buffer, width, item->attrs);
+ struct layout l = {};
+ app_push (&l, g.ui->label (0, prefix));
+ app_push (&l, g.ui->padding (item->attrs, 0.5, 1));
+ app_push_fill (&l, g.ui->label (item->attrs, item->text));
+ free (prefix);
+ return l;
}
static void
@@ -3759,7 +4707,7 @@ debug_tab_push (char *message, chtype attrs)
item->attrs = attrs;
item->timestamp = clock_msec (CLOCK_REALTIME);
- app_invalidate ();
+ xui_invalidate ();
}
static struct tab *
@@ -3770,7 +4718,7 @@ debug_tab_init (void)
struct tab *super = &g_debug_tab.super;
tab_init (super, "Debug");
- super->on_item_draw = debug_tab_on_item_draw;
+ super->on_item_layout = debug_tab_on_item_layout;
return super;
}
@@ -3783,15 +4731,11 @@ spectrum_redraw (void)
{
// A full refresh would be too computationally expensive,
// let's hack around it in this case
- if (g.spectrum_row != -1)
- {
- attrset (APP_ATTR (TAB_BAR));
- mvaddstr (g.spectrum_row, g.spectrum_column, g.spectrum.spectrum);
- attrset (0);
- refresh ();
- }
- else
- app_invalidate ();
+ struct widget *spectrum = app_widget_by_id (WIDGET_SPECTRUM);
+ if (spectrum)
+ spectrum->on_render (spectrum);
+
+ poller_idle_set (&g_xui.flip_event);
}
// When any problem occurs with the FIFO, we'll just give up on it completely
@@ -3805,8 +4749,7 @@ spectrum_discard_fifo (void)
g.spectrum_fd = -1;
spectrum_free (&g.spectrum);
- g.spectrum_row = g.spectrum_column = -1;
- app_invalidate ();
+ xui_invalidate ();
}
}
@@ -3867,6 +4810,8 @@ spectrum_setup_fifo (void)
get_config_string (g.config.root, "settings.spectrum_format");
struct config_item *spectrum_bars =
config_item_get (g.config.root, "settings.spectrum_bars", NULL);
+ struct config_item *spectrum_fps =
+ config_item_get (g.config.root, "settings.spectrum_fps", NULL);
if (!spectrum_path)
return;
@@ -3876,10 +4821,10 @@ spectrum_setup_fifo (void)
if (!path)
print_error ("spectrum: %s", "FIFO path could not be resolved");
- else if (!g.locale_is_utf8)
+ else if (!g_xui.locale_is_utf8)
print_error ("spectrum: %s", "UTF-8 locale required");
- else if (!spectrum_init (&g.spectrum,
- (char *) spectrum_format, spectrum_bars->value.integer, &e))
+ else if (!spectrum_init (&g.spectrum, (char *) spectrum_format,
+ spectrum_bars->value.integer, spectrum_fps->value.integer, &e))
{
print_error ("spectrum: %s", e->message);
error_free (e);
@@ -3900,11 +4845,86 @@ spectrum_setup_fifo (void)
}
#else // ! WITH_FFTW
-#define spectrum_setup_fifo()
-#define spectrum_clear()
-#define spectrum_discard_fifo()
+#define spectrum_setup_fifo() BLOCK_START BLOCK_END
+#define spectrum_clear() BLOCK_START BLOCK_END
+#define spectrum_discard_fifo() BLOCK_START BLOCK_END
#endif // ! WITH_FFTW
+// --- PulseAudio --------------------------------------------------------------
+
+#ifdef WITH_PULSE
+
+static bool
+mpd_find_output (const struct strv *data, const char *wanted)
+{
+ // The plugin field is new in MPD 0.21, by default take any output
+ unsigned long n, accept = 1;
+ for (size_t i = data->len; i--; )
+ {
+ char *key, *value;
+ if (!(key = mpd_parse_kv (data->vector[i], &value)))
+ continue;
+
+ if (!strcasecmp_ascii (key, "outputid"))
+ {
+ if (accept)
+ return true;
+
+ accept = 1;
+ }
+ else if (!strcasecmp_ascii (key, "plugin"))
+ accept &= !strcmp (value, wanted);
+ else if (!strcasecmp_ascii (key, "outputenabled")
+ && xstrtoul (&n, value, 10))
+ accept &= n == 1;
+ }
+ return false;
+}
+
+static void
+mpd_on_outputs_response (const struct mpd_response *response,
+ const struct strv *data, void *user_data)
+{
+ (void) user_data;
+
+ // TODO: check whether an action is actually necessary
+ pulse_free (&g.pulse);
+ if (response->success && !mpd_find_output (data, "pulse"))
+ print_debug ("MPD has no PulseAudio output to control");
+ else
+ {
+ pulse_init (&g.pulse, &g.poller);
+ g.pulse.on_update = xui_invalidate;
+ }
+
+ xui_invalidate ();
+}
+
+static void
+pulse_update (void)
+{
+ struct mpd_client *c = &g.client;
+ if (!g.pulse_control_requested)
+ return;
+
+ // The read permission is sufficient for this command
+ mpd_client_send_command (c, "outputs", NULL);
+ mpd_client_add_task (c, mpd_on_outputs_response, NULL);
+ mpd_client_idle (c, 0);
+}
+
+static void
+pulse_disable (void)
+{
+ pulse_free (&g.pulse);
+ xui_invalidate ();
+}
+
+#else // ! WITH_PULSE
+#define pulse_update() BLOCK_START BLOCK_END
+#define pulse_disable() BLOCK_START BLOCK_END
+#endif // ! WITH_PULSE
+
// --- MPD interface -----------------------------------------------------------
static void
@@ -4006,7 +5026,7 @@ mpd_update_playback_state (void)
if (g.playlist_version != last_playlist_version)
mpd_update_playlist_time ();
- app_invalidate ();
+ xui_invalidate ();
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -4062,7 +5082,7 @@ mpd_find_pos_of_id (const char *desired_id)
return -1;
}
-static char *
+static const char *
mpd_id_of_pos (int pos)
{
compact_map_t map = item_list_get (&g.playlist, pos);
@@ -4072,29 +5092,39 @@ mpd_id_of_pos (int pos)
static void
mpd_process_info (const struct strv *data)
{
- int *selected = &g_current_tab.item_selected;
- int *marked = &g_current_tab.item_mark;
- char *prev_sel_id = mpd_id_of_pos (*selected);
- char *prev_mark_id = mpd_id_of_pos (*marked);
- if (prev_sel_id) prev_sel_id = xstrdup (prev_sel_id);
- if (prev_mark_id) prev_mark_id = xstrdup (prev_mark_id);
+ struct tab *tab = &g_current_tab;
+ char *prev_sel_id = xstrdup0 (mpd_id_of_pos (tab->item_selected));
+ char *prev_mark_id = xstrdup0 (mpd_id_of_pos (tab->item_mark));
+ char *fallback_id = NULL;
+
+ struct tab_range r = tab_selection_range (g.active_tab);
+ if (r.upto >= 0)
+ {
+ if (!(fallback_id = xstrdup0 (mpd_id_of_pos (r.upto + 1))))
+ fallback_id = xstrdup0 (mpd_id_of_pos (r.from - 1));
+ }
mpd_process_info_data (data);
- const char *sel_id = mpd_id_of_pos (*selected);
- const char *mark_id = mpd_id_of_pos (*marked);
+ const char *sel_id = mpd_id_of_pos (tab->item_selected);
+ const char *mark_id = mpd_id_of_pos (tab->item_mark);
if (prev_mark_id && (!mark_id || strcmp (prev_mark_id, mark_id)))
- *marked = mpd_find_pos_of_id (prev_mark_id);
+ tab->item_mark = mpd_find_pos_of_id (prev_mark_id);
if (prev_sel_id && (!sel_id || strcmp (prev_sel_id, sel_id)))
{
- if ((*selected = mpd_find_pos_of_id (prev_sel_id)) < 0)
- *marked = -1;
+ if ((tab->item_selected = mpd_find_pos_of_id (prev_sel_id)) < 0)
+ {
+ tab->item_mark = -1;
+ if (fallback_id)
+ tab->item_selected = mpd_find_pos_of_id (fallback_id);
+ }
app_move_selection (0);
}
free (prev_sel_id);
free (prev_mark_id);
+ free (fallback_id);
}
static void
@@ -4134,7 +5164,7 @@ mpd_on_elapsed_time_tick (void *user_data)
// Try to get called on the next round second of playback
poller_timer_set (&g.elapsed_event, 1000 - elapsed_msec);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -4169,6 +5199,8 @@ mpd_on_events (unsigned subsystems, void *user_data)
if (subsystems & MPD_SUBSYSTEM_DATABASE)
library_tab_reload (NULL);
+ if (subsystems & MPD_SUBSYSTEM_OUTPUT)
+ pulse_update ();
if (subsystems & (MPD_SUBSYSTEM_PLAYER | MPD_SUBSYSTEM_OPTIONS
| MPD_SUBSYSTEM_PLAYLIST | MPD_SUBSYSTEM_MIXER | MPD_SUBSYSTEM_UPDATE))
@@ -4185,6 +5217,58 @@ mpd_queue_reconnect (void)
poller_timer_set (&g.connect_event, 5 * 1000);
}
+// On an error, MPD discards the rest of our enqueuing commands--work it around
+static void mpd_enqueue_step (size_t start_offset);
+
+static void
+mpd_on_enqueue_response (const struct mpd_response *response,
+ const struct strv *data, void *user_data)
+{
+ (void) data;
+ intptr_t start_offset = (intptr_t) user_data;
+
+ if (response->success)
+ strv_reset (&g.enqueue);
+ else
+ {
+ // Their addition may also overflow, but YOLO
+ hard_assert (start_offset >= 0 && response->list_offset >= 0);
+
+ print_error ("%s: %s", response->message_text,
+ g.enqueue.vector[start_offset + response->list_offset]);
+ mpd_enqueue_step (start_offset + response->list_offset + 1);
+ }
+}
+
+static void
+mpd_enqueue_step (size_t start_offset)
+{
+ struct mpd_client *c = &g.client;
+ if (start_offset >= g.enqueue.len)
+ {
+ strv_reset (&g.enqueue);
+ return;
+ }
+
+ // TODO: might want to consider using addid and autoplaying
+ mpd_client_list_begin (c);
+ for (size_t i = start_offset; i < g.enqueue.len; i++)
+ mpd_client_send_command (c, "add", g.enqueue.vector[i], NULL);
+ mpd_client_list_end (c);
+ mpd_client_add_task (c, mpd_on_enqueue_response, (void *) start_offset);
+ mpd_client_idle (c, 0);
+}
+
+static void
+mpd_on_ready (void)
+{
+ mpd_request_info ();
+ library_tab_reload (NULL);
+ spectrum_setup_fifo ();
+ pulse_update ();
+ mpd_enqueue_step (0);
+}
+
static void
mpd_on_password_response (const struct mpd_response *response,
const struct strv *data, void *user_data)
@@ -4194,10 +5278,7 @@ mpd_on_password_response (const struct mpd_response *response,
struct mpd_client *c = &g.client;
if (response->success)
- {
- mpd_request_info ();
- library_tab_reload (NULL);
- }
+ mpd_on_ready ();
else
{
print_error ("%s: %s",
@@ -4220,12 +5301,7 @@ mpd_on_connected (void *user_data)
mpd_client_add_task (c, mpd_on_password_response, NULL);
}
else
- {
- mpd_request_info ();
- library_tab_reload (NULL);
- }
-
- spectrum_setup_fifo ();
+ mpd_on_ready ();
}
static void
@@ -4244,6 +5320,7 @@ mpd_on_failure (void *user_data)
info_tab_update ();
spectrum_discard_fifo ();
+ pulse_disable ();
}
static void
@@ -4297,8 +5374,544 @@ app_on_reconnect (void *user_data)
mpd_queue_reconnect ();
}
free (address);
+ xui_invalidate ();
+}
+
+// --- TUI ---------------------------------------------------------------------
+
+static struct widget *
+tui_make_button (chtype attrs, const char *label, enum action a)
+{
+ struct widget *w = tui_make_label (attrs, 0, label);
+ w->id = WIDGET_BUTTON;
+ w->userdata = a;
+ return w;
+}
+
+static void
+tui_render_gauge (struct widget *self)
+{
+ struct row_buffer buf = row_buffer_make ();
+ if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
+ goto out;
+
+ float ratio = (float) g.song_elapsed / g.song_duration;
+ if (ratio < 0) ratio = 0;
+ if (ratio > 1) ratio = 1;
+
+ // Always compute it in exactly eight times the resolution,
+ // because sometimes Unicode is even useful
+ int len_left = ratio * self->width * 8 + 0.5;
+
+ static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" };
+ int remainder = len_left % 8;
+ len_left /= 8;
+
+ const char *partial = NULL;
+ if (g.use_partial_boxes)
+ partial = partials[remainder];
+ else
+ len_left += remainder >= (int) 4;
+
+ int len_right = self->width - len_left;
+ row_buffer_space (&buf, len_left, APP_ATTR (ELAPSED));
+ if (partial && len_right-- > 0)
+ row_buffer_append (&buf, partial, APP_ATTR (REMAINS));
+ row_buffer_space (&buf, len_right, APP_ATTR (REMAINS));
+
+out:
+ tui_flush_buffer (self, &buf);
+}
+
+// TODO: Perhaps it should save the number within.
+static struct widget *
+tui_make_gauge (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_gauge;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = 1;
+ return w;
+}
+
+static void
+tui_render_spectrum (struct widget *self)
+{
+ // Don't mess up the line editor caret, when it's shown
+ int last_x, last_y;
+ getyx (stdscr, last_y, last_x);
+
+ struct row_buffer buf = row_buffer_make ();
+#ifdef WITH_FFTW
+ row_buffer_append (&buf, g.spectrum.rendered, self->attrs);
+#endif // WITH_FFTW
+ tui_flush_buffer (self, &buf);
+
+ move (last_y, last_x);
+}
+
+static struct widget *
+tui_make_spectrum (chtype attrs, int width)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_spectrum;
+ w->attrs = attrs;
+ w->width = width;
+ w->height = 1;
+ return w;
+}
+
+static void
+tui_render_scrollbar (struct widget *self)
+{
+ // This assumes that we can write to the one-before-last column,
+ // i.e. that it's not covered by any double-wide character (and that
+ // ncurses comes to the right results when counting characters).
+ struct tab *tab = g.active_tab;
+ int visible_items = app_visible_items ();
+
+ hard_assert (tab->item_count != 0);
+ if (!g.use_partial_boxes)
+ {
+ struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1);
+ for (int row = 0; row < visible_items; row++)
+ {
+ move (self->y + row, self->x);
+ if (row < bar.start || row >= bar.start + bar.length)
+ addch (' ' | self->attrs);
+ else
+ addch (' ' | self->attrs | A_REVERSE);
+ }
+ return;
+ }
+
+ struct scrollbar bar = app_compute_scrollbar (tab, visible_items * 8, 8);
+ bar.length += bar.start;
+
+ int start_part = bar.start % 8; bar.start /= 8;
+ int end_part = bar.length % 8; bar.length /= 8;
+
+ // Even with this, the solid part must be at least one character high
+ static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" };
+
+ for (int row = 0; row < visible_items; row++)
+ {
+ chtype attrs = self->attrs;
+ if (row > bar.start && row <= bar.length)
+ attrs ^= A_REVERSE;
+
+ const char *c = " ";
+ if (row == bar.start) c = partials[start_part];
+ if (row == bar.length) c = partials[end_part];
+
+ move (self->y + row, self->x);
+
+ struct row_buffer buf = row_buffer_make ();
+ row_buffer_append (&buf, c, attrs);
+ row_buffer_flush (&buf);
+ row_buffer_free (&buf);
+ }
+}
+
+static struct widget *
+tui_make_scrollbar (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_scrollbar;
+ w->attrs = attrs;
+ w->width = 1;
+ return w;
+}
+
+static struct widget *
+tui_make_list (void)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->width = -1;
+ w->height = g.active_tab->item_count;
+ return w;
+}
+
+static void
+tui_render_editor (struct widget *self)
+{
+ struct row_buffer buf = row_buffer_make ();
+ const struct line_editor *e = &g.editor;
+ int width = self->width;
+ if (e->prompt)
+ {
+ hard_assert (e->prompt < 127);
+ row_buffer_append_c (&buf, e->prompt, self->attrs);
+ width--;
+ }
+
+ int following = 0;
+ for (size_t i = e->point; i < e->len; i++)
+ following += e->w[i];
+
+ int preceding = 0;
+ size_t start = e->point;
+ while (start && preceding < width / 2)
+ preceding += e->w[--start];
+
+ // There can be one extra space at the end of the line but this way we
+ // don't need to care about non-spacing marks following full-width chars
+ while (start && width - preceding - following > 2 /* widest char */)
+ preceding += e->w[--start];
+
+ // XXX: we should also show < > indicators for overflow but it'd probably
+ // considerably complicate this algorithm
+ for (; start < e->len; start++)
+ row_buffer_append_c (&buf, e->line[start], self->attrs);
+ tui_flush_buffer (self, &buf);
+
+ // FIXME: This should be at the end of of tui_render().
+ int caret = !!e->prompt + preceding;
+ move (self->y, self->x + caret);
+ curs_set (1);
+}
+
+static struct widget *
+tui_make_editor (chtype attrs)
+{
+ // TODO: This should ideally measure the text, and copy it to w->text.
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_editor;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = 1;
+ return w;
+}
+
+static struct app_ui app_tui_ui =
+{
+ .padding = tui_make_padding,
+ .label = app_make_label,
+ .button = tui_make_button,
+ .gauge = tui_make_gauge,
+ .spectrum = tui_make_spectrum,
+ .scrollbar = tui_make_scrollbar,
+ .list = tui_make_list,
+ .editor = tui_make_editor,
+};
+
+// --- X11 ---------------------------------------------------------------------
+
+#ifdef WITH_X11
+
+// On a 20x20 raster to make it feasible to design on paper.
+#define X11_STOP {INFINITY, INFINITY}
+static const XPointDouble
+ x11_icon_previous[] =
+ {
+ {10, 0}, {0, 10}, {10, 20}, X11_STOP,
+ {20, 0}, {10, 10}, {20, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_pause[] =
+ {
+ {1, 0}, {7, 0}, {7, 20}, {1, 20}, X11_STOP,
+ {13, 0}, {19, 0}, {19, 20}, {13, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_play[] =
+ {
+ {0, 0}, {20, 10}, {0, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_stop[] =
+ {
+ {0, 0}, {20, 0}, {20, 20}, {0, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_next[] =
+ {
+ {0, 0}, {10, 10}, {0, 20}, X11_STOP,
+ {10, 0}, {20, 10}, {10, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_repeat[] =
+ {
+ {0, 12}, {0, 6}, {3, 3}, {13, 3}, {13, 0}, {20, 4.5},
+ {13, 9}, {13, 6}, {3, 6}, {3, 10}, X11_STOP,
+ {0, 15.5}, {7, 11}, {7, 14}, {17, 14}, {17, 10}, {20, 8},
+ {20, 14}, {17, 17}, {7, 17}, {7, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_random[] =
+ {
+ {0, 6}, {0, 3}, {5, 3}, {6, 4.5}, {4, 7.5}, {3, 6}, X11_STOP,
+ {9, 15.5}, {11, 12.5}, {12, 14}, {13, 14}, {13, 11}, {20, 15.5},
+ {13, 20}, {13, 17}, {10, 17}, X11_STOP,
+ {0, 17}, {0, 14}, {3, 14}, {10, 3}, {13, 3}, {13, 0}, {20, 4.5},
+ {13, 9}, {13, 6}, {12, 6}, {5, 17}, X11_STOP, X11_STOP,
+ },
+ x11_icon_single[] =
+ {
+ {7, 6}, {7, 4}, {9, 2}, {12, 2}, {12, 15}, {14, 15}, {14, 18},
+ {7, 18}, {7, 15}, {9, 15}, {9, 6}, X11_STOP, X11_STOP,
+ },
+ x11_icon_consume[] =
+ {
+ {0, 13}, {0, 7}, {4, 3}, {10, 3}, {14, 7}, {5, 10}, {14, 13},
+ {10, 17}, {4, 17}, X11_STOP,
+ {16, 12}, {16, 8}, {20, 8}, {20, 12}, X11_STOP, X11_STOP,
+ };
+
+static const XPointDouble *
+x11_icon_for_action (enum action action)
+{
+ switch (action)
+ {
+ case ACTION_MPD_PREVIOUS:
+ return x11_icon_previous;
+ case ACTION_MPD_TOGGLE:
+ return g.state == PLAYER_PLAYING ? x11_icon_pause : x11_icon_play;
+ case ACTION_MPD_STOP:
+ return x11_icon_stop;
+ case ACTION_MPD_NEXT:
+ return x11_icon_next;
+ case ACTION_MPD_REPEAT:
+ return x11_icon_repeat;
+ case ACTION_MPD_RANDOM:
+ return x11_icon_random;
+ case ACTION_MPD_SINGLE:
+ return x11_icon_single;
+ case ACTION_MPD_CONSUME:
+ return x11_icon_consume;
+ default:
+ return NULL;
+ }
+}
+
+static void
+x11_render_button (struct widget *self)
+{
+ x11_render_padding (self);
+
+ const XPointDouble *icon = x11_icon_for_action (self->userdata);
+ if (!icon)
+ {
+ x11_render_label (self);
+ return;
+ }
+
+ size_t total = 0;
+ for (size_t i = 0; icon[i].x != INFINITY || icon[i - 1].x != INFINITY; i++)
+ total++;
+
+ // TODO: There should be an attribute for buttons, to handle this better.
+ XRenderColor color = *x11_fg (self);
+ if (!(self->attrs & A_BOLD))
+ {
+ color.alpha /= 2;
+ color.red /= 2;
+ color.green /= 2;
+ color.blue /= 2;
+ }
+
+ Picture source = XRenderCreateSolidFill (g_xui.dpy, &color);
+ const XRenderPictFormat *format
+ = XRenderFindStandardFormat (g_xui.dpy, PictStandardA8);
+
+ int x = self->x, y = self->y + (self->height - self->width) / 2;
+ XPointDouble buffer[total], *p = buffer;
+ for (size_t i = 0; i < total; i++)
+ if (icon[i].x != INFINITY)
+ {
+ p->x = x + icon[i].x / 20.0 * self->width;
+ p->y = y + icon[i].y / 20.0 * self->width;
+ p++;
+ }
+ else if (p != buffer)
+ {
+ XRenderCompositeDoublePoly (g_xui.dpy, PictOpOver,
+ source, g_xui.x11_pixmap_picture, format,
+ 0, 0, 0, 0, buffer, p - buffer, EvenOddRule);
+ p = buffer;
+ }
+ XRenderFreePicture (g_xui.dpy, source);
+}
+
+static struct widget *
+x11_make_button (chtype attrs, const char *label, enum action a)
+{
+ struct widget *w = x11_make_label (attrs, 0, label);
+ w->id = WIDGET_BUTTON;
+ w->userdata = a;
+
+ if (x11_icon_for_action (a))
+ {
+ w->on_render = x11_render_button;
+
+ // It should be padded by the caller horizontally.
+ w->height = g_xui.vunit;
+ w->width = w->height * 3 / 4;
+ }
+ return w;
+}
+
+static void
+x11_render_gauge (struct widget *self)
+{
+ x11_render_padding (self);
+ if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
+ return;
+
+ int part = (float) g.song_elapsed / g.song_duration * self->width;
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_bg_attrs (APP_ATTR (ELAPSED)),
+ self->x,
+ self->y + self->height / 8,
+ part,
+ self->height * 3 / 4);
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_bg_attrs (APP_ATTR (REMAINS)),
+ self->x + part,
+ self->y + self->height / 8,
+ self->width - part,
+ self->height * 3 / 4);
+}
+
+// TODO: Perhaps it should save the number within.
+static struct widget *
+x11_make_gauge (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_gauge;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = g_xui.vunit;
+ return w;
+}
+
+static void
+x11_render_spectrum (struct widget *self)
+{
+ x11_render_padding (self);
+
+#ifdef WITH_FFTW
+ XRectangle rectangles[g.spectrum.bars];
+ int step = self->width / N_ELEMENTS (rectangles);
+ for (int i = 0; i < g.spectrum.bars; i++)
+ {
+ int height = round ((self->height - 2) * g.spectrum.spectrum[i]);
+ rectangles[i] = (XRectangle)
+ {
+ self->x + i * step,
+ self->y + self->height - 1 - height,
+ step,
+ height,
+ };
+ }
+
+ XRenderFillRectangles (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_fg (self), rectangles, N_ELEMENTS (rectangles));
+#endif // WITH_FFTW
+
+ // Enable the spectrum_redraw() hack.
+ XRectangle r = { self->x, self->y, self->width, self->height };
+ XUnionRectWithRegion (&r, g_xui.x11_clip, g_xui.x11_clip);
+}
+
+static struct widget *
+x11_make_spectrum (chtype attrs, int width)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_spectrum;
+ w->attrs = attrs;
+ w->width = width * g_xui.vunit / 2;
+ w->height = g_xui.vunit;
+ return w;
+}
+
+static void
+x11_render_scrollbar (struct widget *self)
+{
+ x11_render_padding (self);
+
+ struct tab *tab = g.active_tab;
+ struct scrollbar bar =
+ app_compute_scrollbar (tab, app_visible_items_height (), g_xui.vunit);
+
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_fg_attrs (self->attrs),
+ self->x,
+ self->y + bar.start,
+ self->width,
+ bar.length);
+}
+
+static struct widget *
+x11_make_scrollbar (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_scrollbar;
+ w->attrs = attrs;
+ w->width = g_xui.vunit / 2;
+ return w;
+}
+
+static struct widget *
+x11_make_list (void)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_padding;
+ return w;
+}
+
+static void
+x11_render_editor (struct widget *self)
+{
+ x11_render_padding (self);
+
+ XftFont *font = x11_widget_font (self)->list->font;
+ XftColor color = { .color = *x11_fg (self) };
+
+ // A simplistic adaptation of line_editor_write() follows.
+ int x = self->x, y = self->y + font->ascent;
+ XGlyphInfo extents = {};
+ if (g.editor.prompt)
+ {
+ FT_UInt i = XftCharIndex (g_xui.dpy, font, g.editor.prompt);
+ XftDrawGlyphs (g_xui.xft_draw, &color, font, x, y, &i, 1);
+ XftGlyphExtents (g_xui.dpy, font, &i, 1, &extents);
+ x += extents.xOff + g_xui.vunit / 4;
+ }
+
+ // TODO: Adapt x11_font_{hadvance,draw}().
+ // TODO: Make this scroll around the caret, and fade like labels.
+ XftDrawString32 (g_xui.xft_draw, &color, font, x, y,
+ g.editor.line, g.editor.len);
+
+ XftTextExtents32 (g_xui.dpy, font, g.editor.line, g.editor.point, &extents);
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ &color.color, x + extents.xOff, self->y, 2, self->height);
+}
+
+static struct widget *
+x11_make_editor (chtype attrs)
+{
+ // TODO: This should ideally measure the text, and copy it to w->text.
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_editor;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = g_xui.vunit;
+ return w;
}
+static struct app_ui app_x11_ui =
+{
+ .padding = x11_make_padding,
+ .label = app_make_label,
+ .button = x11_make_button,
+ .gauge = x11_make_gauge,
+ .spectrum = x11_make_spectrum,
+ .scrollbar = x11_make_scrollbar,
+ .list = x11_make_list,
+ .editor = x11_make_editor,
+
+ .have_icons = true,
+};
+
+#endif // WITH_X11
+
// --- Signals -----------------------------------------------------------------
static int g_signal_pipe[2]; ///< A pipe used to signal... signals
@@ -4366,72 +5979,7 @@ signals_setup_handlers (void)
// --- Initialisation, event handling ------------------------------------------
-static void
-app_on_tty_event (termo_key_t *event, int64_t event_ts)
-{
- // Simple double click detection via release--press delay, only a bit
- // complicated by the fact that we don't know what's being released
- static termo_key_t last_event;
- static int64_t last_event_ts;
- static int last_button;
-
- int y, x, button, y_last, x_last;
- termo_mouse_event_t type, type_last;
- if (termo_interpret_mouse (g.tk, event, &type, &button, &y, &x))
- {
- bool double_click = termo_interpret_mouse
- (g.tk, &last_event, &type_last, NULL, &y_last, &x_last)
- && event_ts - last_event_ts < 500
- && type_last == TERMO_MOUSE_RELEASE && type == TERMO_MOUSE_PRESS
- && y_last == y && x_last == x && last_button == button;
- if (!app_process_mouse (type, y, x, button, double_click))
- beep ();
-
- // Prevent interpreting triple clicks as two double clicks
- if (double_click)
- last_button = 0;
- else if (type == TERMO_MOUSE_PRESS)
- last_button = button;
- }
- else if (!app_process_termo_event (event))
- beep ();
-
- last_event = *event;
- last_event_ts = event_ts;
-}
-
-static void
-app_on_tty_readable (const struct pollfd *fd, void *user_data)
-{
- (void) user_data;
- if (fd->revents & ~(POLLIN | POLLHUP | POLLERR))
- print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents);
-
- poller_timer_reset (&g.tk_timer);
- termo_advisereadable (g.tk);
-
- termo_key_t event;
- int64_t event_ts = clock_msec (CLOCK_BEST);
- termo_result_t res;
- while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY)
- app_on_tty_event (&event, event_ts);
-
- if (res == TERMO_RES_AGAIN)
- poller_timer_set (&g.tk_timer, termo_get_waittime (g.tk));
- else if (res == TERMO_RES_ERROR || res == TERMO_RES_EOF)
- app_quit ();
-}
-
-static void
-app_on_key_timer (void *user_data)
-{
- (void) user_data;
-
- termo_key_t event;
- if (termo_getkey_force (g.tk, &event) == TERMO_RES_KEY)
- if (!app_process_termo_event (&event))
- beep ();
-}
+static bool g_verbose_mode = false;
static void
app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data)
@@ -4444,11 +5992,13 @@ app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data)
if (g_termination_requested && !g.quitting)
app_quit ();
+ // It would be awkward to set up SIGWINCH conditionally,
+ // so have it as a handler within UIs.
if (g_winch_received)
{
g_winch_received = false;
- update_curses_terminal_size ();
- app_invalidate ();
+ if (g_xui.ui->winch)
+ g_xui.ui->winch ();
}
}
@@ -4457,8 +6007,7 @@ app_on_message_timer (void *user_data)
{
(void) user_data;
- cstr_set (&g.message, NULL);
- app_invalidate ();
+ app_hide_message ();
}
static void
@@ -4474,21 +6023,20 @@ app_log_handler (void *user_data, const char *quote, const char *fmt,
struct str message = str_make ();
str_append (&message, quote);
+ size_t quote_len = message.len;
str_append_vprintf (&message, fmt, ap);
- // If the standard error output isn't redirected, try our best at showing
- // the message to the user
- if (!isatty (STDERR_FILENO))
+ // Show it prettified to the user, then maybe log it elsewhere as well.
+ // TODO: Review locale encoding vs UTF-8 in the entire program.
+ message.str[0] = toupper_ascii (message.str[0]);
+ app_show_message (xstrndup (message.str, quote_len),
+ xstrdup (message.str + quote_len));
+
+ if (g_verbose_mode && (g_xui.ui != &tui_ui || !isatty (STDERR_FILENO)))
fprintf (stderr, "%s\n", message.str);
- else if (g_debug_tab.active)
+ if (g_debug_tab.active)
debug_tab_push (str_steal (&message),
user_data == NULL ? 0 : g.attrs[(intptr_t) user_data].attrs);
- else
- {
- cstr_set (&g.message, xstrdup (message.str));
- app_invalidate ();
- poller_timer_set (&g.message_timer, 5000);
- }
str_free (&message);
in_processing = false;
@@ -4501,16 +6049,9 @@ app_init_poller_events (void)
g.signal_event.dispatcher = app_on_signal_pipe_readable;
poller_fd_set (&g.signal_event, POLLIN);
- g.tty_event = poller_fd_make (&g.poller, STDIN_FILENO);
- g.tty_event.dispatcher = app_on_tty_readable;
- poller_fd_set (&g.tty_event, POLLIN);
-
g.message_timer = poller_timer_make (&g.poller);
g.message_timer.dispatcher = app_on_message_timer;
- g.tk_timer = poller_timer_make (&g.poller);
- g.tk_timer.dispatcher = app_on_key_timer;
-
g.connect_event = poller_timer_make (&g.poller);
g.connect_event.dispatcher = app_on_reconnect;
poller_timer_set (&g.connect_event, 0);
@@ -4519,9 +6060,56 @@ app_init_poller_events (void)
g.elapsed_event.dispatcher = g.elapsed_poll
? mpd_on_elapsed_time_tick_poll
: mpd_on_elapsed_time_tick;
+}
- g.refresh_event = poller_idle_make (&g.poller);
- g.refresh_event.dispatcher = app_on_refresh;
+static void
+app_init_ui (bool requested_x11)
+{
+ xui_preinit ();
+
+ g_normal_keys = app_init_bindings ("normal",
+ g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len);
+ g_editor_keys = app_init_bindings ("editor",
+ g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len);
+
+ // It doesn't work 100% (e.g. incompatible with undelining in urxvt)
+ // TODO: make this configurable
+ g.use_partial_boxes = g_xui.locale_is_utf8;
+
+#ifdef WITH_X11
+ g_xui.x11_fontname = get_config_string (g.config.root, "settings.x11_font");
+#endif // WITH_X11
+
+ xui_start (&g.poller, requested_x11, g.attrs, N_ELEMENTS (g.attrs));
+
+#ifdef WITH_X11
+ if (g_xui.ui == &x11_ui)
+ g.ui = &app_x11_ui;
+ else
+#endif // WITH_X11
+ g.ui = &app_tui_ui;
+}
+
+static void
+app_init_enqueue (char *argv[], int argc)
+{
+ // TODO: MPD is unwilling to play directories, so perhaps recurse ourselves
+ char cwd[4096] = "";
+ for (int i = 0; i < argc; i++)
+ {
+ // This is a super-trivial method of URL detection, however anything
+ // contaning the scheme and authority delimiters in a sequence is most
+ // certainly not a filesystem path, and thus it will work as expected.
+ // Error handling may be done by MPD.
+ const char *path_or_URL = argv[i];
+ if (*path_or_URL == '/' || strstr (path_or_URL, "://"))
+ strv_append (&g.enqueue, path_or_URL);
+ else if (!*cwd && !getcwd (cwd, sizeof cwd))
+ exit_fatal ("getcwd: %s", strerror (errno));
+ else
+ strv_append_owned (&g.enqueue,
+ xstrdup_printf ("%s/%s", cwd, path_or_URL));
+ }
}
int
@@ -4530,13 +6118,18 @@ main (int argc, char *argv[])
static const struct opt opts[] =
{
{ 'd', "debug", NULL, 0, "run in debug mode" },
+#ifdef WITH_X11
+ { 'x', "x11", NULL, 0, "use X11 even when run from a terminal" },
+#endif // WITH_X11
{ 'h', "help", NULL, 0, "display this help and exit" },
+ { 'v', "verbose", NULL, 0, "log messages on standard error" },
{ 'V', "version", NULL, 0, "output version information and exit" },
{ 0, NULL, NULL, 0, NULL }
};
- struct opt_handler oh =
- opt_handler_make (argc, argv, opts, NULL, "Terminal-based MPD client.");
+ bool requested_x11 = false;
+ struct opt_handler oh
+ = opt_handler_make (argc, argv, opts, "[URL | PATH]...", "MPD client.");
int c;
while ((c = opt_handler_get (&oh)) != -1)
@@ -4545,6 +6138,12 @@ main (int argc, char *argv[])
case 'd':
g_debug_mode = true;
break;
+ case 'x':
+ requested_x11 = true;
+ break;
+ case 'v':
+ g_verbose_mode = true;
+ break;
case 'h':
opt_handler_usage (&oh, stdout);
exit (EXIT_SUCCESS);
@@ -4559,12 +6158,6 @@ main (int argc, char *argv[])
argc -= optind;
argv += optind;
-
- if (argc)
- {
- opt_handler_usage (&oh, stderr);
- exit (EXIT_FAILURE);
- }
opt_handler_free (&oh);
// We only need to convert to and from the terminal encoding
@@ -4572,15 +6165,11 @@ main (int argc, char *argv[])
print_warning ("failed to set the locale");
app_init_context ();
+ app_init_enqueue (argv, argc);
app_load_configuration ();
- app_init_terminal ();
signals_setup_handlers ();
app_init_poller_events ();
-
- g_normal_keys = app_init_bindings ("normal",
- g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len);
- g_editor_keys = app_init_bindings ("editor",
- g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len);
+ app_init_ui (requested_x11);
if (g_debug_mode)
app_prepend_tab (debug_tab_init ());
@@ -4595,11 +6184,16 @@ main (int argc, char *argv[])
app_prepend_tab (current_tab_init ());
app_switch_tab ((g.help_tab = help_tab_init ()));
+ // TODO: the help tab should be the default for new users only,
+ // so provide a configuration option to flip this
+ if (argc)
+ app_switch_tab (&g_current_tab);
+
g.polling = true;
while (g.polling)
poller_run (&g.poller);
- endwin ();
+ xui_stop ();
g_log_message_real = log_message_stdio;
app_free_context ();
return 0;
diff --git a/nncmpp.desktop b/nncmpp.desktop
new file mode 100644
index 0000000..4ba2668
--- /dev/null
+++ b/nncmpp.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=nncmpp
+GenericName=MPD client
+Icon=nncmpp
+Exec=nncmpp %U
+StartupNotify=false
+# Not registering a MimeType, because that depends on MPD.
+Categories=AudioVideo;Audio;Player;
diff --git a/nncmpp.png b/nncmpp.png
index cd3880d..68d536d 100644
--- a/nncmpp.png
+++ b/nncmpp.png
Binary files differ
diff --git a/nncmpp.svg b/nncmpp.svg
new file mode 100644
index 0000000..8960736
--- /dev/null
+++ b/nncmpp.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.0" width="48" height="48" viewBox="0 0 48 48"
+ xmlns="http://www.w3.org/2000/svg">
+ <g transform="translate(5 4) scale(2 2)">
+ <!-- From x11_icon_play, with a stroke for contrast. -->
+ <path d="M 0 0 20 10 0 20 Z" stroke="#eee" stroke-width="2" fill="#000"
+ stroke-linejoin="round" />
+ </g>
+</svg>
diff --git a/termo b/termo
-Subproject 94a77a10d87367ef33156cd68b2caf601c3f72d
+Subproject 2518b53e5ae4579bf84ed58fa7a62806f64e861