├── .github └── workflows │ └── build.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── interface.go └── test_api.go ├── code_of_conduct.md ├── commands ├── add.go ├── add_test.go ├── bind.go ├── bind_test.go ├── clear.go ├── command.go ├── cursor.go ├── cursor_test.go ├── cut.go ├── cut_test.go ├── inputmode.go ├── isolate.go ├── isolate_test.go ├── list.go ├── next.go ├── paste.go ├── paste_test.go ├── pause.go ├── play.go ├── play_test.go ├── previous.go ├── print.go ├── quit.go ├── redraw.go ├── seek.go ├── seek_test.go ├── select.go ├── select_test.go ├── set.go ├── set_test.go ├── single.go ├── single_test.go ├── sort.go ├── sort_test.go ├── stop.go ├── style.go ├── style_test.go ├── test.go ├── unbind.go ├── unbind_test.go ├── update.go ├── viewport.go ├── viewport_test.go ├── volume.go ├── volume_test.go ├── yank.go └── yank_test.go ├── console └── console.go ├── constants └── constants.go ├── db └── db.go ├── doc ├── README.md ├── commands.md ├── data-model-refactor.md ├── intro.md ├── mpd.md ├── options.md └── styling.md ├── go.mod ├── go.sum ├── index ├── filters │ └── unicodestrip │ │ └── unicodestrip.go ├── index.go ├── mapping.go └── song │ └── song.go ├── input ├── interface.go ├── interface_test.go ├── keys │ └── keys.go ├── lexer │ ├── lexer.go │ └── lexer_test.go └── parser │ ├── set.go │ └── set_test.go ├── install.sh ├── keysequence ├── keysequence.go ├── names.go ├── parser.go └── parser_test.go ├── main.go ├── message └── message.go ├── mpd └── playerstatus.go ├── options ├── bool.go ├── defaults.go ├── int.go ├── options.go ├── options_test.go └── string.go ├── parser └── parser.go ├── pms ├── config.go ├── connection.go ├── main.go ├── pms.go └── setup.go ├── song ├── song.go └── song_test.go ├── songlist ├── collection.go ├── columns.go ├── cursor.go ├── library.go ├── queue.go ├── selection.go └── songlist.go ├── style └── style.go ├── tabcomplete ├── tabcomplete.go └── tabcomplete_test.go ├── test.sh ├── topbar ├── audioformat.go ├── audioformat_test.go ├── elapsed.go ├── list.go ├── mode.go ├── parser.go ├── shortname.go ├── state.go ├── tag.go ├── text.go ├── time.go ├── topbar.go ├── topbar_test.go ├── version.go └── volume.go ├── utils └── utils.go ├── version └── version.go ├── widgets ├── columnheaders.go ├── events.go ├── multibar.go ├── songlist.go ├── topbar.go └── ui.go └── xdg └── xdg.go /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | paths: 5 | - 'Makefile' 6 | - 'go.mod' 7 | - '**.go' 8 | - '.github/workflows/build.yml' 9 | env: 10 | go_version: '1.21' 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-go@v2 18 | with: 19 | go-version: ${{ env.go_version }} 20 | - name: run tests 21 | run: | 22 | go mod download 23 | make test 24 | make pms 25 | make_release: 26 | if: ${{ github.ref == 'refs/heads/master' }} 27 | needs: 28 | - test 29 | runs-on: ubuntu-latest 30 | outputs: 31 | upload_url: ${{ steps.create_release.outputs.upload_url }} 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | steps: 35 | - name: Delete latest release 36 | uses: dev-drprasad/delete-tag-and-release@v0.2.1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | with: 40 | delete_release: true 41 | tag_name: latest 42 | - name: Create release 43 | id: create_release 44 | uses: actions/create-release@v1 45 | with: 46 | tag_name: latest 47 | release_name: Latest build 48 | draft: false 49 | prerelease: false 50 | release: 51 | if: ${{ github.ref == 'refs/heads/master' }} 52 | needs: 53 | - make_release 54 | runs-on: ubuntu-latest 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | strategy: 58 | matrix: 59 | os_arch: 60 | - darwin-amd64 61 | - darwin-arm64 62 | - linux-amd64 63 | - linux-arm64 64 | - linux-arm 65 | - windows-amd64.exe 66 | steps: 67 | - uses: actions/checkout@v2 68 | - uses: actions/setup-go@v2 69 | with: 70 | go-version: ${{ env.go_version }} 71 | - name: Download dependencies 72 | run: | 73 | go mod download 74 | - name: Build binary 75 | run: make ${{ matrix.os_arch }} 76 | - name: Upload binary 77 | uses: actions/upload-release-asset@v1 78 | with: 79 | upload_url: ${{ needs.make_release.outputs.upload_url }} 80 | asset_path: build/pms-${{ matrix.os_arch }} 81 | asset_name: pms-${{ matrix.os_arch }} 82 | asset_content_type: application/octet-stream 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage.txt 3 | *.swp 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to PMS 2 | 3 | Thank you for considering contributing to Practical Music Search. 4 | 5 | You're welcome to report any bugs or request features using the Github issue 6 | tracker. Code contributions are warmly received through pull requests on 7 | Github. Make sure there is an open issue for the feature or bug you want to 8 | tackle, and that it is coherent with the project strategy. 9 | 10 | For general discussion about the project, or to contact the project devs, you 11 | can use the IRC channel `#pms` on Freenode. 12 | 13 | This project adheres to the 14 | [Contributor Covenant Code of Conduct](code_of_conduct.md). 15 | By participating, you are expected to uphold this code. 16 | 17 | ## Setting up the development environment 18 | 19 | If you want to work on PMS, fork this repository and clone the fork to your 20 | `$GOPATH` (`$GOPATH/src/github.com/[YOUR_GITHUB_USERNAME]/pms`). Due to 21 | hardcoded dependencies, cloning your fork into `$GOPATH` is not sufficient. 22 | Thus, you should also create a symlink to trick Go into looking for these 23 | dependencies in your fork. For example: 24 | 25 | ``` 26 | cd $GOPATH/src/github.com 27 | mkdir -p ambientsound 28 | ln -s ../[YOUR_GITHUB_USERNAME]/pms ambientsound/pms 29 | ``` 30 | 31 | After this, `cd` into your fork and run `make`. Now the `pms` command is built 32 | from your fork. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2006-2017 Kim Tore Jensen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell git describe --always --long --dirty) 2 | DATE := $(shell date +%s) 3 | LDFLAGS := -ldflags="-X main.buildVersion=${VERSION}" 4 | 5 | .PHONY: install pms test linux-amd64 linux-arm64 linux-arm darwin-amd64 darwin-arm64 windows-amd64.exe 6 | 7 | install: pms 8 | sh ./install.sh 9 | 10 | pms: 11 | go build ${LDFLAGS} -o build/pms main.go 12 | 13 | test: 14 | go test ./... 15 | 16 | linux-amd64: 17 | GOOS=linux GOARCH=amd64 \ 18 | go build ${LDFLAGS} -o build/pms-linux-amd64 main.go 19 | 20 | linux-arm64: 21 | GOOS=linux GOARCH=arm64 \ 22 | go build ${LDFLAGS} -o build/pms-linux-arm64 main.go 23 | 24 | linux-arm: 25 | GOOS=linux GOARCH=arm \ 26 | go build ${LDFLAGS} -o build/pms-linux-arm main.go 27 | 28 | darwin-amd64: 29 | GOOS=darwin GOARCH=amd64 \ 30 | go build ${LDFLAGS} -o build/pms-darwin-amd64 main.go 31 | 32 | darwin-arm64: 33 | GOOS=darwin GOARCH=arm64 \ 34 | go build ${LDFLAGS} -o build/pms-darwin-arm64 main.go 35 | 36 | windows-amd64.exe: 37 | GOOS=windows GOARCH=amd64 \ 38 | go build ${LDFLAGS} -o build/pms-windows-amd64.exe main.go 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Practical Music Search 2 | 3 | [![Build Status](https://github.com/ambientsound/pms/actions/workflows/build.yml/badge.svg)](https://github.com/ambientsound/pms/actions/workflows/build.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/ambientsound/pms)](https://goreportcard.com/report/github.com/ambientsound/pms) 5 | [![codecov](https://codecov.io/gh/ambientsound/pms/branch/master/graph/badge.svg)](https://codecov.io/gh/ambientsound/pms/branch/master) 6 | [![License](https://img.shields.io/github/license/ambientsound/pms.svg)](LICENSE) 7 | 8 | Practical Music Search is an interactive console client for the [Music Player Daemon](https://www.musicpd.org/), written in Go. Its interface is similar to Vim, and aims to be fast, configurable, and practical. 9 | 10 | PMS has many features that involve sorting, searching, and navigating. It’s designed to let you navigate your music collection in an effective way. Some of the currently implemented features are: 11 | 12 | * Vim-style look and feel! 13 | * Can be configured to consume a very small amount of screen space. 14 | * MPD player controls: play, add, pause, stop, next, prev, volume. 15 | * Highly customizable top bar, tag headers, text styles, colors, and keyboard bindings. 16 | * Fast library search, featuring UTF-8 normalization, fuzzy search, and scoring. 17 | * Selecting songs, by _visual mode_, manual selection, and specific tags. 18 | * Many forms of tracklist manipulation, such as cut, copy, paste, filter, and sort. 19 | * Config files, tab completion, history, and much more! 20 | 21 | ## Documentation 22 | 23 | [Documentation](doc/README.md) is available in the project repository. 24 | 25 | ## Project status 26 | 27 | _NEWS_: Development of PMS has resumed! We continue to appreciate contributions and 28 | strive to make PMS an ever better and continuously evolving MPD client. 29 | 30 | This software was previously written in C++. The master branch now contains a rewrite, currently implemented in Go. 31 | The current goal of the Go implementation is to implement most of the features found in the 0.42 branch. 32 | 33 | This functionality is present in the `0.42.x` branch, but missing in master: 34 | 35 | * Automatically add songs to the queue when it is nearing end. 36 | * Remote playlist management. 37 | * ...and probably more. 38 | 39 | ## Getting started 40 | 41 | You’re assumed to have a working [Go development environment](https://golang.org/doc/install). Building PMS requires Go version 1.13 or higher. 42 | 43 | Assuming you have the `go` binary in your path, you can install PMS using: 44 | 45 | ```sh 46 | git clone https://github.com/ambientsound/pms 47 | cd pms 48 | make install 49 | ``` 50 | 51 | This will put the binary in `$GOBIN/pms`, usually at `~/go/bin/pms`. 52 | 53 | If you prefer to link the binary instead of copying it, set the `INSTALL_TYPE` environment variable to `link`: 54 | 55 | ```sh 56 | INSTALL_TYPE=link make install 57 | ``` 58 | 59 | You need to run PMS in a regular terminal with a TTY. 60 | 61 | If PMS crashes, and you want to report a bug, please include the debug log: 62 | 63 | ```sh 64 | pms --debug /tmp/pms.log 2>>/tmp/pms.log 65 | ``` 66 | 67 | ## Requirements 68 | 69 | PMS wants to build a search index from MPD's database. To be truly practical, PMS must support fuzzy matching, scoring, and sub-millisecond full-text searches. This is accomplished by using [Bleve](https://github.com/blevesearch/bleve), a full-text search and indexing library. 70 | 71 | A full-text search index takes up both space and memory. For a library of about 30 000 songs, you should expect using about 500 MB of disk space and around 1 GB of RAM. 72 | 73 | PMS is multithreaded and benefits from multicore CPUs. 74 | 75 | ## Contributing 76 | 77 | See [how to contribute to PMS](CONTRIBUTING.md). 78 | 79 | ## Authors 80 | 81 | Copyright (c) 2006-2022 Kim Tore Jensen <>. 82 | 83 | * Kim Tore Jensen <> 84 | * Bart Nagel <> 85 | * Thomas Zander <> 86 | 87 | The source code and latest version can be found at Github: 88 | . 89 | -------------------------------------------------------------------------------- /api/interface.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/ambientsound/pms/songlist" 5 | ) 6 | 7 | type Collection interface { 8 | Activate(songlist.Songlist) 9 | ActivateIndex(int) error 10 | Add(songlist.Songlist) 11 | Current() songlist.Songlist 12 | Index() (int, error) 13 | Last() songlist.Songlist 14 | Len() int 15 | Remove(int) error 16 | ValidIndex(int) bool 17 | } 18 | 19 | type SonglistWidget interface { 20 | GetVisibleBoundaries() (int, int) 21 | ScrollViewport(int, bool) 22 | Size() (int, int) 23 | } 24 | 25 | type MultibarWidget interface { 26 | Mode() int 27 | SetMode(int) error 28 | } 29 | 30 | type UI interface { 31 | PostFunc(func()) 32 | Refresh() 33 | } 34 | -------------------------------------------------------------------------------- /api/test_api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/ambientsound/pms/db" 5 | "github.com/ambientsound/pms/input/keys" 6 | "github.com/ambientsound/pms/message" 7 | pms_mpd "github.com/ambientsound/pms/mpd" 8 | "github.com/ambientsound/pms/options" 9 | "github.com/ambientsound/pms/song" 10 | "github.com/ambientsound/pms/songlist" 11 | "github.com/ambientsound/pms/style" 12 | "github.com/fhs/gompd/v2/mpd" 13 | ) 14 | 15 | type testAPI struct { 16 | messages chan message.Message 17 | options *options.Options 18 | song *song.Song 19 | songlist songlist.Songlist 20 | clipboard songlist.Songlist 21 | db *db.Instance 22 | } 23 | 24 | func createTestSong() *song.Song { 25 | s := song.New() 26 | s.SetTags(mpd.Attrs{ 27 | "artist": "foo", 28 | "title": "bar", 29 | }) 30 | return s 31 | } 32 | 33 | func NewTestAPI() API { 34 | return &testAPI{ 35 | clipboard: songlist.New(), 36 | messages: make(chan message.Message, 1024), 37 | options: options.New(), 38 | song: createTestSong(), 39 | songlist: songlist.New(), 40 | db: db.New(), 41 | } 42 | } 43 | 44 | func (api *testAPI) Clipboard() songlist.Songlist { 45 | return api.clipboard 46 | } 47 | 48 | func (api *testAPI) Db() *db.Instance { 49 | return api.db 50 | } 51 | 52 | func (api *testAPI) Library() *songlist.Library { 53 | return nil // FIXME 54 | } 55 | 56 | func (api *testAPI) ListChanged() { 57 | // FIXME 58 | } 59 | 60 | func (api *testAPI) Message(fmt string, a ...interface{}) { 61 | api.messages <- message.Format(fmt, a...) 62 | } 63 | 64 | func (api *testAPI) MpdClient() *mpd.Client { 65 | return nil // FIXME 66 | } 67 | 68 | func (api *testAPI) Multibar() MultibarWidget { 69 | return nil // FIXME 70 | } 71 | 72 | func (api *testAPI) OptionChanged(key string) { 73 | // FIXME 74 | } 75 | 76 | func (api *testAPI) Options() *options.Options { 77 | return api.options 78 | } 79 | 80 | func (api *testAPI) PlayerStatus() pms_mpd.PlayerStatus { 81 | return api.db.PlayerStatus() 82 | } 83 | 84 | func (api *testAPI) Queue() *songlist.Queue { 85 | return nil // FIXME 86 | } 87 | 88 | func (api *testAPI) Quit() { 89 | return // FIXME 90 | } 91 | 92 | func (api *testAPI) Sequencer() *keys.Sequencer { 93 | return nil // FIXME 94 | } 95 | 96 | // SetPlayerStatus sets the player status struct to the provided input 97 | // value, allowing to construct test cases that depend on a particular 98 | // player status 99 | //func (api *testAPI) SetPlayerStatus(p pms_mpd.PlayerStatus) { 100 | //api.status = p 101 | //} 102 | 103 | func (api *testAPI) Song() *song.Song { 104 | return api.song 105 | } 106 | 107 | func (api *testAPI) Songlist() songlist.Songlist { 108 | return api.songlist 109 | } 110 | 111 | func (api *testAPI) Songlists() []songlist.Songlist { 112 | return nil // FIXME 113 | } 114 | 115 | func (api *testAPI) SonglistWidget() SonglistWidget { 116 | return nil // FIXME 117 | } 118 | 119 | func (api *testAPI) Styles() style.Stylesheet { 120 | return nil // FIXME 121 | } 122 | 123 | func (api *testAPI) UI() UI { 124 | return nil // FIXME 125 | } 126 | -------------------------------------------------------------------------------- /code_of_conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [kimtjen@gmail.com][email]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an 62 | incident. Further details of specific enforcement policies may be posted 63 | separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 72 | available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | [email]: mailto:kimtjen@gmail.com 77 | -------------------------------------------------------------------------------- /commands/add.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | "github.com/ambientsound/pms/song" 9 | "github.com/ambientsound/pms/songlist" 10 | "github.com/fhs/gompd/v2/mpd" 11 | ) 12 | 13 | // Add adds songs to MPD's queue. 14 | type Add struct { 15 | newcommand 16 | api api.API 17 | songlist songlist.Songlist 18 | } 19 | 20 | // NewAdd returns Add. 21 | func NewAdd(api api.API) Command { 22 | return &Add{ 23 | api: api, 24 | songlist: songlist.New(), 25 | } 26 | } 27 | 28 | // Parse implements Command. 29 | func (cmd *Add) Parse() error { 30 | 31 | // Add all songs specified on the command line. 32 | Loop: 33 | for { 34 | str := "" 35 | tok, lit := cmd.Scan() 36 | switch tok { 37 | case lexer.TokenWhitespace: 38 | str = "" 39 | continue 40 | case lexer.TokenEnd: 41 | break Loop 42 | default: 43 | str += lit 44 | } 45 | addSong := song.New() 46 | addSong.SetTags(mpd.Attrs{"file": str}) 47 | cmd.songlist.Add(addSong) 48 | } 49 | 50 | // No songs specified on command line. Use songlist selection instead. 51 | if cmd.songlist.Len() == 0 { 52 | cmd.songlist = cmd.api.Songlist().Selection() 53 | if cmd.songlist.Len() == 0 { 54 | return fmt.Errorf("No selection, cannot add without any parameters.") 55 | } 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // Exec implements Command. 62 | func (cmd *Add) Exec() error { 63 | list := cmd.api.Songlist() 64 | queue := cmd.api.Queue() 65 | 66 | err := queue.AddList(cmd.songlist) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | list.ClearSelection() 72 | list.MoveCursor(1) 73 | len := cmd.songlist.Len() 74 | if len == 1 { 75 | song := cmd.songlist.Songs()[0] 76 | cmd.api.Message("Added to queue: %s", song.StringTags["file"]) 77 | } else { 78 | cmd.api.Message("Added %d songs to queue.", len) 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /commands/add_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/commands" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var addTests = []commands.Test{ 12 | // Valid forms 13 | {``, true, initSongTags, nil, []string{}}, 14 | {`foo bar baz`, true, nil, nil, []string{}}, 15 | {`http://example.com/stream.mp3?foo=bar&baz=foo foo bar baz`, true, nil, nil, []string{}}, 16 | {`|`, true, nil, nil, []string{}}, 17 | {`|{}$`, true, nil, nil, []string{}}, 18 | 19 | // No invalid forms, all input is accepted 20 | } 21 | 22 | func TestAdd(t *testing.T) { 23 | commands.TestVerb(t, "add", addTests) 24 | } 25 | 26 | // FIXME: add this callback to test #3. Not working because Queue doesn't add directly. 27 | func testMultipleSongsAdded(data *commands.TestData) { 28 | files := strings.Split(data.Test.Input, " ") 29 | 30 | assert.Equal(data.T, len(files), data.Api.Songlist().Len(), "Number of URIs added differs from songlist length") 31 | for i, song := range data.Api.Songlist().Songs() { 32 | assert.Equal(data.T, files[i], song.StringTags["file"], "Song %d should have URI '%s'", i, files[i]) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /commands/bind.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | "github.com/ambientsound/pms/keysequence" 10 | ) 11 | 12 | // Bind maps a key sequence to the execution of a command. 13 | type Bind struct { 14 | newcommand 15 | api api.API 16 | sentence string 17 | seq keysequence.KeySequence 18 | } 19 | 20 | // NewBind returns Bind. 21 | func NewBind(api api.API) Command { 22 | return &Bind{ 23 | api: api, 24 | } 25 | } 26 | 27 | // Parse implements Command. 28 | func (cmd *Bind) Parse() error { 29 | 30 | // Use the key sequence parser for parsing the next token. 31 | parser := keysequence.NewParser(cmd.S) 32 | 33 | // Parse a valid key sequence from the scanner. 34 | seq, err := parser.ParseKeySequence() 35 | if err != nil { 36 | return err 37 | } 38 | cmd.seq = seq 39 | 40 | // Treat the rest of the line as the literal action to execute when the bind succeeds. 41 | sentence := make([]string, 0, 32) 42 | for { 43 | tok, lit := cmd.Scan() 44 | if tok == lexer.TokenEnd { 45 | break 46 | } else if tok == lexer.TokenIdentifier { 47 | // Quote identifiers? 48 | } 49 | sentence = append(sentence, lit) 50 | } 51 | 52 | if len(sentence) == 0 { 53 | return fmt.Errorf("Unexpected END, expected identifier") 54 | } 55 | 56 | cmd.sentence = strings.Join(sentence, "") 57 | return nil 58 | } 59 | 60 | // Exec implements Command. 61 | func (cmd *Bind) Exec() error { 62 | sequencer := cmd.api.Sequencer() 63 | return sequencer.AddBind(cmd.seq, cmd.sentence) 64 | } 65 | -------------------------------------------------------------------------------- /commands/bind_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var bindTests = []commands.Test{ 10 | // Valid forms 11 | {`foo bar`, true, nil, nil, []string{}}, 12 | {`foo bar baz`, true, nil, nil, []string{}}, 13 | {`[]{}$|"test" foo bar`, true, nil, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {``, false, nil, nil, []string{}}, 17 | {`x`, false, nil, nil, []string{}}, 18 | } 19 | 20 | func TestBind(t *testing.T) { 21 | commands.TestVerb(t, "bind", bindTests) 22 | } 23 | -------------------------------------------------------------------------------- /commands/clear.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Clears the queue queue 10 | type Clear struct { 11 | newcommand 12 | api api.API 13 | } 14 | 15 | // NewClear returns Clear. 16 | func NewClear(api api.API) Command { 17 | return &Clear{ 18 | api: api, 19 | } 20 | } 21 | 22 | // Parse implements Command. 23 | func (cmd *Clear) Parse() error { 24 | return cmd.ParseEnd() 25 | } 26 | 27 | // Exec implements Command. 28 | func (cmd *Clear) Exec() error { 29 | if client := cmd.api.MpdClient(); client != nil { 30 | err := client.Clear() 31 | if err != nil { 32 | cmd.api.Message("clearing queue") 33 | } 34 | return err 35 | } 36 | return fmt.Errorf("Unable to clear: cannot communicate with MPD") 37 | } 38 | -------------------------------------------------------------------------------- /commands/cursor.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/ambientsound/pms/api" 10 | "github.com/ambientsound/pms/input/lexer" 11 | ) 12 | 13 | // Cursor moves the cursor in a songlist widget. It can take human-readable 14 | // parameters such as 'up' and 'down', and it also accepts relative positions 15 | // if a number is given. 16 | type Cursor struct { 17 | newcommand 18 | api api.API 19 | absolute int 20 | current bool 21 | finished bool 22 | nextOfDirection int 23 | nextOfTags []string 24 | relative int 25 | } 26 | 27 | // NewCursor returns Cursor. 28 | func NewCursor(api api.API) Command { 29 | return &Cursor{ 30 | api: api, 31 | } 32 | } 33 | 34 | // Parse parses cursor movement. 35 | func (cmd *Cursor) Parse() error { 36 | songlistWidget := cmd.api.SonglistWidget() 37 | list := cmd.api.Songlist() 38 | 39 | tok, lit := cmd.ScanIgnoreWhitespace() 40 | cmd.setTabCompleteVerbs(lit) 41 | 42 | switch tok { 43 | // In case of a number, scan the actual number and return 44 | case lexer.TokenMinus, lexer.TokenPlus: 45 | cmd.setTabCompleteEmpty() 46 | cmd.Unscan() 47 | _, lit, absolute, err := cmd.ParseInt() 48 | if err != nil { 49 | return err 50 | } 51 | if absolute { 52 | cmd.absolute = lit 53 | } else { 54 | cmd.relative = lit 55 | } 56 | return cmd.ParseEnd() 57 | 58 | case lexer.TokenIdentifier: 59 | default: 60 | return fmt.Errorf("Unexpected '%v', expected number or identifier", lit) 61 | } 62 | 63 | switch lit { 64 | case "up": 65 | cmd.relative = -1 66 | case "down": 67 | cmd.relative = 1 68 | case "home": 69 | cmd.absolute = 0 70 | case "end": 71 | cmd.absolute = list.Len() - 1 72 | case "high": 73 | ymin, _ := songlistWidget.GetVisibleBoundaries() 74 | cmd.absolute = ymin 75 | case "middle": 76 | ymin, ymax := songlistWidget.GetVisibleBoundaries() 77 | cmd.absolute = (ymin + ymax) / 2 78 | case "low": 79 | _, ymax := songlistWidget.GetVisibleBoundaries() 80 | cmd.absolute = ymax 81 | case "current": 82 | cmd.current = true 83 | case "random": 84 | cmd.absolute = cmd.random() 85 | case "nextOf": 86 | cmd.nextOfDirection = 1 87 | return cmd.parseNextOf() 88 | case "prevOf": 89 | cmd.nextOfDirection = -1 90 | return cmd.parseNextOf() 91 | default: 92 | i, err := strconv.Atoi(lit) 93 | if err != nil { 94 | return fmt.Errorf("Cursor command '%s' not recognized, and is not a number", lit) 95 | } 96 | cmd.relative = i 97 | } 98 | 99 | cmd.setTabCompleteEmpty() 100 | 101 | return cmd.ParseEnd() 102 | } 103 | 104 | // Exec is the next Execute(), evading the old system 105 | func (cmd *Cursor) Exec() error { 106 | list := cmd.api.Songlist() 107 | 108 | switch { 109 | case cmd.nextOfDirection != 0: 110 | cmd.absolute = cmd.runNextOf() 111 | case cmd.current: 112 | currentSong := cmd.api.Song() 113 | if currentSong == nil { 114 | return fmt.Errorf("No song is currently playing.") 115 | } 116 | return list.CursorToSong(currentSong) 117 | } 118 | 119 | switch { 120 | case cmd.relative != 0: 121 | list.MoveCursor(cmd.relative) 122 | default: 123 | list.SetCursor(cmd.absolute) 124 | } 125 | 126 | return nil 127 | } 128 | 129 | // setTabCompleteVerbs sets the tab complete list to the list of available sub-commands. 130 | func (cmd *Cursor) setTabCompleteVerbs(lit string) { 131 | cmd.setTabComplete(lit, []string{ 132 | "current", 133 | "down", 134 | "end", 135 | "high", 136 | "home", 137 | "low", 138 | "middle", 139 | "nextOf", 140 | "prevOf", 141 | "random", 142 | "up", 143 | }) 144 | } 145 | 146 | // random returns a random list index in the songlist. 147 | func (cmd *Cursor) random() int { 148 | len := cmd.api.Songlist().Len() 149 | if len == 0 { 150 | return cmd.absolute 151 | } 152 | seed := time.Now().UnixNano() 153 | r := rand.New(rand.NewSource(seed)) 154 | return r.Int() % len 155 | } 156 | 157 | // parseNextOf assigns the nextOf tags and directions, or returns an error if 158 | // no tags are specified. 159 | func (cmd *Cursor) parseNextOf() error { 160 | var err error 161 | song := cmd.api.Songlist().CursorSong() 162 | cmd.nextOfTags, err = cmd.ParseTags(song) 163 | return err 164 | } 165 | 166 | // runNextOf finds the next song with different tags. 167 | func (cmd *Cursor) runNextOf() int { 168 | list := cmd.api.Songlist() 169 | index := list.Cursor() 170 | return list.NextOf(cmd.nextOfTags, index, cmd.nextOfDirection) 171 | } 172 | -------------------------------------------------------------------------------- /commands/cursor_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | "github.com/ambientsound/pms/song" 8 | "github.com/fhs/gompd/v2/mpd" 9 | ) 10 | 11 | var cursorTests = []commands.Test{ 12 | // Valid forms 13 | {`6`, true, nil, nil, []string{}}, 14 | {`+8`, true, nil, nil, []string{}}, 15 | {`-1`, true, nil, nil, []string{}}, 16 | {`up`, true, nil, nil, []string{}}, 17 | {`down`, true, nil, nil, []string{}}, 18 | // FIXME: depends on SonglistWidget, which is not mocked 19 | //{`high`, true}, 20 | //{`middle`, true}, 21 | //{`low`, true}, 22 | {`home`, true, nil, nil, []string{}}, 23 | {`end`, true, nil, nil, []string{}}, 24 | {`current`, true, nil, nil, []string{}}, 25 | {`random`, true, nil, nil, []string{}}, 26 | {`nextOf tag1 tag2`, true, nil, nil, []string{}}, 27 | {`prevOf tag1 tag2`, true, nil, nil, []string{}}, 28 | 29 | // Invalid forms 30 | {`up 1`, false, nil, nil, []string{}}, 31 | {`down 1`, false, nil, nil, []string{}}, 32 | // FIXME: depends on SonglistWidget, which is not mocked 33 | //{`high 1`, false}, 34 | //{`middle 1`, false}, 35 | //{`low 1`, false}, 36 | {`home 1`, false, nil, nil, []string{}}, 37 | {`end 1`, false, nil, nil, []string{}}, 38 | {`current 1`, false, nil, nil, []string{}}, 39 | {`random 1`, false, nil, nil, []string{}}, 40 | {`nextOf`, false, nil, nil, []string{}}, 41 | {`nextOf `, false, initSongTags, nil, []string{"artist", "title"}}, 42 | {`nextOf t`, true, initSongTags, nil, []string{"title"}}, 43 | {`prevOf`, false, nil, nil, []string{}}, 44 | {`prevOf `, false, initSongTags, nil, []string{"artist", "title"}}, 45 | 46 | // Tab completion 47 | {``, false, nil, nil, []string{ 48 | "current", 49 | "down", 50 | "end", 51 | "high", 52 | "home", 53 | "low", 54 | "middle", 55 | "nextOf", 56 | "prevOf", 57 | "random", 58 | "up", 59 | }}, 60 | } 61 | 62 | func TestCursor(t *testing.T) { 63 | commands.TestVerb(t, "cursor", cursorTests) 64 | } 65 | 66 | func initSongTags(data *commands.TestData) { 67 | s := song.New() 68 | s.SetTags(mpd.Attrs{ 69 | "artist": "foo", 70 | "title": "bar", 71 | }) 72 | data.Api.Songlist().Add(s) 73 | } 74 | -------------------------------------------------------------------------------- /commands/cut.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/console" 8 | ) 9 | 10 | // Cut removes songs from songlists. 11 | type Cut struct { 12 | newcommand 13 | api api.API 14 | } 15 | 16 | // NewCut returns Cut. 17 | func NewCut(api api.API) Command { 18 | return &Cut{ 19 | api: api, 20 | } 21 | } 22 | 23 | // Parse implements Command. 24 | func (cmd *Cut) Parse() error { 25 | return cmd.ParseEnd() 26 | } 27 | 28 | // Exec implements Command. 29 | func (cmd *Cut) Exec() error { 30 | list := cmd.api.Songlist() 31 | selection := list.Selection() 32 | indices := list.SelectionIndices() 33 | len := len(indices) 34 | 35 | if len == 0 { 36 | return fmt.Errorf("No tracks selected, cannot remove without any parameters.") 37 | } 38 | 39 | // Remove songs from list 40 | index := indices[0] 41 | err := list.RemoveIndices(indices) 42 | cmd.api.ListChanged() 43 | 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if len == 1 { 49 | cmd.api.Message("Cut out '%s'", selection.Song(0).StringTags["file"]) 50 | } else { 51 | cmd.api.Message("%d fewer songs", len) 52 | } 53 | list.ClearSelection() 54 | list.SetCursor(index) 55 | 56 | // Place songs in clipboard 57 | clipboard := cmd.api.Db().Clipboard("default") 58 | selection.Duplicate(clipboard) 59 | console.Log("Cut %d tracks into clipboard", clipboard.Len()) 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /commands/cut_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var cutTests = []commands.Test{ 10 | // Cut takes to parameters. 11 | {``, true, nil, nil, []string{}}, 12 | {` `, true, nil, nil, []string{}}, 13 | 14 | // Invalid forms 15 | {`foo`, false, nil, nil, []string{}}, 16 | {`foo bar`, false, nil, nil, []string{}}, 17 | } 18 | 19 | func TestCut(t *testing.T) { 20 | commands.TestVerb(t, "cut", cutTests) 21 | } 22 | -------------------------------------------------------------------------------- /commands/inputmode.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/constants" 8 | "github.com/ambientsound/pms/input/lexer" 9 | ) 10 | 11 | // InputMode changes the Multibar's input mode. 12 | type InputMode struct { 13 | command 14 | api api.API 15 | mode int 16 | } 17 | 18 | func NewInputMode(api api.API) Command { 19 | return &InputMode{ 20 | api: api, 21 | } 22 | } 23 | 24 | func (cmd *InputMode) Execute(class int, s string) error { 25 | multibar := cmd.api.Multibar() 26 | 27 | switch class { 28 | case lexer.TokenIdentifier: 29 | switch s { 30 | case "normal": 31 | cmd.mode = constants.MultibarModeNormal 32 | case "input": 33 | cmd.mode = constants.MultibarModeInput 34 | case "search": 35 | cmd.mode = constants.MultibarModeSearch 36 | default: 37 | cmd.mode = multibar.Mode() 38 | } 39 | case lexer.TokenEnd: 40 | multibar.SetMode(cmd.mode) 41 | 42 | default: 43 | return fmt.Errorf("Unknown input '%s', expected END", s) 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /commands/isolate.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | ) 9 | 10 | // Isolate searches for songs that have similar tags as the selection. 11 | type Isolate struct { 12 | newcommand 13 | api api.API 14 | tags []string 15 | } 16 | 17 | // NewIsolate returns Isolate. 18 | func NewIsolate(api api.API) Command { 19 | return &Isolate{ 20 | api: api, 21 | tags: make([]string, 0), 22 | } 23 | } 24 | 25 | // Parse implements Command. 26 | func (cmd *Isolate) Parse() error { 27 | var err error 28 | list := cmd.api.Songlist() 29 | cmd.tags, err = cmd.ParseTags(list.CursorSong()) 30 | return err 31 | } 32 | 33 | // Exec implements Command. 34 | func (cmd *Isolate) Exec() error { 35 | library := cmd.api.Library() 36 | if library == nil { 37 | return fmt.Errorf("Song library is not present.") 38 | } 39 | 40 | db := cmd.api.Db() 41 | panel := db.Panel() 42 | list := cmd.api.Songlist() 43 | selection := list.Selection() 44 | song := list.CursorSong() 45 | 46 | if selection.Len() == 0 { 47 | return fmt.Errorf("Isolate needs at least one track.") 48 | } 49 | 50 | result, err := library.Isolate(selection, cmd.tags) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | if result.Len() == 0 { 56 | return fmt.Errorf("No results found when isolating by %s", strings.Join(cmd.tags, ", ")) 57 | } 58 | 59 | // Sort the new list. 60 | sort := cmd.api.Options().StringValue("sort") 61 | fields := strings.Split(sort, ",") 62 | result.Sort(fields) 63 | 64 | // Clear selection in the source list, and add a new list to the index. 65 | list.ClearSelection() 66 | panel.Add(result) 67 | panel.Activate(result) 68 | list.CursorToSong(song) 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /commands/isolate_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var isolateTests = []commands.Test{ 10 | // Valid forms 11 | {`artist`, true, nil, nil, []string{}}, 12 | {`artist t`, true, initSongTags, nil, []string{"title"}}, 13 | {`artist tr$ack title`, true, initSongTags, nil, []string{"title"}}, 14 | 15 | // Invalid forms 16 | {``, false, nil, nil, []string{}}, 17 | } 18 | 19 | func TestIsolate(t *testing.T) { 20 | commands.TestVerb(t, "isolate", isolateTests) 21 | } 22 | -------------------------------------------------------------------------------- /commands/list.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/console" 9 | "github.com/ambientsound/pms/input/lexer" 10 | "github.com/ambientsound/pms/songlist" 11 | ) 12 | 13 | // List navigates and manipulates songlists. 14 | type List struct { 15 | command 16 | api api.API 17 | relative int 18 | absolute int 19 | duplicate bool 20 | remove bool 21 | } 22 | 23 | func NewList(api api.API) Command { 24 | return &List{ 25 | api: api, 26 | absolute: -1, 27 | } 28 | } 29 | 30 | func (cmd *List) Execute(class int, s string) error { 31 | var err error 32 | var index int 33 | 34 | ui := cmd.api.UI() 35 | collection := cmd.api.Db().Panel() 36 | 37 | switch class { 38 | 39 | case lexer.TokenIdentifier: 40 | switch s { 41 | case "duplicate": 42 | cmd.duplicate = true 43 | case "remove": 44 | cmd.remove = true 45 | case "up", "prev", "previous": 46 | cmd.relative = -1 47 | case "down", "next": 48 | cmd.relative = 1 49 | case "home": 50 | cmd.absolute = 0 51 | case "end": 52 | cmd.absolute = collection.Len() - 1 53 | default: 54 | i, err := strconv.Atoi(s) 55 | if err != nil { 56 | return fmt.Errorf("Cannot navigate lists: position '%s' is not recognized, and is not a number", s) 57 | } 58 | switch { 59 | case cmd.relative != 0 || cmd.absolute != -1: 60 | return fmt.Errorf("Only one number allowed when setting list position") 61 | case cmd.relative != 0: 62 | cmd.relative *= i 63 | default: 64 | cmd.absolute = i - 1 65 | } 66 | } 67 | 68 | case lexer.TokenEnd: 69 | switch { 70 | case cmd.duplicate: 71 | console.Log("Duplicating current songlist.") 72 | orig := collection.Current() 73 | list := songlist.New() 74 | err = orig.Duplicate(list) 75 | if err != nil { 76 | return fmt.Errorf("Error during songlist duplication: %s", err) 77 | } 78 | name := fmt.Sprintf("%s (copy)", orig.Name()) 79 | list.SetName(name) 80 | collection.Add(list) 81 | index = collection.Len() - 1 82 | 83 | case cmd.remove: 84 | list := collection.Current() 85 | console.Log("Removing current songlist '%s'.", list.Name()) 86 | 87 | err = list.Delete() 88 | if err != nil { 89 | return fmt.Errorf("Cannot remove songlist: %s", err) 90 | } 91 | 92 | index, err = collection.Index() 93 | 94 | // If we got an error here, it means that the current songlist is 95 | // not in the list of songlists. In this case, we can reset to the 96 | // last used songlist. 97 | if err != nil { 98 | fallback := collection.Last() 99 | if fallback == nil { 100 | return fmt.Errorf("No songlists left.") 101 | } 102 | console.Log("Songlist was not found in the list of songlists. Activating fallback songlist '%s'.", fallback.Name()) 103 | ui.PostFunc(func() { 104 | collection.Activate(fallback) 105 | }) 106 | return nil 107 | } else { 108 | collection.Remove(index) 109 | } 110 | 111 | // If removing the last songlist, we need to decrease the songlist index by one. 112 | if index == collection.Len() { 113 | index-- 114 | } 115 | 116 | console.Log("Removed songlist, now activating songlist no. %d", index) 117 | 118 | case cmd.relative != 0: 119 | index, err = collection.Index() 120 | if err != nil { 121 | index = 0 122 | } 123 | index += cmd.relative 124 | if !collection.ValidIndex(index) { 125 | len := collection.Len() 126 | index = (index + len) % len 127 | } 128 | console.Log("Switching songlist index to relative %d, equalling absolute %d", cmd.relative, index) 129 | 130 | case cmd.absolute >= 0: 131 | console.Log("Switching songlist index to absolute %d", cmd.absolute) 132 | index = cmd.absolute 133 | 134 | default: 135 | return fmt.Errorf("Unexpected END, expected position. Try one of: next prev ") 136 | } 137 | 138 | ui.PostFunc(func() { 139 | err = collection.ActivateIndex(index) 140 | }) 141 | 142 | default: 143 | return fmt.Errorf("Unknown input '%s', expected END", s) 144 | } 145 | 146 | return err 147 | } 148 | -------------------------------------------------------------------------------- /commands/next.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Next switches to the next song in MPD's queue. 11 | type Next struct { 12 | command 13 | api api.API 14 | } 15 | 16 | func NewNext(api api.API) Command { 17 | return &Next{ 18 | api: api, 19 | } 20 | } 21 | 22 | func (cmd *Next) Execute(class int, s string) error { 23 | switch class { 24 | case lexer.TokenEnd: 25 | client := cmd.api.MpdClient() 26 | if client == nil { 27 | return fmt.Errorf("Unable to play next song: cannot communicate with MPD") 28 | } 29 | return client.Next() 30 | 31 | default: 32 | return fmt.Errorf("Unknown input '%s', expected END", s) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /commands/paste.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Paste inserts songs from the clipboard. 11 | type Paste struct { 12 | newcommand 13 | api api.API 14 | position int 15 | } 16 | 17 | // NewPaste returns Paste. 18 | func NewPaste(api api.API) Command { 19 | return &Paste{ 20 | api: api, 21 | } 22 | } 23 | 24 | // Parse implements Command. 25 | func (cmd *Paste) Parse() error { 26 | tok, lit := cmd.ScanIgnoreWhitespace() 27 | 28 | cmd.setTabCompleteVerbs(lit) 29 | 30 | // Expect either "before" or "after". 31 | switch tok { 32 | case lexer.TokenIdentifier: 33 | switch lit { 34 | case "before": 35 | cmd.position = 0 36 | case "after": 37 | cmd.position = 1 38 | default: 39 | return fmt.Errorf("Unexpected '%s', expected position", lit) 40 | } 41 | cmd.setTabCompleteEmpty() 42 | return cmd.ParseEnd() 43 | 44 | // Fall back to "after" if no arguments given. 45 | case lexer.TokenEnd: 46 | cmd.position = 1 47 | 48 | default: 49 | return fmt.Errorf("Unexpected '%s', expected position", lit) 50 | } 51 | 52 | return nil 53 | } 54 | 55 | // Exec implements Command. 56 | func (cmd *Paste) Exec() error { 57 | list := cmd.api.Songlist() 58 | cursor := list.Cursor() 59 | clipboard := cmd.api.Db().Clipboard("default") 60 | 61 | err := list.InsertList(clipboard, cursor+cmd.position) 62 | cmd.api.ListChanged() 63 | 64 | if err != nil { 65 | return err 66 | } 67 | 68 | cmd.api.Message("%d more tracks", clipboard.Len()) 69 | 70 | return nil 71 | } 72 | 73 | // setTabCompleteVerbs sets the tab complete list to the list of available sub-commands. 74 | func (cmd *Paste) setTabCompleteVerbs(lit string) { 75 | cmd.setTabComplete(lit, []string{ 76 | "after", 77 | "before", 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /commands/paste_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var pasteTests = []commands.Test{ 10 | // Valid forms 11 | {``, true, nil, nil, []string{"after", "before"}}, 12 | {`before`, true, nil, nil, []string{}}, 13 | {`after`, true, initSongTags, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {`bef`, false, nil, nil, []string{"before"}}, 17 | {`before the apocalypse`, false, nil, nil, []string{}}, 18 | {`after midnight`, false, nil, nil, []string{}}, 19 | } 20 | 21 | func TestPaste(t *testing.T) { 22 | commands.TestVerb(t, "paste", pasteTests) 23 | } 24 | -------------------------------------------------------------------------------- /commands/pause.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | 9 | pms_mpd "github.com/ambientsound/pms/mpd" 10 | ) 11 | 12 | // Pause toggles MPD play/paused state. If the player is stopped, Pause will 13 | // attempt to start playback through the 'play' command instead. 14 | type Pause struct { 15 | command 16 | api api.API 17 | } 18 | 19 | func NewPause(api api.API) Command { 20 | return &Pause{ 21 | api: api, 22 | } 23 | } 24 | 25 | func (cmd *Pause) Execute(class int, s string) error { 26 | switch class { 27 | case lexer.TokenEnd: 28 | client := cmd.api.MpdClient() 29 | if client == nil { 30 | return fmt.Errorf("Unable to toggle pause: cannot communicate with MPD") 31 | } 32 | status := cmd.api.PlayerStatus() 33 | switch status.State { 34 | case pms_mpd.StatePause: 35 | return client.Pause(false) 36 | case pms_mpd.StatePlay: 37 | return client.Pause(true) 38 | default: 39 | return client.Play(-1) 40 | } 41 | 42 | default: 43 | return fmt.Errorf("Unknown input '%s', expected END", s) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /commands/play.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | "github.com/fhs/gompd/v2/mpd" 9 | ) 10 | 11 | // Play plays songs in the MPD playlist. 12 | type Play struct { 13 | newcommand 14 | api api.API 15 | cursor bool 16 | selection bool 17 | } 18 | 19 | // NewPlay returns Play. 20 | func NewPlay(api api.API) Command { 21 | return &Play{ 22 | api: api, 23 | } 24 | } 25 | 26 | // Parse implements Command. 27 | func (cmd *Play) Parse() error { 28 | tok, lit := cmd.ScanIgnoreWhitespace() 29 | 30 | cmd.setTabCompleteVerbs(lit) 31 | 32 | switch tok { 33 | case lexer.TokenEnd: 34 | // No parameters; just send 'play' command to MPD 35 | return nil 36 | case lexer.TokenIdentifier: 37 | default: 38 | return fmt.Errorf("Unexpected '%s', expected identifier", lit) 39 | } 40 | 41 | switch lit { 42 | // Play song under cursor 43 | case "cursor": 44 | cmd.cursor = true 45 | // Play selected songs 46 | case "selection": 47 | cmd.selection = true 48 | default: 49 | return fmt.Errorf("Unexpected '%s', expected identifier", lit) 50 | } 51 | 52 | cmd.setTabCompleteEmpty() 53 | 54 | return cmd.ParseEnd() 55 | } 56 | 57 | // Exec implements Command. 58 | func (cmd *Play) Exec() error { 59 | 60 | // Ensure MPD connection. 61 | client := cmd.api.MpdClient() 62 | if client == nil { 63 | return fmt.Errorf("Cannot play: not connected to MPD") 64 | } 65 | 66 | switch { 67 | case cmd.cursor: 68 | // Play song under cursor. 69 | return cmd.playCursor(client) 70 | case cmd.selection: 71 | // Play selected songs. 72 | return cmd.playSelection(client) 73 | } 74 | 75 | // If a selection is not given, start playing with default parameters. 76 | return client.Play(-1) 77 | } 78 | 79 | // playCursor plays the song under the cursor. 80 | func (cmd *Play) playCursor(client *mpd.Client) error { 81 | 82 | // Get the song under the cursor. 83 | song := cmd.api.Songlist().CursorSong() 84 | if song == nil { 85 | return fmt.Errorf("Cannot play: no song under cursor") 86 | } 87 | 88 | // Check if the currently selected song has an ID. If it doesn't, it's not 89 | // from the queue, and the song will have to be added beforehand. 90 | id := song.ID 91 | if song.NullID() { 92 | var err error 93 | id, err = client.AddID(song.StringTags["file"], -1) 94 | if err != nil { 95 | return err 96 | } 97 | } 98 | 99 | // Play the correct song. 100 | return client.PlayID(id) 101 | } 102 | 103 | // playSelection plays the currently selected songs. 104 | func (cmd *Play) playSelection(client *mpd.Client) error { 105 | 106 | // Get the track selection. 107 | selection := cmd.api.Songlist().Selection() 108 | if selection.Len() == 0 { 109 | return fmt.Errorf("Cannot play: no selection") 110 | } 111 | 112 | // Check if the first song has an ID. If it does, just start playing. The 113 | // playback order cannot be guaranteed as the selection might be 114 | // fragmented, so don't touch the selection. 115 | first := selection.Song(0) 116 | if !first.NullID() { 117 | return client.PlayID(first.ID) 118 | } 119 | 120 | // We are not operating directly on the queue; add all songs to the queue now. 121 | queue := cmd.api.Queue() 122 | queueLen := queue.Len() 123 | err := queue.AddList(selection) 124 | if err != nil { 125 | return err 126 | } 127 | cmd.api.Songlist().ClearSelection() 128 | cmd.api.Message("Playing %d new songs", selection.Len()) 129 | 130 | // We haven't got the ID from the first added song, so use positions 131 | // instead. In case of simultaneous operation with another client, this 132 | // might lead to a race condition. Ignore this for now. 133 | return client.Play(queueLen) 134 | } 135 | 136 | // setTabCompleteVerbs sets the tab complete list to the list of available sub-commands. 137 | func (cmd *Play) setTabCompleteVerbs(lit string) { 138 | cmd.setTabComplete(lit, []string{ 139 | "cursor", 140 | "selection", 141 | }) 142 | } 143 | -------------------------------------------------------------------------------- /commands/play_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var playTests = []commands.Test{ 10 | // Valid forms 11 | {``, true, nil, nil, []string{"cursor", "selection"}}, 12 | {`cursor`, true, nil, nil, []string{}}, 13 | {`selection`, true, nil, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {`foo`, false, nil, nil, []string{}}, 17 | {`cursor 1`, false, nil, nil, []string{}}, 18 | {`selection 1`, false, nil, nil, []string{}}, 19 | } 20 | 21 | func TestPlay(t *testing.T) { 22 | commands.TestVerb(t, "play", playTests) 23 | } 24 | -------------------------------------------------------------------------------- /commands/previous.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Previous switches to the previous song in MPD's queue. 11 | type Previous struct { 12 | command 13 | api api.API 14 | } 15 | 16 | func NewPrevious(api api.API) Command { 17 | return &Previous{ 18 | api: api, 19 | } 20 | } 21 | 22 | func (cmd *Previous) Execute(class int, s string) error { 23 | switch class { 24 | case lexer.TokenEnd: 25 | client := cmd.api.MpdClient() 26 | if client == nil { 27 | return fmt.Errorf("Unable to play previous song: cannot communicate with MPD") 28 | } 29 | return client.Previous() 30 | 31 | default: 32 | return fmt.Errorf("Unknown input '%s', expected END", s) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /commands/print.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | ) 10 | 11 | // Print displays information about the selected song's tags. 12 | type Print struct { 13 | command 14 | api api.API 15 | tags []string 16 | } 17 | 18 | func NewPrint(api api.API) Command { 19 | return &Print{ 20 | api: api, 21 | tags: make([]string, 0), 22 | } 23 | } 24 | 25 | func (cmd *Print) Execute(class int, s string) error { 26 | var err error 27 | 28 | switch class { 29 | case lexer.TokenIdentifier: 30 | if len(cmd.tags) > 0 { 31 | return fmt.Errorf("Unexpected '%s', expected END", s) 32 | } 33 | cmd.tags = strings.Split(strings.ToLower(s), ",") 34 | 35 | case lexer.TokenEnd: 36 | if len(cmd.tags) == 0 { 37 | return fmt.Errorf("Unexpected END, expected list of tags to print") 38 | } 39 | list := cmd.api.Songlist() 40 | selection := list.Selection() 41 | switch selection.Len() { 42 | case 0: 43 | return fmt.Errorf("Cannot print song tags; no song selected") 44 | case 1: 45 | song := selection.Song(0) 46 | parts := make([]string, 0) 47 | for _, tag := range cmd.tags { 48 | msg := "" 49 | value, ok := song.StringTags[tag] 50 | if ok { 51 | value = strings.ReplaceAll(value, "%", "%%") 52 | msg = fmt.Sprintf("%s: '%s'", tag, value) 53 | } else { 54 | msg = fmt.Sprintf("%s: ", tag) 55 | } 56 | parts = append(parts, msg) 57 | } 58 | msg := strings.Join(parts, ", ") 59 | cmd.api.Message(msg) 60 | 61 | default: 62 | return fmt.Errorf("Multiple songs selected; cannot print song tags") 63 | } 64 | 65 | list.ClearSelection() 66 | 67 | default: 68 | return fmt.Errorf("Unknown input '%s', expected END", s) 69 | } 70 | 71 | return err 72 | } 73 | -------------------------------------------------------------------------------- /commands/quit.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Quit exits the program. 11 | type Quit struct { 12 | command 13 | api api.API 14 | } 15 | 16 | func NewQuit(api api.API) Command { 17 | return &Quit{ 18 | api: api, 19 | } 20 | } 21 | 22 | func (cmd *Quit) Execute(class int, s string) error { 23 | switch class { 24 | case lexer.TokenEnd: 25 | cmd.api.Quit() 26 | return nil 27 | default: 28 | return fmt.Errorf("Unknown input '%s', expected END", s) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /commands/redraw.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Quit exits the program. 11 | type Redraw struct { 12 | command 13 | api api.API 14 | } 15 | 16 | func NewRedraw(api api.API) Command { 17 | return &Redraw{ 18 | api: api, 19 | } 20 | } 21 | 22 | func (cmd *Redraw) Execute(class int, s string) error { 23 | ui := cmd.api.UI() 24 | switch class { 25 | case lexer.TokenEnd: 26 | ui.PostFunc(func() { 27 | cmd.api.Db().Left().SetUpdated() 28 | cmd.api.Db().Right().SetUpdated() 29 | ui.Refresh() 30 | }) 31 | return nil 32 | default: 33 | return fmt.Errorf("Unknown input '%s', expected END", s) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /commands/seek.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Seek seeks forwards or backwards in the currently playing track. 10 | type Seek struct { 11 | newcommand 12 | api api.API 13 | absolute int 14 | } 15 | 16 | // NewSeek returns Seek. 17 | func NewSeek(api api.API) Command { 18 | return &Seek{ 19 | api: api, 20 | } 21 | } 22 | 23 | // Parse implements Command. 24 | func (cmd *Seek) Parse() error { 25 | 26 | playerStatus := cmd.api.PlayerStatus() 27 | 28 | _, lit, absolute, err := cmd.ParseInt() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if absolute { 34 | cmd.absolute = lit 35 | } else { 36 | cmd.absolute = int(playerStatus.Elapsed) + lit 37 | } 38 | 39 | return cmd.ParseEnd() 40 | } 41 | 42 | // Exec implements Command. 43 | func (cmd *Seek) Exec() error { 44 | mpdClient := cmd.api.MpdClient() 45 | if mpdClient == nil { 46 | return fmt.Errorf("Unable to seek: cannot communicate with MPD") 47 | } 48 | 49 | playerStatus := cmd.api.PlayerStatus() 50 | return mpdClient.Seek(playerStatus.Song, cmd.absolute) 51 | } 52 | -------------------------------------------------------------------------------- /commands/seek_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var seekTests = []commands.Test{ 10 | // Valid forms 11 | {`-2`, true, nil, nil, []string{}}, 12 | {`+13`, true, nil, nil, []string{}}, 13 | {`1329`, true, nil, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {`nan`, false, nil, nil, []string{}}, 17 | {`+++1`, false, nil, nil, []string{}}, 18 | {`-foo`, false, nil, nil, []string{}}, 19 | {`$1`, false, nil, nil, []string{}}, 20 | } 21 | 22 | func TestSeek(t *testing.T) { 23 | commands.TestVerb(t, "seek", seekTests) 24 | } 25 | -------------------------------------------------------------------------------- /commands/select.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Select manipulates song selection within a songlist. 11 | type Select struct { 12 | newcommand 13 | api api.API 14 | toggle bool 15 | visual bool 16 | nearby []string 17 | } 18 | 19 | // NewSelect returns Select. 20 | func NewSelect(api api.API) Command { 21 | return &Select{ 22 | api: api, 23 | nearby: make([]string, 0), 24 | } 25 | } 26 | 27 | // Parse implements Command. 28 | func (cmd *Select) Parse() error { 29 | tok, lit := cmd.ScanIgnoreWhitespace() 30 | 31 | cmd.setTabCompleteVerbs(lit) 32 | 33 | if tok != lexer.TokenIdentifier { 34 | return fmt.Errorf("Unexpected '%s', expected identifier", lit) 35 | } 36 | 37 | switch lit { 38 | case "toggle": 39 | cmd.toggle = true 40 | case "visual": 41 | cmd.visual = true 42 | case "nearby": 43 | return cmd.parseNearby() 44 | default: 45 | return fmt.Errorf("Unexpected '%s', expected identifier", lit) 46 | } 47 | 48 | cmd.setTabCompleteEmpty() 49 | 50 | return cmd.ParseEnd() 51 | } 52 | 53 | // Exec implements Command. 54 | func (cmd *Select) Exec() error { 55 | list := cmd.api.Songlist() 56 | 57 | switch { 58 | case cmd.toggle && list.HasVisualSelection(): 59 | list.CommitVisualSelection() 60 | list.DisableVisualSelection() 61 | 62 | case cmd.visual: 63 | list.ToggleVisualSelection() 64 | return nil 65 | 66 | case len(cmd.nearby) > 0: 67 | return cmd.selectNearby() 68 | 69 | default: 70 | index := list.Cursor() 71 | selected := list.Selected(index) 72 | list.SetSelected(index, !selected) 73 | } 74 | 75 | list.MoveCursor(1) 76 | 77 | return nil 78 | } 79 | 80 | // parseNearby parses tags and inserts them in the nearby list. 81 | func (cmd *Select) parseNearby() error { 82 | 83 | // Data initialization and sanity checks 84 | list := cmd.api.Songlist() 85 | song := list.CursorSong() 86 | 87 | // Retrieve a list of songs 88 | tags, err := cmd.ParseTags(song) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | cmd.nearby = tags 94 | return nil 95 | } 96 | 97 | // selectNearby selects tracks near the cursor with similar tags. 98 | func (cmd *Select) selectNearby() error { 99 | list := cmd.api.Songlist() 100 | index := list.Cursor() 101 | song := list.CursorSong() 102 | 103 | // In case the list has a visual selection, disable that selection instead. 104 | if list.HasVisualSelection() { 105 | list.DisableVisualSelection() 106 | return nil 107 | } 108 | 109 | if song == nil { 110 | return fmt.Errorf("Can't select nearby songs; no song under cursor") 111 | } 112 | 113 | // Find the start and end positions 114 | start := list.NextOf(cmd.nearby, index+1, -1) 115 | end := list.NextOf(cmd.nearby, index, 1) - 1 116 | 117 | // Set visual selection and move cursor to end of selection 118 | list.SetVisualSelection(start, end, start) 119 | list.SetCursor(end) 120 | 121 | return nil 122 | } 123 | 124 | // setTabCompleteVerbs sets the tab complete list to the list of available sub-commands. 125 | func (cmd *Select) setTabCompleteVerbs(lit string) { 126 | cmd.setTabComplete(lit, []string{ 127 | "nearby", 128 | "toggle", 129 | "visual", 130 | }) 131 | } 132 | -------------------------------------------------------------------------------- /commands/select_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var selectTests = []commands.Test{ 10 | // Valid forms 11 | {`visual`, true, nil, nil, []string{}}, 12 | {`toggle`, true, nil, nil, []string{}}, 13 | {`nearby artist tit`, true, initSongTags, nil, []string{"title"}}, 14 | 15 | // Invalid forms 16 | {`foo`, false, nil, nil, []string{}}, 17 | {`visual 1`, false, nil, nil, []string{}}, 18 | {`toggle 1`, false, nil, nil, []string{}}, 19 | {`nearby`, false, nil, nil, []string{}}, 20 | 21 | // Tab completion 22 | {``, false, nil, nil, []string{ 23 | "nearby", 24 | "toggle", 25 | "visual", 26 | }}, 27 | {`t`, false, nil, nil, []string{ 28 | "toggle", 29 | }}, 30 | } 31 | 32 | func TestSelect(t *testing.T) { 33 | commands.TestVerb(t, "select", selectTests) 34 | } 35 | -------------------------------------------------------------------------------- /commands/set.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | "github.com/ambientsound/pms/input/parser" 10 | "github.com/ambientsound/pms/options" 11 | ) 12 | 13 | // Set manipulates a Options table by parsing input tokens from the "set" command. 14 | type Set struct { 15 | newcommand 16 | api api.API 17 | tokens []parser.OptionToken 18 | } 19 | 20 | // NewSet returns Set. 21 | func NewSet(api api.API) Command { 22 | return &Set{ 23 | api: api, 24 | tokens: make([]parser.OptionToken, 0), 25 | } 26 | } 27 | 28 | // Parse implements Command. 29 | func (cmd *Set) Parse() error { 30 | 31 | cmd.setTabCompleteVerbs("") 32 | 33 | for { 34 | // Scan the next token, which must be an identifier. 35 | tok, lit := cmd.ScanIgnoreWhitespace() 36 | switch tok { 37 | case lexer.TokenIdentifier: 38 | break 39 | case lexer.TokenEnd, lexer.TokenComment: 40 | return nil 41 | default: 42 | cmd.setTabCompleteEmpty() 43 | return fmt.Errorf("Unexpected '%s', expected whitespace or END", lit) 44 | } 45 | 46 | cmd.setTabCompleteVerbs(lit) 47 | 48 | // Parse the option statement. 49 | cmd.Unscan() 50 | err := cmd.ParseSet() 51 | if err != nil { 52 | return err 53 | } 54 | } 55 | } 56 | 57 | // ParseSet parses a single "key=val" statement. 58 | func (cmd *Set) ParseSet() error { 59 | tokens := make([]string, 0) 60 | for { 61 | tok, lit := cmd.Scan() 62 | if tok == lexer.TokenWhitespace || tok == lexer.TokenEnd || tok == lexer.TokenComment { 63 | break 64 | } 65 | tokens = append(tokens, lit) 66 | } 67 | 68 | s := strings.Join(tokens, "") 69 | cmd.setTabCompleteVerbs(s) 70 | optionToken := parser.OptionToken{} 71 | err := optionToken.Parse([]rune(s)) 72 | if err != nil { 73 | cmd.setTabCompleteEmpty() 74 | return err 75 | } 76 | 77 | // Figure out tabcomplete 78 | cmd.setTabCompleteOption(optionToken) 79 | 80 | cmd.tokens = append(cmd.tokens, optionToken) 81 | 82 | return nil 83 | } 84 | 85 | // Exec implements Command. 86 | func (cmd *Set) Exec() error { 87 | for _, tok := range cmd.tokens { 88 | opt := cmd.api.Options().Get(tok.Key) 89 | 90 | if opt == nil { 91 | return fmt.Errorf("No such option: %s", tok.Key) 92 | } 93 | 94 | // Queries print options to the statusbar. 95 | if tok.Query { 96 | cmd.api.Message(opt.String()) 97 | continue 98 | } 99 | 100 | switch opt := opt.(type) { 101 | 102 | case *options.BoolOption: 103 | switch { 104 | case !tok.Bool: 105 | return fmt.Errorf("Attempting to give parameters to a boolean option (try 'set no%s' or 'set inv%s')", tok.Key, tok.Key) 106 | case tok.Invert: 107 | opt.SetBool(!opt.BoolValue()) 108 | cmd.api.Message(opt.String()) 109 | case tok.Negate: 110 | opt.SetBool(false) 111 | default: 112 | opt.SetBool(true) 113 | } 114 | 115 | default: 116 | if !tok.Bool { 117 | if err := opt.Set(tok.Value); err != nil { 118 | return err 119 | } 120 | break 121 | } 122 | 123 | // Not a boolean option, and no value. Print the value. 124 | cmd.api.Message(opt.String()) 125 | continue 126 | } 127 | 128 | cmd.api.OptionChanged(opt.Key()) 129 | cmd.api.Message(opt.String()) 130 | } 131 | 132 | return nil 133 | } 134 | 135 | // setTabCompleteVerbs sets the tab complete list to the list of option keys. 136 | func (cmd *Set) setTabCompleteVerbs(lit string) { 137 | cmd.setTabComplete(lit, cmd.api.Options().Keys()) 138 | } 139 | 140 | // setTabCompleteOption sets the tab complete list to an option value and a blank value. 141 | func (cmd *Set) setTabCompleteOption(tok parser.OptionToken) { 142 | // Bool options are already handled by the verb completion. 143 | if tok.Bool { 144 | return 145 | } 146 | 147 | // Get the option object. If it is not found, let the verb completion handle this. 148 | opt := cmd.api.Options().Get(tok.Key) 149 | if opt == nil { 150 | return 151 | } 152 | 153 | // Don't tab complete option values unless the value is empty. 154 | if len(tok.Value) > 0 { 155 | return 156 | } 157 | 158 | // Return two items: the existing value, and the typed value. 159 | cmd.setTabComplete("", []string{ 160 | fmt.Sprintf(`="%s"`, opt.StringValue()), 161 | "=", 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /commands/set_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | "github.com/ambientsound/pms/options" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | var setTests = []commands.Test{ 12 | // Valid forms 13 | {``, true, testSetInit, nil, []string{`bar`, `baz`, `bool`, `foo`, `int`}}, 14 | {`foo=bar`, true, testSetInit, testFooSet(`foo`, `bar`, true), []string{}}, 15 | {`foo="bar baz"`, true, testSetInit, testFooSet(`foo`, `bar baz`, true), []string{}}, 16 | {`foo=${}|;#`, true, testSetInit, testFooSet(`foo`, `${}|;`, true), []string{}}, 17 | {`foo=x bar=x baz=x int=4 invbool`, true, testSetInit, testMultiSet, []string{}}, 18 | {`foo=y foo`, true, testSetInit, testFooSet(`foo`, `y`, true), []string{`foo`}}, 19 | {`baz=`, true, testSetInit, testFooSet(`baz`, ``, true), []string{`="foobar"`, `=`}}, 20 | {`bool`, true, testSetInit, nil, []string{`bool`}}, 21 | 22 | // Invalid forms 23 | {`nonexist=foo`, true, testSetInit, testFooSet(`nonexist`, ``, false), []string{}}, 24 | {`$=""`, false, testSetInit, nil, []string{}}, 25 | } 26 | 27 | func TestSet(t *testing.T) { 28 | commands.TestVerb(t, "set", setTests) 29 | } 30 | 31 | func testSetInit(test *commands.TestData) { 32 | test.Api.Options().Add(options.NewStringOption("foo")) 33 | test.Api.Options().Add(options.NewStringOption("bar")) 34 | test.Api.Options().Add(options.NewStringOption("baz")) 35 | test.Api.Options().Add(options.NewIntOption("int")) 36 | test.Api.Options().Add(options.NewBoolOption("bool")) 37 | test.Api.Options().Get("baz").Set("foobar") 38 | } 39 | 40 | func testFooSet(key, check string, ok bool) func(*commands.TestData) { 41 | return func(test *commands.TestData) { 42 | err := test.Cmd.Exec() 43 | assert.Equal(test.T, ok, err == nil, "Expected OK=%s", ok) 44 | if err != nil { 45 | return 46 | } 47 | val := test.Api.Options().StringValue(key) 48 | assert.Equal(test.T, check, val) 49 | } 50 | } 51 | 52 | func testMultiSet(test *commands.TestData) { 53 | err := test.Cmd.Exec() 54 | assert.Nil(test.T, err) 55 | opts := test.Api.Options() 56 | assert.Equal(test.T, "x", opts.StringValue("foo")) 57 | assert.Equal(test.T, "x", opts.StringValue("bar")) 58 | assert.Equal(test.T, "x", opts.StringValue("baz")) 59 | assert.Equal(test.T, 4, opts.IntValue("int")) 60 | assert.Equal(test.T, true, opts.BoolValue("bool")) 61 | } 62 | -------------------------------------------------------------------------------- /commands/single.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Single toggles MPD's single mode on and off. 11 | type Single struct { 12 | newcommand 13 | api api.API 14 | action string 15 | } 16 | 17 | // NewSingle returns Single. 18 | func NewSingle(api api.API) Command { 19 | return &Single{ 20 | api: api, 21 | } 22 | } 23 | 24 | // Parse implements Command. 25 | func (cmd *Single) Parse() error { 26 | 27 | tok, lit := cmd.ScanIgnoreWhitespace() 28 | cmd.setTabCompleteAction(lit) 29 | 30 | switch tok { 31 | case lexer.TokenIdentifier: 32 | break 33 | case lexer.TokenEnd: 34 | return nil 35 | default: 36 | return fmt.Errorf("Unexpected '%v', expected identifier", lit) 37 | } 38 | 39 | switch lit { 40 | case "on", "off", "toggle": 41 | break 42 | default: 43 | return fmt.Errorf("Unexpected '%v', expected identifier", lit) 44 | } 45 | 46 | cmd.action = lit 47 | 48 | cmd.setTabCompleteEmpty() 49 | return cmd.ParseEnd() 50 | 51 | } 52 | 53 | // Exec implements Command. 54 | func (cmd *Single) Exec() error { 55 | 56 | client := cmd.api.MpdClient() 57 | if client == nil { 58 | return fmt.Errorf("Cannot change single mode: not connected to MPD.") 59 | } 60 | 61 | playerStatus := cmd.api.PlayerStatus() 62 | 63 | switch cmd.action { 64 | case "on": 65 | return client.Single(true) 66 | case "off": 67 | return client.Single(false) 68 | case "toggle", "": 69 | return client.Single(!playerStatus.Single) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // setTabCompleteAction sets the tab complete list to available actions. 76 | func (cmd *Single) setTabCompleteAction(lit string) { 77 | list := []string{ 78 | "on", 79 | "off", 80 | "toggle", 81 | } 82 | cmd.setTabComplete(lit, list) 83 | } 84 | -------------------------------------------------------------------------------- /commands/single_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var singleTests = []commands.Test{ 10 | // Valid forms 11 | {`on`, true, nil, nil, []string{}}, 12 | {`off`, true, nil, nil, []string{}}, 13 | {`toggle`, true, nil, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {`--2`, false, nil, nil, []string{}}, 17 | {`+x`, false, nil, nil, []string{}}, 18 | {`$1`, false, nil, nil, []string{}}, 19 | {`on off`, false, nil, nil, []string{}}, 20 | 21 | // Tab completion 22 | {``, true, nil, nil, []string{ 23 | "on", 24 | "off", 25 | "toggle", 26 | }}, 27 | {`t`, false, nil, nil, []string{ 28 | "toggle", 29 | }}, 30 | {`o`, false, nil, nil, []string{ 31 | "on", 32 | "off", 33 | }}, 34 | } 35 | 36 | func TestSingle(t *testing.T) { 37 | commands.TestVerb(t, "single", singleTests) 38 | } 39 | -------------------------------------------------------------------------------- /commands/sort.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | ) 10 | 11 | // Sort sorts songlists. 12 | type Sort struct { 13 | newcommand 14 | api api.API 15 | tags []string 16 | } 17 | 18 | // NewSort returns Sort. 19 | func NewSort(api api.API) Command { 20 | return &Sort{ 21 | api: api, 22 | } 23 | } 24 | 25 | // Parse implements Command. 26 | func (cmd *Sort) Parse() error { 27 | var err error 28 | 29 | // For tab completion 30 | list := cmd.api.Songlist() 31 | song := list.CursorSong() 32 | 33 | for { 34 | tok, lit := cmd.Scan() 35 | switch tok { 36 | case lexer.TokenWhitespace: 37 | // Initialize tab completion 38 | cmd.setTabCompleteTag("", song) 39 | continue 40 | 41 | case lexer.TokenIdentifier: 42 | // Sort by tags specified on the command line 43 | cmd.Unscan() 44 | cmd.tags, err = cmd.ParseTags(song) 45 | return err 46 | 47 | case lexer.TokenEnd: 48 | // Sort by default tags 49 | sort := cmd.api.Options().StringValue("sort") 50 | cmd.tags = strings.Split(sort, ",") 51 | return nil 52 | 53 | default: 54 | return fmt.Errorf("Unexpected %v, expected tag", lit) 55 | } 56 | } 57 | } 58 | 59 | // Exec implements Command. 60 | func (cmd *Sort) Exec() error { 61 | list := cmd.api.Songlist() 62 | song := list.CursorSong() 63 | err := list.Sort(cmd.tags) 64 | list.CursorToSong(song) 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /commands/sort_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/commands" 8 | "github.com/ambientsound/pms/options" 9 | "github.com/ambientsound/pms/song" 10 | "github.com/fhs/gompd/v2/mpd" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var sortTests = []commands.Test{ 15 | // Valid forms 16 | {``, true, initSort, testSorting, []string{}}, 17 | {`artist title`, true, initSort, testSorting, []string{"title"}}, 18 | {`complex-tag`, true, initSort, testSorting, []string{"complex-tag"}}, 19 | {`tag&|!{ more-tags "x y z"`, true, initSort, testSorting, []string{}}, 20 | 21 | // Invalid forms 22 | {`$`, false, nil, nil, []string{}}, 23 | } 24 | 25 | func TestSort(t *testing.T) { 26 | commands.TestVerb(t, "sort", sortTests) 27 | } 28 | 29 | func testSorting(data *commands.TestData) { 30 | // FIXME: test actual sorting 31 | err := data.Cmd.Exec() 32 | assert.Nil(data.T, err) 33 | } 34 | 35 | func initSort(data *commands.TestData) { 36 | // Set up the sort option 37 | // FIXME 38 | opts := data.Api.Options() 39 | opts.Add(options.NewStringOption("sort")) 40 | opts.Get("sort").Set("title") 41 | 42 | list := data.Api.Songlist() 43 | for i := 0; i < 2; i++ { 44 | for j := 0; j < 10; j++ { 45 | s := song.New() 46 | s.SetTags(mpd.Attrs{ 47 | "artist": fmt.Sprintf("artist %d", 2-i), 48 | "title": fmt.Sprintf("title %d", 10-j), 49 | "complex-tag": fmt.Sprintf("%d%d", j, i), 50 | }) 51 | list.Add(s) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /commands/stop.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Stop stops song playback in MPD. 10 | type Stop struct { 11 | newcommand 12 | api api.API 13 | } 14 | 15 | // NewStop returns Stop. 16 | func NewStop(api api.API) Command { 17 | return &Stop{ 18 | api: api, 19 | } 20 | } 21 | 22 | // Parse implements Command. 23 | func (cmd *Stop) Parse() error { 24 | return cmd.ParseEnd() 25 | } 26 | 27 | // Exec implements Command. 28 | func (cmd *Stop) Exec() error { 29 | if client := cmd.api.MpdClient(); client != nil { 30 | return client.Stop() 31 | } 32 | return fmt.Errorf("Unable to stop: cannot communicate with MPD") 33 | } 34 | -------------------------------------------------------------------------------- /commands/style.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | "github.com/gdamore/tcell/v2" 10 | ) 11 | 12 | // Style manipulates the style table, allowing to set colors and attributes for UI elements. 13 | type Style struct { 14 | newcommand 15 | api api.API 16 | 17 | styleKey string 18 | styleValue tcell.Style 19 | 20 | background bool 21 | foreground bool 22 | } 23 | 24 | // NewStyle returns Style. 25 | func NewStyle(api api.API) Command { 26 | return &Style{ 27 | api: api, 28 | } 29 | } 30 | 31 | // Parse implements Command. 32 | func (cmd *Style) Parse() error { 33 | 34 | // Scan the style key. All names are accepted, even names that are not 35 | // implemented anywhere. 36 | tok, lit := cmd.ScanIgnoreWhitespace() 37 | cmd.setTabCompleteNames(lit) 38 | if tok != lexer.TokenIdentifier { 39 | return fmt.Errorf("Unexpected '%v', expected identifier", lit) 40 | } 41 | cmd.styleKey = lit 42 | 43 | // Scan each style attribute. 44 | for { 45 | tok, lit := cmd.Scan() 46 | 47 | switch tok { 48 | case lexer.TokenWhitespace: 49 | cmd.setTabCompleteStyles("") 50 | continue 51 | case lexer.TokenIdentifier: 52 | break 53 | case lexer.TokenEnd: 54 | return nil 55 | default: 56 | return fmt.Errorf("Unexpected '%v', expected identifier", lit) 57 | } 58 | 59 | cmd.setTabCompleteStyles(lit) 60 | err := cmd.mergeStyle(lit) 61 | if err != nil { 62 | return err 63 | } 64 | } 65 | } 66 | 67 | // Exec implements Command. 68 | func (cmd *Style) Exec() error { 69 | styleMap := cmd.api.Styles() 70 | styleMap[cmd.styleKey] = cmd.styleValue 71 | return nil 72 | } 73 | 74 | // setTabCompleteNames sets the tab complete list to the list of available style keys. 75 | func (cmd *Style) setTabCompleteNames(lit string) { 76 | styleMap := cmd.api.Styles() 77 | list := make(sort.StringSlice, len(styleMap)) 78 | i := 0 79 | for key := range styleMap { 80 | list[i] = key 81 | i++ 82 | } 83 | list.Sort() 84 | cmd.setTabComplete(lit, list) 85 | } 86 | 87 | // setTabCompleteStyles sets the tab complete list to available styles. 88 | func (cmd *Style) setTabCompleteStyles(lit string) { 89 | list := []string{ 90 | "blink", 91 | "bold", 92 | "dim", 93 | "reverse", 94 | "underline", 95 | } 96 | cmd.setTabComplete(lit, list) 97 | } 98 | 99 | func (cmd *Style) mergeStyle(lit string) error { 100 | switch lit { 101 | case "blink": 102 | cmd.styleValue = cmd.styleValue.Blink(true) 103 | case "bold": 104 | cmd.styleValue = cmd.styleValue.Bold(true) 105 | case "dim": 106 | cmd.styleValue = cmd.styleValue.Dim(true) 107 | case "reverse": 108 | cmd.styleValue = cmd.styleValue.Reverse(true) 109 | case "underline": 110 | cmd.styleValue = cmd.styleValue.Underline(true) 111 | default: 112 | if lit[0] == '@' { 113 | lit = "#" + lit[1:] 114 | } 115 | color := tcell.GetColor(lit) 116 | switch { 117 | case !cmd.foreground: 118 | cmd.styleValue = cmd.styleValue.Foreground(color) 119 | cmd.foreground = true 120 | case !cmd.background: 121 | cmd.styleValue = cmd.styleValue.Background(color) 122 | cmd.background = true 123 | default: 124 | return fmt.Errorf("Only two color values are allowed per style.") 125 | } 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /commands/style_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var styleTests = []commands.Test{ 10 | // Valid forms 11 | {`stylekey`, true, nil, nil, []string{}}, 12 | {`stylekey `, true, nil, nil, []string{"blink", "bold", "dim", "reverse", "underline"}}, 13 | {`stylekey bar baz`, true, nil, nil, []string{}}, 14 | {`stylekey color1 color2 blink bold dim reverse underline`, true, nil, nil, []string{"underline"}}, 15 | {`stylekey blink color1 bold dim color2 reverse underline`, true, nil, nil, []string{"underline"}}, 16 | 17 | // Invalid forms 18 | {``, false, nil, nil, []string{}}, 19 | {`stylekey color1 color2 color3`, false, nil, nil, []string{}}, 20 | } 21 | 22 | func TestStyle(t *testing.T) { 23 | commands.TestVerb(t, "style", styleTests) 24 | } 25 | -------------------------------------------------------------------------------- /commands/test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/input/lexer" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestData contains data needed for a single Command table test. 14 | type TestData struct { 15 | T *testing.T 16 | Cmd Command 17 | Api api.API 18 | Test Test 19 | } 20 | 21 | // Test is a structure for test data, and can be used to conveniently 22 | // test Command instances. 23 | type Test struct { 24 | 25 | // The input data for the command, as seen on the command line. 26 | Input string 27 | 28 | // True if the command should parse and execute properly, false otherwise. 29 | Success bool 30 | 31 | // An initialization function for tests. 32 | Init func(data *TestData) 33 | 34 | // A callback function to call for every test, allowing customization of tests. 35 | Callback func(data *TestData) 36 | 37 | // A slice of tab completion candidates to expect. 38 | TabComplete []string 39 | } 40 | 41 | // TestVerb runs table tests for Command implementations. 42 | func TestVerb(t *testing.T, verb string, tests []Test) { 43 | for n, test := range tests { 44 | api := api.NewTestAPI() 45 | 46 | data := &TestData{ 47 | T: t, 48 | Api: api, 49 | Cmd: New(verb, api), 50 | Test: test, 51 | } 52 | 53 | require.NotNil(t, data.Cmd, "Command '%s' is not implemented; it must be added to the `commands.Verb` variable.", verb) 54 | 55 | if data.Test.Init != nil { 56 | t.Logf("### Initializing data for verb test '%s' number %d", test.Input, n+1) 57 | data.Test.Init(data) 58 | } 59 | 60 | t.Logf("### Test %d: '%s'", n+1, test.Input) 61 | TestCommand(data) 62 | } 63 | } 64 | 65 | // TestCommand runs a single test a for Command implementation. 66 | func TestCommand(data *TestData) { 67 | reader := strings.NewReader(data.Test.Input) 68 | scanner := lexer.NewScanner(reader) 69 | 70 | // Parse command 71 | data.Cmd.SetScanner(scanner) 72 | err := data.Cmd.Parse() 73 | 74 | // Test success 75 | if data.Test.Success { 76 | assert.Nil(data.T, err, "Expected success when parsing '%s'", data.Test.Input) 77 | } else { 78 | assert.NotNil(data.T, err, "Expected error when parsing '%s'", data.Test.Input) 79 | } 80 | 81 | // Test tab completes 82 | completes := data.Cmd.TabComplete() 83 | assert.Equal(data.T, data.Test.TabComplete, completes) 84 | 85 | // Test callback function 86 | if data.Test.Callback != nil { 87 | data.Test.Callback(data) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /commands/unbind.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | "github.com/ambientsound/pms/keysequence" 6 | ) 7 | 8 | // Unbind unmaps a key sequence. 9 | type Unbind struct { 10 | newcommand 11 | api api.API 12 | seq keysequence.KeySequence 13 | } 14 | 15 | // NewUnbind returns Unbind. 16 | func NewUnbind(api api.API) Command { 17 | return &Unbind{ 18 | api: api, 19 | } 20 | } 21 | 22 | // Parse implements Command. 23 | func (cmd *Unbind) Parse() error { 24 | 25 | // Use the key sequence parser for parsing the next token. 26 | parser := keysequence.NewParser(cmd.S) 27 | 28 | // Parse a valid key sequence from the scanner. 29 | seq, err := parser.ParseKeySequence() 30 | if err != nil { 31 | return err 32 | } 33 | cmd.seq = seq 34 | 35 | // Reject any further input 36 | return cmd.ParseEnd() 37 | } 38 | 39 | // Exec implements Command. 40 | func (cmd *Unbind) Exec() error { 41 | sequencer := cmd.api.Sequencer() 42 | return sequencer.RemoveBind(cmd.seq) 43 | } 44 | -------------------------------------------------------------------------------- /commands/unbind_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var unbindTests = []commands.Test{ 10 | // Valid forms 11 | {`f`, true, nil, nil, []string{}}, 12 | {`foo`, true, nil, nil, []string{}}, 13 | {`[]{}$|"test"`, true, nil, nil, []string{}}, 14 | 15 | // Invalid forms 16 | {``, false, nil, nil, []string{}}, 17 | {`foo bar`, false, nil, nil, []string{}}, 18 | {`foo bar baz`, false, nil, nil, []string{}}, 19 | } 20 | 21 | func TestUnbind(t *testing.T) { 22 | commands.TestVerb(t, "unbind", unbindTests) 23 | } 24 | -------------------------------------------------------------------------------- /commands/update.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Update updates MPD database. 10 | type Update struct { 11 | newcommand 12 | api api.API 13 | } 14 | 15 | // NewUpdate returns Update. 16 | func NewUpdate(api api.API) Command { 17 | return &Update{ 18 | api: api, 19 | } 20 | } 21 | 22 | // Parse implements Command. 23 | func (cmd *Update) Parse() error { 24 | return cmd.ParseEnd() 25 | } 26 | 27 | // Exec implements Command. 28 | func (cmd *Update) Exec() error { 29 | if client := cmd.api.MpdClient(); client != nil { 30 | jobID, err := client.Update("") 31 | if err != nil { 32 | cmd.api.Message("Updating, jobID is %d", jobID) 33 | } 34 | return err 35 | } 36 | return fmt.Errorf("Unable to update database: cannot communicate with MPD") 37 | } 38 | -------------------------------------------------------------------------------- /commands/viewport.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Viewport acts on the viewport, such as scrolling the current songlist. 11 | type Viewport struct { 12 | newcommand 13 | api api.API 14 | movecursor bool 15 | relative int 16 | } 17 | 18 | // NewViewport returns Viewport. 19 | func NewViewport(api api.API) Command { 20 | return &Viewport{ 21 | api: api, 22 | } 23 | } 24 | 25 | // Parse parses the viewport movement command. 26 | func (cmd *Viewport) Parse() error { 27 | tok, lit := cmd.ScanIgnoreWhitespace() 28 | cmd.setTabCompleteVerbs(lit) 29 | 30 | switch tok { 31 | case lexer.TokenIdentifier: 32 | default: 33 | return fmt.Errorf("Unexpected '%s', expected identifier", lit) 34 | } 35 | 36 | switch lit { 37 | case "down": 38 | cmd.relative = 1 39 | cmd.movecursor = false 40 | case "up": 41 | cmd.relative = -1 42 | cmd.movecursor = false 43 | case "halfpgdn", "halfpagedn", "halfpagedown": 44 | cmd.scrollHalfPage(1) 45 | case "halfpgup", "halfpageup": 46 | cmd.scrollHalfPage(-1) 47 | case "pgdn", "pagedn", "pagedown": 48 | cmd.scrollFullPage(1) 49 | case "pgup", "pageup": 50 | cmd.scrollFullPage(-1) 51 | case "high": 52 | cmd.scrollToCursorAnchor(-1) 53 | case "middle": 54 | cmd.scrollToCursorAnchor(0) 55 | case "low": 56 | cmd.scrollToCursorAnchor(1) 57 | default: 58 | return fmt.Errorf("Viewport command '%s' not recognized", lit) 59 | } 60 | 61 | cmd.setTabCompleteEmpty() 62 | 63 | return cmd.ParseEnd() 64 | } 65 | 66 | // scrollHalfPage configures the command to scroll half a page up or down. 67 | // The direction parameter must be -1 for up or 1 for down. 68 | func (cmd *Viewport) scrollHalfPage(direction int) { 69 | _, y := cmd.api.SonglistWidget().Size() 70 | if y <= 1 { 71 | // Vim always moves at least one line 72 | cmd.relative = direction 73 | } else { 74 | cmd.relative = direction * y / 2 75 | } 76 | cmd.movecursor = true 77 | } 78 | 79 | // scrollFullPage configures the command to scroll a full page up or down. 80 | // The direction parameter must be -1 for up or 1 for down. 81 | func (cmd *Viewport) scrollFullPage(direction int) { 82 | _, y := cmd.api.SonglistWidget().Size() 83 | if y <= 3 { 84 | // Vim scrolls an entire page when 3 or fewer lines visible 85 | cmd.relative = direction * y 86 | } else if y == 4 { 87 | // Vim scrolls 3 lines when 4 lines visible 88 | cmd.relative = direction * 3 89 | } else { 90 | // Vim leaves 2 lines context when 5 or more lines visible 91 | cmd.relative = direction * (y - 2) 92 | } 93 | cmd.movecursor = false 94 | } 95 | 96 | // scrollToCursorAnchor configures the command to scroll to a point 97 | // such that the cursor is left at the top, middle, or bottom. 98 | // The position parameter must be 99 | // positive to move the viewport low (scrolled further down; cursor high), 100 | // zero to leave it in the middle, 101 | // or negative to move the viewport high (scrolled further up; cursor low). 102 | func (cmd *Viewport) scrollToCursorAnchor(position int) { 103 | widget := cmd.api.SonglistWidget() 104 | ymin, ymax := widget.GetVisibleBoundaries() 105 | cursor := cmd.api.Songlist().Cursor() 106 | if position < 0 { 107 | cmd.relative = cursor - ymax 108 | } else if position > 0 { 109 | cmd.relative = cursor - ymin 110 | } else { 111 | _, y := widget.Size() 112 | cmd.relative = cursor - y/2 - ymin 113 | } 114 | cmd.movecursor = false 115 | } 116 | 117 | // Exec implements Command. 118 | func (cmd *Viewport) Exec() error { 119 | widget := cmd.api.SonglistWidget() 120 | 121 | widget.ScrollViewport(cmd.relative, cmd.movecursor) 122 | 123 | return nil 124 | } 125 | 126 | // setTabCompleteVerbs sets the tab complete list to the list of available sub-commands. 127 | func (cmd *Viewport) setTabCompleteVerbs(lit string) { 128 | cmd.setTabComplete(lit, []string{ 129 | "down", 130 | "halfpagedn", 131 | "halfpagedown", 132 | "halfpageup", 133 | "halfpgdn", 134 | "halfpgup", 135 | "high", 136 | "low", 137 | "middle", 138 | "pagedn", 139 | "pagedown", 140 | "pageup", 141 | "pgdn", 142 | "pgup", 143 | "up", 144 | }) 145 | } 146 | -------------------------------------------------------------------------------- /commands/viewport_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var viewportTests = []commands.Test{ 10 | // Valid forms 11 | {`up`, true, nil, nil, []string{}}, 12 | {`down`, true, nil, nil, []string{}}, 13 | // FIXME: depends on SonglistWidget, which is not mocked 14 | //{`pgup`, true}, 15 | //{`pgdn`, true}, 16 | //{`pageup`, true}, 17 | //{`pagedn`, true}, 18 | //{`pagedown`, true}, 19 | //{`halfpgup`, true}, 20 | //{`halfpgdn`, true}, 21 | //{`halfpageup`, true}, 22 | //{`halfpagedn`, true}, 23 | //{`halfpagedown`, true}, 24 | //{`high`, true}, 25 | //{`middle`, true}, 26 | //{`low`, true}, 27 | 28 | // Invalid forms 29 | {`up 1`, false, nil, nil, []string{}}, 30 | {`down 1`, false, nil, nil, []string{}}, 31 | // FIXME: depends on SonglistWidget, which is not mocked 32 | //{`pgup 1`, false}, 33 | //{`pgdn 1`, false}, 34 | //{`pageup 1`, false}, 35 | //{`pagedn 1`, false}, 36 | //{`pagedown 1`, false}, 37 | //{`halfpgup 1`, false}, 38 | //{`halfpgdn 1`, false}, 39 | //{`halfpageup 1`, false}, 40 | //{`halfpagedn 1`, false}, 41 | //{`halfpagedown 1`, false}, 42 | //{`high 1`, false}, 43 | //{`middle 1`, false}, 44 | //{`low 1`, false}, 45 | {`nonsense`, false, nil, nil, []string{}}, 46 | 47 | // Tab completion 48 | {``, false, nil, nil, []string{ 49 | "down", 50 | "halfpagedn", 51 | "halfpagedown", 52 | "halfpageup", 53 | "halfpgdn", 54 | "halfpgup", 55 | "high", 56 | "low", 57 | "middle", 58 | "pagedn", 59 | "pagedown", 60 | "pageup", 61 | "pgdn", 62 | "pgup", 63 | "up", 64 | }}, 65 | {`u`, false, nil, nil, []string{ 66 | "up", 67 | }}, 68 | {`do`, false, nil, nil, []string{ 69 | "down", 70 | }}, 71 | {`page`, false, nil, nil, []string{ 72 | "pagedn", 73 | "pagedown", 74 | "pageup", 75 | }}, 76 | {`halfpage`, false, nil, nil, []string{ 77 | "halfpagedn", 78 | "halfpagedown", 79 | "halfpageup", 80 | }}, 81 | } 82 | 83 | func TestViewport(t *testing.T) { 84 | commands.TestVerb(t, "viewport", viewportTests) 85 | } 86 | -------------------------------------------------------------------------------- /commands/volume.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | var preMuteVolume int 11 | 12 | // Volume adjusts MPD's volume. 13 | type Volume struct { 14 | newcommand 15 | api api.API 16 | sign int 17 | volume int 18 | finished bool 19 | mute bool 20 | } 21 | 22 | // NewVolume returns Volume. 23 | func NewVolume(api api.API) Command { 24 | return &Volume{ 25 | api: api, 26 | } 27 | } 28 | 29 | // Parse implements Command. 30 | func (cmd *Volume) Parse() error { 31 | 32 | playerStatus := cmd.api.PlayerStatus() 33 | 34 | tok, lit := cmd.ScanIgnoreWhitespace() 35 | cmd.setTabComplete(lit, []string{"mute"}) 36 | 37 | // Check for muted status. 38 | if tok == lexer.TokenIdentifier && lit == "mute" { 39 | cmd.mute = true 40 | cmd.setTabCompleteEmpty() 41 | return cmd.ParseEnd() 42 | } 43 | 44 | // If not muted, try to parse a number. 45 | cmd.Unscan() 46 | _, ilit, absolute, err := cmd.ParseInt() 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if absolute { 52 | cmd.volume = ilit 53 | } else { 54 | cmd.volume = int(playerStatus.Volume) + ilit 55 | } 56 | 57 | cmd.validateVolume() 58 | 59 | cmd.setTabCompleteEmpty() 60 | return cmd.ParseEnd() 61 | } 62 | 63 | // validateVolume clamps the volume to the allowable range 64 | func (cmd *Volume) validateVolume() { 65 | if cmd.volume > 100 { 66 | cmd.volume = 100 67 | } else if cmd.volume < 0 { 68 | cmd.volume = 0 69 | } 70 | } 71 | 72 | // Exec implements Command. 73 | func (cmd *Volume) Exec() error { 74 | mpdClient := cmd.api.MpdClient() 75 | if mpdClient == nil { 76 | return fmt.Errorf("Unable to set volume: cannot communicate with MPD") 77 | } 78 | 79 | playerStatus := cmd.api.PlayerStatus() 80 | 81 | switch { 82 | case cmd.mute && playerStatus.Volume == 0: 83 | cmd.volume = preMuteVolume 84 | case cmd.mute && playerStatus.Volume > 0: 85 | preMuteVolume = playerStatus.Volume 86 | cmd.volume = 0 87 | } 88 | 89 | return mpdClient.SetVolume(cmd.volume) 90 | } 91 | -------------------------------------------------------------------------------- /commands/volume_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var volumeTests = []commands.Test{ 10 | // Valid forms 11 | {`-2`, true, nil, nil, []string{}}, 12 | {`+13`, true, nil, nil, []string{}}, 13 | {`1329`, true, nil, nil, []string{}}, 14 | {`mute`, true, nil, nil, []string{}}, 15 | 16 | // Invalid forms 17 | {``, false, nil, nil, []string{"mute"}}, 18 | {`--2`, false, nil, nil, []string{}}, 19 | {`+x`, false, nil, nil, []string{}}, 20 | {`$1`, false, nil, nil, []string{}}, 21 | {`mute more`, false, nil, nil, []string{}}, 22 | } 23 | 24 | func TestVolume(t *testing.T) { 25 | commands.TestVerb(t, "volume", volumeTests) 26 | } 27 | -------------------------------------------------------------------------------- /commands/yank.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Yank copies tracks from the songlist into the clipboard. 10 | type Yank struct { 11 | newcommand 12 | api api.API 13 | } 14 | 15 | // NewYank returns Yank. 16 | func NewYank(api api.API) Command { 17 | return &Yank{ 18 | api: api, 19 | } 20 | } 21 | 22 | // Parse implements Command. 23 | func (cmd *Yank) Parse() error { 24 | return cmd.ParseEnd() 25 | } 26 | 27 | // Exec implements Command. 28 | func (cmd *Yank) Exec() error { 29 | list := cmd.api.Songlist() 30 | selection := list.Selection() 31 | indices := list.SelectionIndices() 32 | len := len(indices) 33 | 34 | if len == 0 { 35 | return fmt.Errorf("No tracks selected.") 36 | } 37 | 38 | // Place songs in clipboard 39 | clipboard := cmd.api.Db().Clipboard("default") 40 | selection.Duplicate(clipboard) 41 | 42 | // Print a message 43 | if len == 1 { 44 | cmd.api.Message("Yanked '%s'", selection.Song(0).StringTags["file"]) 45 | } else { 46 | cmd.api.Message("%d tracks yanked to clipboard.", len) 47 | } 48 | 49 | // Clear selection and move cursor 50 | list.ClearSelection() 51 | list.MoveCursor(1) 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /commands/yank_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/commands" 7 | ) 8 | 9 | var yankTests = []commands.Test{ 10 | // Yank takes to parameters. 11 | {``, true, nil, nil, []string{}}, 12 | {` `, true, nil, nil, []string{}}, 13 | 14 | // Invalid forms 15 | {`foo`, false, nil, nil, []string{}}, 16 | {`foo bar`, false, nil, nil, []string{}}, 17 | } 18 | 19 | func TestYank(t *testing.T) { 20 | commands.TestVerb(t, "yank", yankTests) 21 | } 22 | -------------------------------------------------------------------------------- /console/console.go: -------------------------------------------------------------------------------- 1 | // Package console provides logging functions. 2 | package console 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "time" 8 | ) 9 | 10 | var logFile *os.File 11 | 12 | var start = time.Now() 13 | 14 | // Open opens a log file for writing. 15 | func Open(logfile string) (err error) { 16 | logFile, err = os.Create(logfile) 17 | if err != nil { 18 | return 19 | } 20 | return 21 | } 22 | 23 | // Close closes an open log file. 24 | func Close() { 25 | logFile.Close() 26 | } 27 | 28 | // Log writes a log line to the log file. 29 | // A timestamp and a newline is automatically added. 30 | // If the log file isn't open, nothing is done. 31 | func Log(format string, args ...interface{}) { 32 | if logFile == nil { 33 | return 34 | } 35 | since := time.Since(start) 36 | text := fmt.Sprintf(format, args...) 37 | text = fmt.Sprintf("[%.5f] %s\n", since.Seconds(), text) 38 | logFile.WriteString(text) 39 | } 40 | -------------------------------------------------------------------------------- /constants/constants.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | // Different input modes are handled in different ways. Check 4 | // MultibarWidget.inputMode against these constants. 5 | const ( 6 | MultibarModeNormal = iota 7 | MultibarModeInput 8 | MultibarModeSearch 9 | ) 10 | -------------------------------------------------------------------------------- /db/db.go: -------------------------------------------------------------------------------- 1 | // package db provides a shared object containing all of PMS' data. 2 | package db 3 | 4 | import ( 5 | pms_mpd "github.com/ambientsound/pms/mpd" 6 | "github.com/ambientsound/pms/options" 7 | "github.com/ambientsound/pms/song" 8 | "github.com/ambientsound/pms/songlist" 9 | ) 10 | 11 | // Instance holds state related to mutable data within PMS, such as the current 12 | // state of MPD, any songlists, clipboards, options. 13 | type Instance struct { 14 | // mpd state 15 | mpdStatus pms_mpd.PlayerStatus 16 | currentSong *song.Song 17 | 18 | // song lists 19 | queue *songlist.Queue 20 | library *songlist.Library 21 | songlists []songlist.Songlist 22 | clipboards map[string]songlist.Songlist 23 | options *options.Options 24 | 25 | // panels 26 | left *songlist.Collection 27 | right *songlist.Collection 28 | } 29 | 30 | // New returns Instance. 31 | func New() *Instance { 32 | return &Instance{ 33 | clipboards: make(map[string]songlist.Songlist, 0), 34 | left: songlist.NewCollection(), 35 | right: songlist.NewCollection(), 36 | } 37 | } 38 | 39 | // Clipboard returns a named clipboard. 40 | func (db *Instance) Clipboard(key string) songlist.Songlist { 41 | _, ok := db.clipboards[key] 42 | if !ok { 43 | db.clipboards[key] = songlist.New() 44 | } 45 | return db.clipboards[key] 46 | } 47 | 48 | // CurrentSong returns MPD's currently playing song. 49 | func (db *Instance) CurrentSong() *song.Song { 50 | return db.currentSong 51 | } 52 | 53 | // SetCurrentSong sets MPD's currently playing song. 54 | func (db *Instance) SetCurrentSong(s *song.Song) { 55 | db.currentSong = s 56 | } 57 | 58 | // Queue returns the MPD queue. 59 | func (db *Instance) Queue() *songlist.Queue { 60 | return db.queue 61 | } 62 | 63 | // SetQueue sets the MPD queue. 64 | func (db *Instance) SetQueue(queue *songlist.Queue) { 65 | db.queue = queue 66 | } 67 | 68 | // Library returns the MPD library. 69 | func (db *Instance) Library() *songlist.Library { 70 | return db.library 71 | } 72 | 73 | // SetLibrary sets the MPD library. 74 | func (db *Instance) SetLibrary(library *songlist.Library) { 75 | db.library = library 76 | } 77 | 78 | // PlayerStatus returns a copy of the current MPD player status as seen by PMS. 79 | func (db *Instance) PlayerStatus() pms_mpd.PlayerStatus { 80 | return db.mpdStatus 81 | } 82 | 83 | // SetPlayerStatus sets the MPD player status. 84 | func (db *Instance) SetPlayerStatus(p pms_mpd.PlayerStatus) { 85 | db.mpdStatus = p 86 | } 87 | 88 | // Panel returns the active panel. At the moment, there is only one panel. 89 | func (db *Instance) Panel() *songlist.Collection { 90 | return db.Left() 91 | } 92 | 93 | // Left returns the left panel. 94 | func (db *Instance) Left() *songlist.Collection { 95 | return db.left 96 | } 97 | 98 | // Right returns the right panel. 99 | func (db *Instance) Right() *songlist.Collection { 100 | return db.right 101 | } 102 | -------------------------------------------------------------------------------- /doc/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | This document serves as an entry point to Practical Music Search documentation. 4 | 5 | * If you're new to PMS, you might want to read the [introduction](intro.md). 6 | * [MPD](mpd.md) details how to configure MPD for best performance with PMS, and how to configure PMS to connect to MPD. 7 | * [Commands](commands.md) describes the different commands that control PMS. 8 | * [Options](options.md) describes options that can be changed, and how they affect PMS's functionality. 9 | * [Styling](styling.md) describes how to change the layout, colors, and text styles. 10 | * See the [default configuration](../options/defaults.go) for default options, keyboard bindings, and styles. 11 | -------------------------------------------------------------------------------- /doc/data-model-refactor.md: -------------------------------------------------------------------------------- 1 | # Refactoring how and where data is stored 2 | 3 | ## Background 4 | 5 | There are many different kinds of data stored within PMS. At the time of 6 | writing, they are scattered throughout different parts of the program. As the 7 | application grows more complex, the internal data representation will need to 8 | be consolidated. 9 | 10 | ## Goals 11 | 12 | * Create a single entry point for accessing global data. 13 | * Thread-safe access and manipulation. 14 | 15 | ## Which data is stored? 16 | 17 | The following data collections are shown in list views, directly to the user: 18 | 19 | * Track lists 20 | * Queue 21 | * Library (should be read/write, but undoable) 22 | * Ephemeral lists (search results, etc.) 23 | * Remote playlists (*not implemented*) 24 | * Help screen (*not implemented*) 25 | * Outputs (*not implemented*) 26 | * File browser (*not implemented*) 27 | * Album browser (*not implemented*) 28 | 29 | Other collections, which are not shown in a list view, but should still be accessible from components: 30 | 31 | * Clipboards 32 | * Current song 33 | * Keyboard bindings 34 | * Library (canonical read-only copy) 35 | * MPD statistics 36 | * Options 37 | * Player status 38 | * Search history 39 | * Search index (bound to the library, and might possibly also have temporary in-memory indices for other track lists) 40 | * Tab completion history 41 | * Track list editing history (*not implemented*, also known as undo/redo) 42 | 43 | ## Components using data 44 | 45 | * Commands 46 | * Top bar fragments 47 | * Command-line input 48 | -------------------------------------------------------------------------------- /doc/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## Configuration 4 | 5 | By default, PMS tries to read your configuration from 6 | `$HOME/.config/pms/pms.conf`. 7 | If you defined paths in either `$XDG_CONFIG_DIRS` or `$XDG_CONFIG_HOME`, PMS will look for `pms.conf` there. 8 | 9 | ``` 10 | # Sample PMS configuration file. 11 | # All whitespace, newlines, and text after a hash sign will be ignored. 12 | 13 | # The 'center' option will make sure the cursor is always centered on screen. 14 | set center 15 | 16 | # Some custom keyboard bindings. 17 | bind cursor prevOf year # jump to previous year. 18 | bind cursor nextOf year # jump to next year. 19 | 20 | # Pink statusbar. 21 | style statusbar black darkmagenta 22 | 23 | # Minimalistic topbar. 24 | set topbar="Now playing: ${tag|artist} \\- ${tag|title} (${elapsed})" 25 | ``` 26 | 27 | See the [default configuration](/options/defaults.go). 28 | 29 | 30 | ## Basic movement 31 | 32 | The default bindings for movement are similar to vim. 33 | 34 | `j` and `k` move down and up, 35 | `gt` and `gT` (or just `t` and `T`) move forward and back between lists. 36 | Use `` and `` to move a page down or up, 37 | or `` and ` for half a page at a time. 38 | `gg` and `G` go to the very top and bottom of the list, 39 | while `H`, `M`, and `L` go to the top, middle, and bottom of the current viewport. 40 | 41 | You can also move quickly from album to album using `b` and `e`, 42 | which are examples of [`cursor prevOf` and `cursor nextOf` commands](commands.md#move-the-cursor-and-viewport). 43 | 44 | 45 | ## Adding tracks to the playlist 46 | 47 | A highlighted track (or selection of tracks) can be added to the current list with `a`, 48 | or added and played with ``. 49 | (When the current playlist is focused, `` will just play, rather than also adding a duplicate.) 50 | 51 | `x`, meanwhile, will delete the highlighted track from the list. 52 | 53 | 54 | ## Searching for tracks 55 | 56 | PMS employs a very fast and powerful search engine called _Bleve_. 57 | The following is an example on how to do a search in PMS: 58 | 59 | To start a search, type `/` (or `:inputmode search`). 60 | The tracklist will be cleared, and a slash will appear in the statusline. 61 | Type at least two characters to start searching. 62 | The tracklist will update itself as you type. 63 | 64 | Search results will be sorted by match score. 65 | If you want to sort your search result, press `` (or type `:sort`) to sort by the default sort parameters. 66 | 67 | To drill down into the search, highlight a song, 68 | then press `` (or type `:isolate artist`) to show all tracks with the same artist, 69 | or `` (`:isolate albumartist album`) to show all tracks in the same album. 70 | 71 | To select tracks, type `m` (`:select toggle`) to mark one at a time, 72 | or use the visual selection by typing `v` (`:select visual`). 73 | You could also type `&` (`:select nearby albumartist album`) to select the entire album. 74 | Press `a` (`:add`) to add the selected songs to the queue, 75 | or `` (`:play selection`) to play them immediately. 76 | 77 | 78 | ## Known issues 79 | 80 | If having connection problems, you might be hitting a buffer limit in MPD. 81 | It may help to configure your MPD server according to [configuring PMS and MPD](mpd.md). 82 | -------------------------------------------------------------------------------- /doc/mpd.md: -------------------------------------------------------------------------------- 1 | # Configuring PMS and MPD 2 | 3 | When starting the program, PMS connects to the MPD server specified in the `$MPD_HOST` and `$MPD_PORT` environment variables. 4 | 5 | In order to create a full-text search index for fast searches, 6 | PMS retrieves the entire song library from MPD whenever the library is updated, 7 | and on every startup. 8 | If your song library is big, the `listallinfo` command will overflow MPD's send buffer, 9 | and the connection is dropped. 10 | This can be mitigated by increasing MPD's output buffer size, 11 | and then restarting MPD: 12 | 13 | ``` 14 | cat >>/etc/mpd.conf<<[,[...]]` 18 | 19 | Define which tags should be shown in the tracklist. 20 | 21 | A comma-separated list of tag names must be given, such as the default `artist,track,title,album,year,time`. 22 | 23 | ### Sort order 24 | 25 | * `set sort=[,[...]]` 26 | 27 | Set the default sort order, for when using the [`sort` command](commands.md#manipulating-lists) without any parameters. 28 | 29 | A comma-separated list of tag names must be given, such as the default `file,track,disc,album,year,albumartistsort`. 30 | 31 | ### Information bar ("top bar") 32 | 33 | * `set topbar=` 34 | 35 | Define the layout and visible items in the _top bar_. 36 | See the [styling guide](styling.md#top-bar) for information on how to configure the top bar. 37 | 38 | The default value is `"|$shortname $version||;${tag|artist} - ${tag|title}||${tag|album}, ${tag|year};$volume $mode $elapsed ${state} $time;|[${list|index}/${list|total}] ${list|title}||;;"`. 39 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ambientsound/pms 2 | 3 | require ( 4 | github.com/blevesearch/bleve/v2 v2.3.4 5 | github.com/fhs/gompd/v2 v2.3.0 // indirect 6 | github.com/gdamore/tcell/v2 v2.7.0 7 | github.com/jessevdk/go-flags v1.5.0 8 | github.com/mattn/go-runewidth v0.0.15 9 | github.com/stretchr/testify v1.7.1 10 | golang.org/x/text v0.14.0 11 | ) 12 | 13 | go 1.13 14 | -------------------------------------------------------------------------------- /index/filters/unicodestrip/unicodestrip.go: -------------------------------------------------------------------------------- 1 | // Package unicodestrip provides a Bleve keyword filter which decomposes unicode strings. 2 | package unicodestrip 3 | 4 | import ( 5 | "unicode" 6 | 7 | "github.com/blevesearch/bleve/v2/analysis" 8 | "github.com/blevesearch/bleve/v2/registry" 9 | "golang.org/x/text/transform" 10 | "golang.org/x/text/unicode/norm" 11 | ) 12 | 13 | const Name = "strip_unicode" 14 | 15 | // StripUnicodeFilter is a Bleve keyword filter which decomposes unicode 16 | // strings into their normalized form and strips away non-spacing marks. 17 | // Effectively, this strips away diacritic marks so that searches may be done 18 | // without entering them, e.g. "Télépopmusik" is indexed as "Telepopmusik". 19 | type StripUnicodeFilter struct { 20 | } 21 | 22 | // New returns a new instance of StripUnicodeFilter. 23 | func New() (*StripUnicodeFilter, error) { 24 | return &StripUnicodeFilter{}, nil 25 | } 26 | 27 | // Constructor provides a constructor for Bleve. 28 | func Constructor(config map[string]interface{}, cache *registry.Cache) (analysis.TokenFilter, error) { 29 | return New() 30 | } 31 | 32 | // isMn returns true if the provided rune is a unicode non-spacing mark. 33 | func isMn(r rune) bool { 34 | return unicode.Is(unicode.Mn, r) 35 | } 36 | 37 | // Filter removes non-spacing marks from text in a token stream. 38 | func (s *StripUnicodeFilter) Filter(input analysis.TokenStream) analysis.TokenStream { 39 | chain := transform.Chain(norm.NFKD, transform.RemoveFunc(isMn), norm.NFC) 40 | for _, token := range input { 41 | token.Term, _, _ = transform.Bytes(chain, token.Term) 42 | } 43 | return input 44 | } 45 | 46 | // init registers this plugin with Bleve. 47 | func init() { 48 | registry.RegisterTokenFilter(Name, Constructor) 49 | } 50 | -------------------------------------------------------------------------------- /index/mapping.go: -------------------------------------------------------------------------------- 1 | package index 2 | 3 | import ( 4 | "github.com/ambientsound/pms/index/filters/unicodestrip" 5 | "github.com/blevesearch/bleve/v2" 6 | "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" 7 | "github.com/blevesearch/bleve/v2/analysis/token/edgengram" 8 | "github.com/blevesearch/bleve/v2/analysis/token/lowercase" 9 | "github.com/blevesearch/bleve/v2/analysis/tokenizer/whitespace" 10 | "github.com/blevesearch/bleve/v2/mapping" 11 | ) 12 | 13 | // buildIndexMapping() returns an object that defines how input data is indexed in Bleve. 14 | func buildIndexMapping() (mapping.IndexMapping, error) { 15 | indexMapping := bleve.NewIndexMapping() 16 | 17 | var err error 18 | 19 | err = indexMapping.AddCustomTokenFilter("songEdgeNgram", 20 | map[string]interface{}{ 21 | "min": float64(2), 22 | "max": float64(25), 23 | "type": edgengram.Name, 24 | }) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | err = indexMapping.AddCustomTokenFilter("unicodeStripper", 30 | map[string]interface{}{ 31 | "type": unicodestrip.Name, 32 | }) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | err = indexMapping.AddCustomAnalyzer("songAnalyzer", 38 | map[string]interface{}{ 39 | "type": custom.Name, 40 | "char_filters": []interface{}{}, 41 | "tokenizer": whitespace.Name, 42 | "token_filters": []interface{}{ 43 | `unicodeStripper`, 44 | lowercase.Name, 45 | `songEdgeNgram`, 46 | }, 47 | }) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | indexMapping.DefaultAnalyzer = "songAnalyzer" 53 | 54 | return indexMapping, nil 55 | } 56 | -------------------------------------------------------------------------------- /index/song/song.go: -------------------------------------------------------------------------------- 1 | // Search index songs 2 | 3 | package song 4 | 5 | import ( 6 | "github.com/ambientsound/pms/song" 7 | ) 8 | 9 | // Song is a Bleve document representing a song.Song object. 10 | type Song struct { 11 | Album string 12 | Albumartist string 13 | Artist string 14 | File string 15 | Genre string 16 | Title string 17 | Year string 18 | } 19 | 20 | // New generates a indexable Song document, containing some fields from the song.Song type. 21 | func New(s *song.Song) (is Song) { 22 | is.Album = s.StringTags["album"] 23 | is.Albumartist = s.StringTags["albumartist"] 24 | is.Artist = s.StringTags["artist"] 25 | is.File = s.StringTags["file"] 26 | is.Genre = s.StringTags["genre"] 27 | is.Title = s.StringTags["title"] 28 | is.Year = s.StringTags["year"] 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /input/interface.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/commands" 9 | "github.com/ambientsound/pms/input/lexer" 10 | ) 11 | 12 | // CLI reads user input, tokenizes it, and dispatches the tokens to their respective commands. 13 | type CLI struct { 14 | api api.API 15 | } 16 | 17 | func NewCLI(api api.API) *CLI { 18 | return &CLI{ 19 | api: api, 20 | } 21 | } 22 | 23 | // Exec is the new Execute. 24 | func (i *CLI) Exec(line string) error { 25 | 26 | // Create the token scanner. 27 | reader := strings.NewReader(line) 28 | scanner := lexer.NewScanner(reader) 29 | 30 | // Read the verb of the function. Comments and whitespace are ignored, all 31 | // tokens other than identifiers throw errors. 32 | tok, verb := scanner.ScanIgnoreWhitespace() 33 | switch tok { 34 | case lexer.TokenEnd, lexer.TokenComment: 35 | return nil 36 | case lexer.TokenIdentifier: 37 | break 38 | default: 39 | return fmt.Errorf("Unexpected '%s', expected verb", verb) 40 | } 41 | 42 | // Instantiate the command. 43 | cmd := commands.New(verb, i.api) 44 | if cmd == nil { 45 | return fmt.Errorf("Not a command: %s", verb) 46 | } 47 | 48 | // Parse the command into an AST. 49 | cmd.SetScanner(scanner) 50 | err := cmd.Parse() 51 | if err != nil { 52 | return err 53 | } 54 | 55 | // Execute the AST. 56 | return cmd.Exec() 57 | } 58 | 59 | // Execute sends scanned tokens to Command instances. 60 | // FIXME: this function is deprecated and must be remove when all Command 61 | // classes have been ported. 62 | func (i *CLI) Execute(line string) error { 63 | var cmd commands.Command 64 | var err error 65 | 66 | err = i.Exec(line) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | reader := strings.NewReader(line) 72 | scanner := lexer.NewScanner(reader) 73 | 74 | for { 75 | class, token := scanner.Scan() 76 | 77 | // First identifier; try to find a command handler 78 | if cmd == nil { 79 | switch class { 80 | case lexer.TokenIdentifier: 81 | if ctor, ok := commands.Verbs[token]; ok { 82 | cmd = ctor(i.api) 83 | continue 84 | } 85 | return fmt.Errorf("Not a command: %s", token) 86 | case lexer.TokenComment: 87 | continue 88 | case lexer.TokenEnd: 89 | return nil 90 | case lexer.TokenStop: 91 | cmd = nil 92 | continue 93 | case lexer.TokenWhitespace: 94 | continue 95 | default: 96 | return fmt.Errorf("Unexpected '%s', expected identifier", token) 97 | } 98 | } 99 | 100 | if class == lexer.TokenWhitespace { 101 | continue 102 | } 103 | 104 | err = cmd.Execute(class, token) 105 | 106 | if err != nil { 107 | return err 108 | } 109 | 110 | if class == lexer.TokenEnd { 111 | break 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /input/interface_test.go: -------------------------------------------------------------------------------- 1 | package input_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/input" 8 | "github.com/ambientsound/pms/options" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestCLISet tests that input.CLI registers a handler under the 14 | // verb "set", dispatches the input line to this handler, and correctly 15 | // manipulates the options table. 16 | func TestCLISet(t *testing.T) { 17 | var err error 18 | 19 | a := api.NewTestAPI() 20 | opts := a.Options() 21 | iface := input.NewCLI(a) 22 | 23 | opts.Add(options.NewStringOption("foo")) 24 | err = opts.Get("foo").Set("this string must die") 25 | require.Nil(t, err) 26 | 27 | err = iface.Execute("set foo=something") 28 | assert.Nil(t, err) 29 | 30 | assert.Equal(t, "something", opts.Value("foo")) 31 | } 32 | -------------------------------------------------------------------------------- /input/keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/console" 7 | "github.com/ambientsound/pms/keysequence" 8 | "github.com/gdamore/tcell/v2" 9 | ) 10 | 11 | // Binding holds a parsed, user provided key sequence. 12 | type Binding struct { 13 | Command string 14 | Sequence keysequence.KeySequence 15 | } 16 | 17 | // Sequencer holds all the keyboard bindings and their action mappings. 18 | type Sequencer struct { 19 | binds []Binding 20 | input keysequence.KeySequence 21 | } 22 | 23 | // NewSequencer returns Sequencer. 24 | func NewSequencer() *Sequencer { 25 | return &Sequencer{ 26 | binds: make([]Binding, 0), 27 | input: make(keysequence.KeySequence, 0), 28 | } 29 | } 30 | 31 | // AddBind creates a new key mapping. 32 | func (s *Sequencer) AddBind(seq keysequence.KeySequence, command string) error { 33 | if s.dupes(seq) { 34 | return fmt.Errorf("can't bind: conflicting with already bound key sequence") 35 | } 36 | s.binds = append(s.binds, Binding{Sequence: seq, Command: command}) 37 | return nil 38 | } 39 | 40 | // RemoveBind removes a key mapping. 41 | func (s *Sequencer) RemoveBind(seq keysequence.KeySequence) error { 42 | for i := range s.binds { 43 | if keysequence.Compare(s.binds[i].Sequence, seq) { 44 | // Overwrite this position with the last in the list 45 | s.binds[i] = s.binds[len(s.binds)-1] 46 | 47 | // Truncate to remove the (now duplicate) last entry 48 | s.binds = s.binds[:len(s.binds)-1] 49 | 50 | return nil 51 | } 52 | } 53 | 54 | return fmt.Errorf("can't unbind: sequence not bound") 55 | } 56 | 57 | // KeyInput feeds a keypress to the sequencer. Returns true if there is one match or more, or false if there is no match. 58 | func (s *Sequencer) KeyInput(ev *tcell.EventKey) bool { 59 | console.Log("Key event: %s", keysequence.FormatKey(ev)) 60 | s.input = append(s.input, ev) 61 | if len(s.find(s.input)) == 0 { 62 | s.input = make(keysequence.KeySequence, 0) 63 | return false 64 | } 65 | return true 66 | } 67 | 68 | // String returns the current input sequence as a string. 69 | func (s *Sequencer) String() string { 70 | return keysequence.Format(s.input) 71 | } 72 | 73 | // dupes returns true if binding the given key event sequence will conflict with any other bound sequences. 74 | func (s *Sequencer) dupes(seq keysequence.KeySequence) bool { 75 | matches := s.find(seq) 76 | return len(matches) > 0 77 | } 78 | 79 | // find returns a list of potential matches to key bindings. 80 | func (s *Sequencer) find(seq keysequence.KeySequence) []Binding { 81 | binds := make([]Binding, 0) 82 | for i := range s.binds { 83 | if keysequence.StartsWith(s.binds[i].Sequence, seq) { 84 | binds = append(binds, s.binds[i]) 85 | } 86 | } 87 | return binds 88 | } 89 | 90 | // Match returns a key binding if the current input sequence is found. 91 | func (s *Sequencer) Match() *Binding { 92 | binds := s.find(s.input) 93 | if len(binds) != 1 { 94 | return nil 95 | } 96 | b := binds[0] 97 | //console.Log("Possible match found: %+v ||| %+v", b.Sequence, s.input) 98 | if !keysequence.Compare(b.Sequence, s.input) { 99 | return nil 100 | } 101 | //console.Log("Match found: %+v", b) 102 | s.input = make(keysequence.KeySequence, 0) 103 | return &b 104 | } 105 | -------------------------------------------------------------------------------- /input/lexer/lexer.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "unicode" 8 | ) 9 | 10 | const ( 11 | TokenEnd = iota 12 | TokenAngleLeft 13 | TokenAngleRight 14 | TokenMinus 15 | TokenPlus 16 | TokenClose 17 | TokenComment 18 | TokenEqual 19 | TokenEscape 20 | TokenIdentifier 21 | TokenOpen 22 | TokenQuote 23 | TokenSeparator 24 | TokenStop 25 | TokenVariable 26 | TokenWhitespace 27 | ) 28 | 29 | // runeClass returns the token class of an input character. 30 | func runeClass(r rune) int { 31 | if unicode.IsSpace(r) { 32 | return TokenWhitespace 33 | } 34 | switch r { 35 | case eof: 36 | return TokenEnd 37 | case '<': 38 | return TokenAngleLeft 39 | case '>': 40 | return TokenAngleRight 41 | case '-': 42 | return TokenMinus 43 | case '+': 44 | return TokenPlus 45 | case '"': 46 | return TokenQuote 47 | case ';': 48 | return TokenStop 49 | case '|': 50 | return TokenSeparator 51 | case '$': 52 | return TokenVariable 53 | case '{': 54 | return TokenOpen 55 | case '}': 56 | return TokenClose 57 | case '#': 58 | return TokenComment 59 | case '=': 60 | return TokenEqual 61 | case '\\': 62 | return TokenEscape 63 | default: 64 | return TokenIdentifier 65 | } 66 | } 67 | 68 | // Scanner represents a lexical scanner. 69 | type Scanner struct { 70 | r *bufio.Reader 71 | } 72 | 73 | // NewScanner returns a new instance of Scanner. 74 | func NewScanner(r io.Reader) *Scanner { 75 | return &Scanner{r: bufio.NewReader(r)} 76 | } 77 | 78 | // Scan returns the next token and literal value. 79 | func (s *Scanner) Scan() (class int, lit string) { 80 | ch := s.read() 81 | class = runeClass(ch) 82 | 83 | switch class { 84 | case TokenQuote: 85 | class = TokenIdentifier 86 | lit = s.scanQuoted() 87 | case TokenWhitespace: 88 | s.unread() 89 | lit = s.scanWhitespace() 90 | case TokenComment: 91 | s.unread() 92 | lit = s.scanComment() 93 | case TokenIdentifier: 94 | s.unread() 95 | lit = s.scanIdentifier() 96 | case TokenEscape: 97 | class = TokenIdentifier 98 | lit = s.scanIdentifier() 99 | case TokenEnd: 100 | lit = `` 101 | default: 102 | lit = string(ch) 103 | } 104 | 105 | return 106 | } 107 | 108 | // ScanIgnoreWhitespace scans the next non-whitespace token. 109 | func (s *Scanner) ScanIgnoreWhitespace() (tok int, lit string) { 110 | tok, lit = s.Scan() 111 | if tok == TokenWhitespace { 112 | tok, lit = s.Scan() 113 | } 114 | return 115 | } 116 | 117 | // scanWhitespace consumes the current rune and all contiguous whitespace. 118 | func (s *Scanner) scanWhitespace() string { 119 | var buf bytes.Buffer 120 | buf.WriteRune(s.read()) 121 | 122 | OUTER: 123 | for { 124 | ch := s.read() 125 | class := runeClass(ch) 126 | 127 | switch class { 128 | case TokenWhitespace: 129 | buf.WriteRune(ch) 130 | case TokenEnd: 131 | break OUTER 132 | default: 133 | s.unread() 134 | break OUTER 135 | } 136 | } 137 | 138 | return buf.String() 139 | } 140 | 141 | func (s *Scanner) scanQuoted() string { 142 | var buf bytes.Buffer 143 | escape := false 144 | 145 | OUTER: 146 | for { 147 | ch := s.read() 148 | class := runeClass(ch) 149 | 150 | switch { 151 | case class == TokenEscape && !escape: 152 | escape = true 153 | continue 154 | case class == TokenQuote && !escape: 155 | break OUTER 156 | case class == TokenEnd: 157 | break OUTER 158 | default: 159 | buf.WriteRune(ch) 160 | } 161 | 162 | escape = false 163 | } 164 | 165 | return buf.String() 166 | } 167 | 168 | func (s *Scanner) scanComment() string { 169 | var buf bytes.Buffer 170 | buf.WriteRune(s.read()) 171 | 172 | OUTER: 173 | for { 174 | ch := s.read() 175 | class := runeClass(ch) 176 | 177 | switch class { 178 | case TokenEnd: 179 | break OUTER 180 | default: 181 | buf.WriteRune(ch) 182 | } 183 | } 184 | 185 | return buf.String() 186 | } 187 | 188 | func (s *Scanner) scanIdentifier() string { 189 | var buf bytes.Buffer 190 | buf.WriteRune(s.read()) 191 | escape := false 192 | 193 | OUTER: 194 | for { 195 | ch := s.read() 196 | class := runeClass(ch) 197 | 198 | switch { 199 | case class == TokenEscape && !escape: 200 | escape = true 201 | continue 202 | case class == TokenQuote && !escape: 203 | break OUTER 204 | case class == TokenEnd: 205 | break OUTER 206 | case escape || class == TokenIdentifier: 207 | buf.WriteRune(ch) 208 | default: 209 | s.unread() 210 | break OUTER 211 | } 212 | 213 | escape = false 214 | } 215 | 216 | return buf.String() 217 | } 218 | 219 | // read reads the next rune from the buffered reader. 220 | // Returns the rune(0) if an error occurs (or io.EOF is returned). 221 | func (s *Scanner) read() rune { 222 | ch, _, err := s.r.ReadRune() 223 | if err != nil { 224 | return eof 225 | } 226 | return ch 227 | } 228 | 229 | // unread places the previously read rune back on the reader. 230 | func (s *Scanner) unread() { _ = s.r.UnreadRune() } 231 | 232 | // eof represents a marker rune for the end of the reader. 233 | var eof = rune(0) 234 | -------------------------------------------------------------------------------- /input/lexer/lexer_test.go: -------------------------------------------------------------------------------- 1 | package lexer_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/input/lexer" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type result struct { 12 | class int 13 | str string 14 | } 15 | 16 | var lexerTests = []struct { 17 | input string 18 | expected []result 19 | }{ 20 | { 21 | `a normal sentence`, 22 | []result{ 23 | {class: lexer.TokenIdentifier, str: `a`}, 24 | {class: lexer.TokenWhitespace, str: ` `}, 25 | {class: lexer.TokenIdentifier, str: `normal`}, 26 | {class: lexer.TokenWhitespace, str: ` `}, 27 | {class: lexer.TokenIdentifier, str: `sentence`}, 28 | {class: lexer.TokenEnd, str: ``}, 29 | }, 30 | }, 31 | { 32 | `some "quoted text" here`, 33 | []result{ 34 | {class: lexer.TokenIdentifier, str: `some`}, 35 | {class: lexer.TokenWhitespace, str: ` `}, 36 | {class: lexer.TokenIdentifier, str: `quoted text`}, 37 | {class: lexer.TokenWhitespace, str: ` `}, 38 | {class: lexer.TokenIdentifier, str: `here`}, 39 | {class: lexer.TokenEnd, str: ``}, 40 | }, 41 | }, 42 | { 43 | `;|${}# comment ;|;`, 44 | []result{ 45 | {class: lexer.TokenStop, str: `;`}, 46 | {class: lexer.TokenSeparator, str: `|`}, 47 | {class: lexer.TokenVariable, str: `$`}, 48 | {class: lexer.TokenOpen, str: `{`}, 49 | {class: lexer.TokenClose, str: `}`}, 50 | {class: lexer.TokenComment, str: `# comment ;|;`}, 51 | {class: lexer.TokenEnd, str: ``}, 52 | }, 53 | }, 54 | { 55 | `$"quoted variable" ok`, 56 | []result{ 57 | {class: lexer.TokenVariable, str: `$`}, 58 | {class: lexer.TokenIdentifier, str: `quoted variable`}, 59 | {class: lexer.TokenWhitespace, str: ` `}, 60 | {class: lexer.TokenIdentifier, str: `ok`}, 61 | {class: lexer.TokenEnd, str: ``}, 62 | }, 63 | }, 64 | { 65 | `\v\e\ \r\y "quo\"\\ted $pecial" \$pec\|al`, 66 | []result{ 67 | {class: lexer.TokenIdentifier, str: `ve ry`}, 68 | {class: lexer.TokenWhitespace, str: ` `}, 69 | {class: lexer.TokenIdentifier, str: `quo"\ted $pecial`}, 70 | {class: lexer.TokenWhitespace, str: ` `}, 71 | {class: lexer.TokenIdentifier, str: `$pec|al`}, 72 | {class: lexer.TokenEnd, str: ``}, 73 | }, 74 | }, 75 | } 76 | 77 | func TestLexer(t *testing.T) { 78 | 79 | for n, test := range lexerTests { 80 | 81 | index := 0 82 | reader := strings.NewReader(test.input) 83 | scanner := lexer.NewScanner(reader) 84 | 85 | t.Logf("### Test %d: '%s'", n+1, test.input) 86 | 87 | for { 88 | class, str := scanner.Scan() 89 | 90 | if index == len(test.expected) { 91 | if class == lexer.TokenEnd { 92 | break 93 | } 94 | t.Fatalf("Tokenizer generated too many tokens!") 95 | } 96 | 97 | t.Logf("Token %d: class='%d', literal='%s'", index, class, str) 98 | 99 | check := test.expected[index] 100 | 101 | assert.Equal(t, check.class, class, 102 | "Token class for token %d is wrong; expected %d but got %d", index, check.class, class) 103 | assert.Equal(t, check.str, str, 104 | "String check against token %d failed; expected '%s' but got '%s'", index, check.str, str) 105 | 106 | index++ 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /input/parser/set.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // OptionToken represents a key=value string, which can have setting, inversion, negation, and queries. 8 | type OptionToken struct { 9 | Key string 10 | Value string 11 | Bool bool 12 | Negate bool 13 | Invert bool 14 | Query bool 15 | } 16 | 17 | // Parse parses a option=value string. 18 | func (t *OptionToken) Parse(runes []rune) error { 19 | // Parsing the value is done verbatim, whereas the key has 20 | // modifiers such as !, ?, inv*, no*. 21 | parsingKey := true 22 | 23 | for _, r := range runes { 24 | if !parsingKey { 25 | t.Value += string(r) 26 | continue 27 | } 28 | 29 | if t.Query { 30 | return fmt.Errorf("Trailing characters after '?'") 31 | } else if r == '=' { 32 | parsingKey = false 33 | } else if r == '?' { 34 | t.Query = true 35 | } else if r == '!' { 36 | if t.Invert { 37 | return fmt.Errorf("Double inversion not allowed") 38 | } 39 | t.Invert = true 40 | } else { 41 | t.Key += string(r) 42 | if t.Key == "no" && !t.Negate { 43 | t.Key = "" 44 | t.Negate = true 45 | t.Bool = true 46 | } else if t.Key == "inv" && !t.Invert { 47 | t.Key = "" 48 | t.Invert = true 49 | t.Bool = true 50 | } 51 | } 52 | } 53 | if parsingKey && !t.Query { 54 | t.Bool = true 55 | } 56 | if t.Query { 57 | if t.Invert { 58 | return fmt.Errorf("Query operation cannot be combined with inversion") 59 | } 60 | } else { 61 | if t.Negate && t.Invert { 62 | return fmt.Errorf("Negation and inversion cannot be combined") 63 | } 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /input/parser/set_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/input/parser" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | type parserTable struct { 12 | Name string 13 | Error bool 14 | Input string 15 | Token parser.OptionToken 16 | } 17 | 18 | // TestOptionParser tests the variable tokenizer against a table of well-known inputs and outputs. 19 | func TestOptionParser(t *testing.T) { 20 | table := []parserTable{ 21 | { 22 | Name: "string variable assignment", 23 | Input: "string=foo", 24 | Token: parser.OptionToken{ 25 | Key: "string", 26 | Value: "foo", 27 | Bool: false, 28 | Invert: false, 29 | Negate: false, 30 | Query: false, 31 | }, 32 | }, 33 | { 34 | Name: "variable query", 35 | Input: "string?", 36 | Token: parser.OptionToken{ 37 | Key: "string", 38 | Value: "", 39 | Bool: false, 40 | Invert: false, 41 | Negate: false, 42 | Query: true, 43 | }, 44 | }, 45 | { 46 | Name: "setting boolean option to true", 47 | Input: "bool", 48 | Token: parser.OptionToken{ 49 | Key: "bool", 50 | Value: "", 51 | Bool: true, 52 | Invert: false, 53 | Negate: false, 54 | Query: false, 55 | }, 56 | }, 57 | { 58 | Name: "setting boolean option to false", 59 | Input: "nobool", 60 | Token: parser.OptionToken{ 61 | Key: "bool", 62 | Value: "", 63 | Bool: true, 64 | Invert: false, 65 | Negate: true, 66 | Query: false, 67 | }, 68 | }, 69 | { 70 | Name: "inverting boolean option by 'inv' keyword", 71 | Input: "invbool", 72 | Token: parser.OptionToken{ 73 | Key: "bool", 74 | Value: "", 75 | Bool: true, 76 | Invert: true, 77 | Negate: false, 78 | Query: false, 79 | }, 80 | }, 81 | { 82 | Name: "inverting boolean option by exclamation mark", 83 | Input: "bool!", 84 | Token: parser.OptionToken{ 85 | Key: "bool", 86 | Value: "", 87 | Bool: true, 88 | Invert: true, 89 | Negate: false, 90 | Query: false, 91 | }, 92 | }, 93 | { 94 | Name: "negating boolean option starting with 'no'", 95 | Input: "nononsense", 96 | Token: parser.OptionToken{ 97 | Key: "nonsense", 98 | Value: "", 99 | Bool: true, 100 | Invert: false, 101 | Negate: true, 102 | Query: false, 103 | }, 104 | }, 105 | { 106 | Name: "querying boolean options while negating", 107 | Input: "noproblem?", 108 | Token: parser.OptionToken{ 109 | Key: "problem", 110 | Value: "", 111 | Bool: true, 112 | Invert: false, 113 | Negate: true, 114 | Query: true, 115 | }, 116 | }, 117 | 118 | // Invalid queries 119 | {Input: "var!!", Error: true}, 120 | {Input: "var!?", Error: true}, 121 | {Input: "var?!", Error: true}, 122 | {Input: "novar!?", Error: true}, 123 | {Input: "novar?!", Error: true}, 124 | {Input: "invvar!?", Error: true}, 125 | {Input: "invvar?!", Error: true}, 126 | {Input: "noinvvar", Error: true}, 127 | } 128 | 129 | for i := range table { 130 | name := table[i].Name 131 | if len(name) == 0 { 132 | name = table[i].Input 133 | } 134 | input := table[i].Input 135 | check := table[i].Token 136 | token := parser.OptionToken{} 137 | err := token.Parse([]rune(input)) 138 | if table[i].Error { 139 | assert.NotNil(t, err, fmt.Sprintf("Expected errors when parsing: %s", name)) 140 | } else { 141 | assert.Nil(t, err, fmt.Sprintf("Expected no errors when parsing: %s", name)) 142 | assert.Equal(t, check, token, fmt.Sprintf("Expected result when parsing: %s", name)) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Infer GOBIN from GOPATH if necessary and able to 4 | [ "$GOBIN" == "" ] && [ "$GOPATH" != "" ] && 5 | echo "[warning] missing \$GOBIN, using $GOPATH/bin" && 6 | GOBIN="$GOPATH/bin" 7 | 8 | # Make sure we know where to copy to 9 | [ "$GOBIN" == "" ] && 10 | echo '[error] $GOBIN not set' && 11 | exit 1 12 | 13 | SOURCE="$(pwd)/build/pms" 14 | DESTINATION="$GOBIN/pms" 15 | 16 | # Make sure we have something to copy 17 | [ ! -f "$SOURCE" ] && 18 | echo "[error] $SOURCE not found" && 19 | exit 1 20 | 21 | # Make room for the binary 22 | [ -f "$DESTINATION" ] && 23 | echo "[warning] removing existing binary $DESTINATION" && 24 | rm -f "$DESTINATION" 25 | 26 | # Check if the user wanted to link instead of copy 27 | [ "$INSTALL_TYPE" == "link" ] && 28 | echo "[info] linking $SOURCE to $DESTINATION" && 29 | ln -sf "$SOURCE" "$DESTINATION" && 30 | exit 0 31 | 32 | echo "[info] copying $SOURCE to $DESTINATION" 33 | cp "$SOURCE" "$DESTINATION" 34 | -------------------------------------------------------------------------------- /keysequence/keysequence.go: -------------------------------------------------------------------------------- 1 | package keysequence 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/gdamore/tcell/v2" 8 | ) 9 | 10 | // KeySequence is an ordered sequence of keyboard events. 11 | type KeySequence []*tcell.EventKey 12 | 13 | // CompareKey compares two EventKey instances. 14 | func CompareKey(a, b *tcell.EventKey) bool { 15 | if a.Modifiers() != b.Modifiers() || a.Key() != b.Key() { 16 | return false 17 | } 18 | // Runes don't have to match in case of a special key. 19 | if a.Key() != tcell.KeyRune { 20 | return true 21 | } 22 | return a.Rune() == b.Rune() 23 | } 24 | 25 | // Compare compares two KeySequence instances. 26 | func Compare(a, b KeySequence) bool { 27 | if len(a) != len(b) { 28 | return false 29 | } 30 | return StartsWith(a, b) 31 | } 32 | 33 | // StartsWith return true if a starts with b. 34 | func StartsWith(a, b KeySequence) bool { 35 | if len(b) > len(a) { 36 | return false 37 | } 38 | for i := range b { 39 | if !CompareKey(a[i], b[i]) { 40 | return false 41 | } 42 | } 43 | return true 44 | } 45 | 46 | // FormatKey is similar to tcell.EventKey.Name(), which returns a printable 47 | // value of a key stroke. Format formats it according to PMS' key binding syntax. 48 | func FormatKey(ev *tcell.EventKey) string { 49 | s := "" 50 | m := []string{} 51 | mods := ev.Modifiers() 52 | 53 | // Add modifier keys 54 | if mods&tcell.ModShift != 0 { 55 | m = append(m, "Shift") 56 | } 57 | if mods&tcell.ModAlt != 0 { 58 | m = append(m, "Alt") 59 | } 60 | if mods&tcell.ModMeta != 0 { 61 | m = append(m, "Meta") 62 | } 63 | if mods&tcell.ModCtrl != 0 { 64 | m = append(m, "Ctrl") 65 | } 66 | 67 | // Check if the key already has a name. If not, use the correct rune. If 68 | // there is no matching rune, fall back to a question mark. 69 | ok := false 70 | key := ev.Key() 71 | if s, ok = tcell.KeyNames[key]; !ok { 72 | r := ev.Rune() 73 | if key == tcell.KeyRune { 74 | if r == ' ' { 75 | s = "" 76 | } else { 77 | s = string(ev.Rune()) 78 | } 79 | } else { 80 | s = fmt.Sprintf("<%d,%d>", key, int(r)) 81 | } 82 | } 83 | 84 | // Append any modifier prefixes. 85 | if len(m) != 0 { 86 | if mods&tcell.ModCtrl != 0 && strings.HasPrefix(s, "Ctrl-") { 87 | s = s[5:] 88 | } 89 | return fmt.Sprintf("<%s-%s>", strings.Join(m, "-"), s) 90 | } 91 | return s 92 | } 93 | 94 | // Format reverses a parsed key sequence into its string representation. 95 | func Format(seq KeySequence) string { 96 | s := make([]string, len(seq)) 97 | for i := range seq { 98 | s[i] = FormatKey(seq[i]) 99 | } 100 | return strings.Join(s, "") 101 | } 102 | -------------------------------------------------------------------------------- /keysequence/parser_test.go: -------------------------------------------------------------------------------- 1 | package keysequence_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/input/lexer" 8 | "github.com/ambientsound/pms/keysequence" 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | var parserTests = []struct { 14 | input string 15 | output string 16 | success bool 17 | keyseq keysequence.KeySequence 18 | }{ 19 | { 20 | "abc", 21 | "abc", 22 | true, 23 | keysequence.KeySequence{ 24 | tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone), 25 | tcell.NewEventKey(tcell.KeyRune, 'b', tcell.ModNone), 26 | tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone), 27 | }, 28 | }, 29 | { 30 | "xf", 31 | "xf", 32 | true, 33 | keysequence.KeySequence{ 34 | tcell.NewEventKey(tcell.KeyCtrlC, rune(tcell.KeyCtrlC), tcell.ModCtrl), 35 | tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone), 36 | tcell.NewEventKey(tcell.KeyF1, 0, tcell.ModShift|tcell.ModMeta|tcell.ModAlt), 37 | tcell.NewEventKey(tcell.KeyRune, 'f', tcell.ModNone), 38 | }, 39 | }, 40 | { 41 | "", 42 | "", 43 | true, 44 | keysequence.KeySequence{ 45 | tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModAlt), 46 | }, 47 | }, 48 | { 49 | "x", 50 | "x", 51 | true, 52 | keysequence.KeySequence{ 53 | tcell.NewEventKey(tcell.KeyCtrlX, rune(tcell.KeyCtrlX), tcell.ModCtrl), 54 | tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone), 55 | }, 56 | }, 57 | { 58 | "", 59 | "", 60 | true, 61 | keysequence.KeySequence{ 62 | tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone), 63 | }, 64 | }, 65 | { 66 | "X", 67 | "X", 68 | true, 69 | keysequence.KeySequence{ 70 | tcell.NewEventKey(tcell.KeyRune, ' ', tcell.ModNone), 71 | tcell.NewEventKey(tcell.KeyRune, 'X', tcell.ModNone), 72 | }, 73 | }, 74 | 75 | // Syntax errors 76 | {"", "", false, nil}, 77 | {"<", "", false, nil}, 78 | {"", "", false, nil}, 80 | {"", "", false, nil}, 81 | {"", "", false, nil}, 82 | {"", "", false, nil}, 83 | {"", "", false, nil}, 84 | {"", "", false, nil}, 85 | } 86 | 87 | // Test that key sequences are correctly parsed. 88 | func TestParser(t *testing.T) { 89 | for i, test := range parserTests { 90 | reader := strings.NewReader(test.input) 91 | scanner := lexer.NewScanner(reader) 92 | parser := keysequence.NewParser(scanner) 93 | 94 | t.Logf("Test %d: '%s'", i+1, test.input) 95 | 96 | seq, err := parser.ParseKeySequence() 97 | 98 | // Test success 99 | if test.success { 100 | assert.Nil(t, err, "Unexpected error when parsing '%s': %s", test.input, err) 101 | } else { 102 | assert.NotNil(t, err, "Expected error when parsing '%s'", test.input) 103 | continue 104 | } 105 | 106 | // Assert that names are converted back 107 | conv := keysequence.Format(seq) 108 | assert.Equal(t, test.output, conv, "Assert that reverse generated key sequence names are correct") 109 | 110 | // Assert that key definitions are equal 111 | assert.Equal(t, len(test.keyseq), len(seq), "Assert that key sequences have equal length") 112 | for k := range seq { 113 | t.Logf("Keyseq data in position %d: key=%d, rune='%s', mods=%d", k+1, seq[k].Key(), string(seq[k].Rune()), seq[k].Modifiers()) 114 | if k >= len(test.keyseq) { 115 | continue 116 | } 117 | assert.Equal(t, test.keyseq[k].Key(), seq[k].Key(), "Assert that key event has equal Key() in position %d", k+1) 118 | assert.Equal(t, test.keyseq[k].Rune(), seq[k].Rune(), "Assert that key event has equal Rune() in position %d", k+1) 119 | assert.Equal(t, test.keyseq[k].Modifiers(), seq[k].Modifiers(), "Assert that key event has equal Modifiers() in position %d", k+1) 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/ambientsound/pms/console" 10 | "github.com/ambientsound/pms/pms" 11 | "github.com/ambientsound/pms/version" 12 | "github.com/ambientsound/pms/xdg" 13 | 14 | "github.com/jessevdk/go-flags" 15 | ) 16 | 17 | var buildVersion = "undefined" 18 | 19 | type cliOptions struct { 20 | Version bool `short:"v" long:"version" description:"Print program version"` 21 | Debug string `short:"d" long:"debug" description:"Write debugging info to file"` 22 | MpdHost string `long:"host" description:"MPD host" default-mask:"MPD_HOST environment variable or localhost"` 23 | MpdPort string `long:"port" description:"MPD port" default-mask:"MPD_PORT environment variable or 6600"` 24 | MpdPassword string `long:"password" description:"MPD password"` 25 | } 26 | 27 | // mpdEnvironmentVariables reads the host, port, and password parameters to MPD 28 | // from the MPD_HOST and MPD_PORT environment variables, then returns them to the 29 | // user. In case there is a password in MPD_HOST, it is parsed out. 30 | func mpdEnvironmentVariables(host, port, password string) (string, string, string) { 31 | if len(host) == 0 { 32 | env, ok := os.LookupEnv("MPD_HOST") 33 | if ok { 34 | // If MPD_HOST is found, try to parse out the password. 35 | tokens := strings.SplitN(env, "@", 2) 36 | switch len(tokens) { 37 | case 0: 38 | // Empty string, default to localhost 39 | host = "localhost" 40 | case 1: 41 | // No '@' sign, use host as-is 42 | host = env 43 | case 2: 44 | // password@host 45 | host = tokens[1] 46 | password = tokens[0] 47 | } 48 | } else { 49 | host = "localhost" 50 | } 51 | } 52 | if len(port) == 0 { 53 | env, ok := os.LookupEnv("MPD_PORT") 54 | if ok { 55 | port = env 56 | } else { 57 | port = "6600" 58 | } 59 | } 60 | 61 | return host, port, password 62 | } 63 | 64 | func main() { 65 | var opts cliOptions 66 | 67 | version.SetVersion(buildVersion) 68 | fmt.Printf("%s %s\n", version.LongName(), version.Version()) 69 | 70 | remainder, err := flags.Parse(&opts) 71 | if err != nil { 72 | os.Exit(1) 73 | } 74 | if len(remainder) > 0 { 75 | trailing := strings.Join(remainder, " ") 76 | fmt.Printf("error: trailing characters: %s\n", trailing) 77 | os.Exit(1) 78 | } 79 | 80 | if len(opts.Debug) > 0 { 81 | err := console.Open(opts.Debug) 82 | if err != nil { 83 | fmt.Printf("Error while opening log file: %s", err) 84 | os.Exit(1) 85 | } 86 | } 87 | 88 | if opts.Version { 89 | os.Exit(0) 90 | } 91 | 92 | console.Log("Starting Practical Music Search.") 93 | 94 | p, err := pms.New() 95 | if err != nil { 96 | fmt.Printf("Error starting up: %s", err) 97 | os.Exit(1) 98 | } 99 | 100 | defer func() { 101 | p.QuitSignal <- 0 102 | }() 103 | 104 | // Source default configuration. 105 | p.Message("Applying default configuration.") 106 | if err := p.SourceDefaultConfig(); err != nil { 107 | panic(fmt.Sprintf("BUG in default config: %s\n", err)) 108 | } 109 | 110 | // Source configuration files from all XDG standard directories. 111 | configDirs := xdg.ConfigDirectories() 112 | for _, dir := range configDirs { 113 | path := filepath.Join(dir, "pms.conf") 114 | p.Message("Reading configuration file '%s'.", path) 115 | err = p.SourceConfigFile(path) 116 | if err != nil { 117 | p.Error("Error while reading configuration file '%s': %s", path, err) 118 | } 119 | } 120 | 121 | // If host, port and password is not set by the command-line flags, try to 122 | // read them from the environment variables. 123 | host, port, password := mpdEnvironmentVariables(opts.MpdHost, opts.MpdPort, opts.MpdPassword) 124 | 125 | // Set up the self-healing connection. 126 | p.Connection = pms.NewConnection(p.EventMessage) 127 | p.Connection.Open(host, port, password) 128 | go p.Connection.Run() 129 | 130 | // Every second counts 131 | go p.RunTicker() 132 | 133 | p.Main() 134 | p.Wait() 135 | 136 | console.Log("Exiting normally.") 137 | } 138 | -------------------------------------------------------------------------------- /message/message.go: -------------------------------------------------------------------------------- 1 | // Package message provides simple text message communication. 2 | package message 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/ambientsound/pms/console" 8 | ) 9 | 10 | // Message is a message passed from anywhere inside PMS, relayed to the user 11 | // through the statusbar. 12 | type Message struct { 13 | Text string 14 | Severity int 15 | Type int 16 | } 17 | 18 | // Message severities. INFO messages and above will end up in the statusbar. 19 | const ( 20 | Debug = iota 21 | Info 22 | Error 23 | ) 24 | 25 | // Message types. 26 | const ( 27 | Normal = iota 28 | SequenceText 29 | ) 30 | 31 | // format formats using Sprintf, and returns a new Message. 32 | func format(severity int, t int, format string, a ...interface{}) Message { 33 | return Message{ 34 | Text: fmt.Sprintf(format, a...), 35 | Severity: severity, 36 | Type: t, 37 | } 38 | } 39 | 40 | // Format returns a normal info message. 41 | func Format(fmt string, a ...interface{}) Message { 42 | return format(Info, Normal, fmt, a...) 43 | } 44 | 45 | // Errorf returns a normal error message. 46 | func Errorf(fmt string, a ...interface{}) Message { 47 | return format(Error, Normal, fmt, a...) 48 | } 49 | 50 | // Sequencef returns a sequence text message. 51 | func Sequencef(fmt string, a ...interface{}) Message { 52 | return format(Info, SequenceText, fmt, a...) 53 | } 54 | 55 | // Log prints a message to the debug log. 56 | func Log(msg Message) { 57 | if msg.Type != Normal { 58 | return 59 | } 60 | switch msg.Severity { 61 | case Info: 62 | console.Log(msg.Text) 63 | case Error: 64 | console.Log("ERROR: %s", msg.Text) 65 | case Debug: 66 | console.Log("DEBUG: %s", msg.Text) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /mpd/playerstatus.go: -------------------------------------------------------------------------------- 1 | package mpd 2 | 3 | import "time" 4 | 5 | // PlayerStatus contains information about MPD's player status. 6 | type PlayerStatus struct { 7 | Audio string 8 | Bitrate int 9 | Consume bool 10 | Elapsed float64 11 | ElapsedPercentage float64 12 | Err string 13 | MixRampDB float64 14 | Playlist int 15 | PlaylistLength int 16 | Random bool 17 | Repeat bool 18 | Single bool 19 | Song int 20 | SongID int 21 | State string 22 | Time int 23 | Volume int 24 | 25 | updateTime time.Time 26 | } 27 | 28 | // Strings found in the PlayerStatus.State variable. 29 | const ( 30 | StatePlay string = "play" 31 | StateStop string = "stop" 32 | StatePause string = "pause" 33 | StateUnknown string = "unknown" 34 | ) 35 | 36 | func (p *PlayerStatus) SetTime() { 37 | p.updateTime = time.Now() 38 | } 39 | 40 | func (p *PlayerStatus) Since() time.Duration { 41 | return time.Since(p.updateTime) 42 | } 43 | 44 | func (p PlayerStatus) Tick() PlayerStatus { 45 | if p.State != StatePlay { 46 | return p 47 | } 48 | diff := p.Since() 49 | p.SetTime() 50 | p.Elapsed += diff.Seconds() 51 | if p.Time == 0 { 52 | p.ElapsedPercentage = 0.0 53 | } else { 54 | p.ElapsedPercentage = float64(100) * p.Elapsed / float64(p.Time) 55 | } 56 | return p 57 | } 58 | -------------------------------------------------------------------------------- /options/bool.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import "strconv" 4 | 5 | type BoolOption struct { 6 | key string 7 | value bool 8 | } 9 | 10 | func NewBoolOption(key string) *BoolOption { 11 | return &BoolOption{key: key} 12 | } 13 | 14 | func (o *BoolOption) Set(value string) error { 15 | var err error 16 | o.value, err = strconv.ParseBool(value) 17 | return err 18 | } 19 | 20 | func (o *BoolOption) SetBool(value bool) { 21 | o.value = value 22 | } 23 | 24 | func (o *BoolOption) Key() string { 25 | return o.key 26 | } 27 | 28 | func (o *BoolOption) BoolValue() bool { 29 | return o.value 30 | } 31 | 32 | func (o *BoolOption) Value() interface{} { 33 | return o.value 34 | } 35 | 36 | func (o *BoolOption) String() string { 37 | t := o.Key() 38 | if !o.value { 39 | t = "no" + t 40 | } 41 | return t 42 | } 43 | 44 | func (o *BoolOption) StringValue() string { 45 | return o.String() 46 | } 47 | -------------------------------------------------------------------------------- /options/defaults.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | // AddDefaultOptions adds internal options that can be set by the user through 4 | // the command-line interface. 5 | func (o *Options) AddDefaultOptions() { 6 | o.Add(NewBoolOption("center")) 7 | o.Add(NewStringOption("columns")) 8 | o.Add(NewStringOption("sort")) 9 | o.Add(NewStringOption("topbar")) 10 | } 11 | 12 | // Defaults is the default, internal configuration file. 13 | const Defaults string = ` 14 | # Global options 15 | set nocenter 16 | set columns=artist,track,title,album,year,time 17 | set sort=file,track,disc,album,year,albumartistsort 18 | set topbar="|$shortname $version||;${tag|artist} - ${tag|title}||${tag|album}, ${tag|year};$volume $mode $elapsed ${state} $time;|[${list|index}/${list|total}] ${list|title}||;;" 19 | 20 | # Song tag styles 21 | style album teal 22 | style artist yellow 23 | style date green 24 | style time darkmagenta 25 | style title white bold 26 | style disc darkgreen 27 | style track green 28 | style year green 29 | style originalyear darkgreen 30 | 31 | # Tracklist styles 32 | style allTagsMissing red 33 | style currentSong black yellow 34 | style cursor black white 35 | style header green bold 36 | style mostTagsMissing red 37 | style selection white blue 38 | 39 | # Topbar styles 40 | style elapsedTime green 41 | style elapsedPercentage green 42 | style listIndex darkblue 43 | style listTitle blue bold 44 | style listTotal darkblue 45 | style mute red 46 | style shortName bold 47 | style state default 48 | style switches teal 49 | style tagMissing red 50 | style topbar darkgray 51 | style version gray 52 | style volume green 53 | 54 | # Other styles 55 | style commandText default 56 | style errorText white red bold 57 | style readout default 58 | style searchText white bold 59 | style sequenceText teal 60 | style statusbar default 61 | style visualText teal 62 | 63 | # Keyboard bindings: cursor and viewport movement 64 | bind cursor up 65 | bind k cursor up 66 | bind cursor down 67 | bind j cursor down 68 | bind viewport pgup 69 | bind viewport pgdn 70 | bind viewport pgup 71 | bind viewport pgdn 72 | bind viewport halfpgup 73 | bind viewport halfpgdn 74 | bind viewport up 75 | bind viewport down 76 | bind cursor home 77 | bind gg cursor home 78 | bind cursor end 79 | bind G cursor end 80 | bind gc cursor current 81 | bind R cursor random 82 | bind b cursor prevOf album 83 | bind e cursor nextOf album 84 | bind H cursor high 85 | bind M cursor middle 86 | bind L cursor low 87 | bind zb viewport high 88 | bind z- viewport high 89 | bind zz viewport middle 90 | bind z. viewport middle 91 | bind zt viewport low 92 | bind z viewport low 93 | 94 | # Keyboard bindings: input mode 95 | bind : inputmode input 96 | bind / inputmode search 97 | bind inputmode search 98 | bind v select visual 99 | bind V select visual 100 | 101 | # Keyboard bindings: player and mixer 102 | bind play selection 103 | bind pause 104 | bind s stop 105 | bind h previous 106 | bind l next 107 | bind + volume +2 108 | bind - volume -2 109 | bind seek -5 110 | bind seek +5 111 | bind volume mute 112 | bind S single 113 | 114 | # Keyboard bindings: other 115 | bind quit 116 | bind redraw 117 | bind sort 118 | bind i print file 119 | bind gt list next 120 | bind gT list previous 121 | bind t list next 122 | bind T list previous 123 | bind d list duplicate 124 | bind list remove 125 | bind isolate artist 126 | bind isolate albumartist album 127 | bind & select nearby albumartist album 128 | bind m select toggle 129 | bind a add 130 | bind cut 131 | bind x cut 132 | bind y yank 133 | bind p paste after 134 | bind P paste before 135 | ` 136 | -------------------------------------------------------------------------------- /options/int.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type IntOption struct { 9 | key string 10 | value int 11 | } 12 | 13 | func NewIntOption(key string) *IntOption { 14 | return &IntOption{key: key} 15 | } 16 | 17 | func (o *IntOption) Set(value string) error { 18 | var err error 19 | o.value, err = strconv.Atoi(value) 20 | return err 21 | } 22 | 23 | func (o *IntOption) Key() string { 24 | return o.key 25 | } 26 | 27 | func (o *IntOption) IntValue() int { 28 | return o.value 29 | } 30 | 31 | func (o *IntOption) Value() interface{} { 32 | return o.value 33 | } 34 | 35 | func (o *IntOption) String() string { 36 | return fmt.Sprintf("%s=%d", o.key, o.value) 37 | } 38 | 39 | func (o *IntOption) StringValue() string { 40 | return fmt.Sprintf("%d", o.value) 41 | } 42 | -------------------------------------------------------------------------------- /options/options.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | ) 7 | 8 | type Options struct { 9 | opts map[string]Option 10 | } 11 | 12 | type Option interface { 13 | Key() string 14 | String() string 15 | StringValue() string 16 | Value() interface{} 17 | Set(value string) error 18 | } 19 | 20 | func New() *Options { 21 | o := Options{} 22 | o.opts = make(map[string]Option, 0) 23 | return &o 24 | } 25 | 26 | func (o *Options) Add(opt Option) { 27 | o.opts[opt.Key()] = opt 28 | } 29 | 30 | // Keys returns all registered option keys. 31 | func (o *Options) Keys() []string { 32 | keys := make(sort.StringSlice, 0, len(o.opts)) 33 | for tag := range o.opts { 34 | keys = append(keys, tag) 35 | } 36 | keys.Sort() 37 | return keys 38 | } 39 | 40 | func (o *Options) Get(key string) Option { 41 | return o.opts[key] 42 | } 43 | 44 | func (o *Options) Value(key string) interface{} { 45 | v := o.Get(key) 46 | if v == nil { 47 | return nil 48 | } 49 | return v.Value() 50 | } 51 | 52 | func (o *Options) StringValue(key string) string { 53 | val := o.Value(key) 54 | switch val := val.(type) { 55 | case string: 56 | return val 57 | default: 58 | panic(fmt.Errorf("Expected string option in StringValue(), got %T", val)) 59 | } 60 | } 61 | 62 | func (o *Options) IntValue(key string) int { 63 | val := o.Value(key) 64 | switch val := val.(type) { 65 | case int: 66 | return val 67 | default: 68 | panic(fmt.Errorf("Expected integer option in IntValue(), got %T", val)) 69 | } 70 | } 71 | 72 | func (o *Options) BoolValue(key string) bool { 73 | val := o.Value(key) 74 | switch val := val.(type) { 75 | case bool: 76 | return val 77 | default: 78 | panic(fmt.Errorf("Expected boolean option in BoolValue(), got %T", val)) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /options/options_test.go: -------------------------------------------------------------------------------- 1 | package options_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/options" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestStringOption(t *testing.T) { 11 | opt := options.NewStringOption("foo") 12 | err := opt.Set("bar") 13 | require.Nil(t, err) 14 | if opt.Key() != "foo" { 15 | t.Fatalf("String option key is incorrect!") 16 | } 17 | val := opt.Value() 18 | switch val := val.(type) { 19 | case string: 20 | if val != "bar" { 21 | t.Fatalf("String option value is incorrect!") 22 | } 23 | default: 24 | t.Fatalf("String option value is of wrong type %T!", val) 25 | } 26 | } 27 | 28 | func TestIntOption(t *testing.T) { 29 | opt := options.NewIntOption("foo") 30 | err := opt.Set("3984") 31 | require.Nil(t, err) 32 | if opt.Key() != "foo" { 33 | t.Fatalf("Int option key is incorrect!") 34 | } 35 | val := opt.Value() 36 | switch val := val.(type) { 37 | case int: 38 | if val != 3984 { 39 | t.Fatalf("Int option value is incorrect!") 40 | } 41 | default: 42 | t.Fatalf("Int option value is of wrong type %T!", val) 43 | } 44 | } 45 | 46 | func TestBoolOption(t *testing.T) { 47 | opt := options.NewBoolOption("foo") 48 | err := opt.Set("true") 49 | require.Nil(t, err) 50 | if opt.Key() != "foo" { 51 | t.Fatalf("Bool option key is incorrect!") 52 | } 53 | val := opt.Value() 54 | switch val := val.(type) { 55 | case bool: 56 | if !val { 57 | t.Fatalf("Bool option value is incorrect!") 58 | } 59 | default: 60 | t.Fatalf("Bool option value is of wrong type %T!", val) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /options/string.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import "fmt" 4 | 5 | type StringOption struct { 6 | key string 7 | value string 8 | } 9 | 10 | func NewStringOption(key string) *StringOption { 11 | return &StringOption{key: key} 12 | } 13 | 14 | func (o *StringOption) Set(value string) error { 15 | o.value = value 16 | return nil 17 | } 18 | 19 | func (o *StringOption) Key() string { 20 | return o.key 21 | } 22 | 23 | func (o *StringOption) Value() interface{} { 24 | return o.value 25 | } 26 | 27 | func (o *StringOption) String() string { 28 | return fmt.Sprintf(`%s="%s"`, o.key, o.value) 29 | } 30 | 31 | func (o *StringOption) StringValue() string { 32 | return o.value 33 | } 34 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/ambientsound/pms/input/lexer" 8 | ) 9 | 10 | // Token represent a token: classification and literal text 11 | type Token struct { 12 | Tok int 13 | Lit string 14 | } 15 | 16 | // buf represents the last scanned token. 17 | type buf struct { 18 | Token 19 | n int // buffer size (max=1) 20 | } 21 | 22 | // Parser represents a parser. 23 | type Parser struct { 24 | S *lexer.Scanner 25 | buf buf 26 | scanned []Token 27 | } 28 | 29 | // New returns Parser. 30 | func New(r *lexer.Scanner) *Parser { 31 | return &Parser{S: r} 32 | } 33 | 34 | // SetScanner assigns a scanner object to the parser. 35 | func (p *Parser) SetScanner(s *lexer.Scanner) { 36 | p.S = s 37 | p.buf.n = 0 38 | } 39 | 40 | // Scanned returns all scanned tokens. 41 | func (p *Parser) Scanned() []Token { 42 | return p.scanned 43 | } 44 | 45 | // Scan returns the next token from the underlying scanner. 46 | // If a token has been unscanned then read that instead. 47 | func (p *Parser) Scan() (tok int, lit string) { 48 | // If we have a token on the buffer, then return it. 49 | if p.buf.n != 0 { 50 | p.buf.n = 0 51 | return p.buf.Tok, p.buf.Lit 52 | } 53 | 54 | // Otherwise read the next token from the scanner. 55 | tok, lit = p.S.Scan() 56 | 57 | // Create the scanned buffer. 58 | if p.scanned == nil { 59 | p.scanned = make([]Token, 0) 60 | } 61 | 62 | // Push the data to the scanned buffer. 63 | p.scanned = append(p.scanned, Token{tok, lit}) 64 | 65 | // Save it to the buffer in case we unscan later. 66 | p.buf.Tok, p.buf.Lit = tok, lit 67 | 68 | return 69 | } 70 | 71 | // ScanIgnoreWhitespace scans the next non-whitespace token. 72 | func (p *Parser) ScanIgnoreWhitespace() (tok int, lit string) { 73 | tok, lit = p.Scan() 74 | if tok == lexer.TokenWhitespace { 75 | tok, lit = p.Scan() 76 | } 77 | return 78 | } 79 | 80 | // Unscan pushes the previously read token back onto the buffer. 81 | func (p *Parser) Unscan() { p.buf.n = 1 } 82 | 83 | // ParseEnd parses to the end, and returns an error if the end hasn't been reached. 84 | func (p *Parser) ParseEnd() error { 85 | tok, lit := p.ScanIgnoreWhitespace() 86 | if tok != lexer.TokenEnd { 87 | return fmt.Errorf("Unexpected %v, expected END", lit) 88 | } 89 | return nil 90 | } 91 | 92 | // ParseInt parses the next integer. The integer might be absolute, or a delta 93 | // value such as -2 or +3. 94 | func (p *Parser) ParseInt() (tok int, lit int, absolute bool, err error) { 95 | var multiplier int 96 | 97 | // Scan and see if there is a plus or minus. 98 | tok, slit := p.ScanIgnoreWhitespace() 99 | switch tok { 100 | case lexer.TokenIdentifier: 101 | // Absolute number. 102 | absolute = true 103 | lit, err = strconv.Atoi(slit) 104 | return 105 | case lexer.TokenMinus: 106 | multiplier = -1 107 | case lexer.TokenPlus: 108 | multiplier = 1 109 | default: 110 | err = fmt.Errorf("Unexpected '%s', expected integer", slit) 111 | return 112 | } 113 | 114 | // Scan the next token, which must be an actual number. 115 | tok, slit = p.Scan() 116 | if tok != lexer.TokenIdentifier { 117 | err = fmt.Errorf("Unexpected '%s', expected one or more digits", slit) 118 | return 119 | } 120 | 121 | // Parse the number, use the correct sign, and return. 122 | lit, err = strconv.Atoi(slit) 123 | lit *= multiplier 124 | 125 | return 126 | } 127 | -------------------------------------------------------------------------------- /pms/config.go: -------------------------------------------------------------------------------- 1 | package pms 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/ambientsound/pms/options" 10 | ) 11 | 12 | // SourceDefaultConfig reads, parses, and executes the default config. 13 | func (pms *PMS) SourceDefaultConfig() error { 14 | reader := strings.NewReader(options.Defaults) 15 | return pms.SourceConfig(reader) 16 | } 17 | 18 | // SourceConfigFile reads, parses, and executes a config file. 19 | func (pms *PMS) SourceConfigFile(path string) error { 20 | file, err := os.Open(path) 21 | if err != nil { 22 | return err 23 | } 24 | defer file.Close() 25 | return pms.SourceConfig(file) 26 | } 27 | 28 | // SourceConfig reads, parses, and executes config lines. 29 | func (pms *PMS) SourceConfig(reader io.Reader) error { 30 | scanner := bufio.NewScanner(reader) 31 | for scanner.Scan() { 32 | err := pms.CLI.Execute(scanner.Text()) 33 | if err != nil { 34 | return err 35 | } 36 | } 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pms/connection.go: -------------------------------------------------------------------------------- 1 | package pms 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/ambientsound/pms/console" 8 | "github.com/ambientsound/pms/message" 9 | "github.com/fhs/gompd/v2/mpd" 10 | ) 11 | 12 | // Connection maintains connections to an MPD server. Two separate connections 13 | // are made: one for IDLE events, and another as a control connection. The IDLE 14 | // connection is kept open continuously, while the control connection is 15 | // allowed to time out. 16 | // 17 | // This class is used by calling the Run method as a goroutine. 18 | type Connection struct { 19 | Host string 20 | Port string 21 | Password string 22 | Connected chan struct{} 23 | IdleEvents chan string 24 | messages chan message.Message 25 | mpdClient *mpd.Client 26 | mpdIdle *mpd.Watcher 27 | } 28 | 29 | // NewConnection returns Connection. 30 | func NewConnection(messages chan message.Message) *Connection { 31 | return &Connection{ 32 | messages: messages, 33 | Connected: make(chan struct{}, 16), 34 | IdleEvents: make(chan string, 16), 35 | } 36 | } 37 | 38 | // MpdClient pings the MPD server and returns the client object if both the 39 | // IDLE connection and control connection are ready. Otherwise this function 40 | // returns nil. 41 | func (c *Connection) MpdClient() (*mpd.Client, error) { 42 | var err error 43 | 44 | if c.mpdIdle == nil { 45 | return nil, fmt.Errorf("MPD connection is not ready.") 46 | } 47 | 48 | addr := makeAddress(c.Host, c.Port) 49 | 50 | if c.mpdClient != nil { 51 | err = c.mpdClient.Ping() 52 | if err == nil { 53 | return c.mpdClient, nil 54 | } 55 | console.Log("MPD control connection timeout.") 56 | } 57 | 58 | console.Log("Establishing MPD control connection to %+v...", addr) 59 | 60 | c.mpdClient, err = mpd.DialAuthenticated(addr.network, addr.addr, c.Password) 61 | if err != nil { 62 | return nil, fmt.Errorf("MPD control connection error: %s", err) 63 | } 64 | 65 | console.Log("Established MPD control connection.") 66 | 67 | return c.mpdClient, nil 68 | } 69 | 70 | // Open sets the host, port, and password parameters, closes any existing 71 | // connections, and asynchronously connects to MPD as long as Run() is called. 72 | func (c *Connection) Open(host, port, password string) { 73 | c.Close() 74 | c.Host = host 75 | c.Port = port 76 | c.Password = password 77 | } 78 | 79 | // Close closes any MPD connections. 80 | func (c *Connection) Close() { 81 | if c.mpdClient != nil { 82 | c.mpdClient.Close() 83 | } 84 | if c.mpdIdle != nil { 85 | c.mpdIdle.Close() 86 | } 87 | c.mpdClient = nil 88 | c.mpdIdle = nil 89 | } 90 | 91 | // Run is the main goroutine of Connection. This thread will maintain an IDLE 92 | // connection to the MPD server, and reconnect if the connection has errors. 93 | func (c *Connection) Run() { 94 | for { 95 | // Try to connect IDLE connection until successful. 96 | err := c.connectIdle() 97 | if err != nil { 98 | c.Error("Error connecting to MPD: %s", err) 99 | time.Sleep(1 * time.Second) 100 | continue 101 | } 102 | 103 | // Emit signal. 104 | c.Connected <- struct{}{} 105 | 106 | // Relay all IDLE wakeups on the IdleEvents channel. 107 | go c.relayIdle() 108 | 109 | // Wait until there is a connection error, and clean up. 110 | for err = range c.mpdIdle.Error { 111 | c.Error("Error in MPD IDLE connection: %s", err) 112 | c.mpdClient.Close() 113 | c.mpdIdle.Close() 114 | } 115 | } 116 | } 117 | 118 | // Message sends a message on the message bus. 119 | func (c *Connection) Message(format string, a ...interface{}) { 120 | c.messages <- message.Format(format, a...) 121 | } 122 | 123 | // Error sends an error message on the message bus. 124 | func (c *Connection) Error(format string, a ...interface{}) { 125 | c.messages <- message.Errorf(format, a...) 126 | } 127 | 128 | // connectIdle establishes the IDLE connection to MPD. 129 | func (c *Connection) connectIdle() error { 130 | var err error 131 | 132 | c.mpdClient = nil 133 | c.mpdIdle = nil 134 | 135 | addr := makeAddress(c.Host, c.Port) 136 | 137 | c.Message("Establishing MPD IDLE connection to %+v...", addr) 138 | 139 | c.mpdIdle, err = mpd.NewWatcher(addr.network, addr.addr, c.Password) 140 | if err != nil { 141 | return fmt.Errorf("MPD connection error: %s", err) 142 | } 143 | 144 | c.Message("Connected to MPD server %s.", addr) 145 | 146 | return err 147 | } 148 | 149 | // relayIdle relays IDLE events. This function will exit when there the IDLE 150 | // connection is closed. 151 | func (c *Connection) relayIdle() { 152 | for subsystem := range c.mpdIdle.Event { 153 | c.IdleEvents <- subsystem 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /pms/main.go: -------------------------------------------------------------------------------- 1 | package pms 2 | 3 | import ( 4 | "github.com/ambientsound/pms/console" 5 | "github.com/ambientsound/pms/message" 6 | ) 7 | 8 | // Main does (eventually) read, evaluate, print, loop 9 | func (pms *PMS) Main() { 10 | for { 11 | select { 12 | case <-pms.Connection.Connected: 13 | go pms.handleConnected() 14 | case subsystem := <-pms.Connection.IdleEvents: 15 | pms.handleEventIdle(subsystem) 16 | case <-pms.QuitSignal: 17 | pms.handleQuitSignal() 18 | return 19 | case <-pms.EventLibrary: 20 | pms.handleEventLibrary() 21 | case <-pms.EventQueue: 22 | pms.handleEventQueue() 23 | case <-pms.EventPlayer: 24 | pms.handleEventPlayer() 25 | case key := <-pms.EventOption: 26 | pms.handleEventOption(key) 27 | case msg := <-pms.EventMessage: 28 | pms.handleEventMessage(msg) 29 | case ev := <-pms.ui.EventKeyInput: 30 | pms.KeyInput(ev) 31 | case s := <-pms.ui.EventInputCommand: 32 | pms.Execute(s) 33 | } 34 | 35 | // Draw missing parts after every iteration 36 | pms.ui.App.PostFunc(func() { 37 | pms.ui.App.Update() 38 | }) 39 | } 40 | } 41 | 42 | func (pms *PMS) handleQuitSignal() { 43 | console.Log("Received quit signal, exiting.") 44 | pms.ui.Quit() 45 | } 46 | 47 | func (pms *PMS) handleEventLibrary() { 48 | console.Log("Song library updated in MPD, assigning to UI") 49 | pms.ui.App.PostFunc(func() { 50 | pms.database.Panel().Replace(pms.database.Library()) 51 | }) 52 | } 53 | 54 | func (pms *PMS) handleEventQueue() { 55 | console.Log("Queue updated in MPD, assigning to UI") 56 | pms.ui.App.PostFunc(func() { 57 | pms.database.Panel().Replace(pms.database.Queue()) 58 | }) 59 | } 60 | 61 | func (pms *PMS) handleEventOption(key string) { 62 | console.Log("Option '%s' has been changed", key) 63 | switch key { 64 | case "topbar": 65 | pms.setupTopbar() 66 | case "columns": 67 | // list changed, FIXME 68 | } 69 | } 70 | 71 | func (pms *PMS) handleEventPlayer() { 72 | } 73 | 74 | func (pms *PMS) handleEventMessage(msg message.Message) { 75 | message.Log(msg) 76 | pms.ui.App.PostFunc(func() { 77 | pms.ui.Multibar.SetMessage(msg) 78 | }) 79 | } 80 | 81 | // handleEventIdle triggers actions based on IDLE events. 82 | func (pms *PMS) handleEventIdle(subsystem string) { 83 | var err error 84 | 85 | console.Log("MPD says it has IDLE events on the following subsystem: %s", subsystem) 86 | 87 | switch subsystem { 88 | case "database": 89 | err = pms.SyncLibrary() 90 | case "playlist": 91 | err = pms.SyncQueue() 92 | case "player": 93 | err = pms.UpdatePlayerStatus() 94 | if err != nil { 95 | break 96 | } 97 | err = pms.UpdateCurrentSong() 98 | case "options": 99 | err = pms.UpdatePlayerStatus() 100 | case "mixer": 101 | err = pms.UpdatePlayerStatus() 102 | default: 103 | console.Log("Ignoring updates by subsystem %s", subsystem) 104 | } 105 | 106 | if err != nil { 107 | pms.Error("Lost sync with MPD; reconnecting.") 108 | pms.Connection.Close() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /pms/setup.go: -------------------------------------------------------------------------------- 1 | package pms 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/console" 8 | "github.com/ambientsound/pms/db" 9 | "github.com/ambientsound/pms/input" 10 | "github.com/ambientsound/pms/input/keys" 11 | "github.com/ambientsound/pms/message" 12 | "github.com/ambientsound/pms/options" 13 | "github.com/ambientsound/pms/songlist" 14 | "github.com/ambientsound/pms/style" 15 | "github.com/ambientsound/pms/topbar" 16 | "github.com/ambientsound/pms/widgets" 17 | ) 18 | 19 | func New() (*PMS, error) { 20 | pms := &PMS{} 21 | 22 | pms.database = db.New() 23 | 24 | pms.EventLibrary = make(chan int, 1024) 25 | pms.EventList = make(chan int, 1024) 26 | pms.EventMessage = make(chan message.Message, 1024) 27 | pms.EventPlayer = make(chan int, 1024) 28 | pms.EventOption = make(chan string, 1024) 29 | pms.EventQueue = make(chan int, 1024) 30 | pms.QuitSignal = make(chan int, 1) 31 | pms.stylesheet = make(style.Stylesheet) 32 | 33 | pms.database.SetQueue(songlist.NewQueue(pms.CurrentMpdClient)) 34 | pms.database.SetLibrary(songlist.NewLibrary()) 35 | 36 | pms.Options = options.New() 37 | pms.Options.AddDefaultOptions() 38 | 39 | pms.Sequencer = keys.NewSequencer() 40 | 41 | err := pms.setupUI() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | pms.CLI = input.NewCLI(pms.API()) 47 | 48 | return pms, nil 49 | } 50 | 51 | // setupAPI creates an API object 52 | func (pms *PMS) API() api.API { 53 | return api.BaseAPI( 54 | pms.Database, 55 | pms.EventList, 56 | pms.EventMessage, 57 | pms.EventOption, 58 | pms.database.Library, 59 | pms.CurrentMpdClient, 60 | pms.Multibar, 61 | pms.Options, 62 | pms.database.PlayerStatus, 63 | pms.database.Queue, 64 | pms.QuitSignal, 65 | pms.Sequencer, 66 | pms.database.CurrentSong, 67 | pms.CurrentSonglistWidget, 68 | pms.Stylesheet(), 69 | pms.UI, 70 | ) 71 | } 72 | 73 | func (pms *PMS) setupUI() error { 74 | var err error 75 | 76 | timer := time.Now() 77 | queue := pms.database.Queue() 78 | pms.ui, err = widgets.NewUI(pms.API()) 79 | if err != nil { 80 | return err 81 | } 82 | pms.ui.Start() 83 | pms.database.Panel().Add(queue) 84 | pms.database.Panel().Add(pms.database.Library()) 85 | pms.database.Panel().Activate(queue) 86 | 87 | console.Log("UI initialized in %s", time.Since(timer).String()) 88 | 89 | return nil 90 | } 91 | 92 | func (pms *PMS) setupTopbar() { 93 | config := pms.Options.StringValue("topbar") 94 | matrix, err := topbar.Parse(pms.API(), config) 95 | if err == nil { 96 | pms.ui.Topbar.SetMatrix(matrix) 97 | } else { 98 | pms.Error("Error in topbar configuration: %s", err) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /song/song.go: -------------------------------------------------------------------------------- 1 | package song 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ambientsound/pms/utils" 10 | 11 | "github.com/fhs/gompd/v2/mpd" 12 | ) 13 | 14 | // Song represents a combined view of a song from both MPD and PMS' perspectives. 15 | type Song struct { 16 | ID int 17 | Position int 18 | Time int 19 | Tags Taglist 20 | StringTags StringTaglist 21 | SortTags StringTaglist 22 | } 23 | 24 | type Tag []rune 25 | 26 | type Taglist map[string]Tag 27 | 28 | type StringTaglist map[string]string 29 | 30 | const NullID int = -1 31 | const NullPosition int = -1 32 | 33 | func New() (s *Song) { 34 | s = &Song{} 35 | s.Tags = make(Taglist) 36 | s.StringTags = make(StringTaglist) 37 | s.SortTags = make(StringTaglist) 38 | return 39 | } 40 | 41 | func (s *Song) SetTags(tags mpd.Attrs) { 42 | s.Tags = make(Taglist) 43 | for key := range tags { 44 | lowKey := strings.ToLower(key) 45 | s.Tags[lowKey] = []rune(tags[key]) 46 | s.StringTags[lowKey] = tags[key] 47 | } 48 | s.AutoFill() 49 | s.FillSortTags() 50 | } 51 | 52 | // NullID returns true if the song's ID is not present. 53 | func (s *Song) NullID() bool { 54 | return s.ID == NullID 55 | } 56 | 57 | // NullPosition returns true if the song's osition is not present. 58 | func (s *Song) NullPosition() bool { 59 | return s.Position == NullPosition 60 | } 61 | 62 | // AutoFill post-processes and caches song tags. 63 | func (s *Song) AutoFill() { 64 | var err error 65 | 66 | s.ID, err = strconv.Atoi(s.StringTags["id"]) 67 | if err != nil { 68 | s.ID = NullID 69 | } 70 | s.Position, err = strconv.Atoi(s.StringTags["pos"]) 71 | if err != nil { 72 | s.Position = NullPosition 73 | } 74 | 75 | s.Time, err = strconv.Atoi(s.StringTags["time"]) 76 | if err == nil { 77 | s.Tags["time"] = utils.TimeRunes(s.Time) 78 | } else { 79 | s.Tags["time"] = utils.TimeRunes(-1) 80 | } 81 | if len(s.Tags["date"]) >= 4 { 82 | s.Tags["year"] = s.Tags["date"][:4] 83 | s.StringTags["year"] = string(s.Tags["year"]) 84 | } 85 | if len(s.Tags["originaldate"]) >= 4 { 86 | s.Tags["originalyear"] = s.Tags["originaldate"][:4] 87 | s.StringTags["originalyear"] = string(s.Tags["originalyear"]) 88 | } 89 | } 90 | 91 | // FillSortTags post-processes tags, and saves them as strings for sorting purposes later on. 92 | func (s *Song) FillSortTags() { 93 | for i := range s.Tags { 94 | s.SortTags[i] = strings.ToLower(s.StringTags[i]) 95 | } 96 | 97 | if t, ok := s.SortTags["track"]; ok { 98 | s.SortTags["track"] = trackSort(t) 99 | } 100 | 101 | if _, ok := s.SortTags["artistsort"]; !ok { 102 | s.SortTags["artistsort"] = s.SortTags["artist"] 103 | } 104 | 105 | if _, ok := s.SortTags["albumartist"]; !ok { 106 | s.SortTags["albumartist"] = s.SortTags["artist"] 107 | } 108 | 109 | if _, ok := s.SortTags["albumartistsort"]; !ok { 110 | s.SortTags["albumartistsort"] = s.SortTags["albumartist"] 111 | } 112 | } 113 | 114 | // HasOneOfTags returns true if the song contains at least one of the tags mentioned. 115 | func (s *Song) HasOneOfTags(tags ...string) bool { 116 | for _, tag := range tags { 117 | if _, ok := s.Tags[tag]; ok { 118 | return true 119 | } 120 | } 121 | return false 122 | } 123 | 124 | // TagKeys returns a string slice with all tag keys, sorted in alphabetical order. 125 | func (s *Song) TagKeys() []string { 126 | keys := make(sort.StringSlice, 0, len(s.StringTags)) 127 | for tag := range s.StringTags { 128 | keys = append(keys, tag) 129 | } 130 | keys.Sort() 131 | return keys 132 | } 133 | 134 | func trackSort(s string) string { 135 | tracks := strings.Split(s, "/") 136 | if len(tracks) == 0 { 137 | return s 138 | } 139 | trackNum, err := strconv.Atoi(tracks[0]) 140 | if err != nil { 141 | return s 142 | } 143 | // Assume no release has more than 999 tracks. 144 | return fmt.Sprintf("%03d", trackNum) 145 | } 146 | -------------------------------------------------------------------------------- /song/song_test.go: -------------------------------------------------------------------------------- 1 | package song_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/song" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var autofillTests = []struct { 11 | OriginalSong song.Song 12 | AutoFilledSong song.Song 13 | }{ 14 | { 15 | song.Song{ 16 | Tags: song.Taglist{ 17 | "id": []rune("1337"), 18 | "pos": []rune("33"), 19 | "time": []rune("203"), 20 | "date": []rune("1986-04-22"), 21 | "originaldate": []rune("1986-04-22"), 22 | }, 23 | StringTags: song.StringTaglist{ 24 | "id": "1337", 25 | "pos": "33", 26 | "time": "203", 27 | "date": "1986-04-22", 28 | "originaldate": "1986-04-22", 29 | }, 30 | }, 31 | song.Song{ 32 | ID: 1337, 33 | Position: 33, 34 | Time: 203, 35 | Tags: song.Taglist{ 36 | "id": []rune("1337"), 37 | "pos": []rune("33"), 38 | "time": []rune("03:23"), 39 | "date": []rune("1986-04-22"), 40 | "year": []rune("1986"), 41 | "originaldate": []rune("1986-04-22"), 42 | "originalyear": []rune("1986"), 43 | }, 44 | StringTags: song.StringTaglist{ 45 | "id": "1337", 46 | "pos": "33", 47 | "time": "203", 48 | "date": "1986-04-22", 49 | "year": "1986", 50 | "originaldate": "1986-04-22", 51 | "originalyear": "1986", 52 | }, 53 | }, 54 | }, 55 | } 56 | 57 | func TestAutofill(t *testing.T) { 58 | assert := assert.New(t) 59 | for _, test := range autofillTests { 60 | test.OriginalSong.AutoFill() 61 | assert.Equal(test.AutoFilledSong, test.OriginalSong) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /songlist/collection.go: -------------------------------------------------------------------------------- 1 | package songlist 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "time" 7 | ) 8 | 9 | // Collection holds a set of songlists, and keeps track of movement between different lists. 10 | type Collection struct { 11 | lists []Songlist 12 | index int 13 | last Songlist 14 | current Songlist 15 | updated time.Time 16 | } 17 | 18 | // NewCollection returns Collection. 19 | func NewCollection() *Collection { 20 | return &Collection{ 21 | lists: make([]Songlist, 0), 22 | } 23 | } 24 | 25 | // Activate activates the specified songlist. The songlist is not added to the 26 | // collection. If the songlist is found in the collection, the index of that 27 | // songlist is activated. 28 | func (c *Collection) Activate(s Songlist) { 29 | c.index = -1 30 | for i, stored := range c.lists { 31 | if stored == s { 32 | c.index = i 33 | break 34 | } 35 | } 36 | c.last = c.current 37 | c.current = s 38 | c.SetUpdated() 39 | } 40 | 41 | // ActivateIndex activates the songlist pointed to by the specified index. 42 | func (c *Collection) ActivateIndex(i int) error { 43 | list, err := c.Songlist(i) 44 | if err != nil { 45 | return err 46 | } 47 | c.Activate(list) 48 | return nil 49 | } 50 | 51 | // Add appends a songlist to the collection. 52 | func (c *Collection) Add(s Songlist) { 53 | c.lists = append(c.lists, s) 54 | } 55 | 56 | // Current returns the active songlist. 57 | func (c *Collection) Current() Songlist { 58 | return c.current 59 | } 60 | 61 | // Index returns the current list cursor. 62 | func (c *Collection) Index() (int, error) { 63 | if !c.ValidIndex(c.index) { 64 | return 0, fmt.Errorf("Songlist index is out of range") 65 | } 66 | return c.index, nil 67 | } 68 | 69 | // Last returns the last used songlist. 70 | func (c *Collection) Last() Songlist { 71 | return c.last 72 | } 73 | 74 | // Len returns the songlists count. 75 | func (c *Collection) Len() int { 76 | return len(c.lists) 77 | } 78 | 79 | // Remove removes a songlist from the collection. 80 | func (c *Collection) Remove(index int) error { 81 | if err := c.ValidateIndex(index); err != nil { 82 | return err 83 | } 84 | if index+1 == c.Len() { 85 | c.lists = c.lists[:index] 86 | } else { 87 | c.lists = append(c.lists[:index], c.lists[index+1:]...) 88 | } 89 | return nil 90 | } 91 | 92 | // Replace replaces an existing songlist with its new version. Checking 93 | // is done on a type-level, so this function should not be used for lists where 94 | // several of the same type is contained within the collection. 95 | func (c *Collection) Replace(s Songlist) { 96 | for i := range c.lists { 97 | if reflect.TypeOf(c.lists[i]) != reflect.TypeOf(s) { 98 | continue 99 | } 100 | //console.Log("Songlist UI: replacing songlist of type %T at %p with new list at %p", s, c.lists[i], s) 101 | //console.Log("Songlist UI: comparing %p %p", c.lists[i], c.Songlist()) 102 | 103 | active := c.lists[i] == c.Current() 104 | c.lists[i] = s 105 | 106 | if active { 107 | //console.Log("Songlist UI: replaced songlist is currently active, switching to new songlist.") 108 | c.Activate(s) 109 | } 110 | return 111 | } 112 | 113 | //console.Log("Songlist UI: adding songlist of type %T at address %p since no similar exists", s, s) 114 | c.Add(s) 115 | } 116 | 117 | func (c *Collection) Songlist(index int) (Songlist, error) { 118 | if err := c.ValidateIndex(index); err != nil { 119 | return nil, err 120 | } 121 | return c.lists[index], nil 122 | } 123 | 124 | func (c *Collection) ValidIndex(i int) bool { 125 | return i >= 0 && i < c.Len() 126 | } 127 | 128 | func (c *Collection) ValidateIndex(i int) error { 129 | if !c.ValidIndex(i) { 130 | return fmt.Errorf("Index %d is out of bounds (try between 1 and %d)", i+1, c.Len()) 131 | } 132 | return nil 133 | } 134 | 135 | // Updated returns the timestamp of when this collection was last updated. 136 | func (c *Collection) Updated() time.Time { 137 | return c.updated 138 | } 139 | 140 | // SetUpdated sets the update timestamp of the collection. 141 | func (c *Collection) SetUpdated() { 142 | c.updated = time.Now() 143 | } 144 | -------------------------------------------------------------------------------- /songlist/columns.go: -------------------------------------------------------------------------------- 1 | package songlist 2 | 3 | import ( 4 | "github.com/ambientsound/pms/song" 5 | "github.com/ambientsound/pms/utils" 6 | ) 7 | 8 | type Column struct { 9 | tag string 10 | items int 11 | totalWidth int 12 | maxWidth int 13 | avg int 14 | width int 15 | } 16 | 17 | type Columns []*Column 18 | 19 | type ColumnMap map[string]*Column 20 | 21 | // NewColumn returns a new Column. 22 | func NewColumn(tag string) *Column { 23 | return &Column{tag: tag} 24 | } 25 | 26 | // Set calculates all song's widths. 27 | func (c *Column) Set(s Songlist) { 28 | c.Reset() 29 | for _, song := range s.Songs() { 30 | c.Add(song) 31 | } 32 | } 33 | 34 | // Add a single song's width to the total and maximum width. 35 | func (c *Column) Add(song *song.Song) { 36 | l := len(song.Tags[c.tag]) 37 | if l == 0 { 38 | return 39 | } 40 | c.avg = 0 41 | c.items++ 42 | c.totalWidth += l 43 | c.maxWidth = utils.Max(c.maxWidth, l) 44 | } 45 | 46 | // Remove a single song's tag width from the total and maximum width. 47 | func (c *Column) Remove(song *song.Song) { 48 | l := len(song.Tags[c.tag]) 49 | if l == 0 { 50 | return 51 | } 52 | c.avg = 0 53 | c.items-- 54 | c.totalWidth -= l 55 | // TODO: c.maxWidth is not updated 56 | } 57 | 58 | // Reset sets all values to zero. 59 | func (c *Column) Reset() { 60 | c.items = 0 61 | c.totalWidth = 0 62 | c.maxWidth = 0 63 | c.avg = 0 64 | c.width = 0 65 | } 66 | 67 | // Weight returns the relative usefulness of this column. It might happen that 68 | // a tag appears rarely, but is very long. In this case we reduce the field so 69 | // that other tags get more space. 70 | func (c *Column) Weight(max int) float64 { 71 | return float64(c.items) / float64(max) 72 | } 73 | 74 | // Avg returns the average length of the tag values in this column. 75 | func (c *Column) Avg() int { 76 | if c.avg == 0 { 77 | if c.items == 0 { 78 | c.avg = 0 79 | } else { 80 | c.avg = c.totalWidth / c.items 81 | } 82 | } 83 | //console.Log("Avg() of %s is %d", c.Tag(), c.avg) 84 | return c.avg 85 | } 86 | 87 | // Tag returns the tag name. 88 | func (c *Column) Tag() string { 89 | return c.tag 90 | } 91 | 92 | // MaxWidth returns the length of the longest tag value in this column. 93 | func (c *Column) MaxWidth() int { 94 | return c.maxWidth 95 | } 96 | 97 | // Width returns the column width. 98 | func (c *Column) Width() int { 99 | return c.width 100 | } 101 | 102 | // SetWidth sets the width that the column should consume. 103 | func (c *Column) SetWidth(width int) { 104 | c.width = width 105 | } 106 | 107 | // expand adjusts the column widths equally between the different columns, 108 | // giving affinity to weight. 109 | func (columns Columns) Expand(totalWidth int) { 110 | if len(columns) == 0 { 111 | return 112 | } 113 | 114 | usedWidth := 0 115 | poolSize := len(columns) 116 | saturated := make([]bool, poolSize) 117 | 118 | // Start with the average value 119 | for i := range columns { 120 | avg := columns[i].Avg() 121 | columns[i].SetWidth(avg) 122 | usedWidth += avg 123 | } 124 | 125 | // expand as long as there is space left 126 | for { 127 | for i := range columns { 128 | if usedWidth > totalWidth { 129 | return 130 | } 131 | if poolSize > 0 && saturated[i] { 132 | continue 133 | } 134 | col := columns[i] 135 | if poolSize > 0 && col.Width() > col.MaxWidth() { 136 | saturated[i] = true 137 | poolSize-- 138 | continue 139 | } 140 | col.SetWidth(col.Width() + 1) 141 | usedWidth++ 142 | } 143 | } 144 | } 145 | 146 | // Add adds song tags to all applicable columns. 147 | func (c ColumnMap) Add(song *song.Song) { 148 | for tag := range song.StringTags { 149 | c[tag].Add(song) 150 | } 151 | } 152 | 153 | // Remove removes song tags from all applicable columns. 154 | func (c ColumnMap) Remove(song *song.Song) { 155 | for tag := range song.StringTags { 156 | c[tag].Remove(song) 157 | } 158 | } 159 | 160 | // ensureColumns makes sure that all of a song's tags exists in the column map. 161 | func (s *BaseSonglist) ensureColumns(song *song.Song) { 162 | for tag := range song.StringTags { 163 | if _, ok := s.columns[tag]; !ok { 164 | s.columns[tag] = NewColumn(tag) 165 | } 166 | } 167 | } 168 | 169 | // Columns returns a slice of columns, containing only the columns which has 170 | // the specified tags. 171 | func (s *BaseSonglist) Columns(columns []string) Columns { 172 | cols := make(Columns, 0) 173 | for _, tag := range columns { 174 | col := s.columns[tag] 175 | if col == nil { 176 | col = NewColumn(tag) 177 | } 178 | cols = append(cols, col) 179 | } 180 | return cols 181 | } 182 | -------------------------------------------------------------------------------- /songlist/cursor.go: -------------------------------------------------------------------------------- 1 | package songlist 2 | 3 | import ( 4 | "github.com/ambientsound/pms/song" 5 | ) 6 | 7 | // CursorSong returns the song currently selected by the cursor. 8 | func (s *BaseSonglist) CursorSong() *song.Song { 9 | return s.Song(s.Cursor()) 10 | } 11 | 12 | // MoveCursorUp moves the cursor up by the specified offset. 13 | func (s *BaseSonglist) MoveCursorUp(i int) { 14 | s.MoveCursor(-i) 15 | } 16 | 17 | // MoveCursorUp moves the cursor down by the specified offset. 18 | func (s *BaseSonglist) MoveCursorDown(i int) { 19 | s.MoveCursor(i) 20 | } 21 | 22 | // MoveCursorUp moves the cursor by the specified offset. 23 | func (s *BaseSonglist) MoveCursor(i int) { 24 | s.SetCursor(s.Cursor() + i) 25 | } 26 | 27 | // SetCursor sets the cursor to an absolute position. 28 | func (s *BaseSonglist) SetCursor(i int) { 29 | s.cursor = i 30 | s.ValidateCursor(0, s.Len()-1) 31 | s.expandVisualSelection() 32 | s.SetUpdated() 33 | } 34 | 35 | // Cursor returns the cursor position. 36 | func (s *BaseSonglist) Cursor() int { 37 | return s.cursor 38 | } 39 | 40 | func (s *BaseSonglist) CursorToSong(song *song.Song) error { 41 | index, err := s.Locate(song) 42 | if err != nil { 43 | return err 44 | } 45 | //console.Log("Located %s at position %d, id %d", song.StringTags["file"], index, song.ID) 46 | s.SetCursor(index) 47 | return nil 48 | } 49 | 50 | // ValidateCursor makes sure the cursor is within minimum and maximum boundaries. 51 | func (s *BaseSonglist) ValidateCursor(ymin, ymax int) { 52 | if s.Cursor() < ymin { 53 | s.cursor = ymin 54 | s.SetUpdated() 55 | } 56 | if s.Cursor() > ymax { 57 | s.cursor = ymax 58 | s.SetUpdated() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /songlist/selection.go: -------------------------------------------------------------------------------- 1 | package songlist 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | // ManuallySelected returns true if the given song index is selected through manual selection. 8 | func (s *BaseSonglist) ManuallySelected(i int) bool { 9 | _, ok := s.selection[i] 10 | return ok 11 | } 12 | 13 | // VisuallySelected returns true if the given song index is selected through visual selection. 14 | func (s *BaseSonglist) VisuallySelected(i int) bool { 15 | return s.visualSelection[0] <= i && i <= s.visualSelection[1] 16 | } 17 | 18 | // Selected returns true if the given song index is selected, either through 19 | // visual selection or manual selection. If the song is doubly selected, the 20 | // selection is inversed. 21 | func (s *BaseSonglist) Selected(i int) bool { 22 | a := s.ManuallySelected(i) 23 | b := s.VisuallySelected(i) 24 | return (a || b) && a != b 25 | } 26 | 27 | // SelectionIndices returns a slice of ints holding the position of each 28 | // element in the current selection. If no elements are selected, the cursor 29 | // position is returned. 30 | func (s *BaseSonglist) SelectionIndices() []int { 31 | selection := make([]int, 0, s.Len()) 32 | max := s.Len() 33 | for i := 0; i < max; i++ { 34 | if s.Selected(i) { 35 | selection = append(selection, i) 36 | } 37 | } 38 | if len(selection) == 0 && s.Len() > 0 { 39 | selection = append(selection, s.Cursor()) 40 | } 41 | selection = sort.IntSlice(selection) 42 | return selection 43 | } 44 | 45 | // SetSelection sets the selected status of a single song. 46 | func (s *BaseSonglist) SetSelected(i int, selected bool) { 47 | var x struct{} 48 | _, ok := s.selection[i] 49 | if ok == selected { 50 | return 51 | } 52 | if selected { 53 | s.selection[i] = x 54 | } else { 55 | delete(s.selection, i) 56 | } 57 | } 58 | 59 | // CommitVisualSelection converts the visual selection to manual selection. 60 | func (s *BaseSonglist) CommitVisualSelection() { 61 | if !s.HasVisualSelection() { 62 | return 63 | } 64 | for key := s.visualSelection[0]; key <= s.visualSelection[1]; key++ { 65 | selected := s.Selected(key) 66 | s.SetSelected(key, selected) 67 | } 68 | } 69 | 70 | // ClearSelection removes all selection. 71 | func (s *BaseSonglist) ClearSelection() { 72 | s.selection = make(map[int]struct{}, 0) 73 | s.visualSelection = [3]int{-1, -1, -1} 74 | } 75 | 76 | // Selection returns the current selection as a new Songlist. 77 | func (s *BaseSonglist) Selection() Songlist { 78 | indices := s.SelectionIndices() 79 | return s.Indices(indices) 80 | } 81 | 82 | // validateVisualSelection makes sure the visual selection stays in range of 83 | // the songlist size. 84 | func (s *BaseSonglist) validateVisualSelection(ymin, ymax, ystart int) (int, int, int) { 85 | if s.Len() == 0 || ymin < 0 || ymax < 0 || !s.InRange(ystart) { 86 | return -1, -1, -1 87 | } 88 | if !s.InRange(ymin) { 89 | ymin = 0 90 | } 91 | if !s.InRange(ymax) { 92 | ymax = s.Len() - 1 93 | } 94 | return ymin, ymax, ystart 95 | } 96 | 97 | // VisualSelection returns the min, max, and start position of visual select. 98 | func (s *BaseSonglist) VisualSelection() (int, int, int) { 99 | return s.visualSelection[0], s.visualSelection[1], s.visualSelection[2] 100 | } 101 | 102 | // SetVisualSelection sets the range of the visual selection. Use negative 103 | // integers to un-select all visually selected songs. 104 | func (s *BaseSonglist) SetVisualSelection(ymin, ymax, ystart int) { 105 | s.visualSelection[0], s.visualSelection[1], s.visualSelection[2] = s.validateVisualSelection(ymin, ymax, ystart) 106 | } 107 | 108 | // HasVisualSelection returns true if the songlist is in visual selection mode. 109 | func (s *BaseSonglist) HasVisualSelection() bool { 110 | return s.visualSelection[0] >= 0 && s.visualSelection[1] >= 0 111 | } 112 | 113 | // EnableVisualSelection sets start and stop of the visual selection to the 114 | // cursor position. 115 | func (s *BaseSonglist) EnableVisualSelection() { 116 | cursor := s.Cursor() 117 | s.SetVisualSelection(cursor, cursor, cursor) 118 | } 119 | 120 | // DisableVisualSelection disables visual selection. 121 | func (s *BaseSonglist) DisableVisualSelection() { 122 | s.SetVisualSelection(-1, -1, -1) 123 | } 124 | 125 | // ToggleVisualSelection toggles visual selection on and off. 126 | func (s *BaseSonglist) ToggleVisualSelection() { 127 | if !s.HasVisualSelection() { 128 | s.EnableVisualSelection() 129 | } else { 130 | s.DisableVisualSelection() 131 | } 132 | } 133 | 134 | // expandVisualSelection sets the visual selection boundaries from where it 135 | // started to the current cursor position. 136 | func (s *BaseSonglist) expandVisualSelection() { 137 | if !s.HasVisualSelection() { 138 | return 139 | } 140 | ymin, ymax, ystart := s.VisualSelection() 141 | switch { 142 | case s.Cursor() < ystart: 143 | ymin, ymax = s.Cursor(), ystart 144 | case s.Cursor() > ystart: 145 | ymin, ymax = ystart, s.Cursor() 146 | default: 147 | ymin, ymax = ystart, ystart 148 | } 149 | s.SetVisualSelection(ymin, ymax, ystart) 150 | } 151 | -------------------------------------------------------------------------------- /style/style.go: -------------------------------------------------------------------------------- 1 | package style 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | ) 6 | 7 | type Stylesheet map[string]tcell.Style 8 | 9 | type Stylable interface { 10 | Style(string) tcell.Style 11 | SetStylesheet(Stylesheet) 12 | Stylesheet() Stylesheet 13 | } 14 | 15 | // Styled implements Stylable 16 | type Styled struct { 17 | stylesheet Stylesheet 18 | } 19 | 20 | func (w *Styled) Style(s string) tcell.Style { 21 | return w.stylesheet[s] 22 | } 23 | 24 | func (w *Styled) SetStylesheet(stylesheet Stylesheet) { 25 | w.stylesheet = stylesheet 26 | } 27 | 28 | func (w *Styled) Stylesheet() Stylesheet { 29 | return w.stylesheet 30 | } 31 | -------------------------------------------------------------------------------- /tabcomplete/tabcomplete_test.go: -------------------------------------------------------------------------------- 1 | package tabcomplete_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/commands" 8 | "github.com/ambientsound/pms/tabcomplete" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var tabCompleteTests = []struct { 13 | input string 14 | success bool 15 | completions []string 16 | }{ 17 | {"", true, commands.Keys()}, 18 | {"s", true, []string{ 19 | "se", 20 | "seek", 21 | "select", 22 | "set", 23 | "single", 24 | "sort", 25 | "stop", 26 | "style", 27 | }}, 28 | {"set", true, []string{}}, 29 | {"add ", true, []string{}}, 30 | {"cursor nextOf", true, []string{}}, 31 | {"foobarbaz", false, []string{}}, 32 | {"foobarbaz ", false, []string{}}, 33 | {"$var", false, []string{}}, 34 | {"{foo", false, []string{}}, 35 | {"# bar", false, []string{}}, 36 | } 37 | 38 | func TestTabComplete(t *testing.T) { 39 | for n, test := range tabCompleteTests { 40 | 41 | api := api.NewTestAPI() 42 | 43 | t.Logf("### Test %d: '%s'", n+1, test.input) 44 | 45 | clen := len(test.completions) 46 | tabComplete := tabcomplete.New(test.input, api) 47 | sentences := make([]string, clen) 48 | i := 0 49 | 50 | for i < len(sentences) { 51 | sentence, err := tabComplete.Scan() 52 | if test.success { 53 | assert.Nil(t, err, "Expected success when parsing '%s'", test.input) 54 | } else { 55 | assert.NotNil(t, err, "Expected error when parsing '%s'", test.input) 56 | } 57 | sentences[i] = sentence 58 | i++ 59 | if i == clen { 60 | break 61 | } 62 | } 63 | 64 | assert.Equal(t, test.completions, sentences) 65 | assert.Equal(t, clen, tabComplete.Len()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./... | grep -v vendor); do 7 | go test -coverprofile=profile.out -covermode=atomic $d 8 | if [ -f profile.out ]; then 9 | cat profile.out >> coverage.txt 10 | rm profile.out 11 | fi 12 | done 13 | -------------------------------------------------------------------------------- /topbar/audioformat.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/ambientsound/pms/api" 9 | "github.com/ambientsound/pms/console" 10 | ) 11 | 12 | // Audioformat draws the current audio format. 13 | type Audioformat struct { 14 | api api.API 15 | aspect string 16 | } 17 | 18 | // NewAudioformat returns Audioformat. 19 | func NewAudioformat(a api.API, param string) Fragment { 20 | return &Audioformat{a, param} 21 | } 22 | 23 | // Text implements Fragment. 24 | func (w *Audioformat) Text() (string, string) { 25 | playerStatus := w.api.PlayerStatus() 26 | 27 | if w.aspect == "" { 28 | text := fmt.Sprintf("%v", playerStatus.Audio) 29 | return text, `audioformat` 30 | } 31 | 32 | audioformat := strings.Split(strings.Trim(playerStatus.Audio, "()"), ":") 33 | formatmap := make(map[string]string) 34 | // The audioformat string is defined at 35 | // https://github.com/MusicPlayerDaemon/MPD/blob/master/src/pcm/AudioFormat.cxx 36 | switch len(audioformat) { 37 | case 3: 38 | // mpd sends a tuple like (44100:16:2) 39 | kHz, _ := strconv.Atoi(audioformat[0]) 40 | formatmap["samplerate"] = fmt.Sprintf("%v kHz", float64(kHz)/1000.0) 41 | formatmap["channels"] = fmt.Sprintf("%v ch", audioformat[2]) 42 | switch audioformat[1] { 43 | case "f": 44 | formatmap["resolution"] = "float" 45 | default: 46 | formatmap["resolution"] = fmt.Sprintf("%v bit", audioformat[1]) 47 | } 48 | return formatmap[w.aspect], w.aspect 49 | case 2: 50 | // mpd sends "dsd" strings 51 | samplingfactor, _ := strconv.Atoi(audioformat[0][3:]) 52 | formatmap["samplerate"] = fmt.Sprintf("%v MHz", float64(samplingfactor)*44100.0/1e6) 53 | formatmap["resolution"] = "1 bit" 54 | formatmap["channels"] = fmt.Sprintf("%v ch", audioformat[1]) 55 | return formatmap[w.aspect], w.aspect 56 | default: 57 | // If we end up here, something in mpd has changed 58 | console.Log("Unsupported audio format string: %s", playerStatus.Audio) 59 | text := fmt.Sprintf("%v", playerStatus.Audio) 60 | return text, `audioformat` 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /topbar/audioformat_test.go: -------------------------------------------------------------------------------- 1 | package topbar_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/ambientsound/pms/api" 8 | "github.com/ambientsound/pms/topbar" 9 | ) 10 | 11 | func TestAudioformats(t *testing.T) { 12 | var testcases = []struct { 13 | in, param, want string 14 | }{ 15 | {"(44100:16:2)", "", "(44100:16:2)"}, 16 | {"(192000:24:2)", "", "(192000:24:2)"}, 17 | {"(192000:24:2)", "channels", "2 ch"}, 18 | {"(192000:24:2)", "resolution", "24 bit"}, 19 | {"(192000:24:2)", "samplerate", "192 kHz"}, 20 | {"(dsd128:5)", "channels", "5 ch"}, 21 | {"(dsd128:5)", "resolution", "1 bit"}, 22 | {"(dsd128:5)", "samplerate", fmt.Sprintf("%v MHz", 128*44100.0/1e6)}, 23 | } 24 | for _, tc := range testcases { 25 | api := api.NewTestAPI() 26 | p := api.PlayerStatus() 27 | p.Audio = tc.in 28 | api.Db().SetPlayerStatus(p) 29 | af := topbar.NewAudioformat(api, tc.param) 30 | gotstring, _ := af.Text() 31 | if gotstring != tc.want { 32 | t.Errorf("Wrong audioformat string: got %v, want %v", gotstring, tc.want) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /topbar/elapsed.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | "github.com/ambientsound/pms/utils" 8 | ) 9 | 10 | // Elapsed draws the current song's elapsed time. 11 | type Elapsed struct { 12 | api api.API 13 | f func() (string, string) 14 | } 15 | 16 | // NewElapsed returns Elapsed. 17 | func NewElapsed(a api.API, param string) Fragment { 18 | elapsed := &Elapsed{a, nil} 19 | switch param { 20 | case `percentage`: 21 | elapsed.f = elapsed.textPercentage 22 | default: 23 | elapsed.f = elapsed.textTime 24 | } 25 | return elapsed 26 | } 27 | 28 | // Text implements Fragment. 29 | func (w *Elapsed) Text() (string, string) { 30 | return w.f() 31 | } 32 | 33 | func (w *Elapsed) textTime() (string, string) { 34 | playerStatus := w.api.PlayerStatus() 35 | return utils.TimeString(int(playerStatus.Elapsed)), `elapsedTime` 36 | } 37 | 38 | func (w *Elapsed) textPercentage() (string, string) { 39 | playerStatus := w.api.PlayerStatus() 40 | return fmt.Sprintf("%d", int(playerStatus.ElapsedPercentage)), `elapsedPercentage` 41 | } 42 | -------------------------------------------------------------------------------- /topbar/list.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // List draws information about the current songlist. 10 | type List struct { 11 | api api.API 12 | f func() (string, string) 13 | } 14 | 15 | // NewList returns List. 16 | func NewList(a api.API, param string) Fragment { 17 | list := &List{a, nil} 18 | switch param { 19 | case `index`: 20 | list.f = list.textIndex 21 | case `title`: 22 | list.f = list.textTitle 23 | case `total`: 24 | list.f = list.textTotal 25 | default: 26 | list.f = list.textNone 27 | } 28 | return list 29 | } 30 | 31 | // Text implements Fragment. 32 | func (w *List) Text() (string, string) { 33 | return w.f() 34 | } 35 | 36 | func (w *List) textNone() (string, string) { 37 | return ``, `` 38 | } 39 | 40 | func (w *List) textIndex() (string, string) { 41 | index, err := w.api.Db().Panel().Index() 42 | if err == nil { 43 | return fmt.Sprintf("%d", index+1), `listIndex` 44 | } else { 45 | return `new`, `listIndex` 46 | } 47 | } 48 | 49 | func (w *List) textTotal() (string, string) { 50 | total := w.api.Db().Panel().Len() 51 | return fmt.Sprintf("%d", total), `listTotal` 52 | } 53 | 54 | func (w *List) textTitle() (string, string) { 55 | return w.api.Db().Panel().Current().Name(), `listTitle` 56 | } 57 | -------------------------------------------------------------------------------- /topbar/mode.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Mode draws the four player modes as single characters. 10 | type Mode struct { 11 | api api.API 12 | } 13 | 14 | // NewMode returns Mode. 15 | func NewMode(a api.API, param string) Fragment { 16 | return &Mode{a} 17 | } 18 | 19 | // Text implements Fragment. 20 | func (w *Mode) Text() (string, string) { 21 | var buf bytes.Buffer 22 | playerStatus := w.api.PlayerStatus() 23 | 24 | buf.WriteRune(w.statusRune('c', playerStatus.Consume)) 25 | buf.WriteRune(w.statusRune('z', playerStatus.Random)) 26 | buf.WriteRune(w.statusRune('s', playerStatus.Single)) 27 | buf.WriteRune(w.statusRune('r', playerStatus.Repeat)) 28 | 29 | return buf.String(), `switches` 30 | } 31 | 32 | func (w *Mode) statusRune(r rune, val bool) rune { 33 | if val { 34 | return r 35 | } 36 | return '-' 37 | } 38 | -------------------------------------------------------------------------------- /topbar/shortname.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | "github.com/ambientsound/pms/version" 6 | ) 7 | 8 | // Shortname draws the short name of this application, as defined in the version module. 9 | type Shortname struct { 10 | shortname string 11 | } 12 | 13 | // NewShortname returns Shortname. 14 | func NewShortname(a api.API, param string) Fragment { 15 | return &Shortname{version.ShortName()} 16 | } 17 | 18 | // Text implements Fragment. 19 | func (w *Shortname) Text() (string, string) { 20 | return w.shortname, `shortName` 21 | } 22 | -------------------------------------------------------------------------------- /topbar/state.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | "github.com/ambientsound/pms/mpd" 6 | ) 7 | 8 | var stateStrings = map[string]string{ 9 | mpd.StatePlay: "|>", 10 | mpd.StatePause: "||", 11 | mpd.StateStop: "[]", 12 | mpd.StateUnknown: "??", 13 | } 14 | 15 | var stateUnicodes = map[string]string{ 16 | mpd.StatePlay: "\u25b6", 17 | mpd.StatePause: "\u23f8", 18 | mpd.StateStop: "\u23f9", 19 | mpd.StateUnknown: "\u2bd1", 20 | } 21 | 22 | // State draws the current player state as an ASCII symbol. 23 | type State struct { 24 | api api.API 25 | table map[string]string 26 | } 27 | 28 | // NewState returns State. 29 | func NewState(a api.API, param string) Fragment { 30 | table := stateStrings 31 | if param == "unicode" { 32 | table = stateUnicodes 33 | } 34 | return &State{a, table} 35 | } 36 | 37 | // Text implements Fragment. 38 | func (w *State) Text() (string, string) { 39 | playerStatus := w.api.PlayerStatus() 40 | return w.table[playerStatus.State], `state` 41 | } 42 | -------------------------------------------------------------------------------- /topbar/tag.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | ) 6 | 7 | // Tag draws song tags from the currently playing song. 8 | type Tag struct { 9 | api api.API 10 | tag string 11 | } 12 | 13 | // NewTag returns Tag. 14 | func NewTag(a api.API, param string) Fragment { 15 | return &Tag{a, param} 16 | } 17 | 18 | // Text implements Fragment. 19 | func (w *Tag) Text() (string, string) { 20 | song := w.api.Song() 21 | if song == nil { 22 | return ``, `tagMissing` 23 | } 24 | if text, ok := song.StringTags[w.tag]; ok { 25 | return text, w.tag 26 | } 27 | return ``, `tagMissing` 28 | } 29 | -------------------------------------------------------------------------------- /topbar/text.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | // Text draws a literal text string. 4 | type Text struct { 5 | text string 6 | } 7 | 8 | // NewText returns Text. 9 | func NewText(s string) Fragment { 10 | return &Text{text: s} 11 | } 12 | 13 | // Text implements Fragment. 14 | func (w *Text) Text() (string, string) { 15 | return w.text, `topbar` 16 | } 17 | -------------------------------------------------------------------------------- /topbar/time.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | "github.com/ambientsound/pms/utils" 6 | ) 7 | 8 | // Time draws the current song's length. 9 | type Time struct { 10 | api api.API 11 | } 12 | 13 | // NewTime returns Time. 14 | func NewTime(a api.API, param string) Fragment { 15 | return &Time{a} 16 | } 17 | 18 | // Text implements Fragment. 19 | func (w *Time) Text() (string, string) { 20 | playerStatus := w.api.PlayerStatus() 21 | return utils.TimeString(playerStatus.Time), `time` 22 | } 23 | -------------------------------------------------------------------------------- /topbar/topbar.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/ambientsound/pms/api" 8 | ) 9 | 10 | // Fragment is the smallest possible unit in a topbar. 11 | type Fragment interface { 12 | 13 | // Text returns a string that should be drawn, 14 | // along with its stylesheet identifier. 15 | Text() (string, string) 16 | } 17 | 18 | // fragments is a map of fragments that can be drawn in the topbar, along with 19 | // their textual representation. When implementing a new topbar fragment, place 20 | // its constructor in this map. 21 | var fragments = map[string]func(api.API, string) Fragment{ 22 | "audioformat": NewAudioformat, 23 | "elapsed": NewElapsed, 24 | "list": NewList, 25 | "mode": NewMode, 26 | "shortname": NewShortname, 27 | "state": NewState, 28 | "tag": NewTag, 29 | "time": NewTime, 30 | "version": NewVersion, 31 | "volume": NewVolume, 32 | } 33 | 34 | // NewFragment constructs a new Fragment based on a parsed topbar fragment statement. 35 | func NewFragment(a api.API, stmt *FragmentStatement) (Fragment, error) { 36 | if len(stmt.Variable) == 0 { 37 | return NewText(stmt.Literal), nil 38 | } 39 | if ctor, ok := fragments[stmt.Variable]; ok { 40 | return ctor(a, stmt.Param), nil 41 | } 42 | return nil, fmt.Errorf("Unrecognized variable '${%s}'", stmt.Variable) 43 | } 44 | 45 | // Parse sets up a lexer and parser for a topbar matrix statement, instantiates 46 | // fragments, and returns the parse tree. 47 | func Parse(a api.API, input string) (*MatrixStatement, error) { 48 | reader := strings.NewReader(input) 49 | parser := NewParser(reader) 50 | 51 | matrixStmt, err := parser.ParseMatrix() 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | // Instantiate fragments 57 | for _, rowStmt := range matrixStmt.Rows { 58 | for _, pieceStmt := range rowStmt.Pieces { 59 | for _, fragmentStmt := range pieceStmt.Fragments { 60 | frag, err := NewFragment(a, fragmentStmt) 61 | if err != nil { 62 | return nil, err 63 | } 64 | fragmentStmt.Instance = frag 65 | } 66 | } 67 | } 68 | 69 | return matrixStmt, nil 70 | } 71 | -------------------------------------------------------------------------------- /topbar/version.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "github.com/ambientsound/pms/api" 5 | "github.com/ambientsound/pms/version" 6 | ) 7 | 8 | // Version draws the short name of this application, as defined in the version module. 9 | type Version struct { 10 | version string 11 | } 12 | 13 | // NewVersion returns Version. 14 | func NewVersion(a api.API, param string) Fragment { 15 | return &Version{version.Version()} 16 | } 17 | 18 | // Text implements Fragment. 19 | func (w *Version) Text() (string, string) { 20 | return w.version, `version` 21 | } 22 | -------------------------------------------------------------------------------- /topbar/volume.go: -------------------------------------------------------------------------------- 1 | package topbar 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/ambientsound/pms/api" 7 | ) 8 | 9 | // Volume draws the current volume. 10 | type Volume struct { 11 | api api.API 12 | } 13 | 14 | // NewVolume returns Volume. 15 | func NewVolume(a api.API, param string) Fragment { 16 | return &Volume{a} 17 | } 18 | 19 | // Text implements Fragment. 20 | func (w *Volume) Text() (string, string) { 21 | playerStatus := w.api.PlayerStatus() 22 | switch { 23 | case playerStatus.Volume < 0: 24 | return `!VOL!`, `mute` 25 | case playerStatus.Volume == 0: 26 | return `MUTE`, `mute` 27 | default: 28 | text := fmt.Sprintf("%d%%", playerStatus.Volume) 29 | return text, `volume` 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | // package utils provides simple transformation functions which do not fit anywhere else in particular. 2 | package utils 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // TimeString formats length in seconds as H:mm:ss. 10 | func TimeString(secs int) string { 11 | if secs < 0 { 12 | return `--:--` 13 | } 14 | hours := int(secs / 3600) 15 | secs = secs % 3600 16 | minutes := int(secs / 60) 17 | secs = secs % 60 18 | if hours > 0 { 19 | return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs) 20 | } 21 | return fmt.Sprintf("%02d:%02d", minutes, secs) 22 | } 23 | 24 | // TimeRunes acts as TimeString, but returns a slice of runes. 25 | func TimeRunes(secs int) []rune { 26 | return []rune(TimeString(secs)) 27 | } 28 | 29 | // ReverseRunes returns a new, reversed rune slice. 30 | func ReverseRunes(src []rune) []rune { 31 | dest := make([]rune, len(src)) 32 | for i, j := 0, len(src)-1; i <= j; i, j = i+1, j-1 { 33 | dest[i], dest[j] = src[j], src[i] 34 | } 35 | return dest 36 | } 37 | 38 | // TokenFilter returns a subset of tokens that match the specified prefix. 39 | func TokenFilter(match string, tokens []string) []string { 40 | dest := make([]string, 0, len(tokens)) 41 | for _, tok := range tokens { 42 | if strings.HasPrefix(tok, match) { 43 | dest = append(dest, tok) 44 | } 45 | } 46 | return dest 47 | } 48 | 49 | // Min returns the minimum of a and b. 50 | func Min(a, b int) int { 51 | if a < b { 52 | return a 53 | } 54 | return b 55 | } 56 | 57 | // Max returns the maximum of a and b. 58 | func Max(a, b int) int { 59 | if a > b { 60 | return a 61 | } 62 | return b 63 | } 64 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Package version provides access to the program name and compiled version. 2 | package version 3 | 4 | const shortName string = "PMS" 5 | const longName string = "Practical Music Search" 6 | 7 | var version string = "undefined" 8 | 9 | func ShortName() string { 10 | return shortName 11 | } 12 | 13 | func LongName() string { 14 | return longName 15 | } 16 | 17 | func Version() string { 18 | return version 19 | } 20 | 21 | func SetVersion(v string) { 22 | version = v 23 | } 24 | -------------------------------------------------------------------------------- /widgets/columnheaders.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ambientsound/pms/songlist" 7 | "github.com/ambientsound/pms/style" 8 | "github.com/gdamore/tcell/v2" 9 | "github.com/gdamore/tcell/v2/views" 10 | ) 11 | 12 | type ColumnheadersWidget struct { 13 | columns songlist.Columns 14 | view views.View 15 | 16 | style.Styled 17 | views.WidgetWatchers 18 | } 19 | 20 | func NewColumnheadersWidget() (c *ColumnheadersWidget) { 21 | c = &ColumnheadersWidget{} 22 | c.columns = make(songlist.Columns, 0) 23 | return 24 | } 25 | 26 | func (c *ColumnheadersWidget) SetColumns(cols songlist.Columns) { 27 | c.columns = cols 28 | } 29 | 30 | func (c *ColumnheadersWidget) Draw() { 31 | x := 0 32 | y := 0 33 | for i := range c.columns { 34 | col := c.columns[i] 35 | title := []rune(strings.Title(col.Tag())) 36 | p := 0 37 | for _, r := range title { 38 | c.view.SetContent(x+p, y, r, nil, c.Style("header")) 39 | p++ 40 | } 41 | x += col.Width() 42 | } 43 | } 44 | 45 | func (c *ColumnheadersWidget) SetView(v views.View) { 46 | c.view = v 47 | } 48 | 49 | func (c *ColumnheadersWidget) Size() (int, int) { 50 | x, y := c.view.Size() 51 | y = 1 52 | return x, y 53 | } 54 | 55 | func (w *ColumnheadersWidget) Resize() { 56 | } 57 | 58 | func (w *ColumnheadersWidget) HandleEvent(ev tcell.Event) bool { 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /widgets/events.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/gdamore/tcell/v2/views" 6 | ) 7 | 8 | type eventfulWidget interface { 9 | views.Widget 10 | PostEvent(wev views.EventWidget) 11 | } 12 | 13 | type widgetEvent struct { 14 | widget views.Widget 15 | tcell.EventTime 16 | } 17 | 18 | type EventInputChanged struct { 19 | widgetEvent 20 | } 21 | 22 | func PostEventInputChanged(w eventfulWidget) { 23 | ev := &EventInputChanged{} 24 | ev.SetWidget(w) 25 | w.PostEvent(ev) 26 | } 27 | 28 | type EventInputFinished struct { 29 | widgetEvent 30 | } 31 | 32 | func PostEventInputFinished(w eventfulWidget) { 33 | ev := &EventInputFinished{} 34 | ev.SetWidget(w) 35 | w.PostEvent(ev) 36 | } 37 | 38 | type EventListChanged struct { 39 | widgetEvent 40 | } 41 | 42 | func PostEventListChanged(w eventfulWidget) { 43 | ev := &EventListChanged{} 44 | ev.SetWidget(w) 45 | w.PostEvent(ev) 46 | } 47 | 48 | type EventScroll struct { 49 | widgetEvent 50 | } 51 | 52 | func PostEventScroll(w eventfulWidget) { 53 | ev := &EventScroll{} 54 | ev.SetWidget(w) 55 | w.PostEvent(ev) 56 | } 57 | 58 | func (wev *widgetEvent) Widget() views.Widget { 59 | return wev.widget 60 | } 61 | 62 | func (wev *widgetEvent) SetWidget(widget views.Widget) { 63 | wev.widget = widget 64 | } 65 | -------------------------------------------------------------------------------- /widgets/topbar.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "github.com/ambientsound/pms/console" 5 | "github.com/ambientsound/pms/style" 6 | "github.com/ambientsound/pms/topbar" 7 | "github.com/gdamore/tcell/v2" 8 | "github.com/gdamore/tcell/v2/views" 9 | `github.com/mattn/go-runewidth` 10 | ) 11 | 12 | // Pieces may be aligned to left, center or right. 13 | const ( 14 | AlignLeft = iota 15 | AlignCenter 16 | AlignRight 17 | ) 18 | 19 | // Topbar is a widget that can display a variety of information, such as the 20 | // currently playing song. It is composed of several pieces to form a 21 | // two-dimensional matrix. 22 | type Topbar struct { 23 | matrix *topbar.MatrixStatement 24 | height int // height is both physical and matrix height 25 | 26 | view views.View 27 | style.Styled 28 | views.WidgetWatchers 29 | } 30 | 31 | // NewTopbar creates a new Topbar widget in the desired dimensions. 32 | func NewTopbar() *Topbar { 33 | return &Topbar{ 34 | height: 0, 35 | matrix: &topbar.MatrixStatement{}, 36 | } 37 | } 38 | 39 | // Setup sets up the topbar using the provided configuration string. 40 | func (w *Topbar) SetMatrix(matrix *topbar.MatrixStatement) { 41 | w.matrix = matrix 42 | w.height = len(matrix.Rows) 43 | console.Log("Setting up new topbar with height %d", w.height) 44 | } 45 | 46 | // Draw draws all the pieces in the matrix, from top to bottom, right to left. 47 | func (w *Topbar) Draw() { 48 | xmax, _ := w.Size() 49 | 50 | // Blank screen first 51 | w.view.Fill(' ', w.Style("topbar")) 52 | 53 | for y, rowStmt := range w.matrix.Rows { 54 | // Calculate window buffer width 55 | pieces := len(rowStmt.Pieces) 56 | if pieces == 0 { 57 | continue 58 | } 59 | 60 | for piece, pieceStmt := range rowStmt.Pieces { 61 | // Reset X position to start of window buffer, and align left, 62 | // center or right. 63 | align := autoAlign(piece, pieces) 64 | textWidth := pieceTextWidth(pieceStmt) 65 | x := getPiecesStartX(piece, pieces, xmax) 66 | x2 := getPiecesStartX(piece+1, pieces, xmax) 67 | x = alignX(x, x2-x, textWidth, align) 68 | 69 | for _, fragmentStmt := range pieceStmt.Fragments { 70 | frag := fragmentStmt.Instance 71 | text, styleStr := frag.Text() 72 | style := w.Style(styleStr) 73 | x = w.drawNext(x, y, text, style) 74 | } 75 | } 76 | } 77 | } 78 | 79 | // drawNext draws a string and returns the resulting X position. 80 | func (w *Topbar) drawNext(x, y int, s string, style tcell.Style) int { 81 | for _, r := range s { 82 | w.view.SetContent(x, y, r, nil, style) 83 | x += runewidth.RuneWidth(r) 84 | } 85 | return x 86 | } 87 | 88 | // autoAlign returns a best-guess align for a Piece: the outermost indices are 89 | // left- and right adjusted, while the rest are centered. 90 | func autoAlign(index, total int) int { 91 | switch index { 92 | case 0: 93 | return AlignLeft 94 | case total - 1: 95 | return AlignRight 96 | default: 97 | return AlignCenter 98 | } 99 | } 100 | 101 | // getPiecesStartX calculates the start x-position for a given piece. 102 | // 103 | // Unused space is avoided by assigning extra space to the first pieces, 104 | // if (xmax / pieces) leaves a remainder. 105 | func getPiecesStartX(piece, pieces, xmax int) int { 106 | x := piece * (xmax / pieces) 107 | if piece <= (xmax % pieces) { 108 | return x + piece 109 | } 110 | return x + (xmax % pieces) 111 | } 112 | 113 | // alignX returns the draw start position. 114 | func alignX(x, bufferWidth, textWidth, align int) int { 115 | switch align { 116 | case AlignLeft: 117 | return x 118 | case AlignCenter: 119 | return x + (bufferWidth / 2) - (textWidth / 2) 120 | case AlignRight: 121 | return x + bufferWidth - textWidth 122 | default: 123 | return x 124 | } 125 | } 126 | 127 | func pieceTextWidth(piece *topbar.PieceStatement) int { 128 | width := 0 129 | for _, fragment := range piece.Fragments { 130 | s, _ := fragment.Instance.Text() 131 | width += runewidth.StringWidth(s) 132 | } 133 | return width 134 | } 135 | 136 | func (w *Topbar) HandleEvent(ev tcell.Event) bool { 137 | return false 138 | } 139 | 140 | func (w *Topbar) Size() (int, int) { 141 | x, _ := w.view.Size() 142 | return x, w.height 143 | } 144 | 145 | func (w *Topbar) Resize() { 146 | } 147 | 148 | func (w *Topbar) SetView(v views.View) { 149 | w.view = v 150 | } 151 | -------------------------------------------------------------------------------- /xdg/xdg.go: -------------------------------------------------------------------------------- 1 | // Package xdg provides file paths for cache and configuration, as specified by 2 | // the XDG Base Directory Specification. 3 | // 4 | // See https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html 5 | package xdg 6 | 7 | import ( 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | // appendPmsDirectory adds "pms" to a directory tree. 15 | func appendPmsDirectory(dir string) string { 16 | return filepath.Join(dir, "pms") 17 | } 18 | 19 | // ConfigDirectories returns a list of configuration directories. The least 20 | // important directory is listed first. 21 | func ConfigDirectories() []string { 22 | if runtime.GOOS == "windows" { 23 | if dir, err := os.UserConfigDir(); err == nil { 24 | return []string{appendPmsDirectory(dir)} 25 | } 26 | } 27 | 28 | dirs := make([]string, 0) 29 | 30 | // $XDG_CONFIG_DIRS defines the preference-ordered set of base directories 31 | // to search for configuration files in addition to the $XDG_CONFIG_HOME base 32 | // directory. The directories in $XDG_CONFIG_DIRS should be separated with a 33 | // colon ':'. 34 | xdgConfigDirs := os.Getenv("XDG_CONFIG_DIRS") 35 | if len(xdgConfigDirs) == 0 { 36 | xdgConfigDirs = "/etc/xdg" 37 | } 38 | 39 | // Add entries from $XDG_CONFIG_DIRS to directory list. 40 | configDirs := strings.Split(xdgConfigDirs, string(os.PathListSeparator)) 41 | for i := len(configDirs) - 1; i >= 0; i-- { 42 | if len(configDirs[i]) > 0 { 43 | dir := appendPmsDirectory(configDirs[i]) 44 | dirs = append(dirs, dir) 45 | } 46 | } 47 | 48 | // $XDG_CONFIG_HOME defines the base directory relative to which user 49 | // specific configuration files should be stored. If $XDG_CONFIG_HOME is 50 | // either not set or empty, a default equal to $HOME/.config should be used. 51 | xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") 52 | if len(xdgConfigHome) == 0 { 53 | xdgConfigHome = filepath.Join(os.Getenv("HOME"), ".config") 54 | } 55 | dir := appendPmsDirectory(xdgConfigHome) 56 | 57 | // Add $XDG_CONFIG_HOME to directory list. 58 | dirs = append(dirs, dir) 59 | 60 | return dirs 61 | } 62 | 63 | // CacheDirectory returns the cache base directory. 64 | func CacheDirectory() string { 65 | // $XDG_CACHE_HOME defines the base directory relative to which user 66 | // specific non-essential data files should be stored. If $XDG_CACHE_HOME is 67 | // either not set or empty, a default equal to $HOME/.cache should be used. 68 | xdgCacheHome := os.Getenv("XDG_CACHE_HOME") 69 | if len(xdgCacheHome) == 0 { 70 | xdgCacheHome = filepath.Join(os.Getenv("HOME"), ".cache") 71 | } 72 | 73 | return filepath.Join(xdgCacheHome, "pms") 74 | } 75 | --------------------------------------------------------------------------------