aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.clang-format32
-rw-r--r--.gitignore2
-rw-r--r--CMakeLists.txt232
-rw-r--r--LICENSE2
-rw-r--r--NEWS106
-rw-r--r--README.adoc94
-rw-r--r--config.h.in13
-rw-r--r--contrib/light-theme-256.conf1
-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.adoc166
-rw-r--r--nncmpp.c4199
-rw-r--r--nncmpp.desktop9
-rw-r--r--nncmpp.pngbin4719 -> 36087 bytes
-rw-r--r--nncmpp.svg9
m---------termo0
19 files changed, 3980 insertions, 1401 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 76ca00d..93df5e8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,24 +1,23 @@
-project (nncmpp C)
-cmake_minimum_required (VERSION 2.8.5)
+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)
- set (wdisabled "-Wno-unused-function -Wno-implicit-fallthrough")
+ set (wdisabled "-Wno-unused-function")
set (CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=gnu99 -Wall -Wextra ${wdisabled}")
-endif ("${CMAKE_C_COMPILER_ID}" MATCHES "GNU" OR CMAKE_COMPILER_IS_GNUCC)
-
-# Version
-set (project_VERSION_MAJOR "0")
-set (project_VERSION_MINOR "9")
-set (project_VERSION_PATCH "0")
-
-set (project_VERSION "${project_VERSION_MAJOR}")
-set (project_VERSION "${project_VERSION}.${project_VERSION_MINOR}")
-set (project_VERSION "${project_VERSION}.${project_VERSION_PATCH}")
+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)
@@ -28,65 +27,168 @@ 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)
-else (USE_SYSTEM_TERMO)
+ 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 (USE_SYSTEM_TERMO)
-
-include_directories (${UNISTRING_INCLUDE_DIRS}
- ${NCURSESW_INCLUDE_DIRS} ${Termo_INCLUDE_DIRS} ${curl_INCLUDE_DIRS})
-link_directories (${curl_LIBRARY_DIRS})
+endif ()
+
+pkg_check_modules (fftw fftw3 fftw3f)
+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 ()
+ 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} ${libpulse_INCLUDE_DIRS} ${x11_INCLUDE_DIRS})
+link_directories (${curl_LIBRARY_DIRS}
+ ${fftw_LIBRARY_DIRS} ${libpulse_LIBRARY_DIRS} ${x11_LIBRARY_DIRS})
# Configuration
+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})
+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})
+ if (extra_lib_${extra})
+ list (APPEND extra_libraries ${extra_lib_${extra}})
+ endif ()
+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)
-target_link_libraries (${PROJECT_NAME} ${UNISTRING_LIBRARIES}
- ${NCURSESW_LIBRARIES} termo-static ${curl_LIBRARIES})
+add_executable (${PROJECT_NAME} ${PROJECT_NAME}.c ${actions})
+target_link_libraries (${PROJECT_NAME} ${Unistring_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})
-
-# Generate documentation from program help
-find_program (HELP2MAN_EXECUTABLE help2man)
-if (NOT HELP2MAN_EXECUTABLE)
- message (FATAL_ERROR "help2man not found")
-endif (NOT HELP2MAN_EXECUTABLE)
+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)
+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 ${HELP2MAN_EXECUTABLE} -N
- "${PROJECT_BINARY_DIR}/${page}" -o ${page_output}
- DEPENDS ${page}
- COMMENT "Generating man page for ${page}" VERBATIM)
-endforeach (page)
+ 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})
@@ -94,23 +196,47 @@ foreach (page ${project_MAN_PAGES})
string (REGEX MATCH "\\.([0-9])$" manpage_suffix "${page}")
install (FILES "${page}"
DESTINATION "${CMAKE_INSTALL_MANDIR}/man${CMAKE_MATCH_1}")
-endforeach (page)
+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")
-set (CPACK_PACKAGE_VERSION_MAJOR ${project_VERSION_MAJOR})
-set (CPACK_PACKAGE_VERSION_MINOR ${project_VERSION_MINOR})
-set (CPACK_PACKAGE_VERSION_PATCH ${project_VERSION_PATCH})
set (CPACK_GENERATOR "TGZ;ZIP")
set (CPACK_PACKAGE_FILE_NAME
- "${PROJECT_NAME}-${project_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
-set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${project_VERSION}")
+ "${PROJECT_NAME}-${PROJECT_VERSION}-${CMAKE_SYSTEM_NAME}-${CMAKE_SYSTEM_PROCESSOR}")
+set (CPACK_PACKAGE_INSTALL_DIRECTORY "${PROJECT_NAME}-${PROJECT_VERSION}")
set (CPACK_SOURCE_GENERATOR "TGZ;ZIP")
set (CPACK_SOURCE_IGNORE_FILES "/\\\\.git;/build;/CMakeLists.txt.user")
-set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${project_VERSION}")
+set (CPACK_SOURCE_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}")
set (CPACK_SET_DESTDIR TRUE)
include (CPack)
diff --git a/LICENSE b/LICENSE
index 5826897..7b6617a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2016 - 2020, Přemysl Eric Janouch <p@janouch.name>
+Copyright (c) 2016 - 2023, Přemysl Eric Janouch <p@janouch.name>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
diff --git a/NEWS b/NEWS
index fb01dce..ffe2ecc 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,109 @@
+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)
+
+ * Coming with a real manual page instead of a help2man-generated stub
+
+ * Added a mode to poll MPD for the elapsed time, enabled by default,
+ fixing two cases of improper tracking
+
+ * Started showing song duration in the library
+
+ * Added C-PgUp/PgDown and C-Left/Right bindings to iterate tabs
+
+ * Added VIM-like C-y and C-e bindings for scrolling
+
+ * Added Windows Explorer-like M-Up binding to go up a directory
+
+ * Worked around a cURL bug crashing the application
+
+ * Fixed handling of direct SHOUTcast streams
+
+ * Miscellaneous little fixes
+
+
0.9.0 (2018-11-02)
* Initial release
diff --git a/README.adoc b/README.adoc
index 8a14a30..1a0f199 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,32 +1,51 @@
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].
-Building and Running
---------------------
-Build dependencies: CMake, pkg-config, help2man, liberty (included),
- termo (included) +
-Runtime dependencies: ncursesw, libunistring, cURL
+Documentation
+-------------
+See the link:nncmpp.adoc[man page] for information about usage.
+The rest of this README will concern itself with externalities.
+
+Building
+--------
+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
@@ -43,50 +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
-Note that for versions of CMake before 2.8.9, you need to prefix `cpack` with
-`fakeroot` or file ownership will end up wrong.
-
-Having the program installed, create a configuration file and run it.
-
-Configuration
--------------
-Create _~/.config/nncmpp/nncmpp.conf_ with contents like the following:
-
-....
-settings = {
- address = "localhost:6600"
- password = "<your password>"
- root = "~/Music"
-}
-colors = {
- normal = ""
- highlight = "bold"
- elapsed = "reverse"
- remains = "ul"
- tab_bar = "reverse"
- tab_active = "ul"
- even = ""
- odd = ""
- selection = "reverse"
- multiselect = "-1 6"
- scrollbar = ""
-}
-streams = {
- "dnbradio.com" = "http://www.dnbradio.com/hi.m3u"
- "BassDrive.com" = "http://bassdrive.com/v2/streams/BassDrive.pls"
-}
-....
-
-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 b61ed66..77296dd 100644
--- a/config.h.in
+++ b/config.h.in
@@ -1,10 +1,15 @@
#ifndef CONFIG_H
#define CONFIG_H
-#define PROGRAM_NAME "${CMAKE_PROJECT_NAME}"
-#define PROGRAM_VERSION "${project_VERSION}"
+#define PROGRAM_NAME "${PROJECT_NAME}"
+#define PROGRAM_VERSION "${PROJECT_VERSION}"
-#cmakedefine HAVE_RESIZETERM
+// We use the XDG Base Directory Specification, but may be installed anywhere.
+#define PROJECT_DATADIR "${CMAKE_INSTALL_FULL_DATADIR}"
-#endif // ! CONFIG_H
+#cmakedefine HAVE_RESIZETERM
+#cmakedefine WITH_FFTW
+#cmakedefine WITH_PULSE
+#cmakedefine WITH_X11
+#endif /* ! CONFIG_H */
diff --git a/contrib/light-theme-256.conf b/contrib/light-theme-256.conf
index 55d9848..c315ceb 100644
--- a/contrib/light-theme-256.conf
+++ b/contrib/light-theme-256.conf
@@ -12,6 +12,7 @@ colors = {
selection = "231 202"
multiselect = "231 88"
+ defocused = "231 250"
directory = "16 231 bold"
incoming = "28"
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 1a76b2032e6d18d9f95d9d0bb98edc26023c861
+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
new file mode 100644
index 0000000..33e2834
--- /dev/null
+++ b/nncmpp.adoc
@@ -0,0 +1,166 @@
+nncmpp(1)
+=========
+:doctype: manpage
+:manmanual: nncmpp Manual
+:mansource: nncmpp {release-version}
+
+Name
+----
+nncmpp - MPD client
+
+Synopsis
+--------
+*nncmpp* [_OPTION_]... [_URL_ | _PATH_]...
+
+Description
+-----------
+*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.
+
+*-V*, *--version*::
+ Output version information and exit.
+
+Configuration
+-------------
+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 = "~/.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 = ""
+}
+streams = {
+ "dnbradio.com" = "https://dnbradio.com/hi.pls"
+ "BassDrive.com" = "http://bassdrive.com/v2/streams/BassDrive.pls"
+}
+....
+
+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"
+output plugin to show the audio spectrum. This has some caveats, namely that
+it may not be properly synchronized, only one instance of a client can read from
+a given named pipe at a time, it will cost you some CPU time, and finally you'll
+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
+ ...
+}
+....
+
+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.
+
+_~/.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,
+or submit pull requests.
+
+See also
+--------
+*mpd*(1), *pulseaudio*(1)
diff --git a/nncmpp.c b/nncmpp.c
index 16ef756..0ee6796 100644
--- a/nncmpp.c
+++ b/nncmpp.c
@@ -1,7 +1,7 @@
/*
* nncmpp -- the MPD client you never knew you needed
*
- * Copyright (c) 2016 - 2020, Přemysl Eric Janouch <p@janouch.name>
+ * Copyright (c) 2016 - 2023, Přemysl Eric Janouch <p@janouch.name>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
@@ -21,30 +21,32 @@
// We "need" to have an enum for attributes before including liberty.
// Avoiding colours in the defaults here in order to support dumb terminals.
#define ATTRIBUTE_TABLE(XX) \
- XX( NORMAL, "normal", -1, -1, 0 ) \
- XX( HIGHLIGHT, "highlight", -1, -1, A_BOLD ) \
+ XX( NORMAL, normal, -1, -1, 0 ) \
+ XX( HIGHLIGHT, highlight, -1, -1, A_BOLD ) \
/* Gauge */ \
- XX( ELAPSED, "elapsed", -1, -1, A_REVERSE ) \
- XX( REMAINS, "remains", -1, -1, A_UNDERLINE ) \
+ XX( ELAPSED, elapsed, -1, -1, A_REVERSE ) \
+ XX( REMAINS, remains, -1, -1, A_UNDERLINE ) \
/* Tab bar */ \
- XX( TAB_BAR, "tab_bar", -1, -1, A_REVERSE ) \
- XX( TAB_ACTIVE, "tab_active", -1, -1, A_UNDERLINE ) \
+ XX( TAB_BAR, tab_bar, -1, -1, A_REVERSE ) \
+ XX( TAB_ACTIVE, tab_active, -1, -1, A_BOLD ) \
/* Listview */ \
- XX( HEADER, "header", -1, -1, A_UNDERLINE ) \
- XX( EVEN, "even", -1, -1, 0 ) \
- XX( ODD, "odd", -1, -1, 0 ) \
- XX( DIRECTORY, "directory", -1, -1, 0 ) \
- XX( SELECTION, "selection", -1, -1, A_REVERSE ) \
+ XX( HEADER, header, -1, -1, A_UNDERLINE ) \
+ XX( EVEN, even, -1, -1, 0 ) \
+ XX( ODD, odd, -1, -1, 0 ) \
+ XX( DIRECTORY, directory, -1, -1, 0 ) \
+ XX( SELECTION, selection, -1, -1, A_REVERSE ) \
/* Cyan is good with both black and white.
* Can't use A_REVERSE because bold'd be bright.
* Unfortunately ran out of B&W attributes. */ \
- XX( MULTISELECT, "multiselect",-1, 6, 0 ) \
- XX( SCROLLBAR, "scrollbar", -1, -1, 0 ) \
+ XX( MULTISELECT, multiselect, -1, 6, 0 ) \
+ /* This ought to be indicative enough. */ \
+ XX( DEFOCUSED, defocused, -1, -1, A_UNDERLINE ) \
+ XX( SCROLLBAR, scrollbar, -1, -1, 0 ) \
/* These are for debugging only */ \
- XX( WARNING, "warning", 3, -1, 0 ) \
- XX( ERROR, "error", 1, -1, 0 ) \
- XX( INCOMING, "incoming", 2, -1, 0 ) \
- XX( OUTGOING, "outgoing", 4, -1, 0 )
+ XX( WARNING, warning, 3, -1, 0 ) \
+ XX( ERROR, error, 1, -1, 0 ) \
+ XX( INCOMING, incoming, 2, -1, 0 ) \
+ XX( OUTGOING, outgoing, 4, -1, 0 )
enum
{
@@ -68,59 +70,54 @@ enum
#define LIBERTY_WANT_PROTO_HTTP
#define LIBERTY_WANT_PROTO_MPD
#include "liberty/liberty.c"
-#include "liberty/liberty-tui.c"
-#define HAVE_LIBERTY
-#include "line-editor.c"
+#ifdef WITH_X11
+#define LIBERTY_XUI_WANT_X11
+#endif // WITH_X11
+#include "liberty/liberty-xui.c"
-#include <math.h>
+#include <dirent.h>
#include <locale.h>
-#include <termios.h>
-#ifndef TIOCGWINSZ
-#include <sys/ioctl.h>
-#endif // ! TIOCGWINSZ
-
-// ncurses is notoriously retarded for input handling, we need something
-// different if only to receive mouse events reliably.
-
-#include "termo.h"
+#include <math.h>
// We need cURL to extract links from Internet stream playlists. It'd be way
// too much code to do this all by ourselves, and there's nothing better around.
-
#include <curl/curl.h>
+// The spectrum analyser requires a DFT transform. The FFTW library is fairly
+// efficient, and doesn't have a requirement on the number of bins.
+#ifdef WITH_FFTW
+#include <fftw3.h>
+#endif // WITH_FFTW
+
+// Remote MPD control needs appropriate volume controls.
+#ifdef WITH_PULSE
+#include "liberty/liberty-pulse.c"
+#include <pulse/context.h>
+#include <pulse/error.h>
+#include <pulse/introspect.h>
+#include <pulse/subscribe.h>
+#include <pulse/sample.h>
+#endif // WITH_PULSE
+
#define APP_TITLE PROGRAM_NAME ///< Left top corner
+#include "nncmpp-actions.h"
+
// --- Utilities ---------------------------------------------------------------
-// The standard endwin/refresh sequence makes the terminal flicker
static void
-update_curses_terminal_size (void)
+shell_quote (const char *str, struct str *output)
{
-#if defined (HAVE_RESIZETERM) && defined (TIOCGWINSZ)
- struct winsize size;
- if (!ioctl (STDOUT_FILENO, TIOCGWINSZ, (char *) &size))
+ // See SUSv3 Shell and Utilities, 2.2.3 Double-Quotes
+ str_append_c (output, '"');
+ for (const char *p = str; *p; p++)
{
- char *row = getenv ("LINES");
- char *col = getenv ("COLUMNS");
- unsigned long tmp;
- resizeterm (
- (row && xstrtoul (&tmp, row, 10)) ? tmp : size.ws_row,
- (col && xstrtoul (&tmp, col, 10)) ? tmp : size.ws_col);
+ if (strchr ("`$\"\\", *p))
+ str_append_c (output, '\\');
+ str_append_c (output, *p);
}
-#else // HAVE_RESIZETERM && TIOCGWINSZ
- endwin ();
- refresh ();
-#endif // HAVE_RESIZETERM && TIOCGWINSZ
-}
-
-static int64_t
-clock_msec (clockid_t clock)
-{
- struct timespec tp;
- hard_assert (clock_gettime (clock, &tp) != -1);
- return (int64_t) tp.tv_sec * 1000 + (int64_t) tp.tv_nsec / 1000000;
+ str_append_c (output, '"');
}
static bool
@@ -137,6 +134,8 @@ xbasename (const char *path)
return last_slash ? last_slash + 1 : path;
}
+static char *xstrdup0 (const char *s) { return s ? xstrdup (s) : NULL; }
+
static char *
latin1_to_utf8 (const char *latin1)
{
@@ -156,6 +155,18 @@ latin1_to_utf8 (const char *latin1)
}
static void
+str_enforce_utf8 (struct str *self)
+{
+ if (!utf8_validate (self->str, self->len))
+ {
+ char *sanitized = latin1_to_utf8 (self->str);
+ str_reset (self);
+ str_append (self, sanitized);
+ free (sanitized);
+ }
+}
+
+static void
cstr_uncapitalize (char *s)
{
if (isupper (s[0]) && islower (s[1]))
@@ -174,7 +185,7 @@ print_curl_debug (CURL *easy, curl_infotype type, char *data, size_t len,
for (size_t i = 0; i < len; i++)
{
uint8_t c = data[i];
- copy[i] = c >= 32 || c == '\n' ? c : '.';
+ copy[i] = !iscntrl_ascii (c) || c == '\n' ? c : '.';
}
copy[len] = '\0';
@@ -206,6 +217,35 @@ mpd_parse_kv (char *line, char **value)
return key;
}
+static void
+mpd_read_time (const char *value, int *sec, int *optional_msec)
+{
+ if (!value)
+ return;
+
+ char *end = NULL;
+ long n = strtol (value, &end, 10);
+ if (n < 0 || (*end && *end != '.'))
+ return;
+
+ int msec = 0;
+ if (*end == '.')
+ {
+ // In practice, MPD always uses three decimal digits
+ size_t digits = strspn (++end, "0123456789");
+ if (end[digits])
+ return;
+
+ if (digits--) msec += (*end++ - '0') * 100;
+ if (digits--) msec += (*end++ - '0') * 10;
+ if (digits--) msec += *end++ - '0';
+ }
+
+ *sec = MIN (INT_MAX, n);
+ if (optional_msec)
+ *optional_msec = msec;
+}
+
// --- cURL async wrapper ------------------------------------------------------
// You are meant to subclass this structure, no user_data pointers needed
@@ -234,6 +274,10 @@ struct poller_curl
struct poller_timer timer; ///< cURL timer
CURLM *multi; ///< cURL multi interface handle
struct poller_curl_fd *fds; ///< List of all FDs
+
+ // TODO: also make sure to dispose of them at the end of the program
+
+ int registered; ///< Number of attached easy handles
};
static void
@@ -276,6 +320,8 @@ poller_curl_on_socket_action (CURL *easy, curl_socket_t s, int what,
struct poller_curl_fd *fd;
if (!(fd = socket_data))
{
+ set_cloexec (s);
+
fd = xmalloc (sizeof *fd);
LIST_PREPEND (self->fds, fd);
@@ -342,6 +388,7 @@ poller_curl_init (struct poller_curl *self, struct poller *poller,
|| (mres = curl_multi_setopt (self->multi, CURLMOPT_TIMERDATA, self)))
{
curl_multi_cleanup (self->multi);
+ self->multi = NULL;
return error_set (e, "%s: %s",
"cURL setup failed", curl_multi_strerror (mres));
}
@@ -402,6 +449,7 @@ poller_curl_add (struct poller_curl *self, CURL *easy, struct error **e)
// "CURLMOPT_TIMERFUNCTION [...] will be called from within this function"
if ((mres = curl_multi_add_handle (self->multi, easy)))
return error_set (e, "%s", curl_multi_strerror (mres));
+ self->registered++;
return true;
}
@@ -411,6 +459,7 @@ poller_curl_remove (struct poller_curl *self, CURL *easy, struct error **e)
CURLMcode mres;
if ((mres = curl_multi_remove_handle (self->multi, easy)))
return error_set (e, "%s", curl_multi_strerror (mres));
+ self->registered--;
return true;
}
@@ -526,45 +575,624 @@ item_list_resize (struct item_list *self, size_t len)
self->len = len;
}
+// --- Spectrum analyzer -------------------------------------------------------
+
+// See http://www.zytrax.com/tech/audio/equalization.html
+// for a good write-up about this problem domain
+
+#ifdef WITH_FFTW
+
+struct spectrum
+{
+ int sampling_rate; ///< Number of samples per seconds
+ int channels; ///< Number of sampled channels
+ int bits; ///< Number of bits per sample
+ int bars; ///< Number of output vertical bars
+
+ int bins; ///< Number of DFT bins
+ int useful_bins; ///< Bins up to the Nyquist frequency
+ int samples; ///< Number of windows to average
+ float accumulator_scale; ///< Scaling factor for accum. values
+ int *top_bins; ///< Top DFT bin index for each bar
+ char *rendered; ///< String buffer for the "render"
+ float *spectrum; ///< The "render" as normalized floats
+
+ void *buffer; ///< Input buffer
+ size_t buffer_len; ///< Input buffer fill level
+ size_t buffer_size; ///< Input buffer size
+
+ /// Decode the respective part of the buffer into the second half of data
+ void (*decode) (struct spectrum *, int sample);
+
+ float *data; ///< Normalized audio data
+ float *window; ///< Sampled window function
+ float *windowed; ///< data * window
+ fftwf_complex *out; ///< DFT output
+ fftwf_plan p; ///< DFT plan/FFTW configuration
+ float *accumulator; ///< Accumulated powers of samples
+};
+
+// - - Windows - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+// Out: float[n] of 0..1
+static void
+window_hann (float *coefficients, size_t n)
+{
+ for (size_t i = 0; i < n; i++)
+ {
+ float sine = sin (M_PI * i / n);
+ coefficients[i] = sine * sine;
+ }
+}
+
+// In: float[n] of -1..1, float[n] of 0..1; out: float[n] of -1..1
+static void
+window_apply (const float *in, const float *coefficients, float *out, size_t n)
+{
+ for (size_t i = 0; i < n; i++)
+ out[i] = in[i] * coefficients[i];
+}
+
+// In: float[n] of 0..1; out: float 0..n, describing the coherent gain
+static float
+window_coherent_gain (const float *in, size_t n)
+{
+ float sum = 0;
+ for (size_t i = 0; i < n; i++)
+ sum += in[i];
+ return sum;
+}
+
+// - - Decoding - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+spectrum_decode_8 (struct spectrum *s, int sample)
+{
+ size_t n = s->useful_bins;
+ float *data = s->data + n;
+ for (int8_t *p = (int8_t *) s->buffer + sample * n * s->channels;
+ n--; p += s->channels)
+ {
+ int32_t acc = 0;
+ for (int ch = 0; ch < s->channels; ch++)
+ acc += p[ch];
+ *data++ = (float) acc / s->channels / -INT8_MIN;
+ }
+}
+
+static void
+spectrum_decode_16 (struct spectrum *s, int sample)
+{
+ size_t n = s->useful_bins;
+ float *data = s->data + n;
+ for (int16_t *p = (int16_t *) s->buffer + sample * n * s->channels;
+ n--; p += s->channels)
+ {
+ int32_t acc = 0;
+ for (int ch = 0; ch < s->channels; ch++)
+ acc += p[ch];
+ *data++ = (float) acc / s->channels / -INT16_MIN;
+ }
+}
+
+static void
+spectrum_decode_16_2 (struct spectrum *s, int sample)
+{
+ size_t n = s->useful_bins;
+ float *data = s->data + n;
+ for (int16_t *p = (int16_t *) s->buffer + sample * n * 2; n--; p += 2)
+ *data++ = ((int32_t) p[0] + p[1]) / 2. / -INT16_MIN;
+}
+
+// - - Spectrum analysis - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static const char *spectrum_bars[] =
+ { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" };
+
+/// Assuming the input buffer is full, updates the rendered spectrum
+static void
+spectrum_sample (struct spectrum *s)
+{
+ memset (s->accumulator, 0, sizeof *s->accumulator * s->useful_bins);
+
+ // Credit for the algorithm goes to Audacity's /src/SpectrumAnalyst.cpp,
+ // apparently Welch's method
+ for (int sample = 0; sample < s->samples; sample++)
+ {
+ // We use 50% overlap and start with data from the last run (if any)
+ memmove (s->data, s->data + s->useful_bins,
+ sizeof *s->data * s->useful_bins);
+ s->decode (s, sample);
+
+ window_apply (s->data, s->window, s->windowed, s->bins);
+ fftwf_execute (s->p);
+
+ for (int bin = 0; bin < s->useful_bins; bin++)
+ {
+ // out[0][0] is the DC component, not useful to us
+ float re = s->out[bin + 1][0];
+ float im = s->out[bin + 1][1];
+ s->accumulator[bin] += re * re + im * im;
+ }
+ }
+
+ int last_bin = 0;
+ char *p = s->rendered;
+ for (int bar = 0; bar < s->bars; bar++)
+ {
+ int top_bin = s->top_bins[bar];
+
+ // Think of this as accumulating energies within bands,
+ // so that it matches our non-linear hearing--there's no averaging.
+ // For more precision, we could employ an "equal loudness contour".
+ float acc = 0;
+ for (int bin = last_bin; bin < top_bin; bin++)
+ acc += s->accumulator[bin];
+
+ last_bin = top_bin;
+ float db = 10 * log10f (acc * s->accumulator_scale);
+ if (db > 0)
+ db = 0;
+
+ // Assuming decibels are always negative (i.e., properly normalized).
+ // The division defines the cutoff: 8 * 7 = 56 dB of range.
+ int height = N_ELEMENTS (spectrum_bars) - 1 + (int) (db / 7);
+ p += strlen (strcpy (p, spectrum_bars[MAX (height, 0)]));
+
+ // Even with slightly the higher display resolutions provided by X11,
+ // 60 dB roughly covers the useful range.
+ s->spectrum[bar] = MAX (0, 1 + db / 60);
+ }
+}
+
+static bool
+spectrum_init (struct spectrum *s, char *format, int bars, int fps,
+ struct error **e)
+{
+ errno = 0;
+
+ long sampling_rate, bits, channels;
+ if (!format
+ || (sampling_rate = strtol (format, &format, 10), *format++ != ':')
+ || (bits = strtol (format, &format, 10), *format++ != ':')
+ || (channels = strtol (format, &format, 10), *format)
+ || errno != 0)
+ return error_set (e, "invalid format, expected RATE:BITS:CHANNELS");
+
+ if (sampling_rate < 20000 || sampling_rate > INT_MAX)
+ return error_set (e, "unsupported sampling rate (%ld)", sampling_rate);
+ if (bits != 8 && bits != 16)
+ return error_set (e, "unsupported bit count (%ld)", bits);
+ if (channels < 1 || channels > INT_MAX)
+ return error_set (e, "no channels to sample (%ld)", channels);
+ if (bars < 1 || bars > 12)
+ return error_set (e, "requested too few or too many bars (%d)", bars);
+
+ // All that can fail henceforth is memory allocation
+ *s = (struct spectrum)
+ {
+ .sampling_rate = sampling_rate,
+ .bits = bits,
+ .channels = channels,
+ .bars = bars,
+ };
+
+ // The number of bars is always smaller than that of the samples (bins).
+ // Let's start with the equation of the top FFT bin to use for a given bar:
+ // top_bin = (num_bins + 1) ^ (bar / num_bars) - 1
+ // N.b. if we didn't subtract, the power function would make this ≥ 1.
+ // N.b. we then also need to extend the range by the same amount.
+ //
+ // We need the amount of bins for the first bar to be at least one:
+ // 1 ≤ (num_bins + 1) ^ (1 / num_bars) - 1
+ //
+ // Solving with Wolfram Alpha gives us:
+ // num_bins ≥ (2 ^ num_bars) - 1 [for y > 0]
+ //
+ // And we need to remember that half of the FFT bins are useless/missing--
+ // FFTW skips useless points past the Nyquist frequency.
+ int necessary_bins = 2 << s->bars;
+
+ // Discard frequencies above 20 kHz, which take up a constant ratio
+ // of all bins, given by the sampling rate. A more practical/efficient
+ // solution would be to just handle 96/192/... kHz rates as bitshifts.
+ //
+ // Filtering out sub-20 Hz frequencies would be even more wasteful than
+ // this wild DFT size, so we don't even try. While we may just shift
+ // the lowest used bin easily within the extra range provided by this
+ // extension (the Nyquist is usually above 22 kHz, and it hardly matters
+ // if we go a bit beyond 20 kHz in the last bin), for a small number of bars
+ // the first bin already includes audible frequencies, and even for larger
+ // numbers it wouldn't be too accurate. An exact solution would require
+ // having the amount of bins be strictly a factor of Nyquist / 20 (stemming
+ // from the equation 20 = Nyquist / bins). Since log2(44100 / 2 / 20) > 10,
+ // it would be fairly expensive, and somewhat slowly updating. Always.
+ // (Note that you can increase window overlap to get smoother framerates,
+ // but it would remain laggy.)
+ double audible_ratio = s->sampling_rate / 2. / 20000;
+ s->bins = ceil (necessary_bins * MAX (audible_ratio, 1));
+ s->useful_bins = s->bins / 2;
+
+ int used_bins = necessary_bins / 2;
+ s->rendered = xcalloc (sizeof *s->rendered, s->bars * 3 + 1);
+ s->spectrum = xcalloc (sizeof *s->spectrum, s->bars);
+ s->top_bins = xcalloc (sizeof *s->top_bins, s->bars);
+ for (int bar = 0; bar < s->bars; bar++)
+ {
+ int top_bin = floor (pow (used_bins + 1, (bar + 1.) / s->bars)) - 1;
+ s->top_bins[bar] = MIN (top_bin, used_bins);
+ }
+
+ s->samples = s->sampling_rate / s->bins * 2 / MAX (fps, 1);
+ if (s->samples < 1)
+ s->samples = 1;
+
+ // XXX: we average the channels but might want to average the DFT results
+ if (s->bits == 8) s->decode = spectrum_decode_8;
+ if (s->bits == 16) s->decode = spectrum_decode_16;
+
+ // Micro-optimize to achieve some piece of mind; it's weak but measurable
+ if (s->bits == 16 && s->channels == 2)
+ s->decode = spectrum_decode_16_2;
+
+ s->buffer_size = s->samples * s->useful_bins * s->bits / 8 * s->channels;
+ s->buffer = xcalloc (1, s->buffer_size);
+
+ // Prepare the window
+ s->window = xcalloc (sizeof *s->window, s->bins);
+ window_hann (s->window, s->bins);
+
+ // Multiply by 2 for only using half of the DFT's result, then adjust to
+ // the total energy of the window. Both squared, because the accumulator
+ // contains squared values. Compute the average, and convert to decibels.
+ // See also the mildly confusing https://dsp.stackexchange.com/a/14945.
+ float coherent_gain = window_coherent_gain (s->window, s->bins);
+ s->accumulator_scale = 2 * 2 / coherent_gain / coherent_gain / s->samples;
+
+ s->data = xcalloc (sizeof *s->data, s->bins);
+ s->windowed = fftw_malloc (sizeof *s->windowed * s->bins);
+ s->out = fftw_malloc (sizeof *s->out * (s->useful_bins + 1));
+ s->p = fftwf_plan_dft_r2c_1d (s->bins, s->windowed, s->out, FFTW_MEASURE);
+ s->accumulator = xcalloc (sizeof *s->accumulator, s->useful_bins);
+ return true;
+}
+
+static void
+spectrum_free (struct spectrum *s)
+{
+ free (s->accumulator);
+ fftwf_destroy_plan (s->p);
+ fftw_free (s->out);
+ fftw_free (s->windowed);
+ free (s->data);
+ free (s->window);
+#if 0
+ // We don't particularly want to discard wisdom.
+ fftwf_cleanup ();
+#endif
+
+ free (s->rendered);
+ free (s->spectrum);
+ free (s->top_bins);
+ free (s->buffer);
+
+ memset (s, 0, sizeof *s);
+}
+
+#endif // WITH_FFTW
+
+// --- PulseAudio --------------------------------------------------------------
+
+#ifdef WITH_PULSE
+
+struct pulse
+{
+ struct poller_timer make_context; ///< Event to establish connection
+ pa_mainloop_api *api; ///< PulseAudio event loop proxy
+ pa_context *context; ///< PulseAudio connection context
+ uint32_t sink_candidate; ///< Used while searching for MPD
+ uint32_t sink; ///< The relevant sink or -1
+ pa_cvolume sink_volume; ///< Current volume
+ bool sink_muted; ///< Currently muted?
+
+ void (*on_update) (void); ///< Update callback
+};
+
+static void
+pulse_on_sink_info (pa_context *context, const pa_sink_info *info, int eol,
+ void *userdata)
+{
+ (void) context;
+ (void) eol;
+
+ struct pulse *self = userdata;
+ if (info)
+ {
+ self->sink_volume = info->volume;
+ self->sink_muted = !!info->mute;
+ self->on_update ();
+ }
+}
+
+static void
+pulse_update_from_sink (struct pulse *self)
+{
+ if (self->sink == PA_INVALID_INDEX)
+ return;
+
+ pa_operation_unref (pa_context_get_sink_info_by_index
+ (self->context, self->sink, pulse_on_sink_info, self));
+}
+
+static void
+pulse_on_sink_input_info (pa_context *context,
+ const struct pa_sink_input_info *info, int eol, void *userdata)
+{
+ (void) context;
+ (void) eol;
+
+ struct pulse *self = userdata;
+ if (!info)
+ {
+ if ((self->sink = self->sink_candidate) != PA_INVALID_INDEX)
+ pulse_update_from_sink (self);
+ else
+ self->on_update ();
+ return;
+ }
+
+ // TODO: also save info->mute as a different mute level,
+ // and perhaps info->index (they can appear and disappear)
+ const char *name =
+ pa_proplist_gets (info->proplist, PA_PROP_APPLICATION_NAME);
+ if (name && !strcmp (name, "Music Player Daemon"))
+ self->sink_candidate = info->sink;
+}
+
+static void
+pulse_read_sink_inputs (struct pulse *self)
+{
+ self->sink_candidate = PA_INVALID_INDEX;
+ pa_operation_unref (pa_context_get_sink_input_info_list
+ (self->context, pulse_on_sink_input_info, self));
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_on_event (pa_context *context, pa_subscription_event_type_t event,
+ uint32_t index, void *userdata)
+{
+ (void) context;
+
+ struct pulse *self = userdata;
+ switch (event & PA_SUBSCRIPTION_EVENT_FACILITY_MASK)
+ {
+ case PA_SUBSCRIPTION_EVENT_SINK_INPUT:
+ pulse_read_sink_inputs (self);
+ break;
+ case PA_SUBSCRIPTION_EVENT_SINK:
+ if (index == self->sink)
+ pulse_update_from_sink (self);
+ }
+}
+
+static void
+pulse_on_subscribe_finish (pa_context *context, int success, void *userdata)
+{
+ (void) context;
+
+ struct pulse *self = userdata;
+ if (success)
+ pulse_read_sink_inputs (self);
+ else
+ {
+ print_debug ("PulseAudio failed to subscribe for events");
+ self->on_update ();
+ pa_context_disconnect (context);
+ }
+}
+
+static void
+pulse_on_context_state_change (pa_context *context, void *userdata)
+{
+ struct pulse *self = userdata;
+ switch (pa_context_get_state (context))
+ {
+ case PA_CONTEXT_FAILED:
+ case PA_CONTEXT_TERMINATED:
+ print_debug ("PulseAudio context failed or has been terminated");
+
+ pa_context_unref (context);
+ self->context = NULL;
+ self->sink = PA_INVALID_INDEX;
+ self->on_update ();
+
+ // Retry after an arbitrary delay of 5 seconds
+ poller_timer_set (&self->make_context, 5000);
+ break;
+ case PA_CONTEXT_READY:
+ pa_context_set_subscribe_callback (context, pulse_on_event, userdata);
+ pa_operation_unref (pa_context_subscribe (context,
+ PA_SUBSCRIPTION_MASK_SINK | PA_SUBSCRIPTION_MASK_SINK_INPUT,
+ pulse_on_subscribe_finish, userdata));
+ default:
+ break;
+ }
+}
+
+static void
+pulse_make_context (void *user_data)
+{
+ struct pulse *self = user_data;
+ self->context = pa_context_new (self->api, PROGRAM_NAME);
+ pa_context_set_state_callback (self->context,
+ pulse_on_context_state_change, self);
+ pa_context_connect (self->context, NULL, PA_CONTEXT_NOAUTOSPAWN, NULL);
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_on_finish (pa_context *context, int success, void *userdata)
+{
+ (void) context;
+ (void) success;
+ (void) userdata;
+
+ // Just like... whatever, man
+}
+
+static bool
+pulse_volume_mute (struct pulse *self)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX)
+ return false;
+
+ pa_operation_unref (pa_context_set_sink_mute_by_index (self->context,
+ self->sink, !self->sink_muted, pulse_on_finish, self));
+ return true;
+}
+
+static bool
+pulse_volume_set (struct pulse *self, int arg)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX)
+ return false;
+
+ pa_cvolume volume = self->sink_volume;
+ if (arg > 0)
+ pa_cvolume_inc (&volume, (pa_volume_t) arg * PA_VOLUME_NORM / 100);
+ else
+ pa_cvolume_dec (&volume, (pa_volume_t) -arg * PA_VOLUME_NORM / 100);
+ pa_operation_unref (pa_context_set_sink_volume_by_index (self->context,
+ self->sink, &volume, pulse_on_finish, self));
+ return true;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+pulse_init (struct pulse *self, struct poller *poller)
+{
+ memset (self, 0, sizeof *self);
+ self->sink = PA_INVALID_INDEX;
+ if (!poller)
+ return;
+
+ self->api = poller_pa_new (poller);
+
+ self->make_context = poller_timer_make (poller);
+ self->make_context.dispatcher = pulse_make_context;
+ self->make_context.user_data = self;
+ poller_timer_set (&self->make_context, 0);
+}
+
+static void
+pulse_free (struct pulse *self)
+{
+ if (self->context)
+ pa_context_unref (self->context);
+ if (self->api)
+ {
+ poller_pa_destroy (self->api);
+ poller_timer_reset (&self->make_context);
+ }
+
+ pulse_init (self, NULL);
+}
+
+#define VOLUME_PERCENT(x) (((x) * 100 + PA_VOLUME_NORM / 2) / PA_VOLUME_NORM)
+
+static bool
+pulse_volume_status (struct pulse *self, struct str *s)
+{
+ if (!self->context || self->sink == PA_INVALID_INDEX
+ || !self->sink_volume.channels)
+ return false;
+
+ if (self->sink_muted)
+ {
+ str_append (s, "Muted");
+ return true;
+ }
+
+ str_append_printf (s,
+ "%u%%", VOLUME_PERCENT (self->sink_volume.values[0]));
+ if (!pa_cvolume_channels_equal_to (&self->sink_volume,
+ self->sink_volume.values[0]))
+ {
+ for (size_t i = 1; i < self->sink_volume.channels; i++)
+ str_append_printf (s, " / %u%%",
+ VOLUME_PERCENT (self->sink_volume.values[i]));
+ }
+ return true;
+}
+
+#endif // WITH_PULSE
+
// --- Application -------------------------------------------------------------
-// Function names are prefixed mostly because of curses which clutters the
+// Function names are prefixed mostly because of curses, which clutters the
// global namespace and makes it harder to distinguish what functions relate to.
// The user interface is focused on conceptual simplicity. That is important
-// since we're not using any TUI framework (which are mostly a lost cause to me
-// in the post-Unicode era and not worth pursuing), and the code would get
-// bloated and incomprehensible fast. We mostly rely on "row_buffer" to write
-// text from left to right row after row while keeping track of cells.
+// since we use a custom toolkit, so code would get bloated rather fast--
+// especially given our TUI/GUI duality.
//
// There is an independent top pane displaying general status information,
// followed by a tab bar and a listview served by a per-tab event handler.
//
// For simplicity, the listview can only work with items that are one row high.
+// Widget identification, mostly for mouse events.
+enum
+{
+ WIDGET_NONE = 0, WIDGET_BUTTON, WIDGET_GAUGE, WIDGET_VOLUME,
+ WIDGET_TAB, WIDGET_SPECTRUM, WIDGET_LIST, WIDGET_SCROLLBAR, WIDGET_MESSAGE,
+};
+
+struct layout
+{
+ struct widget *head;
+ struct widget *tail;
+};
+
+struct app_ui
+{
+ struct widget *(*padding) (chtype attrs, float width, float height);
+ struct widget *(*label) (chtype attrs, const char *label);
+ struct widget *(*button) (chtype attrs, const char *label, enum action a);
+ struct widget *(*gauge) (chtype attrs);
+ struct widget *(*spectrum) (chtype attrs, int width);
+ struct widget *(*scrollbar) (chtype attrs);
+ struct widget *(*list) (void);
+ struct widget *(*editor) (chtype attrs);
+
+ bool have_icons;
+};
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
struct tab;
-enum action;
/// Try to handle an action in the tab
typedef bool (*tab_action_fn) (enum action action);
-/// Draw an item to the screen using the row buffer API
-typedef void (*tab_item_draw_fn)
- (size_t item_index, struct row_buffer *buffer, int width);
+/// Return a line of widgets for the row
+typedef struct layout (*tab_item_layout_fn) (size_t item_index);
struct tab
{
LIST_HEADER (struct tab)
char *name; ///< Visible identifier
- size_t name_width; ///< Visible width of the name
-
char *header; ///< The header, should there be any
// Implementation:
tab_action_fn on_action; ///< User action handler callback
- tab_item_draw_fn on_item_draw; ///< Item draw callback
+ tab_item_layout_fn on_item_layout; ///< Item layout callback
// Provided by tab owner:
@@ -591,6 +1219,7 @@ static struct app_context
// Event loop:
struct poller poller; ///< Poller
+ struct poller_curl poller_curl; ///< cURL abstractor
bool quitting; ///< Quit signal for the event loop
bool polling; ///< The event loop is running
@@ -599,6 +1228,7 @@ static struct app_context
struct poller_timer message_timer; ///< Message timeout
char *message; ///< Message to show in the statusbar
+ char *message_detail; ///< Non-emphasized part
// Connection:
@@ -609,9 +1239,9 @@ static struct app_context
struct str_map playback_info; ///< Current song info
struct poller_timer elapsed_event; ///< Seconds elapsed event
- int64_t elapsed_since; ///< Time of the next tick
+ int64_t elapsed_since; ///< Last tick ts or last elapsed time
+ bool elapsed_poll; ///< Poll MPD for the elapsed time?
- // TODO: initialize these to -1
int song; ///< Current song index
int song_elapsed; ///< Song elapsed in seconds
int song_duration; ///< Song duration in seconds
@@ -625,29 +1255,33 @@ static struct app_context
struct config config; ///< Program configuration
struct strv streams; ///< List of "name NUL URI NUL"
+ struct strv enqueue; ///< Items to enqueue once connected
struct tab *help_tab; ///< Special help tab
struct tab *tabs; ///< All other tabs
struct tab *active_tab; ///< Active tab
struct tab *last_tab; ///< Previous tab
- // Emulated widgets:
+ // User interface:
- int header_height; ///< Height of the header
+ struct app_ui *ui; ///< User interface interface
+ int ui_dragging; ///< ID of any dragged widget
- int tabs_offset; ///< Offset to tabs or -1
- int controls_offset; ///< Offset to player controls or -1
- int gauge_offset; ///< Offset to the gauge or -1
- int gauge_width; ///< Width of the gauge, if present
+#ifdef WITH_FFTW
+ struct spectrum spectrum; ///< Spectrum analyser
+ int spectrum_fd; ///< FIFO file descriptor (non-blocking)
+ struct poller_fd spectrum_event; ///< FIFO watcher
+#endif // WITH_FFTW
+
+#ifdef WITH_PULSE
+ struct pulse pulse; ///< PulseAudio control
+#endif // WITH_PULSE
+ bool pulse_control_requested; ///< PulseAudio control desired by user
struct line_editor editor; ///< Line editor
- struct poller_idle refresh_event; ///< Refresh the screen
// Terminal:
- termo_t *tk; ///< termo handle
- struct poller_timer tk_timer; ///< termo timeout timer
- bool locale_is_utf8; ///< The locale is Unicode
bool use_partial_boxes; ///< Use Unicode box drawing chars
struct attrs attrs[ATTRIBUTE_COUNT];
@@ -666,9 +1300,6 @@ tab_init (struct tab *self, const char *name)
// Add some padding for decorative purposes
self->name = xstrdup_printf (" %s ", name);
- // Assuming tab names are pure ASCII, otherwise this would be inaccurate
- // and we'd need to filter it first to replace invalid chars with '?'
- self->name_width = u8_strwidth ((uint8_t *) self->name, locale_charset ());
self->item_selected = 0;
self->item_mark = -1;
}
@@ -692,6 +1323,20 @@ tab_selection_range (struct tab *self)
// --- Configuration -----------------------------------------------------------
+static void
+on_poll_elapsed_time_changed (struct config_item *item)
+{
+ // This is only set once, on application startup
+ g.elapsed_poll = item->value.boolean;
+}
+
+static void
+on_pulseaudio_changed (struct config_item *item)
+{
+ // This is only set once, on application startup
+ g.pulse_control_requested = item->value.boolean;
+}
+
static struct config_schema g_config_settings[] =
{
{ .name = "address",
@@ -701,16 +1346,65 @@ static struct config_schema g_config_settings[] =
{ .name = "password",
.comment = "Password to use for MPD authentication",
.type = CONFIG_ITEM_STRING },
+
+ // NOTE: this is unused--in theory we could allow manual metadata adjustment
+ // NOTE: the "config" command may return "music_directory" for local clients
{ .name = "root",
.comment = "Where all the files MPD is playing are located",
.type = CONFIG_ITEM_STRING },
+
+#ifdef WITH_FFTW
+ { .name = "spectrum_path",
+ .comment = "Visualizer feed path to a FIFO audio output",
+ .type = CONFIG_ITEM_STRING },
+ // MPD's "outputs" command doesn't include this information
+ { .name = "spectrum_format",
+ .comment = "Visualizer feed data format",
+ .type = CONFIG_ITEM_STRING,
+ .default_ = "\"44100:16:2\"" },
+ // 10 is about the useful limit, then it gets too computationally expensive
+ { .name = "spectrum_bars",
+ .comment = "Number of computed audio spectrum bars",
+ .type = CONFIG_ITEM_INTEGER,
+ .default_ = "8" },
+ { .name = "spectrum_fps",
+ .comment = "Maximum frames per second, affects CPU usage",
+ .type = CONFIG_ITEM_INTEGER,
+ .default_ = "30" },
+#endif // WITH_FFTW
+
+#ifdef WITH_PULSE
+ { .name = "pulseaudio",
+ .comment = "Look up MPD in PulseAudio for improved volume controls",
+ .type = CONFIG_ITEM_BOOLEAN,
+ .on_change = on_pulseaudio_changed,
+ .default_ = "off" },
+#endif // WITH_PULSE
+
+#ifdef WITH_X11
+ { .name = "x11_font",
+ .comment = "Fontconfig name/pattern for the X11 font to use",
+ .type = CONFIG_ITEM_STRING,
+ .default_ = "`sans\\-serif-11`" },
+#endif // WITH_X11
+
+ // Disabling this minimises MPD traffic and has the following caveats:
+ // - when MPD stalls on retrieving audio data, we keep ticking
+ // - when the "play" succeeds in ACTION_MPD_REPLACE for the same item as
+ // is currently playing, we do not reset g.song_elapsed (we could ask
+ // for a response which feels racy, or rethink the mechanism there)
+ { .name = "poll_elapsed_time",
+ .comment = "Whether to actively poll MPD for the elapsed time",
+ .type = CONFIG_ITEM_BOOLEAN,
+ .on_change = on_poll_elapsed_time_changed,
+ .default_ = "on" },
{}
};
static struct config_schema g_config_colors[] =
{
#define XX(name_, config, fg_, bg_, attrs_) \
- { .name = config, .type = CONFIG_ITEM_STRING },
+ { .name = #config, .type = CONFIG_ITEM_STRING },
ATTRIBUTE_TABLE (XX)
#undef XX
{}
@@ -745,7 +1439,7 @@ load_config_colors (struct config_item *subtree, void *user_data)
// For simplicity, we should reload the entire table on each change anyway.
const char *value;
#define XX(name, config, fg_, bg_, attrs_) \
- if ((value = get_config_string (subtree, config))) \
+ if ((value = get_config_string (subtree, #config))) \
g.attrs[ATTRIBUTE_ ## name] = attrs_decode (value);
ATTRIBUTE_TABLE (XX)
#undef XX
@@ -836,76 +1530,68 @@ app_init_attributes (void)
#undef XX
}
+static bool
+app_on_insufficient_color (void)
+{
+ app_init_attributes ();
+ return true;
+}
+
static void
app_init_context (void)
{
poller_init (&g.poller);
+ hard_assert (poller_curl_init (&g.poller_curl, &g.poller, NULL));
g.client = mpd_client_make (&g.poller);
+ g.song_elapsed = g.song_duration = g.volume = g.song = -1;
+ g.playlist = item_list_make ();
g.config = config_make ();
g.streams = strv_make ();
- g.playlist = item_list_make ();
+ g.enqueue = strv_make ();
g.playback_info = str_map_make (free);
g.playback_info.key_xfrm = tolower_ascii_strxfrm;
- // This is also approximately what libunistring does internally,
- // since the locale name is canonicalized by locale_charset().
- // Note that non-Unicode locales are handled pretty inefficiently.
- g.locale_is_utf8 = !strcasecmp_ascii (locale_charset (), "UTF-8");
+#ifdef WITH_FFTW
+ g.spectrum_fd = -1;
+#endif // WITH_FFTW
- // It doesn't work 100% (e.g. incompatible with undelining in urxvt)
- // TODO: make this configurable
- g.use_partial_boxes = g.locale_is_utf8;
+#ifdef WITH_PULSE
+ pulse_init (&g.pulse, NULL);
+#endif // WITH_PULSE
app_init_attributes ();
}
static void
-app_init_terminal (void)
-{
- TERMO_CHECK_VERSION;
- if (!(g.tk = termo_new (STDIN_FILENO, NULL, 0)))
- abort ();
- if (!initscr () || nonl () == ERR)
- abort ();
-
- // By default we don't use any colors so they're not required...
- if (start_color () == ERR
- || use_default_colors () == ERR
- || COLOR_PAIRS <= ATTRIBUTE_COUNT)
- return;
-
- for (int a = 0; a < ATTRIBUTE_COUNT; a++)
- {
- // ...thus we can reset back to defaults even after initializing some
- if (g.attrs[a].fg >= COLORS || g.attrs[a].fg < -1
- || g.attrs[a].bg >= COLORS || g.attrs[a].bg < -1)
- {
- app_init_attributes ();
- return;
- }
-
- init_pair (a + 1, g.attrs[a].fg, g.attrs[a].bg);
- g.attrs[a].attrs |= COLOR_PAIR (a + 1);
- }
-}
-
-static void
app_free_context (void)
{
mpd_client_free (&g.client);
str_map_free (&g.playback_info);
strv_free (&g.streams);
+ strv_free (&g.enqueue);
item_list_free (&g.playlist);
+#ifdef WITH_FFTW
+ spectrum_free (&g.spectrum);
+ if (g.spectrum_fd != -1)
+ {
+ poller_fd_reset (&g.spectrum_event);
+ xclose (g.spectrum_fd);
+ }
+#endif // WITH_FFTW
+
+#ifdef WITH_PULSE
+ pulse_free (&g.pulse);
+#endif // WITH_PULSE
+
line_editor_free (&g.editor);
config_free (&g.config);
+ poller_curl_free (&g.poller_curl);
poller_free (&g.poller);
free (g.message);
-
- if (g.tk)
- termo_destroy (g.tk);
+ free (g.message_detail);
}
static void
@@ -918,92 +1604,179 @@ app_quit (void)
g.polling = false;
}
-static bool
-app_is_character_in_locale (ucs4_t ch)
+// --- Layouting ---------------------------------------------------------------
+
+static void
+app_append_layout (struct layout *l, struct layout *dest)
{
- // Avoid the overhead joined with calling iconv() for all characters.
- if (g.locale_is_utf8)
- return true;
+ struct widget *last = dest->tail;
+ if (!last)
+ *dest = *l;
+ else if (l->head)
+ {
+ // Assuming there is no unclaimed vertical space.
+ LIST_FOR_EACH (struct widget, w, l->head)
+ widget_move (w, 0, last->y + last->height);
- // The library really creates a new conversion object every single time
- // and doesn't provide any smarter APIs. Luckily, most users use UTF-8.
- size_t len;
- char *tmp = u32_conv_to_encoding (locale_charset (), iconveh_error,
- &ch, 1, NULL, NULL, &len);
- if (!tmp)
- return false;
- free (tmp);
- return true;
-}
+ last->next = l->head;
+ l->head->prev = last;
+ dest->tail = l->tail;
+ }
-// --- Rendering ---------------------------------------------------------------
+ *l = (struct layout) {};
+}
+/// Replaces negative widths amongst widgets in the layout by redistributing
+/// any width remaining after all positive claims are satisfied from "width".
+/// Also unifies heights to the maximum value of the run.
+/// Then the widths are taken as final, and used to initialize X coordinates.
static void
-app_invalidate (void)
+app_flush_layout_full (struct layout *l, int width, struct layout *dest)
{
- poller_idle_set (&g.refresh_event);
+ hard_assert (l != NULL && l->head != NULL);
+
+ int parts = 0, max_height = 0;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ max_height = MAX (max_height, w->height);
+ if (w->width < 0)
+ parts -= w->width;
+ else
+ width -= w->width;
+ }
+
+ int remaining = MAX (width, 0), part_width = parts ? remaining / parts : 0;
+ struct widget *last = NULL;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ w->height = max_height;
+ if (w->width < 0)
+ {
+ remaining -= (w->width *= -part_width);
+ last = w;
+ }
+ }
+ if (last)
+ last->width += remaining;
+
+ int x = 0;
+ LIST_FOR_EACH (struct widget, w, l->head)
+ {
+ widget_move (w, x - w->x, 0);
+ x += w->width;
+ }
+
+ app_append_layout (l, dest);
}
static void
-app_flush_buffer (struct row_buffer *buf, int width, chtype attrs)
+app_flush_layout (struct layout *l, struct layout *out)
{
- row_buffer_align (buf, width, attrs);
- row_buffer_flush (buf);
- row_buffer_free (buf);
+ app_flush_layout_full (l, g_xui.width, out);
}
-/// Write the given UTF-8 string padded with spaces.
-/// @param[in] attrs Text attributes for the text, including padding.
-static void
-app_write_line (const char *str, chtype attrs)
+static struct widget *
+app_push (struct layout *l, struct widget *w)
{
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, str, attrs);
- app_flush_buffer (&buf, COLS, attrs);
+ LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
+ return w;
}
-// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+static struct widget *
+app_push_fill (struct layout *l, struct widget *w)
+{
+ w->width = -1;
+ LIST_APPEND_WITH_TAIL (l->head, l->tail, w);
+ return w;
+}
+/// Write the given UTF-8 string padded with spaces.
+/// @param[in] attrs Text attributes for the text, including padding.
static void
-app_flush_header (struct row_buffer *buf, chtype attrs)
+app_layout_text (const char *str, chtype attrs, struct layout *out)
{
- move (g.header_height++, 0);
- app_flush_buffer (buf, COLS, attrs);
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs, 0.25, 1));
+ app_push_fill (&l, g.ui->label (attrs, str));
+ app_push (&l, g.ui->padding (attrs, 0.25, 1));
+ app_flush_layout (&l, out);
}
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
static void
-app_draw_song_info (void)
+app_layout_song_info (struct layout *out)
{
compact_map_t map;
if (!(map = item_list_get (&g.playlist, g.song)))
return;
- chtype attr_normal = APP_ATTR (NORMAL);
- chtype attr_highlight = APP_ATTR (HIGHLIGHT);
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
- char *title;
+ // Split the path for files lying within MPD's "music_directory".
+ const char *file = compact_map_find (map, "file");
+ const char *subroot_basename = NULL;
+ if (file && *file != '/' && !strstr (file, "://"))
+ {
+ const char *last_slash = strrchr (file, '/');
+ if (last_slash)
+ subroot_basename = last_slash + 1;
+ else
+ subroot_basename = file;
+ }
+
+ const char *title = NULL;
+ const char *name = compact_map_find (map, "name");
if ((title = compact_map_find (map, "title"))
- || (title = compact_map_find (map, "name"))
- || (title = compact_map_find (map, "file")))
+ || (title = name)
+ || (title = subroot_basename)
+ || (title = file))
{
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, title, attr_highlight);
- app_flush_header (&buf, attr_normal);
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->label (attrs[1], title));
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
+ // Showing a blank line is better than having the controls jump around
+ // while switching between files that we do and don't have enough data for.
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+
char *artist = compact_map_find (map, "artist");
char *album = compact_map_find (map, "album");
- if (!artist && !album)
- return;
+ if (artist || album)
+ {
+ if (artist)
+ {
+ app_push (&l, g.ui->label (attrs[0], "by "));
+ app_push (&l, g.ui->label (attrs[1], artist));
+ }
+ if (album)
+ {
+ app_push (&l, g.ui->label (attrs[0], &" from "[!artist]));
+ app_push (&l, g.ui->label (attrs[1], album));
+ }
+ }
+ else if (subroot_basename && subroot_basename != file)
+ {
+ char *parent = xstrndup (file, subroot_basename - file - 1);
+ app_push (&l, g.ui->label (attrs[0], "in "));
+ app_push (&l, g.ui->label (attrs[1], parent));
+ free (parent);
+ }
+ else if (file && *file != '/' && strstr (file, "://")
+ && name && name != title)
+ {
+ // This is likely to contain the name of an Internet radio.
+ app_push (&l, g.ui->label (attrs[1], name));
+ }
- struct row_buffer buf = row_buffer_make ();
- if (artist)
- row_buffer_append_args (&buf, " by " + !buf.total_width, attr_normal,
- artist, attr_highlight, NULL);
- if (album)
- row_buffer_append_args (&buf, " from " + !buf.total_width, attr_normal,
- album, attr_highlight, NULL);
- app_flush_header (&buf, attr_normal);
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static char *
@@ -1023,164 +1796,147 @@ app_time_string (int seconds)
}
static void
-app_write_time (struct row_buffer *buf, int seconds, chtype attrs)
-{
- char *s = app_time_string (seconds);
- row_buffer_append (buf, s, attrs);
- free (s);
-}
-
-static void
-app_write_gauge (struct row_buffer *buf, float ratio, int width)
+app_layout_status (struct layout *out)
{
- if (ratio < 0) ratio = 0;
- if (ratio > 1) ratio = 1;
-
- // Always compute it in exactly eight times the resolution,
- // because sometimes Unicode is even useful
- int len_left = ratio * width * 8 + 0.5;
-
- static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" };
- int remainder = len_left % 8;
- len_left /= 8;
-
- const char *partial = NULL;
- if (g.use_partial_boxes)
- partial = partials[remainder];
- else
- len_left += remainder >= (int) 4;
-
- int len_right = width - len_left;
- row_buffer_space (buf, len_left, APP_ATTR (ELAPSED));
- if (partial && len_right-- > 0)
- row_buffer_append (buf, partial, APP_ATTR (REMAINS));
- row_buffer_space (buf, len_right, APP_ATTR (REMAINS));
-}
-
-static void
-app_draw_status (void)
-{
- if (g.state != PLAYER_STOPPED)
- app_draw_song_info ();
-
- chtype attr_normal = APP_ATTR (NORMAL);
- chtype attr_highlight = APP_ATTR (HIGHLIGHT);
-
- struct row_buffer buf = row_buffer_make ();
bool stopped = g.state == PLAYER_STOPPED;
- chtype attr_song_action = stopped ? attr_normal : attr_highlight;
+ if (!stopped)
+ app_layout_song_info (out);
+
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ struct layout l = {};
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], "<<", ACTION_MPD_PREVIOUS));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
const char *toggle = g.state == PLAYER_PLAYING ? "||" : "|>";
- row_buffer_append_args (&buf,
- "<<", attr_song_action, " ", attr_normal,
- toggle, attr_highlight, " ", attr_normal,
- "[]", attr_song_action, " ", attr_normal,
- ">>", attr_song_action, " ", attr_normal,
- NULL);
+ app_push (&l, g.ui->button (attrs[1], toggle, ACTION_MPD_TOGGLE));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], "[]", ACTION_MPD_STOP));
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l, g.ui->button (attrs[!stopped], ">>", ACTION_MPD_NEXT));
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
if (stopped)
- row_buffer_append (&buf, "Stopped", attr_normal);
+ app_push_fill (&l, g.ui->label (attrs[0], "Stopped"));
else
{
if (g.song_elapsed >= 0)
{
- app_write_time (&buf, g.song_elapsed, attr_normal);
- row_buffer_append (&buf, " ", attr_normal);
+ char *s = app_time_string (g.song_elapsed);
+ app_push (&l, g.ui->label (attrs[0], s));
+ free (s);
}
if (g.song_duration >= 1)
{
- row_buffer_append (&buf, "/ ", attr_normal);
- app_write_time (&buf, g.song_duration, attr_normal);
- row_buffer_append (&buf, " ", attr_normal);
+ char *s = app_time_string (g.song_duration);
+ app_push (&l, g.ui->label (attrs[0], " / "));
+ app_push (&l, g.ui->label (attrs[0], s));
+ free (s);
}
- row_buffer_append (&buf, " ", attr_normal);
- }
- // It gets a bit complicated due to the only right-aligned item on the row
- char *volume = NULL;
- int remaining = COLS - buf.total_width;
- if (g.volume >= 0)
- {
- volume = xstrdup_printf (" %3d%%", g.volume);
- remaining -= strlen (volume);
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
}
- if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1
- && remaining > 0)
+ struct str volume = str_make ();
+#ifdef WITH_PULSE
+ if (g.pulse_control_requested)
{
- g.gauge_offset = buf.total_width;
- g.gauge_width = remaining;
- app_write_gauge (&buf,
- (float) g.song_elapsed / g.song_duration, remaining);
+ if (pulse_volume_status (&g.pulse, &volume))
+ {
+ if (g.volume >= 0 && g.volume != 100)
+ str_append_printf (&volume, " (%d%%)", g.volume);
+ }
+ else
+ {
+ if (g.volume >= 0)
+ str_append_printf (&volume, "(%d%%)", g.volume);
+ }
}
else
- row_buffer_space (&buf, remaining, attr_normal);
+#endif // WITH_PULSE
+ if (g.volume >= 0)
+ str_append_printf (&volume, "%3d%%", g.volume);
+
+ if (!stopped && g.song_elapsed >= 0 && g.song_duration >= 1)
+ app_push (&l, g.ui->gauge (attrs[0]))
+ ->id = WIDGET_GAUGE;
+ else
+ app_push_fill (&l, g.ui->padding (attrs[0], 0, 1));
- if (volume)
+ if (volume.len)
{
- row_buffer_append (&buf, volume, attr_normal);
- free (volume);
+ app_push (&l, g.ui->padding (attrs[0], 1, 1));
+ app_push (&l, g.ui->label (attrs[0], volume.str))
+ ->id = WIDGET_VOLUME;
}
- g.controls_offset = g.header_height;
- app_flush_header (&buf, attr_normal);
+ str_free (&volume);
+
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static void
-app_draw_header (void)
+app_layout_tabs (struct layout *out)
{
- g.header_height = 0;
-
- g.tabs_offset = -1;
- g.controls_offset = -1;
- g.gauge_offset = -1;
- g.gauge_width = 0;
-
- switch (g.client.state)
- {
- case MPD_CONNECTED:
- app_draw_status ();
- break;
- case MPD_CONNECTING:
- move (g.header_height++, 0);
- app_write_line ("Connecting to MPD...", APP_ATTR (NORMAL));
- break;
- case MPD_DISCONNECTED:
- move (g.header_height++, 0);
- app_write_line ("Disconnected", APP_ATTR (NORMAL));
- }
-
chtype attrs[2] = { APP_ATTR (TAB_BAR), APP_ATTR (TAB_ACTIVE) };
+ struct layout l = {};
// The help tab is disguised so that it's not too intruding
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, APP_TITLE, attrs[g.active_tab == g.help_tab]);
- row_buffer_append (&buf, " ", attrs[false]);
+ app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.25, 1))
+ ->id = WIDGET_TAB;
+ app_push (&l, g.ui->label (attrs[g.active_tab == g.help_tab], APP_TITLE))
+ ->id = WIDGET_TAB;
+
+ // XXX: attrs[0]?
+ app_push (&l, g.ui->padding (attrs[g.active_tab == g.help_tab], 0.5, 1))
+ ->id = WIDGET_TAB;
- g.tabs_offset = g.header_height;
+ int i = 0;
LIST_FOR_EACH (struct tab, iter, g.tabs)
- row_buffer_append (&buf, iter->name, attrs[iter == g.active_tab]);
- app_flush_header (&buf, attrs[false]);
+ {
+ struct widget *w = app_push (&l,
+ g.ui->label (attrs[iter == g.active_tab], iter->name));
+ w->id = WIDGET_TAB;
+ w->userdata = ++i;
+ }
- const char *header = g.active_tab->header;
- if (header)
+ app_push_fill (&l, g.ui->padding (attrs[0], 1, 1));
+
+#ifdef WITH_FFTW
+ // This seems like the most reasonable, otherwise unoccupied space
+ if (g.spectrum_fd != -1)
{
- buf = row_buffer_make ();
- row_buffer_append (&buf, header, APP_ATTR (HEADER));
- app_flush_header (&buf, APP_ATTR (HEADER));
+ app_push (&l, g.ui->spectrum (attrs[0], g.spectrum.bars))
+ ->id = WIDGET_SPECTRUM;
}
+#endif // WITH_FFTW
+
+ app_flush_layout (&l, out);
}
-static int
-app_fitting_items (void)
+static void
+app_layout_padding (chtype attrs, struct layout *out)
{
- // The raw number of items that would have fit on the terminal
- return LINES - g.header_height - 1 /* status bar */;
+ struct layout l = {};
+ app_push_fill (&l, g.ui->padding (attrs, 0, 0.125));
+ app_flush_layout (&l, out);
}
-static int
-app_visible_items (void)
+static void
+app_layout_header (struct layout *out)
{
- return MAX (0, app_fitting_items ());
+ if (g.client.state == MPD_CONNECTED)
+ {
+ app_layout_padding (APP_ATTR (NORMAL), out);
+ app_layout_status (out);
+ app_layout_padding (APP_ATTR (NORMAL), out);
+ }
+
+ app_layout_tabs (out);
+
+ const char *header = g.active_tab->header;
+ if (header)
+ app_layout_text (header, APP_ATTR (HEADER), out);
}
/// Figure out scrollbar appearance. @a s is the minimal slider length as well
@@ -1188,7 +1944,7 @@ app_visible_items (void)
struct scrollbar { long length, start; }
app_compute_scrollbar (struct tab *tab, long visible, long s)
{
- long top = tab->item_top, total = tab->item_count;
+ long top = s * tab->item_top, total = s * tab->item_count;
if (total < visible)
return (struct scrollbar) { 0, 0 };
if (visible == 1)
@@ -1198,7 +1954,7 @@ app_compute_scrollbar (struct tab *tab, long visible, long s)
// Only be at the top or bottom when the top or bottom item can be seen.
// The algorithm isn't optimal but it's a bitch to get right.
- double available_length = s * visible - 2 - s + 1;
+ double available_length = visible - 2 - s + 1;
double lenf = s + available_length * visible / total, length = 0.;
long offset = 1 + available_length * top / total + modf (lenf, &length);
@@ -1206,213 +1962,247 @@ app_compute_scrollbar (struct tab *tab, long visible, long s)
if (top == 0)
return (struct scrollbar) { length, 0 };
if (top + visible >= total)
- return (struct scrollbar) { length, s * visible - length };
+ return (struct scrollbar) { length, visible - length };
return (struct scrollbar) { length, offset };
}
-static void
-app_draw_scrollbar (void)
+static struct layout
+app_layout_row (struct tab *tab, int item_index)
{
- // This assumes that we can write to the one-before-last column,
- // i.e. that it's not covered by any double-wide character (and that
- // ncurses comes to the right results when counting characters).
- //
- // We could also precompute the scrollbar and append it to each row
- // as we render them, plus all the unoccupied rows.
- struct tab *tab = g.active_tab;
- int visible_items = app_visible_items ();
+ int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN);
- hard_assert (tab->item_count != 0);
- if (!g.use_partial_boxes)
- {
- struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1);
- for (int row = 0; row < visible_items; row++)
- {
- move (g.header_height + row, COLS - 1);
- if (row < bar.start || row >= bar.start + bar.length)
- addch (' ' | APP_ATTR (SCROLLBAR));
- else
- addch (' ' | APP_ATTR (SCROLLBAR) | A_REVERSE);
- }
- return;
- }
-
- struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 8);
- bar.length += bar.start;
-
- int start_part = bar.start % 8; bar.start /= 8;
- int end_part = bar.length % 8; bar.length /= 8;
+ bool override_colors = true;
+ if (item_index == tab->item_selected)
+ row_attrs = g_xui.focused
+ ? APP_ATTR (SELECTION) : APP_ATTR (DEFOCUSED);
+ else if (tab->item_mark > -1 &&
+ ((item_index >= tab->item_mark && item_index <= tab->item_selected)
+ || (item_index >= tab->item_selected && item_index <= tab->item_mark)))
+ row_attrs = g_xui.focused
+ ? APP_ATTR (MULTISELECT) : APP_ATTR (DEFOCUSED);
+ else
+ override_colors = false;
- // Even with this, the solid part must be at least one character high
- static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" };
+ // The padding must be added before the recoloring below.
+ struct layout l = tab->on_item_layout (item_index);
+ struct widget *w = g.ui->padding (0, 0.25, 1);
+ LIST_PREPEND (l.head, w);
+ app_push (&l, g.ui->padding (0, 0.25, 1));
- for (int row = 0; row < visible_items; row++)
+ // Combine attributes used by the handler with the defaults.
+ LIST_FOR_EACH (struct widget, w, l.head)
{
- chtype attrs = APP_ATTR (SCROLLBAR);
- if (row > bar.start && row <= bar.length)
- attrs ^= A_REVERSE;
-
- const char *c = " ";
- if (row == bar.start) c = partials[start_part];
- if (row == bar.length) c = partials[end_part];
-
- move (g.header_height + row, COLS - 1);
-
- struct row_buffer buf = row_buffer_make ();
- row_buffer_append (&buf, c, attrs);
- row_buffer_flush (&buf);
- row_buffer_free (&buf);
+ chtype *attrs = &w->attrs;
+ if (override_colors)
+ *attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs;
+ else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR))
+ *attrs |= (row_attrs & ~A_COLOR);
+ else
+ *attrs |= row_attrs;
}
+ return l;
}
static void
-app_draw_view (void)
+app_layout_view (struct layout *out, int height)
{
- move (g.header_height, 0);
- clrtobot ();
+ struct layout l = {};
+ struct widget *list = app_push_fill (&l, g.ui->list ());
+ list->id = WIDGET_LIST;
+ list->height = height;
+ list->width = g_xui.width;
struct tab *tab = g.active_tab;
- bool want_scrollbar = (int) tab->item_count > app_visible_items ();
- int view_width = COLS - want_scrollbar;
+ if ((int) tab->item_count * g_xui.vunit > list->height)
+ {
+ struct widget *scrollbar = g.ui->scrollbar (APP_ATTR (SCROLLBAR));
+ list->width -= scrollbar->width;
+ app_push (&l, scrollbar)->id = WIDGET_SCROLLBAR;
+ }
- int to_show =
- MIN (app_fitting_items (), (int) tab->item_count - tab->item_top);
+ int to_show = MIN ((int) tab->item_count - tab->item_top,
+ ceil ((double) list->height / g_xui.vunit));
+
+ struct layout children = {};
for (int row = 0; row < to_show; row++)
{
int item_index = tab->item_top + row;
- int row_attrs = (item_index & 1) ? APP_ATTR (ODD) : APP_ATTR (EVEN);
-
- bool override_colors = true;
- if (item_index == tab->item_selected)
- row_attrs = APP_ATTR (SELECTION);
- else if (tab->item_mark > -1 &&
- ((item_index >= tab->item_mark && item_index <= tab->item_selected)
- || (item_index >= tab->item_selected && item_index <= tab->item_mark)))
- row_attrs = APP_ATTR (MULTISELECT);
- else
- override_colors = false;
-
- struct row_buffer buf = row_buffer_make ();
- tab->on_item_draw (item_index, &buf, view_width);
-
- // Combine attributes used by the handler with the defaults.
- // Avoiding attrset() because of row_buffer_flush().
- for (size_t i = 0; i < buf.chars_len; i++)
- {
- chtype *attrs = &buf.chars[i].attrs;
- if (override_colors)
- *attrs = (*attrs & ~(A_COLOR | A_REVERSE)) | row_attrs;
- else if ((*attrs & A_COLOR) && (row_attrs & A_COLOR))
- *attrs |= (row_attrs & ~A_COLOR);
- else
- *attrs |= row_attrs;
- }
-
- move (g.header_height + row, 0);
- app_flush_buffer (&buf, view_width, row_attrs);
+ struct layout subl = app_layout_row (tab, item_index);
+ // TODO: Change layouting so that we don't need to know list->width.
+ app_flush_layout_full (&subl, list->width, &children);
}
+ list->children = children.head;
- if (want_scrollbar)
- app_draw_scrollbar ();
+ app_flush_layout (&l, out);
}
static void
-app_write_mpd_status_playlist (struct row_buffer *buf)
+app_layout_mpd_status_playlist (struct layout *l, chtype attrs)
{
- struct str stats = str_make ();
- if (g.playlist.len == 1)
- str_append_printf (&stats, "1 song ");
- else
- str_append_printf (&stats, "%zu songs ", g.playlist.len);
+ char *songs = (g.playlist.len == 1)
+ ? xstrdup_printf ("1 song")
+ : xstrdup_printf ("%zu songs", g.playlist.len);
+ app_push (l, g.ui->label (attrs, songs));
+ free (songs);
int hours = g.playlist_time / 3600;
int minutes = g.playlist_time % 3600 / 60;
if (hours || minutes)
{
- str_append_c (&stats, ' ');
-
+ struct str length = str_make ();
if (hours == 1)
- str_append_printf (&stats, " 1 hour");
+ str_append_printf (&length, " 1 hour");
else if (hours)
- str_append_printf (&stats, " %d hours", hours);
+ str_append_printf (&length, " %d hours", hours);
if (minutes == 1)
- str_append_printf (&stats, " 1 minute");
+ str_append_printf (&length, " 1 minute");
else if (minutes)
- str_append_printf (&stats, " %d minutes", minutes);
+ str_append_printf (&length, " %d minutes", minutes);
+
+ app_push (l, g.ui->padding (attrs, 1, 1));
+ app_push (l, g.ui->label (attrs, length.str + 1));
+ str_free (&length);
+ }
+
+ const char *task = NULL;
+ if (g.poller_curl.registered)
+ task = "Downloading...";
+ else if (str_map_find (&g.playback_info, "updating_db"))
+ task = "Updating database...";
+
+ if (task)
+ {
+ app_push (l, g.ui->padding (attrs, 1, 1));
+ app_push (l, g.ui->label (attrs, task));
}
- row_buffer_append (buf, stats.str, APP_ATTR (NORMAL));
- str_free (&stats);
}
static void
-app_write_mpd_status (struct row_buffer *buf)
+app_layout_mpd_status (struct layout *out)
{
- struct str_map *map = &g.playback_info;
+ struct layout l = {};
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+
if (g.active_tab->item_mark > -1)
{
struct tab_range r = tab_selection_range (g.active_tab);
char *msg = xstrdup_printf (r.from == r.upto
? "Selected %d item" : "Selected %d items", r.upto - r.from + 1);
- row_buffer_append (buf, msg, APP_ATTR (HIGHLIGHT));
+ app_push_fill (&l, g.ui->label (attrs[0], msg));
free (msg);
}
- else if (str_map_find (map, "updating_db"))
- row_buffer_append (buf, "Updating database...", APP_ATTR (NORMAL));
else
- app_write_mpd_status_playlist (buf);
+ {
+ app_layout_mpd_status_playlist (&l, attrs[0]);
+ l.tail->width = -1;
+ }
- const char *s;
+ const char *s = NULL;
+ struct str_map *map = &g.playback_info;
bool repeat = (s = str_map_find (map, "repeat")) && strcmp (s, "0");
bool random = (s = str_map_find (map, "random")) && strcmp (s, "0");
bool single = (s = str_map_find (map, "single")) && strcmp (s, "0");
bool consume = (s = str_map_find (map, "consume")) && strcmp (s, "0");
- struct row_buffer right = row_buffer_make ();
- chtype a[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
- if (repeat) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "repeat", a[repeat], NULL);
- if (random) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "random", a[random], NULL);
- if (single) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "single", a[single], NULL);
- if (consume) row_buffer_append_args (&right,
- " ", APP_ATTR (NORMAL), "consume", a[consume], NULL);
+ if (g.ui->have_icons || repeat)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[repeat], "repeat", ACTION_MPD_REPEAT));
+ }
+ if (g.ui->have_icons || random)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[random], "random", ACTION_MPD_RANDOM));
+ }
+ if (g.ui->have_icons || single)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[single], "single", ACTION_MPD_SINGLE));
+ }
+ if (g.ui->have_icons || consume)
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.5, 1));
+ app_push (&l,
+ g.ui->button (attrs[consume], "consume", ACTION_MPD_CONSUME));
+ }
- row_buffer_space (buf,
- MAX (0, COLS - buf->total_width - right.total_width),
- APP_ATTR (NORMAL));
- row_buffer_append_buffer (buf, &right);
- row_buffer_free (&right);
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
static void
-app_draw_statusbar (void)
+app_layout_statusbar (struct layout *out)
{
- int caret = -1;
+ chtype attrs[2] = { APP_ATTR (NORMAL), APP_ATTR (HIGHLIGHT) };
+ app_layout_padding (attrs[0], out);
- struct row_buffer buf = row_buffer_make ();
+ struct layout l = {};
if (g.message)
- row_buffer_append (&buf, g.message, APP_ATTR (HIGHLIGHT));
- else if (g.editor.line)
- caret = line_editor_write (&g.editor, &buf, COLS, APP_ATTR (HIGHLIGHT));
- else if (g.client.state == MPD_CONNECTED)
- app_write_mpd_status (&buf);
-
- move (LINES - 1, 0);
- app_flush_buffer (&buf, COLS, APP_ATTR (NORMAL));
+ {
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ if (!g.message_detail)
+ app_push_fill (&l, g.ui->label (attrs[1], g.message));
+ else
+ {
+ app_push (&l, g.ui->label (attrs[1], g.message));
+ app_push_fill (&l, g.ui->label (attrs[0], g.message_detail));
+ }
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
- curs_set (0);
- if (caret != -1)
+ app_flush_layout (&l, out);
+ LIST_FOR_EACH (struct widget, w, l.head)
+ w->id = WIDGET_MESSAGE;
+ }
+ else if (g.editor.line)
{
- move (LINES - 1, caret);
- curs_set (1);
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_push (&l, g.ui->editor (attrs[1]));
+ app_push (&l, g.ui->padding (attrs[0], 0.25, 1));
+ app_flush_layout (&l, out);
}
+ else if (g.client.state == MPD_CONNECTED)
+ app_layout_mpd_status (out);
+ else if (g.client.state == MPD_CONNECTING)
+ app_layout_text ("Connecting to MPD...", attrs[0], out);
+ else if (g.client.state == MPD_DISCONNECTED)
+ app_layout_text ("Disconnected", attrs[0], out);
+
+ app_layout_padding (attrs[0], out);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+static struct widget *
+app_widget_by_id (int id)
+{
+ LIST_FOR_EACH (struct widget, w, g_xui.widgets)
+ if (w->id == id)
+ return w;
+ return NULL;
+}
+
+static int
+app_visible_items_height (void)
+{
+ struct widget *list = app_widget_by_id (WIDGET_LIST);
+ hard_assert (list != NULL);
+
+ // The raw number of items that would have fit on the terminal
+ return MAX (0, list->height);
+}
+
+static int
+app_visible_items (void)
+{
+ return app_visible_items_height () / g_xui.vunit;
+}
+
/// Checks what items are visible and returns if the range was alright
static bool
app_fix_view_range (void)
@@ -1438,17 +2228,27 @@ app_fix_view_range (void)
}
static void
-app_on_refresh (void *user_data)
+app_layout (void)
{
- (void) user_data;
- poller_idle_reset (&g.refresh_event);
+ struct layout top = {}, bottom = {};
+ app_layout_header (&top);
+ app_layout_statusbar (&bottom);
+
+ int available_height = g_xui.height;
+ if (top.tail)
+ available_height -= top.tail->y + top.tail->height;
+ if (bottom.tail)
+ available_height -= bottom.tail->y + bottom.tail->height;
+
+ struct layout widgets = {};
+ app_append_layout (&top, &widgets);
+ app_layout_view (&widgets, available_height);
+ app_append_layout (&bottom, &widgets);
+ g_xui.widgets = widgets.head;
- app_draw_header ();
app_fix_view_range();
- app_draw_view ();
- app_draw_statusbar ();
- refresh ();
+ curs_set (0);
}
// --- Actions -----------------------------------------------------------------
@@ -1458,7 +2258,7 @@ static bool
app_scroll (int n)
{
g.active_tab->item_top += n;
- app_invalidate ();
+ xui_invalidate ();
return app_fix_view_range ();
}
@@ -1480,6 +2280,19 @@ app_ensure_selection_visible (void)
}
static bool
+app_center_cursor (void)
+{
+ struct tab *tab = g.active_tab;
+ if (tab->item_selected < 0 || !tab->item_count)
+ return false;
+
+ int offset = tab->item_selected - tab->item_top;
+ int target = app_visible_items () / 2;
+ app_scroll (offset - target);
+ return true;
+}
+
+static bool
app_move_selection (int diff)
{
struct tab *tab = g.active_tab;
@@ -1489,19 +2302,52 @@ app_move_selection (int diff)
bool result = !diff || tab->item_selected != fixed;
tab->item_selected = fixed;
- app_invalidate ();
+ xui_invalidate ();
app_ensure_selection_visible ();
return result;
}
+static void
+app_show_message (char *message, char *detail)
+{
+ cstr_set (&g.message, message);
+ cstr_set (&g.message_detail, detail);
+ poller_timer_set (&g.message_timer, 5000);
+ xui_invalidate ();
+}
+
+static void
+app_hide_message (void)
+{
+ if (!g.message)
+ return;
+
+ cstr_set (&g.message, NULL);
+ cstr_set (&g.message_detail, NULL);
+ poller_timer_reset (&g.message_timer);
+ xui_invalidate ();
+}
+
+static void
+app_on_clipboard_copy (const char *text)
+{
+ app_show_message (xstrdup ("Text copied to clipboard: "), xstrdup (text));
+}
+
+static struct widget *
+app_make_label (chtype attrs, const char *label)
+{
+ return g_xui.ui->label (attrs, 0, label);
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static void
app_prepend_tab (struct tab *tab)
{
LIST_PREPEND (g.tabs, tab);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -1512,7 +2358,7 @@ app_switch_tab (struct tab *tab)
g.last_tab = g.active_tab;
g.active_tab = tab;
- app_invalidate ();
+ xui_invalidate ();
}
static bool
@@ -1530,105 +2376,12 @@ app_goto_tab (int tab_index)
// --- Actions -----------------------------------------------------------------
-#define ACTIONS(XX) \
- XX( NONE, "Do nothing" ) \
- \
- XX( QUIT, "Quit" ) \
- XX( REDRAW, "Redraw screen" ) \
- XX( TAB_HELP, "Switch to help tab" ) \
- XX( TAB_LAST, "Switch to last tab" ) \
- XX( TAB_PREVIOUS, "Switch to previous tab" ) \
- XX( TAB_NEXT, "Switch to next tab" ) \
- \
- XX( MPD_TOGGLE, "Toggle play/pause" ) \
- XX( MPD_STOP, "Stop playback" ) \
- XX( MPD_PREVIOUS, "Previous song" ) \
- XX( MPD_NEXT, "Next song" ) \
- XX( MPD_BACKWARD, "Seek backwards" ) \
- XX( MPD_FORWARD, "Seek forwards" ) \
- XX( MPD_VOLUME_UP, "Increase volume" ) \
- XX( MPD_VOLUME_DOWN, "Decrease volume" ) \
- \
- XX( MPD_SEARCH, "Global search" ) \
- XX( MPD_ADD, "Add selection to playlist" ) \
- XX( MPD_REPLACE, "Replace playlist" ) \
- XX( MPD_REPEAT, "Toggle repeat" ) \
- XX( MPD_RANDOM, "Toggle random playback" ) \
- XX( MPD_SINGLE, "Toggle single song playback" ) \
- XX( MPD_CONSUME, "Toggle consume" ) \
- XX( MPD_UPDATE_DB, "Update MPD database" ) \
- XX( MPD_COMMAND, "Send raw command to MPD" ) \
- \
- XX( CHOOSE, "Choose item" ) \
- XX( DELETE, "Delete item" ) \
- XX( UP, "Go up a level" ) \
- XX( MULTISELECT, "Toggle multiselect" ) \
- \
- XX( SCROLL_UP, "Scroll up" ) \
- XX( SCROLL_DOWN, "Scroll down" ) \
- XX( MOVE_UP, "Move selection up" ) \
- XX( MOVE_DOWN, "Move selection down" ) \
- \
- XX( GOTO_TOP, "Go to top" ) \
- XX( GOTO_BOTTOM, "Go to bottom" ) \
- XX( GOTO_ITEM_PREVIOUS, "Go to previous item" ) \
- XX( GOTO_ITEM_NEXT, "Go to next item" ) \
- XX( GOTO_PAGE_PREVIOUS, "Go to previous page" ) \
- XX( GOTO_PAGE_NEXT, "Go to next page" ) \
- \
- XX( GOTO_VIEW_TOP, "Select top item" ) \
- XX( GOTO_VIEW_CENTER, "Select center item" ) \
- XX( GOTO_VIEW_BOTTOM, "Select bottom item" ) \
- \
- XX( EDITOR_CONFIRM, "Confirm input" ) \
- \
- XX( EDITOR_B_CHAR, "Go back a character" ) \
- XX( EDITOR_F_CHAR, "Go forward a character" ) \
- XX( EDITOR_B_WORD, "Go back a word" ) \
- XX( EDITOR_F_WORD, "Go forward a word" ) \
- XX( EDITOR_HOME, "Go to start of line" ) \
- XX( EDITOR_END, "Go to end of line" ) \
- \
- XX( EDITOR_B_DELETE, "Delete last character" ) \
- XX( EDITOR_F_DELETE, "Delete next character" ) \
- XX( EDITOR_B_KILL_WORD, "Delete last word" ) \
- XX( EDITOR_B_KILL_LINE, "Delete everything up to BOL" ) \
- XX( EDITOR_F_KILL_LINE, "Delete everything up to EOL" )
-
-enum action
-{
-#define XX(name, description) ACTION_ ## name,
- ACTIONS (XX)
-#undef XX
- ACTION_COUNT
-};
-
-static struct action_info
-{
- const char *name; ///< Name for user bindings
- const char *description; ///< Human-readable description
-}
-g_actions[] =
-{
-#define XX(name, description) { #name, description },
- ACTIONS (XX)
-#undef XX
-};
-
-/// Accept a more human format of action-name instead of ACTION_NAME
-static int action_toupper (int c) { return c == '-' ? '_' : toupper_ascii (c); }
-
static int
action_resolve (const char *name)
{
- const unsigned char *s = (const unsigned char *) name;
for (int i = 0; i < ACTION_COUNT; i++)
- {
- const char *target = g_actions[i].name;
- for (size_t k = 0; action_toupper (s[k]) == target[k]; k++)
- if (!s[k] && !target[k])
- return i;
- }
+ if (!strcasecmp_ascii (g_action_names[i], name))
+ return i;
return -1;
}
@@ -1689,7 +2442,7 @@ app_setvol (int value)
}
static void
-app_on_editor_end (bool confirmed)
+app_on_mpd_command_editor_end (bool confirmed)
{
struct mpd_client *c = &g.client;
if (!confirmed)
@@ -1704,6 +2457,63 @@ app_on_editor_end (bool confirmed)
mpd_client_idle (c, 0);
}
+static size_t
+incremental_search_match (const ucs4_t *needle, size_t len,
+ const ucs4_t *chars, size_t chars_len)
+{
+ // XXX: this is slow and simplistic, but unistring is awkward to use
+ size_t best = 0;
+ for (size_t start = 0; start < chars_len; start++)
+ {
+ size_t i = 0;
+ for (; i < len && start + i < chars_len; i++)
+ if (uc_tolower (needle[i]) != uc_tolower (chars[start + i]))
+ break;
+ best = MAX (best, i);
+ }
+ return best;
+}
+
+static void
+incremental_search_on_changed (void)
+{
+ struct tab *tab = g.active_tab;
+ if (!tab->item_count)
+ return;
+
+ size_t best = 0, current = 0, index = MAX (tab->item_selected, 0), i = 0;
+ while (i++ < tab->item_count)
+ {
+ struct str s = str_make ();
+ LIST_FOR_EACH (struct widget, w, tab->on_item_layout (index).head)
+ {
+ str_append (&s, w->text);
+ widget_destroy (w);
+ }
+
+ size_t len;
+ ucs4_t *text = u8_to_u32 ((const uint8_t *) s.str, s.len, NULL, &len);
+ str_free (&s);
+ current = incremental_search_match
+ (g.editor.line, g.editor.len, text, len);
+ free (text);
+ if (best < current)
+ {
+ best = current;
+ tab->item_selected = index;
+ app_move_selection (0);
+ }
+ index = (index + 1) % tab->item_count;
+ }
+}
+
+static void
+incremental_search_on_end (bool confirmed)
+{
+ (void) confirmed;
+ // Required callback, nothing to do here.
+}
+
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static bool
@@ -1721,7 +2531,7 @@ app_process_action (enum action action)
struct tab *tab = g.active_tab;
if (tab->on_action && tab->on_action (action))
{
- app_invalidate ();
+ xui_invalidate ();
return true;
}
@@ -1730,26 +2540,31 @@ app_process_action (enum action action)
case ACTION_NONE:
return true;
case ACTION_QUIT:
+ app_quit ();
+ return true;
+ case ACTION_REDRAW:
+ clear ();
+ xui_invalidate ();
+ return true;
+
+ case ACTION_ABORT:
// It is a pseudomode, avoid surprising the user
if (tab->item_mark > -1)
{
tab->item_mark = -1;
- app_invalidate ();
+ xui_invalidate ();
return true;
}
-
- app_quit ();
- return true;
- case ACTION_REDRAW:
- clear ();
- app_invalidate ();
- return true;
+ return false;
case ACTION_MPD_COMMAND:
line_editor_start (&g.editor, ':');
- g.editor.on_end = app_on_editor_end;
- app_invalidate ();
+ g.editor.on_end = app_on_mpd_command_editor_end;
+ xui_invalidate ();
+ app_hide_message ();
return true;
default:
+ print_error ("\"%s\" is not allowed here",
+ g_action_descriptions[action]);
return false;
case ACTION_MULTISELECT:
@@ -1757,12 +2572,19 @@ app_process_action (enum action action)
|| !tab->item_count || tab->item_selected < 0)
return false;
- app_invalidate ();
+ xui_invalidate ();
if (tab->item_mark > -1)
tab->item_mark = -1;
else
tab->item_mark = tab->item_selected;
return true;
+ case ACTION_INCREMENTAL_SEARCH:
+ line_editor_start (&g.editor, '/');
+ g.editor.on_changed = incremental_search_on_changed;
+ g.editor.on_end = incremental_search_on_end;
+ xui_invalidate ();
+ app_hide_message ();
+ return true;
case ACTION_TAB_LAST:
if (!g.last_tab)
@@ -1804,19 +2626,26 @@ app_process_action (enum action action)
case ACTION_MPD_CONSUME: return app_mpd_toggle ("consume");
case ACTION_MPD_UPDATE_DB: return MPD_SIMPLE ("update");
- case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 10);
- case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 10);
+ case ACTION_MPD_VOLUME_UP: return app_setvol (g.volume + 5);
+ case ACTION_MPD_VOLUME_DOWN: return app_setvol (g.volume - 5);
+
+#ifdef WITH_PULSE
+ case ACTION_PULSE_VOLUME_UP: return pulse_volume_set (&g.pulse, +5);
+ case ACTION_PULSE_VOLUME_DOWN: return pulse_volume_set (&g.pulse, -5);
+ case ACTION_PULSE_MUTE: return pulse_volume_mute (&g.pulse);
+#endif // WITH_PULSE
- // XXX: these should rather be parametrized
+ // XXX: these two should rather be parametrized
case ACTION_SCROLL_UP: return app_scroll (-3);
- case ACTION_SCROLL_DOWN: return app_scroll (3);
+ case ACTION_SCROLL_DOWN: return app_scroll (+3);
+ case ACTION_CENTER_CURSOR: return app_center_cursor ();
case ACTION_GOTO_TOP:
if (tab->item_count)
{
g.active_tab->item_selected = 0;
app_ensure_selection_visible ();
- app_invalidate ();
+ xui_invalidate ();
}
return true;
case ACTION_GOTO_BOTTOM:
@@ -1825,7 +2654,7 @@ app_process_action (enum action action)
g.active_tab->item_selected =
MAX (0, (int) g.active_tab->item_count - 1);
app_ensure_selection_visible ();
- app_invalidate ();
+ xui_invalidate ();
}
return true;
@@ -1855,10 +2684,10 @@ app_process_action (enum action action)
static bool
app_editor_process_action (enum action action)
{
- app_invalidate ();
+ xui_invalidate ();
switch (action)
{
- case ACTION_QUIT:
+ case ACTION_ABORT:
line_editor_abort (&g.editor, false);
g.editor.on_end = NULL;
return true;
@@ -1867,6 +2696,8 @@ app_editor_process_action (enum action action)
g.editor.on_end = NULL;
return true;
default:
+ print_error ("\"%s\" is not allowed here",
+ g_action_descriptions[action]);
return false;
case ACTION_EDITOR_B_CHAR:
@@ -1882,6 +2713,13 @@ app_editor_process_action (enum action action)
case ACTION_EDITOR_END:
return line_editor_action (&g.editor, LINE_EDITOR_END);
+ case ACTION_EDITOR_UPCASE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_UPCASE_WORD);
+ case ACTION_EDITOR_DOWNCASE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_DOWNCASE_WORD);
+ case ACTION_EDITOR_CAPITALIZE_WORD:
+ return line_editor_action (&g.editor, LINE_EDITOR_CAPITALIZE_WORD);
+
case ACTION_EDITOR_B_DELETE:
return line_editor_action (&g.editor, LINE_EDITOR_B_DELETE);
case ACTION_EDITOR_F_DELETE:
@@ -1897,93 +2735,156 @@ app_editor_process_action (enum action action)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+// Carefully chosen to limit the possibility of ever hitting termo keymods.
+enum { APP_KEYMOD_DOUBLE_CLICK = 1 << 15 };
+
static bool
-app_process_left_mouse_click (int line, int column, bool double_click)
+app_process_left_mouse_click (struct widget *w, int x, int y, int modifiers)
{
- if (line == g.controls_offset)
+ switch (w->id)
{
- // XXX: there could be a push_widget(buf, text, attrs, handler)
- // function to help with this but it might not be worth it
- enum action action = ACTION_NONE;
- if (column >= 0 && column <= 1) action = ACTION_MPD_PREVIOUS;
- if (column >= 3 && column <= 4) action = ACTION_MPD_TOGGLE;
- if (column >= 6 && column <= 7) action = ACTION_MPD_STOP;
- if (column >= 9 && column <= 10) action = ACTION_MPD_NEXT;
-
- if (action)
- return app_process_action (action);
-
- int gauge_offset = column - g.gauge_offset;
- if (g.gauge_offset < 0
- || gauge_offset < 0 || gauge_offset >= g.gauge_width)
- return false;
-
- float position = (float) gauge_offset / g.gauge_width;
+ case WIDGET_BUTTON:
+ app_process_action (w->userdata);
+ break;
+ case WIDGET_GAUGE:
+ {
+ // TODO: We should avoid queuing up too many.
+ float position = (float) x / w->width;
if (g.song_duration >= 1)
{
char *where = xstrdup_printf ("%f", position * g.song_duration);
MPD_SIMPLE ("seekcur", where);
free (where);
}
+ break;
}
- else if (line == g.tabs_offset)
+ case WIDGET_TAB:
{
- struct tab *winner = NULL;
- int indent = strlen (APP_TITLE);
- if (column < indent)
- {
- app_switch_tab (g.help_tab);
- return true;
- }
- for (struct tab *iter = g.tabs; !winner && iter; iter = iter->next)
- {
- if (column < (indent += iter->name_width))
- winner = iter;
- }
- if (!winner)
- return false;
+ struct tab *tab = g.help_tab;
+ int i = 0;
+ LIST_FOR_EACH (struct tab, iter, g.tabs)
+ if (++i == w->userdata)
+ tab = iter;
- app_switch_tab (winner);
+ app_switch_tab (tab);
+ break;
}
- else if (line >= g.header_height)
+ case WIDGET_LIST:
{
struct tab *tab = g.active_tab;
- int row_index = line - g.header_height;
+ int row_index = y / g_xui.vunit;
if (row_index < 0
|| row_index >= (int) tab->item_count - tab->item_top)
return false;
- // TODO: handle the scrollbar a bit better than this
- int visible_items = app_visible_items ();
- if ((int) tab->item_count > visible_items && column == COLS - 1)
- tab->item_top = (float) row_index / visible_items
- * (int) tab->item_count - visible_items / 2;
- else
- tab->item_selected = row_index + tab->item_top;
- app_invalidate ();
+ if (!(modifiers & TERMO_KEYMOD_SHIFT))
+ tab->item_mark = -1;
+ else if (!tab->can_multiselect || tab->item_selected < 0)
+ return false;
+ else if (tab->item_mark < 0)
+ tab->item_mark = tab->item_selected;
+
+ tab->item_selected = row_index + tab->item_top;
+ app_ensure_selection_visible ();
+ xui_invalidate ();
- if (double_click)
+ if (modifiers & APP_KEYMOD_DOUBLE_CLICK)
app_process_action (ACTION_CHOOSE);
+ break;
+ }
+ case WIDGET_SCROLLBAR:
+ {
+ struct tab *tab = g.active_tab;
+ int visible_items = app_visible_items ();
+ tab->item_top = (double) y / w->height
+ * (int) tab->item_count - visible_items / 2;
+ xui_invalidate ();
+ app_fix_view_range ();
+ break;
+ }
+ case WIDGET_MESSAGE:
+ app_hide_message ();
}
return true;
}
static bool
-app_process_mouse (termo_mouse_event_t type, int line, int column, int button,
- bool double_click)
+app_process_mouse (termo_mouse_event_t type, int x, int y, int button,
+ int modifiers)
{
- if (type != TERMO_MOUSE_PRESS)
+ // XXX: Terminals don't let us know which button has been released,
+ // so we can't press buttons at that point. We'd need a special "click"
+ // event handler that could be handled better under X11.
+ if (type == TERMO_MOUSE_RELEASE)
+ {
+ g.ui_dragging = WIDGET_NONE;
return true;
+ }
+
+ if (type == TERMO_MOUSE_DRAG)
+ {
+ if (g.ui_dragging != WIDGET_GAUGE
+ && g.ui_dragging != WIDGET_SCROLLBAR)
+ return true;
+
+ struct widget *target = app_widget_by_id (g.ui_dragging);
+ x -= target->x;
+ y -= target->y;
+ return app_process_left_mouse_click (target, x, y, modifiers);
+ }
if (g.editor.line)
+ {
line_editor_abort (&g.editor, false);
+ xui_invalidate ();
+ }
+
+ struct widget *target = NULL;
+ LIST_FOR_EACH (struct widget, w, g_xui.widgets)
+ if (x >= w->x && x < w->x + w->width
+ && y >= w->y && y < w->y + w->height)
+ target = w;
+ if (!target)
+ return false;
- if (button == 1)
- return app_process_left_mouse_click (line, column, double_click);
- else if (button == 4)
- return app_process_action (ACTION_SCROLL_UP);
- else if (button == 5)
- return app_process_action (ACTION_SCROLL_DOWN);
+ x -= target->x;
+ y -= target->y;
+ switch (button)
+ {
+ case 1:
+ g.ui_dragging = target->id;
+ return app_process_left_mouse_click (target, x, y, modifiers);
+ case 4:
+ switch (target->id)
+ {
+ case WIDGET_LIST:
+ return app_process_action (ACTION_SCROLL_UP);
+ case WIDGET_VOLUME:
+ return app_process_action (
+#ifdef WITH_PULSE
+ g.pulse_control_requested ? ACTION_PULSE_VOLUME_UP :
+#endif // WITH_PULSE
+ ACTION_MPD_VOLUME_UP);
+ case WIDGET_GAUGE:
+ return app_process_action (ACTION_MPD_FORWARD);
+ }
+ break;
+ case 5:
+ switch (target->id)
+ {
+ case WIDGET_LIST:
+ return app_process_action (ACTION_SCROLL_DOWN);
+ case WIDGET_VOLUME:
+ return app_process_action (
+#ifdef WITH_PULSE
+ g.pulse_control_requested ? ACTION_PULSE_VOLUME_DOWN :
+#endif // WITH_PULSE
+ ACTION_MPD_VOLUME_DOWN);
+ case WIDGET_GAUGE:
+ return app_process_action (ACTION_MPD_BACKWARD);
+ }
+ break;
+ }
return false;
}
@@ -2005,16 +2906,19 @@ static struct binding_default
}
g_normal_defaults[] =
{
- { "Escape", ACTION_QUIT },
{ "q", ACTION_QUIT },
{ "C-l", ACTION_REDRAW },
+ { "Escape", ACTION_ABORT },
{ "M-Tab", ACTION_TAB_LAST },
{ "F1", ACTION_TAB_HELP },
+ { "S-Tab", ACTION_TAB_PREVIOUS },
+ { "Tab", ACTION_TAB_NEXT },
{ "C-Left", ACTION_TAB_PREVIOUS },
{ "C-Right", ACTION_TAB_NEXT },
{ "C-PageUp", ACTION_TAB_PREVIOUS },
{ "C-PageDown", ACTION_TAB_NEXT },
+ { "o", ACTION_GOTO_PLAYING },
{ "Home", ACTION_GOTO_TOP },
{ "End", ACTION_GOTO_BOTTOM },
{ "M-<", ACTION_GOTO_TOP },
@@ -2035,6 +2939,7 @@ g_normal_defaults[] =
{ "C-f", ACTION_GOTO_PAGE_NEXT },
{ "C-y", ACTION_SCROLL_UP },
{ "C-e", ACTION_SCROLL_DOWN },
+ { "z", ACTION_CENTER_CURSOR },
{ "H", ACTION_GOTO_VIEW_TOP },
{ "M", ACTION_GOTO_VIEW_CENTER },
@@ -2044,8 +2949,11 @@ g_normal_defaults[] =
{ "Enter", ACTION_CHOOSE },
{ "Delete", ACTION_DELETE },
{ "d", ACTION_DELETE },
+ { "?", ACTION_DESCRIBE },
+ { "M-Up", ACTION_UP },
{ "Backspace", ACTION_UP },
{ "v", ACTION_MULTISELECT },
+ { "C-s", ACTION_INCREMENTAL_SEARCH },
{ "/", ACTION_MPD_SEARCH },
{ "a", ACTION_MPD_ADD },
{ "r", ACTION_MPD_REPLACE },
@@ -2062,11 +2970,15 @@ g_normal_defaults[] =
{ "Space", ACTION_MPD_TOGGLE },
{ "C-Space", ACTION_MPD_STOP },
{ "u", ACTION_MPD_UPDATE_DB },
- { "M-PageUp", ACTION_MPD_VOLUME_UP },
- { "M-PageDown", ACTION_MPD_VOLUME_DOWN },
+ { "+", ACTION_MPD_VOLUME_UP },
+ { "-", ACTION_MPD_VOLUME_DOWN },
},
g_editor_defaults[] =
{
+ { "C-g", ACTION_ABORT },
+ { "Escape", ACTION_ABORT },
+ { "Enter", ACTION_EDITOR_CONFIRM },
+
{ "Left", ACTION_EDITOR_B_CHAR },
{ "Right", ACTION_EDITOR_F_CHAR },
{ "C-b", ACTION_EDITOR_B_CHAR },
@@ -2078,6 +2990,10 @@ g_editor_defaults[] =
{ "C-a", ACTION_EDITOR_HOME },
{ "C-e", ACTION_EDITOR_END },
+ { "M-u", ACTION_EDITOR_UPCASE_WORD },
+ { "M-l", ACTION_EDITOR_DOWNCASE_WORD },
+ { "M-c", ACTION_EDITOR_CAPITALIZE_WORD },
+
{ "C-h", ACTION_EDITOR_B_DELETE },
{ "DEL", ACTION_EDITOR_B_DELETE },
{ "Backspace", ACTION_EDITOR_B_DELETE },
@@ -2086,17 +3002,13 @@ g_editor_defaults[] =
{ "C-u", ACTION_EDITOR_B_KILL_LINE },
{ "C-k", ACTION_EDITOR_F_KILL_LINE },
{ "C-w", ACTION_EDITOR_B_KILL_WORD },
-
- { "C-g", ACTION_QUIT },
- { "Escape", ACTION_QUIT },
- { "Enter", ACTION_EDITOR_CONFIRM },
};
static int
app_binding_cmp (const void *a, const void *b)
{
const struct binding *aa = a, *bb = b;
- int cmp = termo_keycmp (g.tk, &aa->decoded, &bb->decoded);
+ int cmp = termo_keycmp (g_xui.tk, &aa->decoded, &bb->decoded);
return cmp ? cmp : bb->order - aa->order;
}
@@ -2107,7 +3019,7 @@ app_next_binding (struct str_map_iter *iter, termo_key_t *key, int *action)
while ((v = str_map_iter_next (iter)))
{
*action = ACTION_NONE;
- if (*termo_strpkey_utf8 (g.tk,
+ if (*termo_strpkey_utf8 (g_xui.tk,
iter->link->key, key, TERMO_FORMAT_ALTISMETA))
print_error ("%s: invalid binding", iter->link->key);
else if (v->type == CONFIG_ITEM_NULL)
@@ -2136,7 +3048,7 @@ app_init_bindings (const char *keymap,
termo_key_t decoded;
for (size_t i = 0; i < defaults_len; i++)
{
- hard_assert (!*termo_strpkey_utf8 (g.tk,
+ hard_assert (!*termo_strpkey_utf8 (g_xui.tk,
defaults[i].key, &decoded, TERMO_FORMAT_ALTISMETA));
a[a_len++] = (struct binding) { decoded, defaults[i].action, order++ };
}
@@ -2158,7 +3070,8 @@ app_init_bindings (const char *keymap,
for (size_t in = 0; in < a_len; in++)
{
a[in].order = 0;
- if (!out || termo_keycmp (g.tk, &a[in].decoded, &a[out - 1].decoded))
+ if (!out
+ || termo_keycmp (g_xui.tk, &a[in].decoded, &a[out - 1].decoded))
a[out++] = a[in];
}
@@ -2166,20 +3079,52 @@ app_init_bindings (const char *keymap,
return a;
}
+static char *
+app_strfkey (const termo_key_t *key)
+{
+ // For display purposes, this is highly desirable
+ int flags = termo_get_flags (g_xui.tk);
+ termo_set_flags (g_xui.tk, flags | TERMO_FLAG_SPACESYMBOL);
+ termo_key_t fixed = *key;
+ termo_canonicalise (g_xui.tk, &fixed);
+ termo_set_flags (g_xui.tk, flags);
+
+ char buf[16] = "";
+ termo_strfkey_utf8 (g_xui.tk,
+ buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA);
+ return xstrdup (buf);
+}
+
static bool
app_process_termo_event (termo_key_t *event)
{
+ char *formatted = app_strfkey (event);
+ print_debug ("%s", formatted);
+ free (formatted);
+
+ bool handled = false;
+ if ((handled = event->type == TERMO_TYPE_FOCUS))
+ {
+ xui_invalidate ();
+ // Senseless fall-through
+ }
+
struct binding dummy = { *event, 0, 0 }, *binding;
if (g.editor.line)
{
+ if (event->type == TERMO_TYPE_KEY
+ || event->type == TERMO_TYPE_FUNCTION
+ || event->type == TERMO_TYPE_KEYSYM)
+ app_hide_message ();
+
if ((binding = bsearch (&dummy, g_editor_keys, g_editor_keys_len,
sizeof *binding, app_binding_cmp)))
return app_editor_process_action (binding->action);
if (event->type != TERMO_TYPE_KEY || event->modifiers != 0)
- return false;
+ return handled;
line_editor_insert (&g.editor, event->code.codepoint);
- app_invalidate ();
+ xui_invalidate ();
return true;
}
if ((binding = bsearch (&dummy, g_normal_keys, g_normal_keys_len,
@@ -2195,43 +3140,42 @@ app_process_termo_event (termo_key_t *event)
if (app_goto_tab ((n == 0 ? 10 : n) - 1))
return true;
}
- return false;
+ return handled;
}
// --- Current tab -------------------------------------------------------------
static struct tab g_current_tab;
-static void
-current_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+current_tab_on_item_layout (size_t item_index)
{
// TODO: configurable output, maybe dynamically sized columns
- int length_len = 1 /*separator */ + 2 /* h */ + 3 /* m */+ 3 /* s */;
-
compact_map_t map = item_list_get (&g.playlist, item_index);
const char *artist = compact_map_find (map, "artist");
const char *title = compact_map_find (map, "title");
chtype attrs = (int) item_index == g.song ? A_BOLD : 0;
+ struct layout l = {};
if (artist && title)
- row_buffer_append_args (buffer,
- artist, attrs, " - ", attrs, title, attrs, NULL);
+ {
+ char *joined = xstrdup_printf ("%s - %s", artist, title);
+ app_push_fill (&l, g.ui->label (attrs, joined));
+ free (joined);
+ }
else
- row_buffer_append (buffer, compact_map_find (map, "file"), attrs);
+ app_push_fill (&l, g.ui->label (attrs, compact_map_find (map, "file")));
- row_buffer_align (buffer, width - length_len, attrs);
+ int duration = -1;
+ mpd_read_time (compact_map_find (map, "duration"), &duration, NULL);
+ mpd_read_time (compact_map_find (map, "time"), &duration, NULL);
- char *s = NULL;
- unsigned long n;
- const char *time = compact_map_find (map, "time");
- if (!time || !xstrtoul (&n, time, 10) || !(s = app_time_string (n)))
- s = xstrdup ("?");
-
- char *right_aligned = xstrdup_printf ("%*s", length_len, s);
- row_buffer_append (buffer, right_aligned, attrs);
- free (right_aligned);
+ char *s = duration < 0 ? xstrdup ("-") : app_time_string (duration);
+ app_push (&l, g.ui->padding (attrs, 1, 1));
+ app_push (&l, g.ui->label (attrs, s));
free (s);
+
+ return l;
}
static void
@@ -2298,6 +3242,13 @@ current_tab_on_action (enum action action)
switch (action)
{
const char *id;
+ case ACTION_GOTO_PLAYING:
+ if (g.song < 0 || (size_t) g.song >= tab->item_count)
+ return false;
+
+ tab->item_selected = g.song;
+ app_ensure_selection_visible ();
+ return true;
case ACTION_MOVE_UP:
return current_tab_move_selection (-1);
case ACTION_MOVE_DOWN:
@@ -2306,6 +3257,12 @@ current_tab_on_action (enum action action)
tab->item_mark = -1;
return map && (id = compact_map_find (map, "id"))
&& MPD_SIMPLE ("playid", id);
+ case ACTION_DESCRIBE:
+ if (!map || !(id = compact_map_find (map, "file")))
+ return false;
+
+ app_show_message (xstrdup ("Path: "), xstrdup (id));
+ return true;
case ACTION_DELETE:
{
struct mpd_client *c = &g.client;
@@ -2336,7 +3293,7 @@ current_tab_update (void)
g_current_tab.item_count = g.playlist.len;
g_current_tab.item_mark =
MIN ((int) g.playlist.len - 1, g_current_tab.item_mark);
- app_invalidate ();
+ xui_invalidate ();
}
static struct tab *
@@ -2346,7 +3303,7 @@ current_tab_init (void)
tab_init (super, "Current");
super->can_multiselect = true;
super->on_action = current_tab_on_action;
- super->on_item_draw = current_tab_on_item_draw;
+ super->on_item_layout = current_tab_on_item_layout;
return super;
}
@@ -2361,73 +3318,87 @@ struct library_level
char path[]; ///< Path of the level
};
-static struct
-{
- struct tab super; ///< Parent class
- struct str path; ///< Current path
- struct strv items; ///< Current items (type, name, path)
- struct library_level *above; ///< Upper levels
-
- bool searching; ///< Search mode is active
-}
-g_library_tab;
-
enum
{
// This list is also ordered by ASCII and important for sorting
- LIBRARY_ROOT = '/', ///< Root entry
- LIBRARY_UP = '^', ///< Upper directory
- LIBRARY_DIR = 'd', ///< Directory
- LIBRARY_FILE = 'f' ///< File
+ LIBRARY_ROOT = '/', ///< Root entry
+ LIBRARY_UP = '^', ///< Upper directory
+ LIBRARY_DIR = 'd', ///< Directory
+ LIBRARY_FILE = 'f', ///< File
+ LIBRARY_PLAYLIST = 'p', ///< Playlist (unsupported)
};
struct library_tab_item
{
int type; ///< Type of the item
- const char *name; ///< Visible name
- const char *path; ///< MPD path
+ int duration; ///< Duration or -1 if N/A or unknown
+ char *name; ///< Visible name
+ const char *path; ///< MPD path (follows the name)
};
-static void
-library_tab_add (int type, const char *name, const char *path)
+static struct
{
- strv_append_owned (&g_library_tab.items,
- xstrdup_printf ("%c%s%c%s", type, name, 0, path));
+ struct tab super; ///< Parent class
+ struct str path; ///< Current path
+ struct library_level *above; ///< Upper levels
+
+ /// Current items
+ ARRAY (struct library_tab_item, items)
+
+ bool searching; ///< Search mode is active
}
+g_library_tab;
-static struct library_tab_item
-library_tab_resolve (const char *raw)
+static void
+library_tab_add (int type, int duration, const char *name, const char *path)
{
- struct library_tab_item item;
- item.type = *raw++;
- item.name = raw;
- item.path = strchr (raw, '\0') + 1;
- return item;
+ // Slightly reduce memory overhead while retaining friendly access
+ size_t name_len = strlen (name), path_len = strlen (path);
+ char *combined = xmalloc (++name_len + ++path_len);
+
+ ARRAY_RESERVE (g_library_tab.items, 1);
+ g_library_tab.items[g_library_tab.items_len++] = (struct library_tab_item)
+ {
+ .type = type,
+ .duration = duration,
+ .name = memcpy (combined, name, name_len),
+ .path = memcpy (combined + name_len, path, path_len),
+ };
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-static void
-library_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+library_tab_on_item_layout (size_t item_index)
{
- (void) width;
- hard_assert (item_index < g_library_tab.items.len);
+ hard_assert (item_index < g_library_tab.items_len);
- struct library_tab_item x =
- library_tab_resolve (g_library_tab.items.vector[item_index]);
+ struct library_tab_item *x = &g_library_tab.items[item_index];
const char *prefix, *name;
- switch (x.type)
+ switch (x->type)
{
- case LIBRARY_ROOT: prefix = "/"; name = ""; break;
- case LIBRARY_UP: prefix = "/"; name = ".."; break;
- case LIBRARY_DIR: prefix = "/"; name = x.name; break;
- case LIBRARY_FILE: prefix = " "; name = x.name; break;
+ case LIBRARY_ROOT: prefix = "/"; name = ""; break;
+ case LIBRARY_UP: prefix = "/"; name = ".."; break;
+ case LIBRARY_DIR: prefix = "/"; name = x->name; break;
+ case LIBRARY_FILE: prefix = " "; name = x->name; break;
default: hard_assert (!"invalid item type");
}
- chtype attrs = x.type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0;
- row_buffer_append_args (buffer, prefix, attrs, name, attrs, NULL);
+
+ chtype attrs = x->type != LIBRARY_FILE ? APP_ATTR (DIRECTORY) : 0;
+ struct layout l = {};
+
+ app_push (&l, g.ui->label (attrs, prefix));
+ app_push_fill (&l, g.ui->label (attrs, name));
+
+ if (x->duration >= 0)
+ {
+ char *s = app_time_string (x->duration);
+ app_push (&l, g.ui->padding (0, 1, 1));
+ app_push (&l, g.ui->label (attrs, s));
+ free (s);
+ }
+ return l;
}
static char
@@ -2435,31 +3406,38 @@ library_tab_header_type (const char *key)
{
if (!strcasecmp_ascii (key, "file")) return LIBRARY_FILE;
if (!strcasecmp_ascii (key, "directory")) return LIBRARY_DIR;
+ if (!strcasecmp_ascii (key, "playlist")) return LIBRARY_PLAYLIST;
return 0;
}
static void
library_tab_chunk (char type, const char *path, struct str_map *map)
{
+ // CUE files appear once as a directory and another time as a playlist,
+ // just skip them entirely
+ if (type == LIBRARY_PLAYLIST)
+ return;
+
const char *artist = str_map_find (map, "artist");
const char *title = str_map_find (map, "title");
char *name = (artist && title)
? xstrdup_printf ("%s - %s", artist, title)
: xstrdup (xbasename (path));
- library_tab_add (type, name, path);
+
+ int duration = -1;
+ mpd_read_time (str_map_find (map, "duration"), &duration, NULL);
+ mpd_read_time (str_map_find (map, "time"), &duration, NULL);
+ library_tab_add (type, duration, name, path);
free (name);
}
static int
-library_tab_compare (char **a, char **b)
+library_tab_compare (struct library_tab_item *a, struct library_tab_item *b)
{
- struct library_tab_item xa = library_tab_resolve (*a);
- struct library_tab_item xb = library_tab_resolve (*b);
-
- if (xa.type != xb.type)
- return xa.type - xb.type;
+ if (a->type != b->type)
+ return a->type - b->type;
- return app_casecmp ((uint8_t *) xa.path, (uint8_t *) xb.path);
+ return app_casecmp ((uint8_t *) a->path, (uint8_t *) b->path);
}
static char *
@@ -2520,8 +3498,7 @@ library_tab_change_level (const char *new_path)
str_reset (path);
str_append (path, new_path);
- free (g_library_tab.super.header);
- g_library_tab.super.header = NULL;
+ cstr_set (&g_library_tab.super.header, NULL);
g_library_tab.super.item_mark = -1;
if (path->len)
@@ -2529,15 +3506,24 @@ library_tab_change_level (const char *new_path)
}
static void
+library_tab_reset (void)
+{
+ for (size_t i = 0; i < g_library_tab.items_len; i++)
+ free (g_library_tab.items[i].name);
+ free (g_library_tab.items);
+ ARRAY_INIT (g_library_tab.items);
+}
+
+static void
library_tab_load_data (const struct strv *data)
{
- strv_reset (&g_library_tab.items);
+ library_tab_reset ();
char *parent = library_tab_parent ();
if (parent)
{
- library_tab_add (LIBRARY_ROOT, "", "");
- library_tab_add (LIBRARY_UP, "", parent);
+ library_tab_add (LIBRARY_ROOT, -1, "", "");
+ library_tab_add (LIBRARY_UP, -1, "", parent);
free (parent);
}
@@ -2557,19 +3543,19 @@ library_tab_load_data (const struct strv *data)
}
str_map_free (&map);
- struct strv *items = &g_library_tab.items;
- qsort (items->vector, items->len, sizeof *items->vector,
+ struct library_tab_item *items = g_library_tab.items;
+ size_t len = g_library_tab.super.item_count = g_library_tab.items_len;
+ qsort (items, len, sizeof *items,
(int (*) (const void *, const void *)) library_tab_compare);
- g_library_tab.super.item_count = items->len;
// XXX: this unmarks even if just the database updates
g_library_tab.super.item_mark = -1;
// Don't force the selection visible when there's no need to touch it
- if (g_library_tab.super.item_selected >= (int) items->len)
+ if (g_library_tab.super.item_selected >= (int) len)
app_move_selection (0);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -2654,9 +3640,8 @@ library_tab_is_range_playable (struct tab_range range)
{
for (int i = range.from; i <= range.upto; i++)
{
- struct library_tab_item x =
- library_tab_resolve (g_library_tab.items.vector[i]);
- if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE)
+ struct library_tab_item *x = &g_library_tab.items[i];
+ if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
return true;
}
return false;
@@ -2674,9 +3659,7 @@ library_tab_on_action (enum action action)
if (range.from < 0)
return false;
- struct library_tab_item x =
- library_tab_resolve (g_library_tab.items.vector[range.from]);
-
+ struct library_tab_item *x = &g_library_tab.items[range.from];
switch (action)
{
case ACTION_CHOOSE:
@@ -2684,16 +3667,22 @@ library_tab_on_action (enum action action)
if (range.from != range.upto)
break;
- switch (x.type)
+ switch (x->type)
{
case LIBRARY_ROOT:
case LIBRARY_UP:
- case LIBRARY_DIR: library_tab_reload (x.path); break;
- case LIBRARY_FILE: MPD_SIMPLE ("add", x.path); break;
+ case LIBRARY_DIR: library_tab_reload (x->path); break;
+ case LIBRARY_FILE: MPD_SIMPLE ("add", x->path); break;
default: hard_assert (!"invalid item type");
}
tab->item_mark = -1;
return true;
+ case ACTION_DESCRIBE:
+ if (!*x->path)
+ break;
+
+ app_show_message (xstrdup ("Path: "), xstrdup (x->path));
+ return true;
case ACTION_UP:
{
char *parent = library_tab_parent ();
@@ -2719,8 +3708,7 @@ library_tab_on_action (enum action action)
free (fake_subdir);
}
- free (tab->header);
- tab->header = xstrdup_printf ("Global search");
+ cstr_set (&tab->header, xstrdup_printf ("Global search"));
g_library_tab.searching = true;
// Since we've already changed the header, empty the list,
@@ -2730,7 +3718,7 @@ library_tab_on_action (enum action action)
library_tab_load_data (&empty);
strv_free (&empty);
- app_invalidate ();
+ xui_invalidate ();
return true;
}
case ACTION_MPD_ADD:
@@ -2739,10 +3727,9 @@ library_tab_on_action (enum action action)
for (int i = range.from; i <= range.upto; i++)
{
- struct library_tab_item x =
- library_tab_resolve (g_library_tab.items.vector[i]);
- if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE)
- MPD_SIMPLE ("add", x.path);
+ struct library_tab_item *x = &g_library_tab.items[i];
+ if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
+ MPD_SIMPLE ("add", x->path);
}
tab->item_mark = -1;
return true;
@@ -2758,10 +3745,9 @@ library_tab_on_action (enum action action)
mpd_client_send_command (c, "clear", NULL);
for (int i = range.from; i <= range.upto; i++)
{
- struct library_tab_item x =
- library_tab_resolve (g_library_tab.items.vector[i]);
- if (x.type == LIBRARY_DIR || x.type == LIBRARY_FILE)
- mpd_client_send_command (c, "add", x.path, NULL);
+ struct library_tab_item *x = &g_library_tab.items[i];
+ if (x->type == LIBRARY_DIR || x->type == LIBRARY_FILE)
+ mpd_client_send_command (c, "add", x->path, NULL);
}
if (g.state == PLAYER_PLAYING)
mpd_client_send_command (c, "play", NULL);
@@ -2781,13 +3767,13 @@ static struct tab *
library_tab_init (void)
{
g_library_tab.path = str_make ();
- g_library_tab.items = strv_make ();
+ // g_library_tab.items is fine with zero initialisation
struct tab *super = &g_library_tab.super;
tab_init (super, "Library");
super->can_multiselect = true;
super->on_action = library_tab_on_action;
- super->on_item_draw = library_tab_on_item_draw;
+ super->on_item_layout = library_tab_on_item_layout;
return super;
}
@@ -2799,8 +3785,8 @@ struct stream_tab_task
{
struct poller_curl_task curl; ///< Superclass
struct str data; ///< Downloaded data
- bool polling; ///< Still downloading
bool replace; ///< Should playlist be replaced?
+ struct curl_slist *alias_ok;
};
static bool
@@ -2857,8 +3843,13 @@ streams_tab_parse_playlist (const char *playlist, const char *content_type,
|| (content_type && is_content_type (content_type, "audio", "x-scpls")))
extract_re = "^File[^=]*=(.+)";
else if ((lines.len && !strcasecmp_ascii (lines.vector[0], "#EXTM3U"))
+ || (content_type && is_content_type (content_type, "audio", "mpegurl"))
|| (content_type && is_content_type (content_type, "audio", "x-mpegurl")))
- extract_re = "^([^#].*)";
+ // This could be "^([^#].*)", however 1. we would need to resolve
+ // relative URIs, and 2. relative URIs probably mean a Media Playlist,
+ // which must be passed to MPD. The better thing to do here would be to
+ // reject anything with EXT-X-TARGETDURATION, and to resolve the URIs.
+ extract_re = "^(https?://.+)";
regex_t *re = regex_compile (extract_re, REG_EXTENDED, NULL);
hard_assert (re != NULL);
@@ -2876,12 +3867,28 @@ streams_tab_extract_links (struct str *data, const char *content_type,
for (size_t i = 0; i < data->len; i++)
{
uint8_t c = data->str[i];
- if ((c < 32) & (c != '\t') & (c != '\r') & (c != '\n'))
+ if (iscntrl_ascii (c) & (c != '\t') & (c != '\r') & (c != '\n'))
return false;
}
streams_tab_parse_playlist (data->str, content_type, out);
- return true;
+ return out->len != 0;
+}
+
+static void
+streams_tab_task_finalize (struct stream_tab_task *self)
+{
+ curl_easy_cleanup (self->curl.easy);
+ curl_slist_free_all (self->alias_ok);
+ str_free (&self->data);
+ free (self);
+}
+
+static void
+streams_tab_task_dispose (struct stream_tab_task *self)
+{
+ hard_assert (poller_curl_remove (&g.poller_curl, self->curl.easy, NULL));
+ streams_tab_task_finalize (self);
}
static void
@@ -2889,19 +3896,18 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
{
struct stream_tab_task *self =
CONTAINER_OF (task, struct stream_tab_task, curl);
- self->polling = false;
if (msg->data.result
&& msg->data.result != CURLE_WRITE_ERROR)
{
cstr_uncapitalize (self->curl.curl_error);
print_error ("%s", self->curl.curl_error);
- return;
+ goto dispose;
}
struct mpd_client *c = &g.client;
if (c->state != MPD_CONNECTED)
- return;
+ goto dispose;
CURL *easy = msg->easy_handle;
CURLcode res;
@@ -2914,13 +3920,13 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
{
print_error ("%s: %s",
"cURL info retrieval failed", curl_easy_strerror (res));
- return;
+ goto dispose;
}
// cURL is not willing to parse the ICY header, the code is zero then
if (code && code != 200)
{
print_error ("%s: %ld", "unexpected HTTP response", code);
- return;
+ goto dispose;
}
mpd_client_list_begin (c);
@@ -2939,6 +3945,9 @@ streams_tab_on_downloaded (CURLMsg *msg, struct poller_curl_task *task)
mpd_client_list_end (c);
mpd_client_add_task (c, mpd_on_simple_response, NULL);
mpd_client_idle (c, 0);
+
+dispose:
+ streams_tab_task_dispose (self);
}
static size_t
@@ -2957,56 +3966,44 @@ write_callback (char *ptr, size_t size, size_t nmemb, void *user_data)
static bool
streams_tab_process (const char *uri, bool replace, struct error **e)
{
- struct poller poller;
- poller_init (&poller);
+ // TODO: streams_tab_task_dispose() on that running task
+ if (g.poller_curl.registered)
+ {
+ print_error ("waiting for the last stream to time out");
+ return false;
+ }
- struct poller_curl pc;
- hard_assert (poller_curl_init (&pc, &poller, NULL));
- struct stream_tab_task task;
- hard_assert (poller_curl_spawn (&task.curl, NULL));
+ struct stream_tab_task *task = xcalloc (1, sizeof *task);
+ hard_assert (poller_curl_spawn (&task->curl, NULL));
- CURL *easy = task.curl.easy;
- task.data = str_make ();
- task.replace = replace;
- bool result = false;
+ CURL *easy = task->curl.easy;
+ task->data = str_make ();
+ task->replace = replace;
+ task->alias_ok = curl_slist_append (NULL, "ICY 200 OK");
CURLcode res;
if ((res = curl_easy_setopt (easy, CURLOPT_FOLLOWLOCATION, 1L))
|| (res = curl_easy_setopt (easy, CURLOPT_NOPROGRESS, 1L))
- // TODO: make the timeout a bit larger once we're asynchronous
- || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 5L))
+ || (res = curl_easy_setopt (easy, CURLOPT_TIMEOUT, 10L))
// Not checking anything, we just want some data, any data
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYPEER, 0L))
|| (res = curl_easy_setopt (easy, CURLOPT_SSL_VERIFYHOST, 0L))
|| (res = curl_easy_setopt (easy, CURLOPT_URL, uri))
+ || (res = curl_easy_setopt (easy, CURLOPT_HTTP200ALIASES, task->alias_ok))
|| (res = curl_easy_setopt (easy, CURLOPT_VERBOSE, (long) g_debug_mode))
|| (res = curl_easy_setopt (easy, CURLOPT_DEBUGFUNCTION, print_curl_debug))
- || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task.data))
+ || (res = curl_easy_setopt (easy, CURLOPT_WRITEDATA, &task->data))
|| (res = curl_easy_setopt (easy, CURLOPT_WRITEFUNCTION, write_callback)))
{
error_set (e, "%s: %s", "cURL setup failed", curl_easy_strerror (res));
- goto error;
+ streams_tab_task_finalize (task);
+ return false;
}
- task.curl.on_done = streams_tab_on_downloaded;
- hard_assert (poller_curl_add (&pc, task.curl.easy, NULL));
-
- // TODO: don't run a subloop, run the task fully asynchronously
- task.polling = true;
- while (task.polling)
- poller_run (&poller);
-
- hard_assert (poller_curl_remove (&pc, task.curl.easy, NULL));
- result = true;
-
-error:
- curl_easy_cleanup (task.curl.easy);
- str_free (&task.data);
- poller_curl_free (&pc);
-
- poller_free (&poller);
- return result;
+ task->curl.on_done = streams_tab_on_downloaded;
+ hard_assert (poller_curl_add (&g.poller_curl, task->curl.easy, NULL));
+ return true;
}
static bool
@@ -3029,6 +4026,9 @@ streams_tab_on_action (enum action action)
case ACTION_MPD_ADD:
streams_tab_process (uri, false, &e);
break;
+ case ACTION_DESCRIBE:
+ app_show_message (xstrdup (uri), NULL);
+ break;
default:
return false;
}
@@ -3040,12 +4040,12 @@ streams_tab_on_action (enum action action)
return true;
}
-static void
-streams_tab_on_item_draw (size_t item_index, struct row_buffer *buffer,
- int width)
+static struct layout
+streams_tab_on_item_layout (size_t item_index)
{
- (void) width;
- row_buffer_append (buffer, g.streams.vector[item_index], 0);
+ struct layout l = {};
+ app_push_fill (&l, g.ui->label (0, g.streams.vector[item_index]));
+ return l;
}
static struct tab *
@@ -3054,80 +4054,468 @@ streams_tab_init (void)
static struct tab super;
tab_init (&super, "Streams");
super.on_action = streams_tab_on_action;
- super.on_item_draw = streams_tab_on_item_draw;
+ super.on_item_layout = streams_tab_on_item_layout;
super.item_count = g.streams.len;
return &super;
}
// --- Info tab ----------------------------------------------------------------
+struct info_tab_plugin
+{
+ LIST_HEADER (struct info_tab_plugin)
+
+ char *path; ///< Filesystem path to plugin
+ char *description; ///< What the plugin does
+};
+
+static struct info_tab_plugin *
+info_tab_plugin_load (const char *path)
+{
+ // Shell quoting is less annoying than process management.
+ struct str escaped = str_make ();
+ shell_quote (path, &escaped);
+ FILE *fp = popen (escaped.str, "r");
+ str_free (&escaped);
+ if (!fp)
+ {
+ print_error ("%s: %s", path, strerror (errno));
+ return NULL;
+ }
+
+ struct str description = str_make ();
+ char buf[BUFSIZ];
+ size_t len;
+ while ((len = fread (buf, 1, sizeof buf, fp)) == sizeof buf)
+ str_append_data (&description, buf, len);
+ str_append_data (&description, buf, len);
+ if (pclose (fp))
+ {
+ str_free (&description);
+ print_error ("%s: %s", path, strerror (errno));
+ return NULL;
+ }
+
+ char *newline = strpbrk (description.str, "\r\n");
+ if (newline)
+ {
+ description.len = newline - description.str;
+ *newline = '\0';
+ }
+ str_enforce_utf8 (&description);
+ if (!description.len)
+ {
+ str_free (&description);
+ print_error ("%s: %s", path, "missing description");
+ return NULL;
+ }
+
+ struct info_tab_plugin *plugin = xcalloc (1, sizeof *plugin);
+ plugin->path = xstrdup (path);
+ plugin->description = str_steal (&description);
+ return plugin;
+}
+
+static void
+info_tab_plugin_load_dir (struct str_map *basename_to_path, const char *dirname)
+{
+ DIR *dir = opendir (dirname);
+ if (!dir)
+ {
+ print_debug ("opendir: %s: %s", dirname, strerror (errno));
+ return;
+ }
+
+ struct dirent *entry = NULL;
+ while ((entry = readdir (dir)))
+ {
+ struct stat st = {};
+ char *path = xstrdup_printf ("%s/%s", dirname, entry->d_name);
+ if (stat (path, &st) || !S_ISREG (st.st_mode))
+ {
+ free (path);
+ continue;
+ }
+
+ // Empty files silently erase formerly found basenames.
+ if (!st.st_size)
+ cstr_set (&path, NULL);
+
+ str_map_set (basename_to_path, entry->d_name, path);
+ }
+ closedir (dir);
+}
+
+static int
+strv_sort_cb (const void *a, const void *b)
+{
+ return strcmp (*(const char **) a, *(const char **) b);
+}
+
+static struct info_tab_plugin *
+info_tab_plugin_load_all (void)
+{
+ struct str_map basename_to_path = str_map_make (free);
+ struct strv paths = strv_make ();
+ get_xdg_data_dirs (&paths);
+ strv_append (&paths, PROJECT_DATADIR);
+ for (size_t i = paths.len; i--; )
+ {
+ char *dirname =
+ xstrdup_printf ("%s/" PROGRAM_NAME "/info", paths.vector[i]);
+ info_tab_plugin_load_dir (&basename_to_path, dirname);
+ free (dirname);
+ }
+ strv_free (&paths);
+
+ struct strv sorted = strv_make ();
+ struct str_map_iter iter = str_map_iter_make (&basename_to_path);
+ while (str_map_iter_next (&iter))
+ strv_append (&sorted, iter.link->key);
+ qsort (sorted.vector, sorted.len, sizeof *sorted.vector, strv_sort_cb);
+
+ struct info_tab_plugin *result = NULL;
+ for (size_t i = sorted.len; i--; )
+ {
+ const char *path = str_map_find (&basename_to_path, sorted.vector[i]);
+ struct info_tab_plugin *plugin = info_tab_plugin_load (path);
+ if (plugin)
+ LIST_PREPEND (result, plugin);
+ }
+ str_map_free (&basename_to_path);
+ strv_free (&sorted);
+ return result;
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+struct info_tab_item
+{
+ char *prefix; ///< Fixed-width prefix column or NULL
+ char *text; ///< Text or NULL
+ bool formatted; ///< Interpret inline formatting marks?
+ struct info_tab_plugin *plugin; ///< Activatable plugin
+};
+
+static void
+info_tab_item_free (struct info_tab_item *self)
+{
+ cstr_set (&self->prefix, NULL);
+ cstr_set (&self->text, NULL);
+}
+
static struct
{
struct tab super; ///< Parent class
- struct strv keys; ///< Data keys
- struct strv values; ///< Data values
+ struct info_tab_item *items; ///< Items array
+ size_t items_alloc; ///< How many items are allocated
+
+ struct info_tab_plugin *plugins; ///< Plugins
+
+ int plugin_songid; ///< Song ID or -1
+ pid_t plugin_pid; ///< Running plugin's process ID or -1
+ int plugin_stdout; ///< pid != -1: read end of stdout
+ struct poller_fd plugin_event; ///< pid != -1: stdout is readable
+ struct str plugin_output; ///< pid != -1: buffer, otherwise result
}
g_info_tab;
+static chtype
+info_tab_format_decode_toggle (char c)
+{
+ switch (c)
+ {
+ case '\x01':
+ return A_BOLD;
+ case '\x02':
+#ifdef A_ITALIC
+ return A_ITALIC;
+#else
+ return A_UNDERLINE;
+#endif
+ default:
+ return 0;
+ }
+}
+
static void
-info_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+info_tab_format (struct layout *l, const char *text)
+{
+ chtype attrs = 0;
+ for (const char *p = text; *p; p++)
+ {
+ chtype toggled = info_tab_format_decode_toggle (*p);
+ if (!toggled)
+ continue;
+
+ if (p != text)
+ {
+ char *slice = xstrndup (text, p - text);
+ app_push (l, g.ui->label (attrs, slice));
+ free (slice);
+ }
+
+ attrs ^= toggled;
+ text = p + 1;
+ }
+ if (*text)
+ app_push (l, g.ui->label (attrs, text));
+}
+
+static struct layout
+info_tab_on_item_layout (size_t item_index)
{
- (void) width;
+ struct info_tab_item *item = &g_info_tab.items[item_index];
+ struct layout l = {};
+ if (item->prefix)
+ {
+ char *prefix = xstrdup_printf ("%s:", item->prefix);
+ app_push (&l, g.ui->label (A_BOLD, prefix))
+ ->width = 8 * g_xui.hunit;
+ app_push (&l, g.ui->padding (0, 0.5, 1));
+ }
- // It looks like we could do with a generic list structure that just
- // stores formatted row_buffers. Let's see for other tabs:
- // - Current -- unusable, has dynamic column alignment
- // - Library -- could work for the "icons"
- // - Streams -- useless
- // - Debug -- it'd take up considerably more space
- // However so far we're only showing show key-value pairs.
+ if (item->plugin)
+ app_push (&l, g.ui->label (A_BOLD, item->plugin->description));
+ else if (!item->text || !*item->text)
+ app_push (&l, g.ui->padding (0, 1, 1));
+ else if (item->formatted)
+ info_tab_format (&l, item->text);
+ else
+ app_push (&l, g.ui->label (0, item->text));
- row_buffer_append_args (buffer,
- g_info_tab.keys.vector[item_index], A_BOLD, ":", A_BOLD, NULL);
- row_buffer_space (buffer, 8 - buffer->total_width, 0);
- row_buffer_append (buffer, g_info_tab.values.vector[item_index], 0);
+ if (l.tail)
+ l.tail->width = -1;
+ return l;
+}
+
+static struct info_tab_item *
+info_tab_prepare (void)
+{
+ if (g_info_tab.super.item_count == g_info_tab.items_alloc)
+ g_info_tab.items = xreallocarray (g_info_tab.items,
+ sizeof *g_info_tab.items, (g_info_tab.items_alloc <<= 1));
+
+ struct info_tab_item *item =
+ &g_info_tab.items[g_info_tab.super.item_count++];
+ memset (item, 0, sizeof *item);
+ return item;
}
static void
info_tab_add (compact_map_t data, const char *field)
{
- const char *value = compact_map_find (data, field);
- if (!value) value = "";
-
- strv_append (&g_info_tab.keys, field);
- strv_append (&g_info_tab.values, value);
- g_info_tab.super.item_count++;
+ struct info_tab_item *item = info_tab_prepare ();
+ item->prefix = xstrdup (field);
+ item->text = xstrdup0 (compact_map_find (data, field));
}
static void
info_tab_update (void)
{
- strv_reset (&g_info_tab.keys);
- strv_reset (&g_info_tab.values);
- g_info_tab.super.item_count = 0;
+ while (g_info_tab.super.item_count)
+ info_tab_item_free (&g_info_tab.items[--g_info_tab.super.item_count]);
- compact_map_t map;
- if ((map = item_list_get (&g.playlist, g.song)))
+ compact_map_t map = item_list_get (&g.playlist, g.song);
+ if (!map)
+ return;
+
+ info_tab_add (map, "Title");
+ info_tab_add (map, "Artist");
+ info_tab_add (map, "Album");
+ info_tab_add (map, "Track");
+ info_tab_add (map, "Genre");
+ // We actually receive it as "file", but the key is also used for display
+ info_tab_add (map, "File");
+
+ if (g_info_tab.plugins)
{
- info_tab_add (map, "Title");
- info_tab_add (map, "Artist");
- info_tab_add (map, "Album");
- info_tab_add (map, "Track");
- info_tab_add (map, "Genre");
- // Yes, it is "file", but this is also for display
- info_tab_add (map, "File");
+ (void) info_tab_prepare ();
+ LIST_FOR_EACH (struct info_tab_plugin, plugin, g_info_tab.plugins)
+ info_tab_prepare ()->plugin = plugin;
+ }
+
+ if (g_info_tab.plugin_pid != -1)
+ {
+ (void) info_tab_prepare ();
+ info_tab_prepare ()->text = xstrdup ("Processing...");
+ return;
+ }
+
+ const char *songid = compact_map_find (map, "Id");
+ if (songid && atoi (songid) == g_info_tab.plugin_songid
+ && g_info_tab.plugin_output.len)
+ {
+ struct strv lines = strv_make ();
+ cstr_split (g_info_tab.plugin_output.str, "\r\n", false, &lines);
+
+ (void) info_tab_prepare ();
+ for (size_t i = 0; i < lines.len; i++)
+ {
+ struct info_tab_item *item = info_tab_prepare ();
+ item->formatted = true;
+ item->text = lines.vector[i];
+ }
+ free (lines.vector);
+ }
+}
+
+// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+
+static void
+info_tab_plugin_abort (void)
+{
+ if (g_info_tab.plugin_pid == -1)
+ return;
+
+ // XXX: our methods of killing are very crude, we hope to improve;
+ // at least install a SIGCHLD handler to collect zombies
+ (void) kill (-g_info_tab.plugin_pid, SIGTERM);
+
+ int status = 0;
+ while (waitpid (g_info_tab.plugin_pid, &status, WNOHANG) == -1
+ && errno == EINTR)
+ ;
+ if (WIFEXITED (status) && WEXITSTATUS (status) != EXIT_SUCCESS)
+ print_error ("plugin reported failure");
+
+ g_info_tab.plugin_pid = -1;
+ poller_fd_reset (&g_info_tab.plugin_event);
+ xclose (g_info_tab.plugin_stdout);
+ g_info_tab.plugin_stdout = -1;
+}
+
+static void
+info_tab_on_plugin_stdout (const struct pollfd *fd, void *user_data)
+{
+ (void) user_data;
+
+ struct str *buf = &g_info_tab.plugin_output;
+ switch (socket_io_try_read (fd->fd, buf))
+ {
+ case SOCKET_IO_OK:
+ str_enforce_utf8 (buf);
+ return;
+ case SOCKET_IO_ERROR:
+ print_error ("error reading from plugin: %s", strerror (errno));
+ // Fall-through
+ case SOCKET_IO_EOF:
+ info_tab_plugin_abort ();
+ info_tab_update ();
+ xui_invalidate ();
+ }
+}
+
+static void
+info_tab_plugin_run (struct info_tab_plugin *plugin, compact_map_t map)
+{
+ info_tab_plugin_abort ();
+ if (!map)
+ return;
+
+ const char *songid = compact_map_find (map, "Id");
+ const char *title = compact_map_find (map, "Title");
+ const char *artist = compact_map_find (map, "Artist");
+ const char *album = compact_map_find (map, "Album");
+ if (!songid || !title || !artist)
+ {
+ print_error ("unknown song title or artist");
+ return;
+ }
+
+ int stdout_pipe[2];
+ if (pipe (stdout_pipe))
+ {
+ print_error ("%s: %s", "pipe", strerror (errno));
+ return;
+ }
+
+ enum { READ, WRITE };
+ set_cloexec (stdout_pipe[READ]);
+ set_cloexec (stdout_pipe[WRITE]);
+
+ const char *argv[] =
+ { xbasename (plugin->path), title, artist, album, NULL };
+
+ pid_t child = fork ();
+ switch (child)
+ {
+ case -1:
+ print_error ("%s: %s", "fork", strerror (errno));
+ xclose (stdout_pipe[READ]);
+ xclose (stdout_pipe[WRITE]);
+ return;
+ case 0:
+ if (setpgid (0, 0) == -1 || !freopen ("/dev/null", "r", stdin)
+ || dup2 (stdout_pipe[WRITE], STDOUT_FILENO) == -1
+ || dup2 (stdout_pipe[WRITE], STDERR_FILENO) == -1)
+ _exit (EXIT_FAILURE);
+
+ signal (SIGPIPE, SIG_DFL);
+
+ (void) execv (plugin->path, (char **) argv);
+ fprintf (stderr, "%s\n", strerror (errno));
+ _exit (EXIT_FAILURE);
+ default:
+ // Resolve the race, even though it isn't critical for us
+ (void) setpgid (child, child);
+
+ g_info_tab.plugin_songid = atoi (songid);
+ g_info_tab.plugin_pid = child;
+ set_blocking ((g_info_tab.plugin_stdout = stdout_pipe[READ]), false);
+ xclose (stdout_pipe[WRITE]);
+
+ struct poller_fd *event = &g_info_tab.plugin_event;
+ *event = poller_fd_make (&g.poller, g_info_tab.plugin_stdout);
+ event->dispatcher = info_tab_on_plugin_stdout;
+ str_reset (&g_info_tab.plugin_output);
+ poller_fd_set (&g_info_tab.plugin_event, POLLIN);
+ }
+}
+
+static bool
+info_tab_on_action (enum action action)
+{
+ struct tab *tab = g.active_tab;
+ if (tab->item_selected < 0
+ || tab->item_selected >= (int) tab->item_count)
+ return false;
+
+ struct info_tab_item *item = &g_info_tab.items[tab->item_selected];
+ if (!item->plugin)
+ return false;
+
+ switch (action)
+ {
+ case ACTION_DESCRIBE:
+ app_show_message (xstrdup ("Path: "), xstrdup (item->plugin->path));
+ return true;
+ case ACTION_CHOOSE:
+ info_tab_plugin_run (item->plugin, item_list_get (&g.playlist, g.song));
+ info_tab_update ();
+ xui_invalidate ();
+ return true;
+ default:
+ return false;
}
}
static struct tab *
info_tab_init (void)
{
- g_info_tab.keys = strv_make ();
- g_info_tab.values = strv_make ();
+ g_info_tab.items =
+ xcalloc ((g_info_tab.items_alloc = 16), sizeof *g_info_tab.items);
+
+ g_info_tab.plugins = info_tab_plugin_load_all ();
+ g_info_tab.plugin_songid = -1;
+ g_info_tab.plugin_pid = -1;
+ g_info_tab.plugin_stdout = -1;
+ g_info_tab.plugin_output = str_make ();
struct tab *super = &g_info_tab.super;
tab_init (super, "Info");
- super->on_item_draw = info_tab_on_item_draw;
+ super->on_action = info_tab_on_action;
+ super->on_item_layout = info_tab_on_item_layout;
return super;
}
@@ -3146,29 +4534,25 @@ help_tab_on_action (enum action action)
{
struct tab *tab = &g_help_tab.super;
if (tab->item_selected < 0
- || tab->item_selected >= (int) g_help_tab.actions_len
- || action != ACTION_CHOOSE)
+ || tab->item_selected >= (int) g_help_tab.actions_len)
return false;
- action = g_help_tab.actions[tab->item_selected];
- return action != ACTION_NONE
- && action != ACTION_CHOOSE // avoid recursion
- && app_process_action (action);
-}
+ enum action a = g_help_tab.actions[tab->item_selected];
+ if (!a)
+ return false;
-static void
-help_tab_strfkey (const termo_key_t *key, struct strv *out)
-{
- // For display purposes, this is highly desirable
- int flags = termo_get_flags (g.tk);
- termo_set_flags (g.tk, flags | TERMO_FLAG_SPACESYMBOL);
- termo_key_t fixed = *key;
- termo_canonicalise (g.tk, &fixed);
- termo_set_flags (g.tk, flags);
+ if (action == ACTION_DESCRIBE)
+ {
+ app_show_message (xstrdup ("Configuration name: "),
+ xstrdup (g_action_names[a]));
+ return true;
+ }
+ if (action != ACTION_CHOOSE || a == ACTION_CHOOSE /* avoid recursion */)
+ return false;
- char buf[16];
- termo_strfkey_utf8 (g.tk, buf, sizeof buf, &fixed, TERMO_FORMAT_ALTISMETA);
- strv_append (out, buf);
+ // XXX: We can't propagate failure to ring the terminal/X11 bell, but we
+ // don't want to let our caller show a bad "can't do that" message either.
+ return app_process_action (a), true;
}
static void
@@ -3192,12 +4576,12 @@ help_tab_group (struct binding *keys, size_t len, struct strv *out,
struct strv ass = strv_make ();
for (size_t k = 0; k < len; k++)
if (keys[k].action == i)
- help_tab_strfkey (&keys[k].decoded, &ass);
+ strv_append_owned (&ass, app_strfkey (&keys[k].decoded));
if (ass.len)
{
char *joined = strv_join (&ass, ", ");
strv_append_owned (out, xstrdup_printf
- (" %-30s %s", g_actions[i].description, joined));
+ (" %s%c%s", g_action_descriptions[i], 0, joined));
free (joined);
bound[i] = true;
@@ -3214,19 +4598,27 @@ help_tab_unbound (struct strv *out, bool bound[ACTION_COUNT])
if (!bound[i])
{
strv_append_owned (out,
- xstrdup_printf (" %-30s", g_actions[i].description));
+ xstrdup_printf (" %s%c", g_action_descriptions[i], 0));
help_tab_assign_action (i);
}
}
-static void
-help_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+static struct layout
+help_tab_on_item_layout (size_t item_index)
{
- (void) width;
-
hard_assert (item_index < g_help_tab.lines.len);
const char *line = g_help_tab.lines.vector[item_index];
- row_buffer_append (buffer, line, *line == ' ' ? 0 : A_BOLD);
+
+ struct layout l = {};
+ app_push_fill (&l, g.ui->label (*line == ' ' ? 0 : A_BOLD, line));
+
+ const char *definition = strchr (line, 0) + 1;
+ if (*line == ' ' && *definition)
+ {
+ app_push (&l, g.ui->padding (0, 0.5, 1));
+ app_push_fill (&l, g.ui->label (0, definition));
+ }
+ return l;
}
static struct tab *
@@ -3261,7 +4653,7 @@ help_tab_init (void)
struct tab *super = &g_help_tab.super;
tab_init (super, "Help");
super->on_action = help_tab_on_action;
- super->on_item_draw = help_tab_on_item_draw;
+ super->on_item_layout = help_tab_on_item_layout;
super->item_count = lines->len;
return super;
}
@@ -3283,8 +4675,8 @@ static struct
}
g_debug_tab;
-static void
-debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
+static struct layout
+debug_tab_on_item_layout (size_t item_index)
{
hard_assert (item_index < g_debug_tab.items_len);
struct debug_item *item = &g_debug_tab.items[item_index];
@@ -3296,14 +4688,13 @@ debug_tab_on_item_draw (size_t item_index, struct row_buffer *buffer, int width)
char *prefix = xstrdup_printf
("%s.%03d", buf, (int) (item->timestamp % 1000));
- row_buffer_append (buffer, prefix, 0);
- free (prefix);
-
- row_buffer_append (buffer, " ", item->attrs);
- row_buffer_append (buffer, item->text, item->attrs);
- // We override the formatting including colors -- do it for the whole line
- row_buffer_align (buffer, width, item->attrs);
+ struct layout l = {};
+ app_push (&l, g.ui->label (0, prefix));
+ app_push (&l, g.ui->padding (item->attrs, 0.5, 1));
+ app_push_fill (&l, g.ui->label (item->attrs, item->text));
+ free (prefix);
+ return l;
}
static void
@@ -3316,7 +4707,7 @@ debug_tab_push (char *message, chtype attrs)
item->attrs = attrs;
item->timestamp = clock_msec (CLOCK_REALTIME);
- app_invalidate ();
+ xui_invalidate ();
}
static struct tab *
@@ -3327,32 +4718,216 @@ debug_tab_init (void)
struct tab *super = &g_debug_tab.super;
tab_init (super, "Debug");
- super->on_item_draw = debug_tab_on_item_draw;
+ super->on_item_layout = debug_tab_on_item_layout;
return super;
}
-// --- MPD interface -----------------------------------------------------------
+// --- Spectrum analyser -------------------------------------------------------
+
+#ifdef WITH_FFTW
static void
-mpd_read_time (const char *value, int *sec, int *optional_msec)
+spectrum_redraw (void)
{
- if (!value)
+ // A full refresh would be too computationally expensive,
+ // let's hack around it in this case
+ struct widget *spectrum = app_widget_by_id (WIDGET_SPECTRUM);
+ if (spectrum)
+ spectrum->on_render (spectrum);
+
+ poller_idle_set (&g_xui.flip_event);
+}
+
+// When any problem occurs with the FIFO, we'll just give up on it completely
+static void
+spectrum_discard_fifo (void)
+{
+ if (g.spectrum_fd != -1)
+ {
+ poller_fd_reset (&g.spectrum_event);
+ xclose (g.spectrum_fd);
+ g.spectrum_fd = -1;
+
+ spectrum_free (&g.spectrum);
+ xui_invalidate ();
+ }
+}
+
+static void
+spectrum_on_fifo_readable (const struct pollfd *pfd, void *user_data)
+{
+ (void) user_data;
+ struct spectrum *s = &g.spectrum;
+
+ bool update = false;
+ ssize_t n;
+restart:
+ while ((n = read (pfd->fd,
+ s->buffer + s->buffer_len, s->buffer_size - s->buffer_len)) > 0)
+ if ((s->buffer_len += n) == s->buffer_size)
+ {
+ update = true;
+ spectrum_sample (s);
+ s->buffer_len = 0;
+ }
+
+ if (!n)
+ spectrum_discard_fifo ();
+ else if (errno == EINTR)
+ goto restart;
+ else if (errno != EAGAIN)
+ {
+ print_error ("spectrum: %s", strerror (errno));
+ spectrum_discard_fifo ();
+ }
+ else if (update)
+ spectrum_redraw ();
+}
+
+// When playback is stopped, we need to feed the analyser some zeroes ourselves.
+// We could also just hide it. Hard to say which is simpler or better.
+static void
+spectrum_clear (void)
+{
+ if (g.spectrum_fd != -1)
+ {
+ struct spectrum *s = &g.spectrum;
+ memset (s->buffer, 0, s->buffer_size);
+ spectrum_sample (s);
+ spectrum_sample (s);
+ s->buffer_len = 0;
+
+ spectrum_redraw ();
+ }
+}
+
+static void
+spectrum_setup_fifo (void)
+{
+ const char *spectrum_path =
+ get_config_string (g.config.root, "settings.spectrum_path");
+ const char *spectrum_format =
+ get_config_string (g.config.root, "settings.spectrum_format");
+ struct config_item *spectrum_bars =
+ config_item_get (g.config.root, "settings.spectrum_bars", NULL);
+ struct config_item *spectrum_fps =
+ config_item_get (g.config.root, "settings.spectrum_fps", NULL);
+ if (!spectrum_path)
return;
- char *end, *period = strchr (value, '.');
- if (optional_msec && period)
+ struct error *e = NULL;
+ char *path = resolve_filename
+ (spectrum_path, resolve_relative_config_filename);
+
+ if (!path)
+ print_error ("spectrum: %s", "FIFO path could not be resolved");
+ else if (!g_xui.locale_is_utf8)
+ print_error ("spectrum: %s", "UTF-8 locale required");
+ else if (!spectrum_init (&g.spectrum, (char *) spectrum_format,
+ spectrum_bars->value.integer, spectrum_fps->value.integer, &e))
{
- unsigned long n = strtoul (period + 1, &end, 10);
- if (*end)
- return;
- *optional_msec = MIN (INT_MAX, n);
+ print_error ("spectrum: %s", e->message);
+ error_free (e);
+ }
+ else if ((g.spectrum_fd = open (path, O_RDONLY | O_NONBLOCK)) == -1)
+ {
+ print_error ("spectrum: %s: %s", path, strerror (errno));
+ spectrum_free (&g.spectrum);
+ }
+ else
+ {
+ g.spectrum_event = poller_fd_make (&g.poller, g.spectrum_fd);
+ g.spectrum_event.dispatcher = spectrum_on_fifo_readable;
+ poller_fd_set (&g.spectrum_event, POLLIN);
+ }
+
+ free (path);
+}
+
+#else // ! WITH_FFTW
+#define spectrum_setup_fifo() BLOCK_START BLOCK_END
+#define spectrum_clear() BLOCK_START BLOCK_END
+#define spectrum_discard_fifo() BLOCK_START BLOCK_END
+#endif // ! WITH_FFTW
+
+// --- PulseAudio --------------------------------------------------------------
+
+#ifdef WITH_PULSE
+
+static bool
+mpd_find_output (const struct strv *data, const char *wanted)
+{
+ // The plugin field is new in MPD 0.21, by default take any output
+ unsigned long n, accept = 1;
+ for (size_t i = data->len; i--; )
+ {
+ char *key, *value;
+ if (!(key = mpd_parse_kv (data->vector[i], &value)))
+ continue;
+
+ if (!strcasecmp_ascii (key, "outputid"))
+ {
+ if (accept)
+ return true;
+
+ accept = 1;
+ }
+ else if (!strcasecmp_ascii (key, "plugin"))
+ accept &= !strcmp (value, wanted);
+ else if (!strcasecmp_ascii (key, "outputenabled")
+ && xstrtoul (&n, value, 10))
+ accept &= n == 1;
+ }
+ return false;
+}
+
+static void
+mpd_on_outputs_response (const struct mpd_response *response,
+ const struct strv *data, void *user_data)
+{
+ (void) user_data;
+
+ // TODO: check whether an action is actually necessary
+ pulse_free (&g.pulse);
+ if (response->success && !mpd_find_output (data, "pulse"))
+ print_debug ("MPD has no PulseAudio output to control");
+ else
+ {
+ pulse_init (&g.pulse, &g.poller);
+ g.pulse.on_update = xui_invalidate;
}
- unsigned long n = strtoul (value, &end, 10);
- if (end == period || !*end)
- *sec = MIN (INT_MAX, n);
+
+ xui_invalidate ();
}
static void
+pulse_update (void)
+{
+ struct mpd_client *c = &g.client;
+ if (!g.pulse_control_requested)
+ return;
+
+ // The read permission is sufficient for this command
+ mpd_client_send_command (c, "outputs", NULL);
+ mpd_client_add_task (c, mpd_on_outputs_response, NULL);
+ mpd_client_idle (c, 0);
+}
+
+static void
+pulse_disable (void)
+{
+ pulse_free (&g.pulse);
+ xui_invalidate ();
+}
+
+#else // ! WITH_PULSE
+#define pulse_update() BLOCK_START BLOCK_END
+#define pulse_disable() BLOCK_START BLOCK_END
+#endif // ! WITH_PULSE
+
+// --- MPD interface -----------------------------------------------------------
+
+static void
mpd_update_playlist_time (void)
{
g.playlist_time = 0;
@@ -3369,6 +4944,33 @@ mpd_update_playlist_time (void)
}
static void
+mpd_set_elapsed_timer (int msec_past_second)
+{
+ int delay_msec = 1000 - msec_past_second; // Until the next round second
+ if (!g.elapsed_poll)
+ {
+ poller_timer_set (&g.elapsed_event, delay_msec);
+ // Remember when the last round second was, relative to monotonic time
+ g.elapsed_since = clock_msec (CLOCK_BEST) - msec_past_second;
+ return;
+ }
+
+ // We may receive an earlier time, this seems to compensate for it well
+ // (I haven't seen it trigger more than 50ms too early)
+ delay_msec += 100;
+
+ // When playback stalls, avoid busy looping with the server
+ int elapsed_msec = g.song_elapsed * 1000 + msec_past_second;
+ if (elapsed_msec == g.elapsed_since)
+ delay_msec = MAX (delay_msec, 500);
+
+ // In polling mode, we're interested in progress rather than stability.
+ // We can reuse both the poller_timer struct and the timestamp field.
+ poller_timer_set (&g.elapsed_event, delay_msec);
+ g.elapsed_since = elapsed_msec;
+}
+
+static void
mpd_update_playback_state (void)
{
struct str_map *map = &g.playback_info;
@@ -3383,6 +4985,10 @@ mpd_update_playback_state (void)
if (!strcmp (state, "play")) g.state = PLAYER_PLAYING;
if (!strcmp (state, "pause")) g.state = PLAYER_PAUSED;
}
+ if (g.state == PLAYER_STOPPED)
+ {
+ spectrum_clear ();
+ }
// Values in "time" are always rounded. "elapsed", introduced in MPD 0.16,
// is in millisecond precision and "duration" as well, starting with 0.20.
@@ -3404,13 +5010,11 @@ mpd_update_playback_state (void)
mpd_read_time (duration, &g.song_duration, NULL);
strv_free (&fields);
- // We could also just poll the server each half a second but let's not
poller_timer_reset (&g.elapsed_event);
if (g.state == PLAYER_PLAYING)
- {
- poller_timer_set (&g.elapsed_event, 1000 - msec_past_second);
- g.elapsed_since = clock_msec (CLOCK_BEST) - msec_past_second;
- }
+ mpd_set_elapsed_timer (msec_past_second);
+ else
+ g.elapsed_since = -1;
// The server sends -1 when nothing is being played right now
unsigned long n;
@@ -3422,7 +5026,7 @@ mpd_update_playback_state (void)
if (g.playlist_version != last_playlist_version)
mpd_update_playlist_time ();
- app_invalidate ();
+ xui_invalidate ();
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@@ -3478,7 +5082,7 @@ mpd_find_pos_of_id (const char *desired_id)
return -1;
}
-static char *
+static const char *
mpd_id_of_pos (int pos)
{
compact_map_t map = item_list_get (&g.playlist, pos);
@@ -3488,29 +5092,39 @@ mpd_id_of_pos (int pos)
static void
mpd_process_info (const struct strv *data)
{
- int *selected = &g_current_tab.item_selected;
- int *marked = &g_current_tab.item_mark;
- char *prev_sel_id = mpd_id_of_pos (*selected);
- char *prev_mark_id = mpd_id_of_pos (*marked);
- if (prev_sel_id) prev_sel_id = xstrdup (prev_sel_id);
- if (prev_mark_id) prev_mark_id = xstrdup (prev_mark_id);
+ struct tab *tab = &g_current_tab;
+ char *prev_sel_id = xstrdup0 (mpd_id_of_pos (tab->item_selected));
+ char *prev_mark_id = xstrdup0 (mpd_id_of_pos (tab->item_mark));
+ char *fallback_id = NULL;
+
+ struct tab_range r = tab_selection_range (g.active_tab);
+ if (r.upto >= 0)
+ {
+ if (!(fallback_id = xstrdup0 (mpd_id_of_pos (r.upto + 1))))
+ fallback_id = xstrdup0 (mpd_id_of_pos (r.from - 1));
+ }
mpd_process_info_data (data);
- const char *sel_id = mpd_id_of_pos (*selected);
- const char *mark_id = mpd_id_of_pos (*marked);
+ const char *sel_id = mpd_id_of_pos (tab->item_selected);
+ const char *mark_id = mpd_id_of_pos (tab->item_mark);
if (prev_mark_id && (!mark_id || strcmp (prev_mark_id, mark_id)))
- *marked = mpd_find_pos_of_id (prev_mark_id);
+ tab->item_mark = mpd_find_pos_of_id (prev_mark_id);
if (prev_sel_id && (!sel_id || strcmp (prev_sel_id, sel_id)))
{
- if ((*selected = mpd_find_pos_of_id (prev_sel_id)) < 0)
- *marked = -1;
+ if ((tab->item_selected = mpd_find_pos_of_id (prev_sel_id)) < 0)
+ {
+ tab->item_mark = -1;
+ if (fallback_id)
+ tab->item_selected = mpd_find_pos_of_id (fallback_id);
+ }
app_move_selection (0);
}
free (prev_sel_id);
free (prev_mark_id);
+ free (fallback_id);
}
static void
@@ -3535,18 +5149,22 @@ mpd_on_info_response (const struct mpd_response *response,
}
static void
-mpd_on_tick (void *user_data)
+mpd_on_elapsed_time_tick (void *user_data)
{
(void) user_data;
+
+ // Compute how much time has elapsed since the last round second
int64_t diff_msec = clock_msec (CLOCK_BEST) - g.elapsed_since;
int elapsed_sec = diff_msec / 1000;
int elapsed_msec = diff_msec % 1000;
g.song_elapsed += elapsed_sec;
g.elapsed_since += elapsed_sec * 1000;
+
+ // Try to get called on the next round second of playback
poller_timer_set (&g.elapsed_event, 1000 - elapsed_msec);
- app_invalidate ();
+ xui_invalidate ();
}
static void
@@ -3565,6 +5183,15 @@ mpd_request_info (void)
}
static void
+mpd_on_elapsed_time_tick_poll (void *user_data)
+{
+ (void) user_data;
+
+ // As soon as the reply arrives, we (may) set the timer again
+ mpd_request_info ();
+}
+
+static void
mpd_on_events (unsigned subsystems, void *user_data)
{
(void) user_data;
@@ -3572,6 +5199,8 @@ mpd_on_events (unsigned subsystems, void *user_data)
if (subsystems & MPD_SUBSYSTEM_DATABASE)
library_tab_reload (NULL);
+ if (subsystems & MPD_SUBSYSTEM_OUTPUT)
+ pulse_update ();
if (subsystems & (MPD_SUBSYSTEM_PLAYER | MPD_SUBSYSTEM_OPTIONS
| MPD_SUBSYSTEM_PLAYLIST | MPD_SUBSYSTEM_MIXER | MPD_SUBSYSTEM_UPDATE))
@@ -3588,6 +5217,58 @@ mpd_queue_reconnect (void)
poller_timer_set (&g.connect_event, 5 * 1000);
}
+// On an error, MPD discards the rest of our enqueuing commands--work it around
+static void mpd_enqueue_step (size_t start_offset);
+
+static void
+mpd_on_enqueue_response (const struct mpd_response *response,
+ const struct strv *data, void *user_data)
+{
+ (void) data;
+ intptr_t start_offset = (intptr_t) user_data;
+
+ if (response->success)
+ strv_reset (&g.enqueue);
+ else
+ {
+ // Their addition may also overflow, but YOLO
+ hard_assert (start_offset >= 0 && response->list_offset >= 0);
+
+ print_error ("%s: %s", response->message_text,
+ g.enqueue.vector[start_offset + response->list_offset]);
+ mpd_enqueue_step (start_offset + response->list_offset + 1);
+ }
+}
+
+static void
+mpd_enqueue_step (size_t start_offset)
+{
+ struct mpd_client *c = &g.client;
+ if (start_offset >= g.enqueue.len)
+ {
+ strv_reset (&g.enqueue);
+ return;
+ }
+
+ // TODO: might want to consider using addid and autoplaying
+ mpd_client_list_begin (c);
+ for (size_t i = start_offset; i < g.enqueue.len; i++)
+ mpd_client_send_command (c, "add", g.enqueue.vector[i], NULL);
+ mpd_client_list_end (c);
+ mpd_client_add_task (c, mpd_on_enqueue_response, (void *) start_offset);
+ mpd_client_idle (c, 0);
+}
+
+static void
+mpd_on_ready (void)
+{
+ mpd_request_info ();
+ library_tab_reload (NULL);
+ spectrum_setup_fifo ();
+ pulse_update ();
+ mpd_enqueue_step (0);
+}
+
static void
mpd_on_password_response (const struct mpd_response *response,
const struct strv *data, void *user_data)
@@ -3597,10 +5278,7 @@ mpd_on_password_response (const struct mpd_response *response,
struct mpd_client *c = &g.client;
if (response->success)
- {
- mpd_request_info ();
- library_tab_reload (NULL);
- }
+ mpd_on_ready ();
else
{
print_error ("%s: %s",
@@ -3623,10 +5301,7 @@ mpd_on_connected (void *user_data)
mpd_client_add_task (c, mpd_on_password_response, NULL);
}
else
- {
- mpd_request_info ();
- library_tab_reload (NULL);
- }
+ mpd_on_ready ();
}
static void
@@ -3643,6 +5318,9 @@ mpd_on_failure (void *user_data)
mpd_update_playback_state ();
current_tab_update ();
info_tab_update ();
+
+ spectrum_discard_fifo ();
+ pulse_disable ();
}
static void
@@ -3696,8 +5374,544 @@ app_on_reconnect (void *user_data)
mpd_queue_reconnect ();
}
free (address);
+ xui_invalidate ();
+}
+
+// --- TUI ---------------------------------------------------------------------
+
+static struct widget *
+tui_make_button (chtype attrs, const char *label, enum action a)
+{
+ struct widget *w = tui_make_label (attrs, 0, label);
+ w->id = WIDGET_BUTTON;
+ w->userdata = a;
+ return w;
+}
+
+static void
+tui_render_gauge (struct widget *self)
+{
+ struct row_buffer buf = row_buffer_make ();
+ if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
+ goto out;
+
+ float ratio = (float) g.song_elapsed / g.song_duration;
+ if (ratio < 0) ratio = 0;
+ if (ratio > 1) ratio = 1;
+
+ // Always compute it in exactly eight times the resolution,
+ // because sometimes Unicode is even useful
+ int len_left = ratio * self->width * 8 + 0.5;
+
+ static const char *partials[] = { " ", "▏", "▎", "▍", "▌", "▋", "▊", "▉" };
+ int remainder = len_left % 8;
+ len_left /= 8;
+
+ const char *partial = NULL;
+ if (g.use_partial_boxes)
+ partial = partials[remainder];
+ else
+ len_left += remainder >= (int) 4;
+
+ int len_right = self->width - len_left;
+ row_buffer_space (&buf, len_left, APP_ATTR (ELAPSED));
+ if (partial && len_right-- > 0)
+ row_buffer_append (&buf, partial, APP_ATTR (REMAINS));
+ row_buffer_space (&buf, len_right, APP_ATTR (REMAINS));
+
+out:
+ tui_flush_buffer (self, &buf);
+}
+
+// TODO: Perhaps it should save the number within.
+static struct widget *
+tui_make_gauge (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_gauge;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = 1;
+ return w;
+}
+
+static void
+tui_render_spectrum (struct widget *self)
+{
+ // Don't mess up the line editor caret, when it's shown
+ int last_x, last_y;
+ getyx (stdscr, last_y, last_x);
+
+ struct row_buffer buf = row_buffer_make ();
+#ifdef WITH_FFTW
+ row_buffer_append (&buf, g.spectrum.rendered, self->attrs);
+#endif // WITH_FFTW
+ tui_flush_buffer (self, &buf);
+
+ move (last_y, last_x);
+}
+
+static struct widget *
+tui_make_spectrum (chtype attrs, int width)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_spectrum;
+ w->attrs = attrs;
+ w->width = width;
+ w->height = 1;
+ return w;
+}
+
+static void
+tui_render_scrollbar (struct widget *self)
+{
+ // This assumes that we can write to the one-before-last column,
+ // i.e. that it's not covered by any double-wide character (and that
+ // ncurses comes to the right results when counting characters).
+ struct tab *tab = g.active_tab;
+ int visible_items = app_visible_items ();
+
+ hard_assert (tab->item_count != 0);
+ if (!g.use_partial_boxes)
+ {
+ struct scrollbar bar = app_compute_scrollbar (tab, visible_items, 1);
+ for (int row = 0; row < visible_items; row++)
+ {
+ move (self->y + row, self->x);
+ if (row < bar.start || row >= bar.start + bar.length)
+ addch (' ' | self->attrs);
+ else
+ addch (' ' | self->attrs | A_REVERSE);
+ }
+ return;
+ }
+
+ struct scrollbar bar = app_compute_scrollbar (tab, visible_items * 8, 8);
+ bar.length += bar.start;
+
+ int start_part = bar.start % 8; bar.start /= 8;
+ int end_part = bar.length % 8; bar.length /= 8;
+
+ // Even with this, the solid part must be at least one character high
+ static const char *partials[] = { "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁" };
+
+ for (int row = 0; row < visible_items; row++)
+ {
+ chtype attrs = self->attrs;
+ if (row > bar.start && row <= bar.length)
+ attrs ^= A_REVERSE;
+
+ const char *c = " ";
+ if (row == bar.start) c = partials[start_part];
+ if (row == bar.length) c = partials[end_part];
+
+ move (self->y + row, self->x);
+
+ struct row_buffer buf = row_buffer_make ();
+ row_buffer_append (&buf, c, attrs);
+ row_buffer_flush (&buf);
+ row_buffer_free (&buf);
+ }
+}
+
+static struct widget *
+tui_make_scrollbar (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_scrollbar;
+ w->attrs = attrs;
+ w->width = 1;
+ return w;
+}
+
+static struct widget *
+tui_make_list (void)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->width = -1;
+ w->height = g.active_tab->item_count;
+ return w;
+}
+
+static void
+tui_render_editor (struct widget *self)
+{
+ struct row_buffer buf = row_buffer_make ();
+ const struct line_editor *e = &g.editor;
+ int width = self->width;
+ if (e->prompt)
+ {
+ hard_assert (e->prompt < 127);
+ row_buffer_append_c (&buf, e->prompt, self->attrs);
+ width--;
+ }
+
+ int following = 0;
+ for (size_t i = e->point; i < e->len; i++)
+ following += e->w[i];
+
+ int preceding = 0;
+ size_t start = e->point;
+ while (start && preceding < width / 2)
+ preceding += e->w[--start];
+
+ // There can be one extra space at the end of the line but this way we
+ // don't need to care about non-spacing marks following full-width chars
+ while (start && width - preceding - following > 2 /* widest char */)
+ preceding += e->w[--start];
+
+ // XXX: we should also show < > indicators for overflow but it'd probably
+ // considerably complicate this algorithm
+ for (; start < e->len; start++)
+ row_buffer_append_c (&buf, e->line[start], self->attrs);
+ tui_flush_buffer (self, &buf);
+
+ // FIXME: This should be at the end of of tui_render().
+ int caret = !!e->prompt + preceding;
+ move (self->y, self->x + caret);
+ curs_set (1);
+}
+
+static struct widget *
+tui_make_editor (chtype attrs)
+{
+ // TODO: This should ideally measure the text, and copy it to w->text.
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = tui_render_editor;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = 1;
+ return w;
+}
+
+static struct app_ui app_tui_ui =
+{
+ .padding = tui_make_padding,
+ .label = app_make_label,
+ .button = tui_make_button,
+ .gauge = tui_make_gauge,
+ .spectrum = tui_make_spectrum,
+ .scrollbar = tui_make_scrollbar,
+ .list = tui_make_list,
+ .editor = tui_make_editor,
+};
+
+// --- X11 ---------------------------------------------------------------------
+
+#ifdef WITH_X11
+
+// On a 20x20 raster to make it feasible to design on paper.
+#define X11_STOP {INFINITY, INFINITY}
+static const XPointDouble
+ x11_icon_previous[] =
+ {
+ {10, 0}, {0, 10}, {10, 20}, X11_STOP,
+ {20, 0}, {10, 10}, {20, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_pause[] =
+ {
+ {1, 0}, {7, 0}, {7, 20}, {1, 20}, X11_STOP,
+ {13, 0}, {19, 0}, {19, 20}, {13, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_play[] =
+ {
+ {0, 0}, {20, 10}, {0, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_stop[] =
+ {
+ {0, 0}, {20, 0}, {20, 20}, {0, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_next[] =
+ {
+ {0, 0}, {10, 10}, {0, 20}, X11_STOP,
+ {10, 0}, {20, 10}, {10, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_repeat[] =
+ {
+ {0, 12}, {0, 6}, {3, 3}, {13, 3}, {13, 0}, {20, 4.5},
+ {13, 9}, {13, 6}, {3, 6}, {3, 10}, X11_STOP,
+ {0, 15.5}, {7, 11}, {7, 14}, {17, 14}, {17, 10}, {20, 8},
+ {20, 14}, {17, 17}, {7, 17}, {7, 20}, X11_STOP, X11_STOP,
+ },
+ x11_icon_random[] =
+ {
+ {0, 6}, {0, 3}, {5, 3}, {6, 4.5}, {4, 7.5}, {3, 6}, X11_STOP,
+ {9, 15.5}, {11, 12.5}, {12, 14}, {13, 14}, {13, 11}, {20, 15.5},
+ {13, 20}, {13, 17}, {10, 17}, X11_STOP,
+ {0, 17}, {0, 14}, {3, 14}, {10, 3}, {13, 3}, {13, 0}, {20, 4.5},
+ {13, 9}, {13, 6}, {12, 6}, {5, 17}, X11_STOP, X11_STOP,
+ },
+ x11_icon_single[] =
+ {
+ {7, 6}, {7, 4}, {9, 2}, {12, 2}, {12, 15}, {14, 15}, {14, 18},
+ {7, 18}, {7, 15}, {9, 15}, {9, 6}, X11_STOP, X11_STOP,
+ },
+ x11_icon_consume[] =
+ {
+ {0, 13}, {0, 7}, {4, 3}, {10, 3}, {14, 7}, {5, 10}, {14, 13},
+ {10, 17}, {4, 17}, X11_STOP,
+ {16, 12}, {16, 8}, {20, 8}, {20, 12}, X11_STOP, X11_STOP,
+ };
+
+static const XPointDouble *
+x11_icon_for_action (enum action action)
+{
+ switch (action)
+ {
+ case ACTION_MPD_PREVIOUS:
+ return x11_icon_previous;
+ case ACTION_MPD_TOGGLE:
+ return g.state == PLAYER_PLAYING ? x11_icon_pause : x11_icon_play;
+ case ACTION_MPD_STOP:
+ return x11_icon_stop;
+ case ACTION_MPD_NEXT:
+ return x11_icon_next;
+ case ACTION_MPD_REPEAT:
+ return x11_icon_repeat;
+ case ACTION_MPD_RANDOM:
+ return x11_icon_random;
+ case ACTION_MPD_SINGLE:
+ return x11_icon_single;
+ case ACTION_MPD_CONSUME:
+ return x11_icon_consume;
+ default:
+ return NULL;
+ }
}
+static void
+x11_render_button (struct widget *self)
+{
+ x11_render_padding (self);
+
+ const XPointDouble *icon = x11_icon_for_action (self->userdata);
+ if (!icon)
+ {
+ x11_render_label (self);
+ return;
+ }
+
+ size_t total = 0;
+ for (size_t i = 0; icon[i].x != INFINITY || icon[i - 1].x != INFINITY; i++)
+ total++;
+
+ // TODO: There should be an attribute for buttons, to handle this better.
+ XRenderColor color = *x11_fg (self);
+ if (!(self->attrs & A_BOLD))
+ {
+ color.alpha /= 2;
+ color.red /= 2;
+ color.green /= 2;
+ color.blue /= 2;
+ }
+
+ Picture source = XRenderCreateSolidFill (g_xui.dpy, &color);
+ const XRenderPictFormat *format
+ = XRenderFindStandardFormat (g_xui.dpy, PictStandardA8);
+
+ int x = self->x, y = self->y + (self->height - self->width) / 2;
+ XPointDouble buffer[total], *p = buffer;
+ for (size_t i = 0; i < total; i++)
+ if (icon[i].x != INFINITY)
+ {
+ p->x = x + icon[i].x / 20.0 * self->width;
+ p->y = y + icon[i].y / 20.0 * self->width;
+ p++;
+ }
+ else if (p != buffer)
+ {
+ XRenderCompositeDoublePoly (g_xui.dpy, PictOpOver,
+ source, g_xui.x11_pixmap_picture, format,
+ 0, 0, 0, 0, buffer, p - buffer, EvenOddRule);
+ p = buffer;
+ }
+ XRenderFreePicture (g_xui.dpy, source);
+}
+
+static struct widget *
+x11_make_button (chtype attrs, const char *label, enum action a)
+{
+ struct widget *w = x11_make_label (attrs, 0, label);
+ w->id = WIDGET_BUTTON;
+ w->userdata = a;
+
+ if (x11_icon_for_action (a))
+ {
+ w->on_render = x11_render_button;
+
+ // It should be padded by the caller horizontally.
+ w->height = g_xui.vunit;
+ w->width = w->height * 3 / 4;
+ }
+ return w;
+}
+
+static void
+x11_render_gauge (struct widget *self)
+{
+ x11_render_padding (self);
+ if (g.state == PLAYER_STOPPED || g.song_elapsed < 0 || g.song_duration < 1)
+ return;
+
+ int part = (float) g.song_elapsed / g.song_duration * self->width;
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_bg_attrs (APP_ATTR (ELAPSED)),
+ self->x,
+ self->y + self->height / 8,
+ part,
+ self->height * 3 / 4);
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_bg_attrs (APP_ATTR (REMAINS)),
+ self->x + part,
+ self->y + self->height / 8,
+ self->width - part,
+ self->height * 3 / 4);
+}
+
+// TODO: Perhaps it should save the number within.
+static struct widget *
+x11_make_gauge (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_gauge;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = g_xui.vunit;
+ return w;
+}
+
+static void
+x11_render_spectrum (struct widget *self)
+{
+ x11_render_padding (self);
+
+#ifdef WITH_FFTW
+ XRectangle rectangles[g.spectrum.bars];
+ int step = self->width / N_ELEMENTS (rectangles);
+ for (int i = 0; i < g.spectrum.bars; i++)
+ {
+ int height = round ((self->height - 2) * g.spectrum.spectrum[i]);
+ rectangles[i] = (XRectangle)
+ {
+ self->x + i * step,
+ self->y + self->height - 1 - height,
+ step,
+ height,
+ };
+ }
+
+ XRenderFillRectangles (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_fg (self), rectangles, N_ELEMENTS (rectangles));
+#endif // WITH_FFTW
+
+ // Enable the spectrum_redraw() hack.
+ XRectangle r = { self->x, self->y, self->width, self->height };
+ XUnionRectWithRegion (&r, g_xui.x11_clip, g_xui.x11_clip);
+}
+
+static struct widget *
+x11_make_spectrum (chtype attrs, int width)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_spectrum;
+ w->attrs = attrs;
+ w->width = width * g_xui.vunit / 2;
+ w->height = g_xui.vunit;
+ return w;
+}
+
+static void
+x11_render_scrollbar (struct widget *self)
+{
+ x11_render_padding (self);
+
+ struct tab *tab = g.active_tab;
+ struct scrollbar bar =
+ app_compute_scrollbar (tab, app_visible_items_height (), g_xui.vunit);
+
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ x11_fg_attrs (self->attrs),
+ self->x,
+ self->y + bar.start,
+ self->width,
+ bar.length);
+}
+
+static struct widget *
+x11_make_scrollbar (chtype attrs)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_scrollbar;
+ w->attrs = attrs;
+ w->width = g_xui.vunit / 2;
+ return w;
+}
+
+static struct widget *
+x11_make_list (void)
+{
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_padding;
+ return w;
+}
+
+static void
+x11_render_editor (struct widget *self)
+{
+ x11_render_padding (self);
+
+ XftFont *font = x11_widget_font (self)->list->font;
+ XftColor color = { .color = *x11_fg (self) };
+
+ // A simplistic adaptation of line_editor_write() follows.
+ int x = self->x, y = self->y + font->ascent;
+ XGlyphInfo extents = {};
+ if (g.editor.prompt)
+ {
+ FT_UInt i = XftCharIndex (g_xui.dpy, font, g.editor.prompt);
+ XftDrawGlyphs (g_xui.xft_draw, &color, font, x, y, &i, 1);
+ XftGlyphExtents (g_xui.dpy, font, &i, 1, &extents);
+ x += extents.xOff + g_xui.vunit / 4;
+ }
+
+ // TODO: Adapt x11_font_{hadvance,draw}().
+ // TODO: Make this scroll around the caret, and fade like labels.
+ XftDrawString32 (g_xui.xft_draw, &color, font, x, y,
+ g.editor.line, g.editor.len);
+
+ XftTextExtents32 (g_xui.dpy, font, g.editor.line, g.editor.point, &extents);
+ XRenderFillRectangle (g_xui.dpy, PictOpSrc, g_xui.x11_pixmap_picture,
+ &color.color, x + extents.xOff, self->y, 2, self->height);
+}
+
+static struct widget *
+x11_make_editor (chtype attrs)
+{
+ // TODO: This should ideally measure the text, and copy it to w->text.
+ struct widget *w = xcalloc (1, sizeof *w + 1);
+ w->on_render = x11_render_editor;
+ w->attrs = attrs;
+ w->width = -1;
+ w->height = g_xui.vunit;
+ return w;
+}
+
+static struct app_ui app_x11_ui =
+{
+ .padding = x11_make_padding,
+ .label = app_make_label,
+ .button = x11_make_button,
+ .gauge = x11_make_gauge,
+ .spectrum = x11_make_spectrum,
+ .scrollbar = x11_make_scrollbar,
+ .list = x11_make_list,
+ .editor = x11_make_editor,
+
+ .have_icons = true,
+};
+
+#endif // WITH_X11
+
// --- Signals -----------------------------------------------------------------
static int g_signal_pipe[2]; ///< A pipe used to signal... signals
@@ -3765,72 +5979,7 @@ signals_setup_handlers (void)
// --- Initialisation, event handling ------------------------------------------
-static void
-app_on_tty_event (termo_key_t *event, int64_t event_ts)
-{
- // Simple double click detection via release--press delay, only a bit
- // complicated by the fact that we don't know what's being released
- static termo_key_t last_event;
- static int64_t last_event_ts;
- static int last_button;
-
- int y, x, button, y_last, x_last;
- termo_mouse_event_t type, type_last;
- if (termo_interpret_mouse (g.tk, event, &type, &button, &y, &x))
- {
- bool double_click = termo_interpret_mouse
- (g.tk, &last_event, &type_last, NULL, &y_last, &x_last)
- && event_ts - last_event_ts < 500
- && type_last == TERMO_MOUSE_RELEASE && type == TERMO_MOUSE_PRESS
- && y_last == y && x_last == x && last_button == button;
- if (!app_process_mouse (type, y, x, button, double_click))
- beep ();
-
- // Prevent interpreting triple clicks as two double clicks
- if (double_click)
- last_button = 0;
- else if (type == TERMO_MOUSE_PRESS)
- last_button = button;
- }
- else if (!app_process_termo_event (event))
- beep ();
-
- last_event = *event;
- last_event_ts = event_ts;
-}
-
-static void
-app_on_tty_readable (const struct pollfd *fd, void *user_data)
-{
- (void) user_data;
- if (fd->revents & ~(POLLIN | POLLHUP | POLLERR))
- print_debug ("fd %d: unexpected revents: %d", fd->fd, fd->revents);
-
- poller_timer_reset (&g.tk_timer);
- termo_advisereadable (g.tk);
-
- termo_key_t event;
- int64_t event_ts = clock_msec (CLOCK_BEST);
- termo_result_t res;
- while ((res = termo_getkey (g.tk, &event)) == TERMO_RES_KEY)
- app_on_tty_event (&event, event_ts);
-
- if (res == TERMO_RES_AGAIN)
- poller_timer_set (&g.tk_timer, termo_get_waittime (g.tk));
- else if (res == TERMO_RES_ERROR || res == TERMO_RES_EOF)
- app_quit ();
-}
-
-static void
-app_on_key_timer (void *user_data)
-{
- (void) user_data;
-
- termo_key_t event;
- if (termo_getkey_force (g.tk, &event) == TERMO_RES_KEY)
- if (!app_process_termo_event (&event))
- beep ();
-}
+static bool g_verbose_mode = false;
static void
app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data)
@@ -3843,11 +5992,13 @@ app_on_signal_pipe_readable (const struct pollfd *fd, void *user_data)
if (g_termination_requested && !g.quitting)
app_quit ();
+ // It would be awkward to set up SIGWINCH conditionally,
+ // so have it as a handler within UIs.
if (g_winch_received)
{
g_winch_received = false;
- update_curses_terminal_size ();
- app_invalidate ();
+ if (g_xui.ui->winch)
+ g_xui.ui->winch ();
}
}
@@ -3856,9 +6007,7 @@ app_on_message_timer (void *user_data)
{
(void) user_data;
- free (g.message);
- g.message = NULL;
- app_invalidate ();
+ app_hide_message ();
}
static void
@@ -3874,22 +6023,20 @@ app_log_handler (void *user_data, const char *quote, const char *fmt,
struct str message = str_make ();
str_append (&message, quote);
+ size_t quote_len = message.len;
str_append_vprintf (&message, fmt, ap);
- // If the standard error output isn't redirected, try our best at showing
- // the message to the user
- if (!isatty (STDERR_FILENO))
+ // Show it prettified to the user, then maybe log it elsewhere as well.
+ // TODO: Review locale encoding vs UTF-8 in the entire program.
+ message.str[0] = toupper_ascii (message.str[0]);
+ app_show_message (xstrndup (message.str, quote_len),
+ xstrdup (message.str + quote_len));
+
+ if (g_verbose_mode && (g_xui.ui != &tui_ui || !isatty (STDERR_FILENO)))
fprintf (stderr, "%s\n", message.str);
- else if (g_debug_tab.active)
+ if (g_debug_tab.active)
debug_tab_push (str_steal (&message),
user_data == NULL ? 0 : g.attrs[(intptr_t) user_data].attrs);
- else
- {
- free (g.message);
- g.message = xstrdup (message.str);
- app_invalidate ();
- poller_timer_set (&g.message_timer, 5000);
- }
str_free (&message);
in_processing = false;
@@ -3902,25 +6049,67 @@ app_init_poller_events (void)
g.signal_event.dispatcher = app_on_signal_pipe_readable;
poller_fd_set (&g.signal_event, POLLIN);
- g.tty_event = poller_fd_make (&g.poller, STDIN_FILENO);
- g.tty_event.dispatcher = app_on_tty_readable;
- poller_fd_set (&g.tty_event, POLLIN);
-
g.message_timer = poller_timer_make (&g.poller);
g.message_timer.dispatcher = app_on_message_timer;
- g.tk_timer = poller_timer_make (&g.poller);
- g.tk_timer.dispatcher = app_on_key_timer;
-
g.connect_event = poller_timer_make (&g.poller);
g.connect_event.dispatcher = app_on_reconnect;
poller_timer_set (&g.connect_event, 0);
g.elapsed_event = poller_timer_make (&g.poller);
- g.elapsed_event.dispatcher = mpd_on_tick;
+ g.elapsed_event.dispatcher = g.elapsed_poll
+ ? mpd_on_elapsed_time_tick_poll
+ : mpd_on_elapsed_time_tick;
+}
+
+static void
+app_init_ui (bool requested_x11)
+{
+ xui_preinit ();
+
+ g_normal_keys = app_init_bindings ("normal",
+ g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len);
+ g_editor_keys = app_init_bindings ("editor",
+ g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len);
+
+ // It doesn't work 100% (e.g. incompatible with undelining in urxvt)
+ // TODO: make this configurable
+ g.use_partial_boxes = g_xui.locale_is_utf8;
+
+#ifdef WITH_X11
+ g_xui.x11_fontname = get_config_string (g.config.root, "settings.x11_font");
+#endif // WITH_X11
+
+ xui_start (&g.poller, requested_x11, g.attrs, N_ELEMENTS (g.attrs));
+
+#ifdef WITH_X11
+ if (g_xui.ui == &x11_ui)
+ g.ui = &app_x11_ui;
+ else
+#endif // WITH_X11
+ g.ui = &app_tui_ui;
+}
- g.refresh_event = poller_idle_make (&g.poller);
- g.refresh_event.dispatcher = app_on_refresh;
+static void
+app_init_enqueue (char *argv[], int argc)
+{
+ // TODO: MPD is unwilling to play directories, so perhaps recurse ourselves
+ char cwd[4096] = "";
+ for (int i = 0; i < argc; i++)
+ {
+ // This is a super-trivial method of URL detection, however anything
+ // contaning the scheme and authority delimiters in a sequence is most
+ // certainly not a filesystem path, and thus it will work as expected.
+ // Error handling may be done by MPD.
+ const char *path_or_URL = argv[i];
+ if (*path_or_URL == '/' || strstr (path_or_URL, "://"))
+ strv_append (&g.enqueue, path_or_URL);
+ else if (!*cwd && !getcwd (cwd, sizeof cwd))
+ exit_fatal ("getcwd: %s", strerror (errno));
+ else
+ strv_append_owned (&g.enqueue,
+ xstrdup_printf ("%s/%s", cwd, path_or_URL));
+ }
}
int
@@ -3929,13 +6118,18 @@ main (int argc, char *argv[])
static const struct opt opts[] =
{
{ 'd', "debug", NULL, 0, "run in debug mode" },
+#ifdef WITH_X11
+ { 'x', "x11", NULL, 0, "use X11 even when run from a terminal" },
+#endif // WITH_X11
{ 'h', "help", NULL, 0, "display this help and exit" },
+ { 'v', "verbose", NULL, 0, "log messages on standard error" },
{ 'V', "version", NULL, 0, "output version information and exit" },
{ 0, NULL, NULL, 0, NULL }
};
- struct opt_handler oh =
- opt_handler_make (argc, argv, opts, NULL, "MPD client.");
+ bool requested_x11 = false;
+ struct opt_handler oh
+ = opt_handler_make (argc, argv, opts, "[URL | PATH]...", "MPD client.");
int c;
while ((c = opt_handler_get (&oh)) != -1)
@@ -3944,6 +6138,12 @@ main (int argc, char *argv[])
case 'd':
g_debug_mode = true;
break;
+ case 'x':
+ requested_x11 = true;
+ break;
+ case 'v':
+ g_verbose_mode = true;
+ break;
case 'h':
opt_handler_usage (&oh, stdout);
exit (EXIT_SUCCESS);
@@ -3958,12 +6158,6 @@ main (int argc, char *argv[])
argc -= optind;
argv += optind;
-
- if (argc)
- {
- opt_handler_usage (&oh, stderr);
- exit (EXIT_FAILURE);
- }
opt_handler_free (&oh);
// We only need to convert to and from the terminal encoding
@@ -3971,15 +6165,11 @@ main (int argc, char *argv[])
print_warning ("failed to set the locale");
app_init_context ();
+ app_init_enqueue (argv, argc);
app_load_configuration ();
- app_init_terminal ();
signals_setup_handlers ();
app_init_poller_events ();
-
- g_normal_keys = app_init_bindings ("normal",
- g_normal_defaults, N_ELEMENTS (g_normal_defaults), &g_normal_keys_len);
- g_editor_keys = app_init_bindings ("editor",
- g_editor_defaults, N_ELEMENTS (g_editor_defaults), &g_editor_keys_len);
+ app_init_ui (requested_x11);
if (g_debug_mode)
app_prepend_tab (debug_tab_init ());
@@ -3994,11 +6184,16 @@ main (int argc, char *argv[])
app_prepend_tab (current_tab_init ());
app_switch_tab ((g.help_tab = help_tab_init ()));
+ // TODO: the help tab should be the default for new users only,
+ // so provide a configuration option to flip this
+ if (argc)
+ app_switch_tab (&g_current_tab);
+
g.polling = true;
while (g.polling)
poller_run (&g.poller);
- endwin ();
+ xui_stop ();
g_log_message_real = log_message_stdio;
app_free_context ();
return 0;
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 30e0eee1a82df265642cff6d57452ff660f0f2c
+Subproject 2518b53e5ae4579bf84ed58fa7a62806f64e861