├── .envrc ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── flake.lock ├── flake.nix ├── rustfmt.toml ├── src ├── app.rs ├── capture_manager.rs ├── command.rs ├── config.rs ├── config │ ├── char_set.rs │ ├── keybinding.rs │ ├── name_template.rs │ ├── names.rs │ ├── tag.rs │ └── theme.rs ├── device_kind.rs ├── device_widget.rs ├── dropdown_widget.rs ├── event.rs ├── input.rs ├── lib.rs ├── main.rs ├── media_class.rs ├── meter.rs ├── monitor.rs ├── monitor │ ├── deserialize.rs │ ├── device.rs │ ├── event_sender.rs │ ├── execute.rs │ ├── link.rs │ ├── metadata.rs │ ├── node.rs │ ├── proxy_registry.rs │ ├── stream.rs │ ├── stream_registry.rs │ └── sync_registry.rs ├── node_widget.rs ├── object.rs ├── object_list.rs ├── opt.rs ├── state.rs ├── trace.rs ├── truncate.rs └── view.rs └── wiremix.toml /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.direnv/ 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Changed 11 | 12 | - Get control characters from termios for emulating SIGINT/SIGQUIT/EOF. 13 | 14 | ## [0.4.0] - 2025-05-18 15 | 16 | ### Changed 17 | 18 | - Combine bindings for opening a dropdown and choosing a dropdown item. 19 | 20 | ### Fixed 21 | 22 | - Fix a problem with ensuring that there is always an object selected. 23 | 24 | ## [0.3.0] - 2025-05-13 25 | 26 | ### Added 27 | 28 | - Nix package to flake.nix. 29 | - Command-line and configuration file option for setting the initial tab. 30 | 31 | ### Fixed 32 | 33 | - Fix a discrepancy between wiremix.toml char set and real defaults. 34 | 35 | ## [0.2.0] - 2025-05-05 36 | 37 | ### Added 38 | 39 | - This CHANGELOG file. 40 | - Shift+Tab default keybinding. 41 | 42 | ### Changed 43 | 44 | - Enable LTO and set codegen-units to 1. 45 | 46 | ## [0.1.1] - 2025-04-30 47 | 48 | ### Fixed 49 | 50 | - Fix typos and outdated information in README and wiremix.toml. 51 | 52 | ## [0.1.0] - 2025-04-24 53 | 54 | ### Added 55 | 56 | - Initial release of wiremix. 57 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wiremix" 3 | version = "0.4.0" 4 | authors = ["Thomas Sowell "] 5 | description = "A TUI mixer for PipeWire" 6 | readme = "README.md" 7 | repository = "https://github.com/tsowell/wiremix" 8 | license = "MIT OR Apache-2.0" 9 | categories = ["command-line-utilities", "multimedia::audio"] 10 | keywords = ["mixer", "pipewire", "volume", "audio", "tui"] 11 | edition = "2021" 12 | rust-version = "1.74.1" 13 | include = ["src/**/*", "Cargo.toml", "LICENSE*", "README.md", "wiremix.toml"] 14 | 15 | [dependencies] 16 | anyhow = "1.0.95" 17 | clap = { version = "4.5.26", features = ["derive"] } 18 | crossterm = { version = "0.28.1", features = ["event-stream", "serde"] } 19 | futures = "0.3.31" 20 | futures-timer = "3.0.3" 21 | itertools = "0.14.0" 22 | libspa = "0.8.0" 23 | libspa-sys = "0.8.0" 24 | log = "0.4.24" 25 | nix = { version = "0.29.0", features = ["event", "term"] } 26 | pipewire = { version = "0.8.0", features = ["v0_3_44"] } 27 | ratatui = { version = "0.29.0", features = ["serde"] } 28 | regex = "1.11.1" 29 | scopeguard = "1.2.0" 30 | serde = { version = "1.0.218", features = ["derive"] } 31 | serde_json = "1.0.137" 32 | serde_with = "3.12.0" 33 | smallvec = "1.14.0" 34 | toml = "0.8.20" 35 | tracing = { version = "0.1.41", optional = true } 36 | tracing-error = { version = "0.2.1", optional = true } 37 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"], optional = true } 38 | unicode-width = "0.2.0" 39 | 40 | [dev-dependencies] 41 | strum = { version = "0.27.1", features = ["derive"] } 42 | 43 | [features] 44 | trace = ["dep:tracing", "dep:tracing-error", "dep:tracing-subscriber"] 45 | 46 | [profile.release] 47 | codegen-units = 1 48 | lto = true 49 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wiremix 2 | 3 | wiremix is a simple TUI audio mixer for PipeWire. You can use it to adjust 4 | volumes, route audio between devices and applications, and configure audio 5 | device settings like input/output ports and profiles. 6 | 7 | wiremix's interface is more or less a clone of the wonderful 8 | [ncpamixer](https://github.com/fulhax/ncpamixer) which was itself inspired by 9 | pavucontrol, so users of either should find it familiar. 10 | 11 | Issues and pull requests are welcome! 12 | 13 | 14 | 15 | ## Installation 16 | 17 | ### Package Managers 18 | 19 | * Arch Linux: Install the [official package](https://archlinux.org/packages/extra/x86_64/wiremix/) 20 | via `pacman -S wiremix` or `paru -S wiremix-git` for the 21 | latest development version from the [AUR](https://aur.archlinux.org/packages/wiremix-git). 22 | * Nix: `nix run github:tsowell/wiremix` or add wiremix to your configuration 23 | using the provided [flake.nix](./flake.nix) 24 | 25 | ### Manual Installation 26 | 27 | wiremix depends on Rust and the PipeWire libraries. To install all 28 | dependencies: 29 | 30 | * Ubuntu: `sudo apt install cargo libpipewire-0.3-dev pkg-config clang` 31 | * Debian: `sudo apt install libpipewire-0.3-dev pkg-config clang` (you will 32 | also need to install a somewhat recent Rust toolchain - rustup is one way) 33 | * Fedora: `sudo dnf install cargo pipewire-devel clang` 34 | 35 | Then install wiremix with `cargo install wiremix` 36 | 37 | ## Quick Start 38 | 39 | 1. Run `wiremix` to launch with default settings 40 | 2. Use mouse and keyboard bindings to operate the mixer 41 | - Arrow keys or hjkl to navigate and adjust volume 42 | - Tab or HL to change tabs 43 | - c to open a dropdown to route audio to a different destination 44 | - m to mute/unmute 45 | - d set an input or output device as the default source/sink 46 | 47 | ## Command-line Options 48 | 49 | ``` 50 | PipeWire mixer 51 | 52 | Usage: wiremix [OPTIONS] 53 | 54 | Options: 55 | -c, --config Override default config file path 56 | -r, --remote The name of the remote to connect to 57 | -f, --fps Target frames per second (or 0 for unlimited) 58 | -s, --char-set Character set to use [built-in sets: default, compat, extracompat] 59 | -t, --theme Theme to use [built-in themes: default, nocolor, plain] 60 | -p, --peaks Audio peak meters [possible values: off, mono, auto] 61 | --no-mouse Disable mouse support 62 | --mouse Enable mouse support 63 | -v, --tab Initial tab view [possible values: playback, recording, output, input, configuration] 64 | -h, --help Print help 65 | -V, --version Print version 66 | ``` 67 | 68 | Command-line options override corresponding settings in the configuration file. 69 | 70 | ## Input Bindings 71 | 72 | Everything except quitting can also be done with the mouse. Some of the 73 | less-intuitive mouse controls are: 74 | 75 | * Click the numeric volume percentage to toggle muting. 76 | * Scroll through lists and dropdowns with the mouse wheel or click on scroll 77 | buttons (default appearence: `•••`) 78 | * Right-click to set as the default source/sink 79 | 80 | ### Default Keyboard Bindings 81 | 82 | | Input | Action | 83 | | ------------- | ----------------------- | 84 | | q | Quit | 85 | | m | Toggle mute | 86 | | d | Set default source/sink | 87 | | l/Right arrow | Increment volume | 88 | | h/Left arrow | Decrement volume | 89 | | Enter/c | Open dropdown or choose | 90 | | Esc | Cancel dropdown | 91 | | j/Down arrow | Move down | 92 | | k/Up arrow | Move up | 93 | | H/Shift+Tab | Select previous tab | 94 | | L/Tab | Select next tab | 95 | | ` (Backtick) | Set volume 0% | 96 | | 1 | Set volume 10% | 97 | | 2 | Set volume 20% | 98 | | 3 | Set volume 30% | 99 | | 4 | Set volume 40% | 100 | | 5 | Set volume 50% | 101 | | 6 | Set volume 60% | 102 | | 7 | Set volume 70% | 103 | | 8 | Set volume 80% | 104 | | 9 | Set volume 90% | 105 | | 0 | Set volume 100% | 106 | 107 | ## Configuration 108 | 109 | wiremix can be configured through a TOML configuration file. 110 | 111 | It searches for the configuration file in these locations (in order of 112 | precedence): 113 | 114 | 1. Path specified on the command-line via `-c`/`--config` 115 | 2. `$XDG_CONFIG_HOME/wiremix/wiremix.toml` 116 | 3. `~/.config/wiremix/wiremix.toml` 117 | 118 | This README only describes basic capabilities. Please see 119 | [wiremix.toml](./wiremix.toml) in this repository for detailed documentation on 120 | configuring wiremix. It also provides a reference for all of wiremix's 121 | defaults. 122 | 123 | The configuration specified in the file is merged with wiremix's defaults, so 124 | it only needs to specify the options that need to be changed. It is recommended 125 | to start with an empty configuration file and use this repository's 126 | [wiremix.toml](./wiremix.toml) as a reference. 127 | 128 | ### Basic Configuration 129 | 130 | Everything that can specified on the command-line has a corresponding option in 131 | the configuration file. 132 | 133 | ```toml 134 | #remote = "pipewire-0" 135 | #fps = 60.0 136 | mouse = true 137 | peaks = "auto" 138 | char_set = "default" 139 | theme = "default" 140 | tab = "playback" 141 | ``` 142 | 143 | ### Keybindings 144 | 145 | The configuration file can customize keyboard controls for all wiremix actions. 146 | See [wiremix.toml](./wiremix.toml) for more details. 147 | 148 | #### Examples 149 | 150 | ```toml 151 | keybindings = [ 152 | # Use ncpamixer-style absolute volume bindings 153 | { key = { Char = "`" }, action = "Nothing" }, 154 | { key = { Char = "0" }, action = { SetAbsoluteVolume = 0.0 } }, 155 | # Chars 1-9 already work like ncpamixer 156 | ] 157 | ``` 158 | 159 | ```toml 160 | keybindings = [ 161 | # Use F-keys to select tabs 162 | { key = { F = 1 }, action = { SelectTab = 0 } }, 163 | { key = { F = 2 }, action = { SelectTab = 1 } }, 164 | { key = { F = 3 }, action = { SelectTab = 2 } }, 165 | { key = { F = 4 }, action = { SelectTab = 3 } }, 166 | { key = { F = 5 }, action = { SelectTab = 4 } }, 167 | ] 168 | ``` 169 | 170 | ### Character Sets 171 | 172 | Character sets define the symbols used in the user interface. You can define 173 | multiple character sets and switch between them using the `char_set` 174 | configuration option or the `-s`/`--char-set` command-line argument. 175 | 176 | There are three built-in character sets. 177 | 178 | 1. `default` is the default set. It may contain symbols that can't be rendered 179 | with your terminal or console. 180 | 2. `compat` uses only symbols from 181 | [cross-platform-terminal-characters](https://github.com/ehmicky/cross-platform-terminal-characters). 182 | 3. `extracompat` uses only ASCII symbols. 183 | 184 | The configuration file allows for both modifying built-in character sets and 185 | creating custom ones. 186 | 187 | See [wiremix.toml](./wiremix.toml) for more details. 188 | 189 | ### Themes 190 | 191 | Themes define colors and other text attributes for UI elements. They are 192 | similar to character sets in that you can define your own themes and switch 193 | between them with the `theme` configuration option or the `-t`/`--theme` 194 | command-line arguments. 195 | 196 | There are three built-in themes: 197 | 198 | 1. `default` is the default theme. 199 | 2. `nocolor` uses no color, only attributes. 200 | 3. `plain` uses only the default style - no colors or attributes. 201 | 202 | The configuration file allows for both modifying built-in themes and creating 203 | custom ones. 204 | 205 | See [wiremix.toml](./wiremix.toml) for more details. 206 | 207 | ### Names 208 | 209 | You can customize how streams, endpoints, and devices are displayed in the user 210 | interface using a template system to generate names from PipeWire properties. 211 | 212 | It's likely that any particular naming scheme won't work well with 100% of your 213 | software and devices, so you can also specify alternate name templates to use 214 | for PipeWire nodes matching configurable criteria. 215 | 216 | See [wiremix.toml](./wiremix.toml) for more details. 217 | 218 | #### Examples 219 | 220 | The default naming scheme is: 221 | 222 | ```toml 223 | [names] 224 | stream = [ "{node:node.name}: {node:media.name}" ] 225 | endpoint = [ "{device:device.nick}", "{node:node.description}" ] 226 | device = [ "{device:device.nick}", "{device:device.description}" ] 227 | ``` 228 | 229 | Not all nodes and devices have the same properties present, so if multiple 230 | naming templates are specified, wiremix will try to resolve them in order and 231 | use the first one that works. 232 | 233 | For ncpamixer-style names you can use: 234 | 235 | ```toml 236 | [names] 237 | stream = [ "{node:node.name}: {node:media.name}" ] 238 | endpoint = [ "{node:node.description}" ] 239 | device = [ "{device:device.description}" ] 240 | ``` 241 | 242 | I use these overrides with the default names: 243 | 244 | ```toml 245 | # This device's device.name is truncated to "USB-C to 3.5mm Headphone Jack 246 | # A". This override makes wiremix use device.description instead, which for 247 | # this device is "USB-C to 3.5mm Headphone Jack Adapter". 248 | [[names.overrides]] 249 | types = [ "endpoint", "device" ] 250 | property = "device:device.name" 251 | value = "alsa_card.usb-Apple__Inc._USB-C_to_3.5mm_Headphone_Jack_Adapter_DWH841302FEJKLTA3-00" 252 | templates = [ "{device:device.description}" ] 253 | 254 | # The Spotify client's node.name is "spotify", and it also uses "Spotify" for 255 | # media.name. This override makes wiremix use just the node.name, so it shows 256 | # as "spotify" instead of "spotify: Spotify". 257 | [[names.overrides]] 258 | types = [ "stream" ] 259 | property = "node:node.name" 260 | value = "spotify" 261 | templates = [ "{node:node.name}" ] 262 | 263 | # mpv is also a bit redundant with the default naming scheme - it suffixes 264 | # media.name with "- mpv". This override makes it show as "foo - mpv" instead 265 | # of "mpv: foo - mpv". 266 | [[names.overrides]] 267 | types = [ "stream" ] 268 | property = "node:node.name" 269 | value = "mpv" 270 | templates = [ "{node:media.name}" ] 271 | ``` 272 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1743315132, 24 | "narHash": "sha256-6hl6L/tRnwubHcA4pfUUtk542wn2Om+D4UnDhlDW9BE=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "52faf482a3889b7619003c0daec593a1912fddc1", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs_2": { 38 | "locked": { 39 | "lastModified": 1736320768, 40 | "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", 41 | "owner": "NixOS", 42 | "repo": "nixpkgs", 43 | "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "NixOS", 48 | "ref": "nixpkgs-unstable", 49 | "repo": "nixpkgs", 50 | "type": "github" 51 | } 52 | }, 53 | "root": { 54 | "inputs": { 55 | "flake-utils": "flake-utils", 56 | "nixpkgs": "nixpkgs", 57 | "rust-overlay": "rust-overlay" 58 | } 59 | }, 60 | "rust-overlay": { 61 | "inputs": { 62 | "nixpkgs": "nixpkgs_2" 63 | }, 64 | "locked": { 65 | "lastModified": 1743388531, 66 | "narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=", 67 | "owner": "oxalica", 68 | "repo": "rust-overlay", 69 | "rev": "011de3c895927300651d9c2cb8e062adf17aa665", 70 | "type": "github" 71 | }, 72 | "original": { 73 | "owner": "oxalica", 74 | "repo": "rust-overlay", 75 | "type": "github" 76 | } 77 | }, 78 | "systems": { 79 | "locked": { 80 | "lastModified": 1681028828, 81 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 82 | "owner": "nix-systems", 83 | "repo": "default", 84 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-systems", 89 | "repo": "default", 90 | "type": "github" 91 | } 92 | } 93 | }, 94 | "root": "root", 95 | "version": 7 96 | } 97 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | rust-overlay.url = "github:oxalica/rust-overlay"; 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: 9 | flake-utils.lib.eachDefaultSystem (system: 10 | let 11 | overlays = [ (import rust-overlay) ]; 12 | pkgs = import nixpkgs { 13 | inherit system overlays; 14 | }; 15 | rust = pkgs.rust-bin.stable.latest.default; 16 | nativeBuildInputs = with pkgs; [ 17 | rust 18 | pkg-config 19 | rustPlatform.bindgenHook 20 | ]; 21 | buildInputs = with pkgs; [ 22 | pipewire 23 | ]; 24 | in 25 | { 26 | devShells.default = pkgs.mkShell { 27 | inherit nativeBuildInputs; 28 | inherit buildInputs; 29 | }; 30 | 31 | packages.default = let 32 | cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); 33 | in pkgs.rustPlatform.buildRustPackage rec { 34 | inherit nativeBuildInputs; 35 | inherit buildInputs; 36 | 37 | pname = cargoToml.package.name; 38 | version = cargoToml.package.version; 39 | 40 | src = self; 41 | 42 | cargoLock.lockFile = ./Cargo.lock; 43 | }; 44 | } 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | -------------------------------------------------------------------------------- /src/capture_manager.rs: -------------------------------------------------------------------------------- 1 | //! Track nodes being captured. 2 | 3 | use std::collections::HashSet; 4 | 5 | use crate::command::Command; 6 | use crate::object::ObjectId; 7 | use crate::state::Node; 8 | 9 | /// Track nodes being captured. This can be passed to 10 | /// `crate::state::State::update()` which uses the on_ methods to queue up 11 | /// start and stop capture commands. Once all updates are complete, the pending 12 | /// commands can be retrieved via `flush()` and executed. 13 | #[derive(Default, Debug)] 14 | pub struct CaptureManager { 15 | capturing: HashSet, 16 | commands: Vec, 17 | } 18 | 19 | impl CaptureManager { 20 | /// Call when a node's capture eligibility might have changed. 21 | pub fn on_node(&mut self, node: &Node) { 22 | if !node.media_class.as_ref().is_some_and(|media_class| { 23 | media_class.is_source() 24 | || media_class.is_sink_input() 25 | || media_class.is_source_output() 26 | }) { 27 | return; 28 | } 29 | 30 | if node.object_serial.is_none() { 31 | return; 32 | } 33 | 34 | if self.capturing.contains(&node.id) { 35 | return; 36 | } 37 | 38 | let command = self.start_capture_command(node); 39 | self.commands.extend(command); 40 | } 41 | 42 | /// Call when a node gets a new input link. 43 | pub fn on_link(&mut self, node: &Node) { 44 | if !node.media_class.as_ref().is_some_and(|media_class| { 45 | media_class.is_sink() 46 | || media_class.is_source() 47 | || media_class.is_sink_input() 48 | || media_class.is_source_output() 49 | }) { 50 | return; 51 | } 52 | 53 | let command = self.start_capture_command(node); 54 | self.commands.extend(command); 55 | } 56 | 57 | /// Call when a node's output positions have changed. 58 | pub fn on_positions_changed(&mut self, node: &Node) { 59 | if !self.capturing.contains(&node.id) { 60 | return; 61 | } 62 | 63 | let command = self.start_capture_command(node); 64 | self.commands.extend(command); 65 | } 66 | 67 | /// Call when a node has no more input links. 68 | pub fn on_removed(&mut self, node: &Node) { 69 | let command = self.stop_capture_command(node); 70 | self.commands.extend(command); 71 | } 72 | 73 | fn start_capture_command(&mut self, node: &Node) -> Option { 74 | let object_serial = &node.object_serial?; 75 | let capture_sink = 76 | node.media_class.as_ref().is_some_and(|media_class| { 77 | media_class.is_sink() || media_class.is_source() 78 | }); 79 | 80 | self.capturing.insert(node.id); 81 | 82 | Some(Command::NodeCaptureStart( 83 | node.id, 84 | *object_serial, 85 | capture_sink, 86 | )) 87 | } 88 | 89 | fn stop_capture_command(&mut self, node: &Node) -> Option { 90 | self.capturing.remove(&node.id); 91 | 92 | Some(Command::NodeCaptureStop(node.id)) 93 | } 94 | 95 | /// Get a list of pending commands. 96 | pub fn flush(&mut self) -> Vec { 97 | std::mem::take(&mut self.commands) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/command.rs: -------------------------------------------------------------------------------- 1 | //! PipeWire controls which can be executed by the monitor module. 2 | 3 | use crate::object::ObjectId; 4 | 5 | #[derive(Debug)] 6 | pub enum Command { 7 | NodeMute(ObjectId, bool), 8 | DeviceMute(ObjectId, i32, i32, bool), 9 | NodeVolumes(ObjectId, Vec), 10 | DeviceVolumes(ObjectId, i32, i32, Vec), 11 | DeviceSetRoute(ObjectId, i32, i32), 12 | DeviceSetProfile(ObjectId, i32), 13 | NodeCaptureStart(ObjectId, i32, bool), 14 | NodeCaptureStop(ObjectId), 15 | MetadataSetProperty(ObjectId, u32, String, Option, Option), 16 | } 17 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Mixer configuration. 2 | 3 | mod char_set; 4 | mod keybinding; 5 | mod name_template; 6 | mod names; 7 | mod tag; 8 | mod theme; 9 | 10 | use std::collections::HashMap; 11 | use std::convert::TryFrom; 12 | use std::env; 13 | use std::fs; 14 | use std::path::{Path, PathBuf}; 15 | 16 | use anyhow::Context; 17 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 18 | use ratatui::{style::Style, widgets::block::BorderType}; 19 | use serde::Deserialize; 20 | use toml; 21 | 22 | use crate::app::{Action, TabKind}; 23 | use crate::opt::Opt; 24 | 25 | #[derive(Debug)] 26 | pub struct Config { 27 | pub remote: Option, 28 | pub fps: Option, 29 | pub mouse: bool, 30 | pub peaks: Peaks, 31 | pub char_set: CharSet, 32 | pub theme: Theme, 33 | pub keybindings: HashMap, 34 | pub names: Names, 35 | pub tab: TabKind, 36 | } 37 | 38 | /// Represents a configuration deserialized from a file. This gets baked into a 39 | /// Config, which, for example, has a single char_set and theme. 40 | #[derive(Deserialize, Debug)] 41 | #[cfg_attr(test, derive(PartialEq))] 42 | #[serde(deny_unknown_fields)] 43 | struct ConfigFile { 44 | remote: Option, 45 | fps: Option, 46 | #[serde(default = "default_mouse")] 47 | mouse: bool, 48 | peaks: Option, 49 | #[serde(default = "default_char_set_name")] 50 | char_set: String, 51 | #[serde(default = "default_theme_name")] 52 | theme: String, 53 | #[serde( 54 | default = "Keybinding::defaults", 55 | deserialize_with = "Keybinding::merge" 56 | )] 57 | keybindings: HashMap, 58 | #[serde(default)] 59 | names: Names, 60 | #[serde( 61 | default = "CharSet::defaults", 62 | deserialize_with = "CharSet::merge" 63 | )] 64 | char_sets: HashMap, 65 | #[serde(default = "Theme::defaults", deserialize_with = "Theme::merge")] 66 | themes: HashMap, 67 | tab: Option, 68 | } 69 | 70 | // The serde defaults need to be repeated here, which is used to generate a 71 | // default ConfigFile when there is no config file to parse. 72 | impl Default for ConfigFile { 73 | fn default() -> Self { 74 | Self { 75 | remote: Default::default(), 76 | fps: Default::default(), 77 | mouse: default_mouse(), 78 | peaks: Default::default(), 79 | char_set: default_char_set_name(), 80 | theme: default_theme_name(), 81 | keybindings: Keybinding::defaults(), 82 | names: Default::default(), 83 | char_sets: CharSet::defaults(), 84 | themes: Theme::defaults(), 85 | tab: Default::default(), 86 | } 87 | } 88 | } 89 | 90 | #[derive(Deserialize, Default, Debug, Clone, PartialEq, clap::ValueEnum)] 91 | #[serde(rename_all = "lowercase")] 92 | pub enum Peaks { 93 | Off, 94 | Mono, 95 | #[default] 96 | Auto, 97 | } 98 | 99 | #[derive(Deserialize, Debug)] 100 | #[serde(deny_unknown_fields)] 101 | pub struct Keybinding { 102 | pub key: KeyCode, 103 | #[serde(default = "Keybinding::default_modifiers")] 104 | pub modifiers: KeyModifiers, 105 | pub action: Action, 106 | } 107 | 108 | #[derive(Deserialize, Debug)] 109 | #[cfg_attr(test, derive(PartialEq))] 110 | #[serde(deny_unknown_fields)] 111 | pub struct Names { 112 | #[serde(default = "Names::default_stream")] 113 | pub stream: Vec, 114 | #[serde(default = "Names::default_endpoint")] 115 | pub endpoint: Vec, 116 | #[serde(default = "Names::default_device")] 117 | pub device: Vec, 118 | #[serde(default)] 119 | pub overrides: Vec, 120 | } 121 | 122 | #[derive(PartialEq, Deserialize, Debug)] 123 | #[serde(rename_all = "lowercase")] 124 | pub enum OverrideType { 125 | Stream, 126 | Endpoint, 127 | Device, 128 | } 129 | 130 | #[derive(Deserialize, Debug)] 131 | #[cfg_attr(test, derive(PartialEq))] 132 | #[serde(deny_unknown_fields)] 133 | pub struct NameOverride { 134 | pub types: Vec, 135 | pub property: names::Tag, 136 | pub value: String, 137 | pub templates: Vec, 138 | } 139 | 140 | #[derive(Debug)] 141 | #[cfg_attr(test, derive(PartialEq))] 142 | pub struct CharSet { 143 | pub default_device: String, 144 | pub default_stream: String, 145 | pub selector_top: String, 146 | pub selector_middle: String, 147 | pub selector_bottom: String, 148 | pub tab_marker_left: String, 149 | pub tab_marker_right: String, 150 | pub list_more: String, 151 | pub volume_empty: String, 152 | pub volume_filled: String, 153 | pub meter_left_inactive: String, 154 | pub meter_left_active: String, 155 | pub meter_left_overload: String, 156 | pub meter_right_inactive: String, 157 | pub meter_right_active: String, 158 | pub meter_right_overload: String, 159 | pub meter_center_left_inactive: String, 160 | pub meter_center_left_active: String, 161 | pub meter_center_right_inactive: String, 162 | pub meter_center_right_active: String, 163 | pub dropdown_icon: String, 164 | pub dropdown_selector: String, 165 | pub dropdown_more: String, 166 | pub dropdown_border: BorderType, 167 | } 168 | 169 | #[derive(Deserialize, Debug)] 170 | #[cfg_attr(test, derive(PartialEq))] 171 | pub struct Theme { 172 | pub default_device: Style, 173 | pub default_stream: Style, 174 | pub selector: Style, 175 | pub tab: Style, 176 | pub tab_selected: Style, 177 | pub tab_marker: Style, 178 | pub list_more: Style, 179 | pub node_title: Style, 180 | pub node_target: Style, 181 | pub volume: Style, 182 | pub volume_empty: Style, 183 | pub volume_filled: Style, 184 | pub meter_inactive: Style, 185 | pub meter_active: Style, 186 | pub meter_overload: Style, 187 | pub meter_center_inactive: Style, 188 | pub meter_center_active: Style, 189 | pub config_device: Style, 190 | pub config_profile: Style, 191 | pub dropdown_icon: Style, 192 | pub dropdown_border: Style, 193 | pub dropdown_item: Style, 194 | pub dropdown_selected: Style, 195 | pub dropdown_more: Style, 196 | } 197 | 198 | fn default_mouse() -> bool { 199 | true 200 | } 201 | 202 | fn default_char_set_name() -> String { 203 | String::from("default") 204 | } 205 | 206 | fn default_theme_name() -> String { 207 | String::from("default") 208 | } 209 | 210 | impl ConfigFile { 211 | /// Override configuration with command-line arguments. 212 | pub fn apply_opt(&mut self, opt: &Opt) { 213 | if let Some(remote) = &opt.remote { 214 | self.remote = Some(remote.clone()); 215 | } 216 | 217 | if let Some(fps) = opt.fps { 218 | self.fps = (fps != 0.0).then_some(fps); 219 | } 220 | 221 | if opt.no_mouse { 222 | self.mouse = false; 223 | } 224 | 225 | if opt.mouse { 226 | self.mouse = true; 227 | } 228 | 229 | if let Some(peaks) = &opt.peaks { 230 | self.peaks = Some(peaks.clone()); 231 | } 232 | 233 | if let Some(char_set) = &opt.char_set { 234 | self.char_set = char_set.clone(); 235 | } 236 | 237 | if let Some(theme) = &opt.theme { 238 | self.theme = theme.clone(); 239 | } 240 | 241 | if let Some(tab) = &opt.tab { 242 | self.tab = Some(*tab); 243 | } 244 | } 245 | } 246 | 247 | impl TryFrom for Config { 248 | type Error = anyhow::Error; 249 | 250 | fn try_from(mut config_file: ConfigFile) -> Result { 251 | let Some(char_set) = 252 | config_file.char_sets.remove(&config_file.char_set) 253 | else { 254 | anyhow::bail!( 255 | "char_set '{}' does not exist", 256 | &config_file.char_set 257 | ); 258 | }; 259 | 260 | let Some(theme) = config_file.themes.remove(&config_file.theme) else { 261 | anyhow::bail!("theme '{}' does not exist", &config_file.theme); 262 | }; 263 | 264 | Ok(Self { 265 | remote: config_file.remote, 266 | fps: config_file.fps, 267 | mouse: config_file.mouse, 268 | peaks: config_file.peaks.unwrap_or_default(), 269 | char_set, 270 | theme, 271 | keybindings: config_file.keybindings, 272 | names: config_file.names, 273 | tab: config_file.tab.unwrap_or_default(), 274 | }) 275 | } 276 | } 277 | 278 | impl Config { 279 | /// Returns the configuration file path. 280 | pub fn default_path() -> Option { 281 | if let Ok(xdg_config) = env::var("XDG_CONFIG_HOME") { 282 | return Some(Path::new(&xdg_config).join("wiremix/wiremix.toml")); 283 | } 284 | 285 | if let Ok(home) = env::var("HOME") { 286 | return Some(Path::new(&home).join(".config/wiremix/wiremix.toml")); 287 | } 288 | 289 | None 290 | } 291 | 292 | /// Parse configuration from the file at the supplied path. 293 | pub fn try_new( 294 | path: Option<&Path>, 295 | opt: &Opt, 296 | ) -> Result { 297 | let mut config_file: ConfigFile = match path { 298 | Some(path) if path.exists() => { 299 | let context = || { 300 | format!( 301 | "Failed to read configuration from file '{}'", 302 | path.display() 303 | ) 304 | }; 305 | 306 | let toml_str = 307 | fs::read_to_string(path).with_context(context)?; 308 | 309 | toml::from_str(&toml_str).with_context(context)? 310 | } 311 | _ => ConfigFile::default(), 312 | }; 313 | 314 | config_file.apply_opt(opt); 315 | let config_file = config_file; 316 | 317 | Self::try_from(config_file) 318 | } 319 | } 320 | 321 | #[cfg(test)] 322 | mod tests { 323 | use super::*; 324 | 325 | #[test] 326 | fn empty_config_matches_default() { 327 | let empty_config: ConfigFile = toml::from_str("").unwrap(); 328 | 329 | assert_eq!(empty_config, ConfigFile::default()); 330 | } 331 | 332 | #[test] 333 | fn unknown_field_config_file() { 334 | let config = r#" 335 | unknown = "unknown" 336 | "#; 337 | assert!(toml::from_str::(&config).is_err()); 338 | } 339 | 340 | #[test] 341 | fn unknown_field_keybinding() { 342 | let config = r#" 343 | key = { Char = "x" } 344 | action = "Nothing" 345 | unknown = "unknown" 346 | "#; 347 | assert!(toml::from_str::(&config).is_err()); 348 | } 349 | 350 | #[test] 351 | fn unknown_field_names() { 352 | let config = r#" 353 | unknown = "unknown" 354 | "#; 355 | assert!(toml::from_str::(&config).is_err()); 356 | } 357 | 358 | #[test] 359 | fn unknown_field_name_override() { 360 | let config = r#" 361 | types = [ "stream" ] 362 | property = "node:node.name" 363 | value = "value" 364 | templates = [ "template" ] 365 | unknown = "unknown" 366 | "#; 367 | assert!(toml::from_str::(&config).is_err()); 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/config/char_set.rs: -------------------------------------------------------------------------------- 1 | //! Implementation for [`CharSet`](`crate::config::CharSet`). Defines default 2 | //! character sets and handles merging of configured char sets with defaults. 3 | 4 | use std::collections::HashMap; 5 | 6 | use ratatui::widgets::block::BorderType; 7 | use serde::{de::Error, Deserialize}; 8 | 9 | use crate::config::CharSet; 10 | 11 | // This is what actually gets parsed from the config. 12 | #[derive(Deserialize, Debug)] 13 | #[serde(deny_unknown_fields)] 14 | pub struct CharSetOverlay { 15 | inherit: Option, 16 | default_device: Option, 17 | default_stream: Option, 18 | selector_top: Option, 19 | selector_middle: Option, 20 | selector_bottom: Option, 21 | tab_marker_left: Option, 22 | tab_marker_right: Option, 23 | list_more: Option, 24 | volume_empty: Option, 25 | volume_filled: Option, 26 | meter_left_inactive: Option, 27 | meter_left_active: Option, 28 | meter_left_overload: Option, 29 | meter_right_inactive: Option, 30 | meter_right_active: Option, 31 | meter_right_overload: Option, 32 | meter_center_left_inactive: Option, 33 | meter_center_left_active: Option, 34 | meter_center_right_inactive: Option, 35 | meter_center_right_active: Option, 36 | dropdown_icon: Option, 37 | dropdown_selector: Option, 38 | dropdown_more: Option, 39 | dropdown_border: Option, 40 | } 41 | 42 | #[derive(Deserialize, Debug)] 43 | enum BorderTypeDef { 44 | Plain, 45 | Rounded, 46 | Double, 47 | Thick, 48 | QuadrantInside, 49 | QuadrantOutside, 50 | } 51 | 52 | impl From for BorderType { 53 | fn from(def: BorderTypeDef) -> Self { 54 | match def { 55 | BorderTypeDef::Plain => Self::Plain, 56 | BorderTypeDef::Rounded => Self::Rounded, 57 | BorderTypeDef::Double => Self::Double, 58 | BorderTypeDef::Thick => Self::Thick, 59 | BorderTypeDef::QuadrantInside => Self::QuadrantInside, 60 | BorderTypeDef::QuadrantOutside => Self::QuadrantOutside, 61 | } 62 | } 63 | } 64 | 65 | impl TryFrom for CharSet { 66 | type Error = anyhow::Error; 67 | 68 | fn try_from(overlay: CharSetOverlay) -> Result { 69 | let mut char_set: Self = match overlay.inherit.as_deref() { 70 | Some("default") => CharSet::default(), 71 | Some("compat") => CharSet::compat(), 72 | Some("extracompat") => CharSet::extracompat(), 73 | Some(inherit) => { 74 | anyhow::bail!("'{}' is not a built-in character set", inherit) 75 | } 76 | None => CharSet::default(), 77 | }; 78 | 79 | macro_rules! validate_and_set { 80 | // Overwrite default char with char from overlay while validating 81 | // width. Length of 0 means don't check width. 82 | ($field:ident, $length:expr) => { 83 | if let Some(value) = overlay.$field { 84 | if $length > 0 85 | && unicode_width::UnicodeWidthStr::width(value.as_str()) 86 | != $length 87 | { 88 | anyhow::bail!( 89 | "{} must be {} characters wide", 90 | stringify!($field), 91 | $length 92 | ); 93 | } 94 | char_set.$field = value; 95 | } 96 | }; 97 | } 98 | 99 | validate_and_set!(default_device, 1); 100 | validate_and_set!(default_stream, 1); 101 | validate_and_set!(selector_top, 1); 102 | validate_and_set!(selector_middle, 1); 103 | validate_and_set!(selector_bottom, 1); 104 | validate_and_set!(tab_marker_left, 1); 105 | validate_and_set!(tab_marker_right, 1); 106 | validate_and_set!(list_more, 0); 107 | validate_and_set!(volume_empty, 1); 108 | validate_and_set!(volume_filled, 1); 109 | validate_and_set!(meter_left_inactive, 1); 110 | validate_and_set!(meter_left_active, 1); 111 | validate_and_set!(meter_left_overload, 1); 112 | validate_and_set!(meter_right_inactive, 1); 113 | validate_and_set!(meter_right_active, 1); 114 | validate_and_set!(meter_right_overload, 1); 115 | validate_and_set!(meter_center_left_inactive, 1); 116 | validate_and_set!(meter_center_left_active, 1); 117 | validate_and_set!(meter_center_right_inactive, 1); 118 | validate_and_set!(meter_center_right_active, 1); 119 | validate_and_set!(dropdown_icon, 1); 120 | validate_and_set!(dropdown_selector, 1); 121 | validate_and_set!(dropdown_more, 0); 122 | 123 | if let Some(dropdown_border) = overlay.dropdown_border { 124 | char_set.dropdown_border = dropdown_border.into(); 125 | } 126 | 127 | Ok(char_set) 128 | } 129 | } 130 | 131 | impl Default for CharSet { 132 | fn default() -> Self { 133 | Self { 134 | default_device: String::from("◇"), 135 | default_stream: String::from("◇"), 136 | selector_top: String::from("░"), 137 | selector_middle: String::from("▒"), 138 | selector_bottom: String::from("░"), 139 | tab_marker_left: String::from("["), 140 | tab_marker_right: String::from("]"), 141 | list_more: String::from("•••"), 142 | volume_empty: String::from("╌"), 143 | volume_filled: String::from("━"), 144 | meter_left_inactive: String::from("▮"), 145 | meter_left_active: String::from("▮"), 146 | meter_left_overload: String::from("▮"), 147 | meter_right_inactive: String::from("▮"), 148 | meter_right_active: String::from("▮"), 149 | meter_right_overload: String::from("▮"), 150 | meter_center_left_inactive: String::from("▮"), 151 | meter_center_left_active: String::from("▮"), 152 | meter_center_right_inactive: String::from("▮"), 153 | meter_center_right_active: String::from("▮"), 154 | dropdown_icon: String::from("▼"), 155 | dropdown_selector: String::from(">"), 156 | dropdown_more: String::from("•••"), 157 | dropdown_border: BorderType::Rounded, 158 | } 159 | } 160 | } 161 | 162 | impl CharSet { 163 | pub fn defaults() -> HashMap { 164 | HashMap::from([ 165 | (String::from("default"), CharSet::default()), 166 | (String::from("compat"), CharSet::compat()), 167 | (String::from("extracompat"), CharSet::extracompat()), 168 | ]) 169 | } 170 | 171 | fn compat() -> CharSet { 172 | Self { 173 | default_device: String::from("◊"), 174 | default_stream: String::from("◊"), 175 | selector_top: String::from("░"), 176 | selector_middle: String::from("▒"), 177 | selector_bottom: String::from("░"), 178 | tab_marker_left: String::from("["), 179 | tab_marker_right: String::from("]"), 180 | list_more: String::from("•••"), 181 | volume_empty: String::from("─"), 182 | volume_filled: String::from("━"), 183 | meter_left_inactive: String::from("┃"), 184 | meter_left_active: String::from("┃"), 185 | meter_left_overload: String::from("┃"), 186 | meter_right_inactive: String::from("┃"), 187 | meter_right_active: String::from("┃"), 188 | meter_right_overload: String::from("┃"), 189 | meter_center_left_inactive: String::from("█"), 190 | meter_center_left_active: String::from("█"), 191 | meter_center_right_inactive: String::from("█"), 192 | meter_center_right_active: String::from("█"), 193 | dropdown_icon: String::from("▼"), 194 | dropdown_selector: String::from(">"), 195 | dropdown_more: String::from("•••"), 196 | dropdown_border: BorderType::Plain, 197 | } 198 | } 199 | 200 | fn extracompat() -> CharSet { 201 | Self { 202 | default_device: String::from("*"), 203 | default_stream: String::from("*"), 204 | selector_top: String::from("-"), 205 | selector_middle: String::from("="), 206 | selector_bottom: String::from("-"), 207 | tab_marker_left: String::from("["), 208 | tab_marker_right: String::from("]"), 209 | list_more: String::from("~~~"), 210 | volume_empty: String::from("-"), 211 | volume_filled: String::from("="), 212 | meter_left_inactive: String::from("="), 213 | meter_left_active: String::from("#"), 214 | meter_left_overload: String::from("!"), 215 | meter_right_inactive: String::from("="), 216 | meter_right_active: String::from("#"), 217 | meter_right_overload: String::from("!"), 218 | meter_center_left_inactive: String::from("["), 219 | meter_center_left_active: String::from("["), 220 | meter_center_right_inactive: String::from("]"), 221 | meter_center_right_active: String::from("]"), 222 | dropdown_icon: String::from("\\"), 223 | dropdown_selector: String::from(">"), 224 | dropdown_more: String::from("~~~"), 225 | dropdown_border: BorderType::Plain, 226 | } 227 | } 228 | 229 | /// Merge deserialized charsets with defaults 230 | pub fn merge<'de, D>( 231 | deserializer: D, 232 | ) -> Result, D::Error> 233 | where 234 | D: serde::Deserializer<'de>, 235 | { 236 | let configured = 237 | HashMap::::deserialize(deserializer)?; 238 | let mut merged = configured 239 | .into_iter() 240 | .map(|(key, value)| { 241 | CharSet::try_from(value) 242 | .map_err(D::Error::custom) 243 | .map(move |charset| (key, charset)) 244 | }) 245 | .collect::, D::Error>>()?; 246 | if !merged.contains_key("default") { 247 | merged.insert(String::from("default"), CharSet::default()); 248 | } 249 | if !merged.contains_key("compat") { 250 | merged.insert(String::from("compat"), CharSet::compat()); 251 | } 252 | if !merged.contains_key("extracompat") { 253 | merged.insert(String::from("extracompat"), CharSet::extracompat()); 254 | } 255 | Ok(merged) 256 | } 257 | } 258 | 259 | #[cfg(test)] 260 | mod tests { 261 | use super::*; 262 | 263 | #[test] 264 | fn empty_overlay() { 265 | let config = r#""#; 266 | 267 | let overlay = toml::from_str::(&config).unwrap(); 268 | CharSet::try_from(overlay).unwrap(); 269 | } 270 | 271 | #[test] 272 | fn builtins_present() { 273 | #[derive(Deserialize)] 274 | struct S { 275 | #[serde(deserialize_with = "CharSet::merge")] 276 | char_sets: HashMap, 277 | } 278 | let config = r#"[char_sets.test]"#; 279 | 280 | let s = toml::from_str::(&config).unwrap(); 281 | for name in CharSet::defaults().keys() { 282 | assert!(s.char_sets.contains_key(name)); 283 | } 284 | } 285 | 286 | #[test] 287 | fn override_default() { 288 | let config = r#" 289 | dropdown_icon = "$" 290 | "#; 291 | 292 | let overlay = toml::from_str::(&config).unwrap(); 293 | let char_set = CharSet::try_from(overlay).unwrap(); 294 | 295 | assert_eq!(char_set.dropdown_icon, "$") 296 | } 297 | 298 | #[test] 299 | fn width_too_narrow() { 300 | let config = r#" 301 | meter_right_active = "" 302 | "#; 303 | 304 | let overlay = toml::from_str::(&config).unwrap(); 305 | let char_set = CharSet::try_from(overlay); 306 | assert!(char_set.is_err()); 307 | } 308 | 309 | #[test] 310 | fn width_too_wide() { 311 | let config = r#" 312 | meter_right_active = "$$" 313 | "#; 314 | 315 | let overlay = toml::from_str::(&config).unwrap(); 316 | let char_set = CharSet::try_from(overlay); 317 | assert!(char_set.is_err()); 318 | } 319 | 320 | #[test] 321 | fn width_correct() { 322 | let config = r#" 323 | meter_right_active = "$" 324 | "#; 325 | 326 | let overlay = toml::from_str::(&config).unwrap(); 327 | let char_set = CharSet::try_from(overlay).unwrap(); 328 | assert_eq!(char_set.meter_right_active, "$"); 329 | } 330 | 331 | #[test] 332 | fn width_1_column_grapheme_cluster() { 333 | let config = r#" 334 | meter_right_active = "⚓︎" 335 | "#; 336 | 337 | let overlay = toml::from_str::(&config).unwrap(); 338 | let char_set = CharSet::try_from(overlay).unwrap(); 339 | assert_eq!(char_set.meter_right_active, "⚓︎"); 340 | } 341 | 342 | #[test] 343 | fn width_2_column_grapheme_cluster() { 344 | let config = r#" 345 | meter_right_active = "🏳️‍🌈" 346 | "#; 347 | 348 | let overlay = toml::from_str::(&config).unwrap(); 349 | let char_set = CharSet::try_from(overlay); 350 | assert!(char_set.is_err()); 351 | } 352 | 353 | #[test] 354 | fn width_unlimited() { 355 | let config = r#" 356 | list_more = "" 357 | dropdown_more = "$$$$$$$$$$$$$$$$$$$$$$$$" 358 | "#; 359 | 360 | let overlay = toml::from_str::(&config).unwrap(); 361 | let char_set = CharSet::try_from(overlay).unwrap(); 362 | assert_eq!(char_set.list_more, ""); 363 | assert_eq!(char_set.dropdown_more, "$$$$$$$$$$$$$$$$$$$$$$$$"); 364 | } 365 | 366 | #[test] 367 | fn inherit_nonexistent() { 368 | let config = r#" 369 | inherit = "doesntexist" 370 | meter_right_active = "$" 371 | "#; 372 | 373 | let overlay = toml::from_str::(&config).unwrap(); 374 | let char_set = CharSet::try_from(overlay); 375 | assert!(char_set.is_err()); 376 | } 377 | 378 | #[test] 379 | fn inherit() { 380 | for (builtin_key, builtin) in CharSet::defaults().iter() { 381 | let config = format!( 382 | r#" 383 | inherit = "{}" 384 | meter_right_active = "$" 385 | "#, 386 | builtin_key 387 | ); 388 | 389 | let overlay = toml::from_str::(&config).unwrap(); 390 | let char_set = CharSet::try_from(overlay).unwrap(); 391 | assert_eq!(char_set.meter_right_active, "$"); 392 | assert_eq!(char_set.meter_left_active, builtin.meter_left_active); 393 | } 394 | } 395 | 396 | #[test] 397 | fn unknown_field() { 398 | let config = r#" 399 | unknown = "unknown" 400 | "#; 401 | assert!(toml::from_str::(&config).is_err()); 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/config/keybinding.rs: -------------------------------------------------------------------------------- 1 | //! Implementation for [`Keybinding`](`crate::config::Keybinding`). Defines 2 | //! default bindings and handles merging of configured bindings with defaults. 3 | 4 | use std::collections::HashMap; 5 | use std::os::fd::AsFd; 6 | 7 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; 8 | use nix::sys::termios::{self, SpecialCharacterIndices}; 9 | use serde::Deserialize; 10 | 11 | use crate::config::{Action, Keybinding}; 12 | 13 | impl Keybinding { 14 | pub fn defaults() -> HashMap { 15 | let event = |code| KeyEvent::new(code, KeyModifiers::NONE); 16 | 17 | HashMap::from([ 18 | (event(KeyCode::Char('q')), Action::Exit), 19 | (event(KeyCode::Char('m')), Action::ToggleMute), 20 | (event(KeyCode::Char('d')), Action::SetDefault), 21 | (event(KeyCode::Char('l')), Action::SetRelativeVolume(0.01)), 22 | (event(KeyCode::Right), Action::SetRelativeVolume(0.01)), 23 | (event(KeyCode::Char('h')), Action::SetRelativeVolume(-0.01)), 24 | (event(KeyCode::Left), Action::SetRelativeVolume(-0.01)), 25 | (event(KeyCode::Esc), Action::CloseDropdown), 26 | (event(KeyCode::Char('c')), Action::ActivateDropdown), 27 | (event(KeyCode::Enter), Action::ActivateDropdown), 28 | (event(KeyCode::Char('j')), Action::MoveDown), 29 | (event(KeyCode::Down), Action::MoveDown), 30 | (event(KeyCode::Char('k')), Action::MoveUp), 31 | (event(KeyCode::Up), Action::MoveUp), 32 | (event(KeyCode::Char('H')), Action::TabLeft), 33 | (event(KeyCode::Char('L')), Action::TabRight), 34 | ( 35 | KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), 36 | Action::TabLeft, 37 | ), 38 | (event(KeyCode::Tab), Action::TabRight), 39 | (event(KeyCode::Char('`')), Action::SetAbsoluteVolume(0.00)), 40 | (event(KeyCode::Char('1')), Action::SetAbsoluteVolume(0.10)), 41 | (event(KeyCode::Char('2')), Action::SetAbsoluteVolume(0.20)), 42 | (event(KeyCode::Char('3')), Action::SetAbsoluteVolume(0.30)), 43 | (event(KeyCode::Char('4')), Action::SetAbsoluteVolume(0.40)), 44 | (event(KeyCode::Char('5')), Action::SetAbsoluteVolume(0.50)), 45 | (event(KeyCode::Char('6')), Action::SetAbsoluteVolume(0.60)), 46 | (event(KeyCode::Char('7')), Action::SetAbsoluteVolume(0.70)), 47 | (event(KeyCode::Char('8')), Action::SetAbsoluteVolume(0.80)), 48 | (event(KeyCode::Char('9')), Action::SetAbsoluteVolume(0.90)), 49 | (event(KeyCode::Char('0')), Action::SetAbsoluteVolume(1.00)), 50 | ]) 51 | } 52 | 53 | pub fn default_modifiers() -> KeyModifiers { 54 | KeyModifiers::NONE 55 | } 56 | 57 | /// Merge deserialized keybindings with defaults 58 | pub fn merge<'de, D>( 59 | deserializer: D, 60 | ) -> Result, D::Error> 61 | where 62 | D: serde::Deserializer<'de>, 63 | { 64 | let mut keybindings = Self::defaults(); 65 | 66 | let configured = Vec::::deserialize(deserializer)?; 67 | 68 | for keybinding in configured.into_iter() { 69 | keybindings.insert( 70 | KeyEvent::new(keybinding.key, keybinding.modifiers), 71 | keybinding.action, 72 | ); 73 | } 74 | 75 | // Emulate signals 76 | keybindings.extend(Self::control_char_keybindings()); 77 | 78 | Ok(keybindings) 79 | } 80 | 81 | /// Return keybindings emulating effects of certain terminal special 82 | /// characters 83 | fn control_char_keybindings() -> HashMap { 84 | let mut bindings = HashMap::new(); 85 | 86 | let Ok(termios) = termios::tcgetattr(std::io::stdin().as_fd()) else { 87 | return bindings; 88 | }; 89 | 90 | const SPECIAL_CHAR_INDICES: &[SpecialCharacterIndices] = &[ 91 | SpecialCharacterIndices::VINTR, 92 | SpecialCharacterIndices::VQUIT, 93 | SpecialCharacterIndices::VEOF, 94 | ]; 95 | 96 | for &index in SPECIAL_CHAR_INDICES { 97 | let byte = termios.control_chars[index as usize]; 98 | 99 | let key_event = match byte { 100 | // Handle control characters that are represented by crossterm 101 | // as non-Char KeyCodes 102 | 9 => KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), 103 | 27 => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), 104 | // CrossTerm reports Ctrl-\ as Ctrl-4 105 | 28 => KeyEvent::new(KeyCode::Char('4'), KeyModifiers::CONTROL), 106 | 107 | // Translate the other control characters to control + 108 | // a printable character 109 | 1..=31 => KeyEvent::new( 110 | KeyCode::Char((byte + 96) as char), 111 | KeyModifiers::CONTROL, 112 | ), 113 | 114 | // Pass the printable characters as-is with no modifiers 115 | 32..=126 => KeyEvent::new( 116 | KeyCode::Char(byte as char), 117 | KeyModifiers::NONE, 118 | ), 119 | _ => continue, 120 | }; 121 | 122 | bindings.insert(key_event, Action::Exit); 123 | } 124 | 125 | bindings 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/config/name_template.rs: -------------------------------------------------------------------------------- 1 | //! A type for validating and rendering name template strings. 2 | //! 3 | //! Templates are strings with tags enclosed in { and }. All tag contents must 4 | //! be parsable into Tags in order by the string to be accepted. 5 | //! { without a matching } or } without a matching { are invalid. 6 | //! { and } can be escaped with {{ and }}. 7 | use anyhow::{anyhow, bail}; 8 | use serde_with::DeserializeFromStr; 9 | 10 | use crate::config::tag::Tag; 11 | 12 | #[derive(Debug, DeserializeFromStr)] 13 | #[cfg_attr(test, derive(PartialEq))] 14 | pub struct NameTemplate { 15 | parts: Vec, 16 | } 17 | 18 | #[derive(Debug)] 19 | #[cfg_attr(test, derive(PartialEq))] 20 | enum Part { 21 | Literal(String), 22 | Tag(Tag), 23 | } 24 | 25 | impl std::str::FromStr for NameTemplate { 26 | type Err = anyhow::Error; 27 | 28 | fn from_str(s: &str) -> Result { 29 | Self::parse_string(s) 30 | } 31 | } 32 | 33 | impl NameTemplate { 34 | fn parse_string(s: &str) -> Result { 35 | // Sort string into literal and tag parts while unescaping {{ and }} 36 | // to { and }. 37 | let mut parts = Vec::new(); 38 | let mut chars = s.chars().peekable(); 39 | let mut current_part = String::new(); 40 | 41 | while let Some(ch) = chars.next() { 42 | match ch { 43 | '{' => { 44 | // Handle escaped brace: {{. 45 | if chars.peek() == Some(&'{') { 46 | current_part.push('{'); 47 | chars.next(); // Consume the extra. 48 | continue; 49 | } else { 50 | // Start of a tag. 51 | if !current_part.is_empty() { 52 | parts.push(Part::Literal(current_part)); 53 | current_part = String::new(); 54 | } 55 | 56 | let tag_content = Self::parse_tag(&mut chars)?; 57 | let tag = tag_content.parse::().map_err(|_| { 58 | anyhow!("\"{}\" is not implemented", tag_content) 59 | })?; 60 | 61 | parts.push(Part::Tag(tag)); 62 | } 63 | } 64 | '}' => { 65 | // Handle escaped brace: }}. 66 | if chars.peek() == Some(&'}') { 67 | current_part.push('}'); 68 | chars.next(); // Consume the extra. 69 | } else { 70 | bail!("'}}' without '{{'"); 71 | } 72 | } 73 | _ => current_part.push(ch), 74 | } 75 | } 76 | 77 | if !current_part.is_empty() { 78 | parts.push(Part::Literal(current_part)); 79 | } 80 | 81 | Ok(NameTemplate { parts }) 82 | } 83 | 84 | fn parse_tag( 85 | chars: &mut std::iter::Peekable, 86 | ) -> Result { 87 | let mut content = String::new(); 88 | 89 | for ch in chars.by_ref() { 90 | match ch { 91 | '}' => { 92 | return Ok(content); 93 | } 94 | '{' => bail!("'{{' without '}}'"), 95 | _ => content.push(ch), 96 | } 97 | } 98 | 99 | Err(anyhow!("'{{' without '}}'")) 100 | } 101 | 102 | /// Renders a template string using the provided lookup function to convert 103 | /// Tags into replacement strings. 104 | pub fn render>( 105 | &self, 106 | lookup: impl Fn(&Tag) -> Option, 107 | ) -> Option { 108 | let mut result = String::new(); 109 | for part in &self.parts { 110 | match part { 111 | Part::Literal(literal) => result.push_str(literal), 112 | Part::Tag(tag) => result.push_str(lookup(tag)?.as_ref()), 113 | } 114 | } 115 | 116 | Some(result) 117 | } 118 | } 119 | 120 | #[cfg(test)] 121 | mod tests { 122 | use super::*; 123 | use crate::config::tag::{DeviceTag, NodeTag, Tag}; 124 | 125 | #[test] 126 | fn no_tags() { 127 | let s = String::from("Hello"); 128 | let template: Result = s.parse(); 129 | assert!(template.is_ok()); 130 | assert_eq!( 131 | template.unwrap(), 132 | NameTemplate { 133 | parts: vec![Part::Literal(s.clone())], 134 | } 135 | ); 136 | } 137 | 138 | #[test] 139 | fn good_tag() { 140 | let s = String::from("Hello {node:node.name}"); 141 | let template: Result = s.parse(); 142 | assert!(template.is_ok()); 143 | assert_eq!( 144 | template.unwrap(), 145 | NameTemplate { 146 | parts: vec![ 147 | Part::Literal(String::from("Hello ")), 148 | Part::Tag(Tag::Node(NodeTag::NodeName)), 149 | ], 150 | } 151 | ); 152 | } 153 | 154 | #[test] 155 | fn unimplemented_tag() { 156 | let s = String::from("Hello {world}"); 157 | let template: Result = s.parse(); 158 | assert!(template.is_err()); 159 | } 160 | 161 | #[test] 162 | fn escapes() { 163 | let s = String::from("Hello }} {{ {{ {node:node.name} }}"); 164 | let template: Result = s.parse(); 165 | assert!(template.is_ok()); 166 | assert_eq!( 167 | template.unwrap(), 168 | NameTemplate { 169 | parts: vec![ 170 | Part::Literal(String::from("Hello } { { ")), 171 | Part::Tag(Tag::Node(NodeTag::NodeName)), 172 | Part::Literal(String::from(" }")), 173 | ], 174 | } 175 | ); 176 | } 177 | 178 | #[test] 179 | fn extra_opening() { 180 | let s = String::from("Hello { {node:node.name}}"); 181 | let template: Result = s.parse(); 182 | assert!(template.is_err()); 183 | } 184 | 185 | #[test] 186 | fn extra_closing() { 187 | let s = String::from("Hello {node:node.name}}"); 188 | let template: Result = s.parse(); 189 | assert!(template.is_err()); 190 | } 191 | 192 | #[test] 193 | fn empty_tag() { 194 | let s = String::from("Hello {}"); 195 | let template: Result = s.parse(); 196 | assert!(template.is_err()); 197 | } 198 | 199 | #[test] 200 | fn nested_escapes() { 201 | let s = String::from("Hello {{{{}}}}"); 202 | let template: Result = s.parse(); 203 | assert!(template.is_ok()); 204 | assert_eq!( 205 | template.unwrap(), 206 | NameTemplate { 207 | parts: vec![Part::Literal(String::from("Hello {{}}")),], 208 | } 209 | ); 210 | } 211 | 212 | #[test] 213 | fn render_empty() { 214 | let s = String::from(""); 215 | let template: Result = s.parse(); 216 | assert!(template.is_ok()); 217 | let rendered = template.unwrap().render(|_| None::<&str>); 218 | assert_eq!(rendered, Some(s)); 219 | } 220 | 221 | #[test] 222 | fn render_tags() { 223 | let s = String::from("{node:node.name}{device:device.name}"); 224 | let template: Result = s.parse(); 225 | assert!(template.is_ok()); 226 | let rendered = template.unwrap().render(|tag| match tag { 227 | Tag::Node(NodeTag::NodeName) => Some(String::from("foo")), 228 | Tag::Device(DeviceTag::DeviceName) => Some(String::from("bar")), 229 | _ => None, 230 | }); 231 | assert_eq!(rendered, Some(String::from("foobar"))); 232 | } 233 | 234 | #[test] 235 | fn render_missing_tag() { 236 | let s = String::from("{node:node.name}{device:device.name}"); 237 | let template: Result = s.parse(); 238 | assert!(template.is_ok()); 239 | let rendered = template.unwrap().render(|tag| match tag { 240 | Tag::Node(NodeTag::NodeName) => Some(String::from("foo")), 241 | _ => None, 242 | }); 243 | assert_eq!(rendered, None) 244 | } 245 | 246 | #[test] 247 | fn render_mixed() { 248 | let s = String::from("let {node:node.name} = {device:device.name};"); 249 | let template: Result = s.parse(); 250 | assert!(template.is_ok()); 251 | let rendered = template.unwrap().render(|tag| match tag { 252 | Tag::Node(NodeTag::NodeName) => Some(String::from("foo")), 253 | Tag::Device(DeviceTag::DeviceName) => Some(String::from("bar")), 254 | _ => None, 255 | }); 256 | assert_eq!(rendered, Some(String::from("let foo = bar;"))); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/config/names.rs: -------------------------------------------------------------------------------- 1 | //! Implementation for [`Names`](`crate::config::Names`). Defines default name 2 | //! templates and handles resolving templates into strings. 3 | 4 | use crate::config; 5 | use crate::state; 6 | 7 | pub use crate::config::{name_template::NameTemplate, tag::Tag}; 8 | use crate::config::{ 9 | tag::{DeviceTag, NodeTag}, 10 | Names, 11 | }; 12 | 13 | impl Names { 14 | pub fn default_stream() -> Vec { 15 | vec!["{node:node.name}: {node:media.name}".parse().unwrap()] 16 | } 17 | 18 | pub fn default_endpoint() -> Vec { 19 | vec![ 20 | "{device:device.nick}".parse().unwrap(), 21 | "{node:node.description}".parse().unwrap(), 22 | ] 23 | } 24 | 25 | pub fn default_device() -> Vec { 26 | vec![ 27 | "{device:device.nick}".parse().unwrap(), 28 | "{device:device.description}".parse().unwrap(), 29 | ] 30 | } 31 | 32 | /// Tries to resolve an object's name. 33 | /// 34 | /// Returns a name using the first template string that can be successfully 35 | /// resolved using the resolver. 36 | /// 37 | /// Precedence is: 38 | /// 39 | /// 1. Overrides 40 | /// 2. Stream/endpoint/device default templates 41 | /// 3. Fallback 42 | pub fn resolve( 43 | &self, 44 | state: &state::State, 45 | resolver: &T, 46 | ) -> Option { 47 | resolver 48 | .templates(state, self) 49 | .iter() 50 | .find_map(|template| { 51 | template.render(|tag| resolver.resolve_tag(state, *tag)) 52 | }) 53 | .or(resolver.fallback().cloned()) 54 | } 55 | } 56 | 57 | impl Default for Names { 58 | fn default() -> Self { 59 | Self { 60 | stream: Self::default_stream(), 61 | endpoint: Self::default_endpoint(), 62 | device: Self::default_device(), 63 | overrides: Vec::new(), 64 | } 65 | } 66 | } 67 | 68 | pub trait NameResolver { 69 | fn resolve_tag<'a>( 70 | &'a self, 71 | state: &'a state::State, 72 | tag: Tag, 73 | ) -> Option<&'a String>; 74 | 75 | fn fallback(&self) -> Option<&String>; 76 | 77 | fn templates<'a>( 78 | &self, 79 | state: &state::State, 80 | names: &'a config::Names, 81 | ) -> &'a Vec; 82 | 83 | fn name_override<'a>( 84 | &self, 85 | state: &state::State, 86 | overrides: &'a [config::NameOverride], 87 | override_type: config::OverrideType, 88 | ) -> Option<&'a Vec> { 89 | overrides.iter().find_map(|name_override| { 90 | (name_override.types.contains(&override_type) 91 | && self.resolve_tag(state, name_override.property) 92 | == Some(&name_override.value)) 93 | .then_some(&name_override.templates) 94 | }) 95 | } 96 | } 97 | 98 | impl NameResolver for state::Device { 99 | /// Resolve a tag using Device. 100 | fn resolve_tag<'a>( 101 | &'a self, 102 | _state: &'a state::State, 103 | tag: Tag, 104 | ) -> Option<&'a String> { 105 | match tag { 106 | Tag::Device(DeviceTag::DeviceName) => self.name.as_ref(), 107 | Tag::Device(DeviceTag::DeviceNick) => self.nick.as_ref(), 108 | Tag::Device(DeviceTag::DeviceDescription) => { 109 | self.description.as_ref() 110 | } 111 | Tag::Node(_) => None, 112 | } 113 | } 114 | 115 | fn fallback(&self) -> Option<&String> { 116 | self.name.as_ref() 117 | } 118 | 119 | fn templates<'a>( 120 | &self, 121 | state: &state::State, 122 | names: &'a config::Names, 123 | ) -> &'a Vec { 124 | self.name_override( 125 | state, 126 | &names.overrides, 127 | config::OverrideType::Device, 128 | ) 129 | .unwrap_or(&names.device) 130 | } 131 | } 132 | 133 | impl NameResolver for state::Node { 134 | /// Resolve a tag using Node. Falls back on resolving using the linked 135 | /// Device, if present. 136 | fn resolve_tag<'a>( 137 | &'a self, 138 | state: &'a state::State, 139 | tag: Tag, 140 | ) -> Option<&'a String> { 141 | match tag { 142 | Tag::Node(NodeTag::NodeName) => self.name.as_ref(), 143 | Tag::Node(NodeTag::NodeNick) => self.nick.as_ref(), 144 | Tag::Node(NodeTag::NodeDescription) => self.description.as_ref(), 145 | Tag::Node(NodeTag::MediaName) => self.media_name.as_ref(), 146 | Tag::Device(_) => { 147 | let device = state.devices.get(&self.device_id?)?; 148 | device.resolve_tag(state, tag) 149 | } 150 | } 151 | } 152 | 153 | fn fallback(&self) -> Option<&String> { 154 | self.name.as_ref() 155 | } 156 | 157 | fn templates<'a>( 158 | &self, 159 | state: &state::State, 160 | names: &'a config::Names, 161 | ) -> &'a Vec { 162 | match self.media_class.as_ref() { 163 | Some(media_class) 164 | if media_class.is_sink() || media_class.is_source() => 165 | { 166 | self.name_override( 167 | state, 168 | &names.overrides, 169 | config::OverrideType::Endpoint, 170 | ) 171 | .unwrap_or(&names.endpoint) 172 | } 173 | _ => self 174 | .name_override( 175 | state, 176 | &names.overrides, 177 | config::OverrideType::Stream, 178 | ) 179 | .unwrap_or(&names.stream), 180 | } 181 | } 182 | } 183 | 184 | #[cfg(test)] 185 | mod tests { 186 | use super::*; 187 | use crate::capture_manager::CaptureManager; 188 | use crate::config::{NameOverride, Names, OverrideType}; 189 | use crate::event::MonitorEvent; 190 | use crate::media_class::MediaClass; 191 | use crate::object::ObjectId; 192 | use crate::state::State; 193 | 194 | #[test] 195 | fn default_stream() { 196 | // Just make sure this doesn't panic. 197 | let _ = Names::default_stream(); 198 | } 199 | 200 | #[test] 201 | fn default_endpoint() { 202 | // Just make sure this doesn't panic. 203 | let _ = Names::default_endpoint(); 204 | } 205 | 206 | #[test] 207 | fn default_device() { 208 | // Just make sure this doesn't panic. 209 | let _ = Names::default_device(); 210 | } 211 | 212 | fn init() -> (State, CaptureManager, ObjectId, ObjectId) { 213 | let mut state = State::default(); 214 | let mut capture_manager = CaptureManager::default(); 215 | 216 | let device_id = ObjectId::from_raw_id(0); 217 | let node_id = ObjectId::from_raw_id(1); 218 | 219 | let events = vec![ 220 | MonitorEvent::DeviceName(device_id, String::from("Device name")), 221 | MonitorEvent::DeviceNick(device_id, String::from("Device nick")), 222 | MonitorEvent::NodeName(node_id, String::from("Node name")), 223 | MonitorEvent::NodeNick(node_id, String::from("Node nick")), 224 | ]; 225 | 226 | for event in events { 227 | state.update(&mut capture_manager, event); 228 | } 229 | 230 | (state, capture_manager, device_id, node_id) 231 | } 232 | 233 | #[test] 234 | fn render_endpoint() { 235 | let (mut state, mut capture_manager, _, node_id) = init(); 236 | 237 | state.update( 238 | &mut capture_manager, 239 | MonitorEvent::NodeMediaClass( 240 | node_id, 241 | MediaClass::from("Audio/Sink"), 242 | ), 243 | ); 244 | let names = Names { 245 | endpoint: vec!["{node:node.nick}".parse().unwrap()], 246 | ..Default::default() 247 | }; 248 | 249 | let node = state.nodes.get(&node_id).unwrap(); 250 | let result = names.resolve(&state, node); 251 | assert_eq!(result, Some(String::from("Node nick"))) 252 | } 253 | 254 | #[test] 255 | fn render_endpoint_missing_tag() { 256 | let (mut state, mut capture_manager, _, node_id) = init(); 257 | 258 | state.update( 259 | &mut capture_manager, 260 | MonitorEvent::NodeMediaClass( 261 | node_id, 262 | MediaClass::from("Audio/Sink"), 263 | ), 264 | ); 265 | 266 | let names = Names { 267 | endpoint: vec!["{node:node.description}".parse().unwrap()], 268 | ..Default::default() 269 | }; 270 | 271 | let node = state.nodes.get(&node_id).unwrap(); 272 | let result = names.resolve(&state, node); 273 | // Should fall back to node name 274 | assert_eq!(result, Some(String::from("Node name"))) 275 | } 276 | 277 | #[test] 278 | fn render_device_missing_tag() { 279 | let (state, _, device_id, _) = init(); 280 | 281 | let names = Names { 282 | device: vec!["{device:device.description}".parse().unwrap()], 283 | ..Default::default() 284 | }; 285 | 286 | let device = state.devices.get(&device_id).unwrap(); 287 | let result = names.resolve(&state, device); 288 | // Should fall back to device name 289 | assert_eq!(result, Some(String::from("Device name"))) 290 | } 291 | 292 | #[test] 293 | fn render_endpoint_linked_device() { 294 | let (mut state, mut capture_manager, device_id, node_id) = init(); 295 | 296 | state.update( 297 | &mut capture_manager, 298 | MonitorEvent::NodeMediaClass( 299 | node_id, 300 | MediaClass::from("Audio/Sink"), 301 | ), 302 | ); 303 | state.update( 304 | &mut capture_manager, 305 | MonitorEvent::NodeDeviceId(node_id, device_id), 306 | ); 307 | 308 | let names = Names { 309 | endpoint: vec!["{device:device.nick}".parse().unwrap()], 310 | ..Default::default() 311 | }; 312 | 313 | let node = state.nodes.get(&node_id).unwrap(); 314 | let result = names.resolve(&state, node); 315 | assert_eq!(result, Some(String::from("Device nick"))) 316 | } 317 | 318 | #[test] 319 | fn render_endpoint_linked_device_missing_tag() { 320 | let (mut state, mut capture_manager, device_id, node_id) = init(); 321 | 322 | state.update( 323 | &mut capture_manager, 324 | MonitorEvent::NodeMediaClass( 325 | node_id, 326 | MediaClass::from("Audio/Sink"), 327 | ), 328 | ); 329 | state.update( 330 | &mut capture_manager, 331 | MonitorEvent::NodeDeviceId(node_id, device_id), 332 | ); 333 | 334 | let names = Names { 335 | endpoint: vec!["{device:device.description}".parse().unwrap()], 336 | ..Default::default() 337 | }; 338 | 339 | let node = state.nodes.get(&node_id).unwrap(); 340 | let result = names.resolve(&state, node); 341 | // Should fall back to node name 342 | assert_eq!(result, Some(String::from("Node name"))) 343 | } 344 | 345 | #[test] 346 | fn render_endpoint_no_linked_device() { 347 | let (mut state, mut capture_manager, _, node_id) = init(); 348 | 349 | state.update( 350 | &mut capture_manager, 351 | MonitorEvent::NodeMediaClass( 352 | node_id, 353 | MediaClass::from("Audio/Sink"), 354 | ), 355 | ); 356 | 357 | let names = Names { 358 | endpoint: vec!["{device:device.nick}".parse().unwrap()], 359 | ..Default::default() 360 | }; 361 | 362 | let node = state.nodes.get(&node_id).unwrap(); 363 | let result = names.resolve(&state, node); 364 | // Should fall back to node name 365 | assert_eq!(result, Some(String::from("Node name"))) 366 | } 367 | 368 | #[test] 369 | fn render_stream() { 370 | let (state, _, _, node_id) = init(); 371 | 372 | let names = Names { 373 | stream: vec!["{node:node.nick}".parse().unwrap()], 374 | ..Default::default() 375 | }; 376 | 377 | let node = state.nodes.get(&node_id).unwrap(); 378 | let result = names.resolve(&state, node); 379 | assert_eq!(result, Some(String::from("Node nick"))) 380 | } 381 | 382 | #[test] 383 | fn render_precedence() { 384 | let (state, _, _, node_id) = init(); 385 | 386 | let names = Names { 387 | stream: vec![ 388 | "{node:node.description}".parse().unwrap(), 389 | "{node:node.nick}".parse().unwrap(), 390 | ], 391 | ..Default::default() 392 | }; 393 | 394 | let node = state.nodes.get(&node_id).unwrap(); 395 | let result = names.resolve(&state, node); 396 | assert_eq!(result, Some(String::from("Node nick"))) 397 | } 398 | 399 | #[test] 400 | fn render_override_match() { 401 | let (state, _, _, node_id) = init(); 402 | 403 | let names = Names { 404 | overrides: vec![NameOverride { 405 | types: vec![OverrideType::Device, OverrideType::Stream], 406 | property: Tag::Node(NodeTag::NodeName), 407 | value: String::from("Node name"), 408 | templates: vec![ 409 | "{node:node.description}".parse().unwrap(), 410 | "{node:node.nick}".parse().unwrap(), 411 | ], 412 | }], 413 | ..Default::default() 414 | }; 415 | 416 | let node = state.nodes.get(&node_id).unwrap(); 417 | let result = names.resolve(&state, node); 418 | assert_eq!(result, Some(String::from("Node nick"))) 419 | } 420 | 421 | #[test] 422 | fn render_override_type_mismatch() { 423 | let (state, _, _, node_id) = init(); 424 | 425 | let names = Names { 426 | overrides: vec![NameOverride { 427 | types: vec![OverrideType::Device], 428 | property: Tag::Node(NodeTag::NodeName), 429 | value: String::from("Node name"), 430 | templates: vec!["{node:node.nick}".parse().unwrap()], 431 | }], 432 | ..Default::default() 433 | }; 434 | 435 | let node = state.nodes.get(&node_id).unwrap(); 436 | let result = names.resolve(&state, node); 437 | assert_eq!(result, Some(String::from("Node name"))) 438 | } 439 | 440 | #[test] 441 | fn render_override_value_mismatch() { 442 | let (state, _, _, node_id) = init(); 443 | 444 | let names = Names { 445 | overrides: vec![NameOverride { 446 | types: vec![OverrideType::Device], 447 | property: Tag::Node(NodeTag::NodeDescription), 448 | value: String::from("Node name"), 449 | templates: vec!["{node:node.nick}".parse().unwrap()], 450 | }], 451 | ..Default::default() 452 | }; 453 | 454 | let node = state.nodes.get(&node_id).unwrap(); 455 | let result = names.resolve(&state, node); 456 | assert_eq!(result, Some(String::from("Node name"))) 457 | } 458 | 459 | #[test] 460 | fn render_override_empty_templates() { 461 | let (state, _, _, node_id) = init(); 462 | 463 | let names = Names { 464 | overrides: vec![NameOverride { 465 | types: vec![OverrideType::Device, OverrideType::Stream], 466 | property: Tag::Node(NodeTag::NodeName), 467 | value: String::from("Node name"), 468 | templates: vec![], 469 | }], 470 | ..Default::default() 471 | }; 472 | 473 | let node = state.nodes.get(&node_id).unwrap(); 474 | let result = names.resolve(&state, node); 475 | assert_eq!(result, Some(String::from("Node name"))) 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /src/config/tag.rs: -------------------------------------------------------------------------------- 1 | //! Represent valid name templating tags 2 | 3 | use serde_with::DeserializeFromStr; 4 | 5 | #[derive(Debug, Copy, Clone, DeserializeFromStr)] 6 | #[cfg_attr(test, derive(PartialEq))] 7 | pub enum Tag { 8 | Device(DeviceTag), 9 | Node(NodeTag), 10 | } 11 | 12 | // These correspond to PipeWire property names. 13 | #[allow(clippy::enum_variant_names)] 14 | #[derive(Debug, Copy, Clone)] 15 | #[cfg_attr(test, derive(PartialEq, strum::EnumIter))] 16 | pub enum DeviceTag { 17 | DeviceName, 18 | DeviceNick, 19 | DeviceDescription, 20 | } 21 | 22 | #[derive(Debug, Copy, Clone)] 23 | #[cfg_attr(test, derive(PartialEq, strum::EnumIter))] 24 | pub enum NodeTag { 25 | NodeName, 26 | NodeNick, 27 | NodeDescription, 28 | MediaName, 29 | } 30 | 31 | #[allow(clippy::to_string_trait_impl)] // This is not for display. 32 | impl ToString for Tag { 33 | fn to_string(&self) -> String { 34 | match self { 35 | Tag::Device(DeviceTag::DeviceName) => { 36 | String::from("device:device.name") 37 | } 38 | Tag::Device(DeviceTag::DeviceNick) => { 39 | String::from("device:device.nick") 40 | } 41 | Tag::Device(DeviceTag::DeviceDescription) => { 42 | String::from("device:device.description") 43 | } 44 | Tag::Node(NodeTag::NodeName) => String::from("node:node.name"), 45 | Tag::Node(NodeTag::NodeNick) => String::from("node:node.nick"), 46 | Tag::Node(NodeTag::NodeDescription) => { 47 | String::from("node:node.description") 48 | } 49 | Tag::Node(NodeTag::MediaName) => String::from("node:media.name"), 50 | } 51 | } 52 | } 53 | 54 | impl std::str::FromStr for Tag { 55 | type Err = String; 56 | 57 | fn from_str(s: &str) -> Result { 58 | match s { 59 | "device:device.name" => Ok(Tag::Device(DeviceTag::DeviceName)), 60 | "device:device.nick" => Ok(Tag::Device(DeviceTag::DeviceNick)), 61 | "device:device.description" => { 62 | Ok(Tag::Device(DeviceTag::DeviceDescription)) 63 | } 64 | "node:node.name" => Ok(Tag::Node(NodeTag::NodeName)), 65 | "node:node.nick" => Ok(Tag::Node(NodeTag::NodeNick)), 66 | "node:node.description" => Ok(Tag::Node(NodeTag::NodeDescription)), 67 | "node:media.name" => Ok(Tag::Node(NodeTag::MediaName)), 68 | _ => Err(format!("\"{}\" is not implemented", s)), 69 | } 70 | } 71 | } 72 | 73 | #[cfg(test)] 74 | mod tests { 75 | use super::*; 76 | use strum::IntoEnumIterator; 77 | 78 | #[test] 79 | fn device_variants() { 80 | for device_tag in DeviceTag::iter() { 81 | // Do a round-trip conversion and compare results. 82 | let tag = Tag::Device(device_tag); 83 | let tag_str = tag.to_string(); 84 | let parsed_tag: Tag = tag_str.parse().unwrap(); 85 | assert_eq!(tag, parsed_tag); 86 | } 87 | } 88 | 89 | #[test] 90 | fn node_variants() { 91 | for node_tag in NodeTag::iter() { 92 | // Do a round-trip conversion and compare results. 93 | let tag = Tag::Node(node_tag); 94 | let tag_str = tag.to_string(); 95 | let parsed_tag: Tag = tag_str.parse().unwrap(); 96 | assert_eq!(tag, parsed_tag); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/config/theme.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use ratatui::style::{Color, Modifier, Style}; 4 | use serde::{de::Error, Deserialize}; 5 | 6 | use crate::config::Theme; 7 | 8 | // This is what actually gets parsed from the config. 9 | #[derive(Deserialize, Debug)] 10 | #[serde(deny_unknown_fields)] 11 | pub struct ThemeOverlay { 12 | inherit: Option, 13 | default_device: Option, 14 | default_stream: Option, 15 | selector: Option, 16 | tab: Option, 17 | tab_selected: Option, 18 | tab_marker: Option, 19 | list_more: Option, 20 | node_title: Option, 21 | node_target: Option, 22 | volume: Option, 23 | volume_empty: Option, 24 | volume_filled: Option, 25 | meter_inactive: Option, 26 | meter_active: Option, 27 | meter_overload: Option, 28 | meter_center_inactive: Option, 29 | meter_center_active: Option, 30 | config_device: Option, 31 | config_profile: Option, 32 | dropdown_icon: Option, 33 | dropdown_border: Option, 34 | dropdown_item: Option, 35 | dropdown_selected: Option, 36 | dropdown_more: Option, 37 | } 38 | 39 | #[derive(Deserialize, Debug)] 40 | #[serde(deny_unknown_fields)] 41 | struct StyleDef { 42 | pub fg: Option, 43 | pub bg: Option, 44 | pub underline_color: Option, 45 | #[serde(default = "default_modifier")] 46 | pub add_modifier: Modifier, 47 | #[serde(default = "default_modifier")] 48 | pub sub_modifier: Modifier, 49 | } 50 | 51 | fn default_modifier() -> Modifier { 52 | Modifier::empty() 53 | } 54 | 55 | impl From for Style { 56 | fn from(def: StyleDef) -> Self { 57 | Self { 58 | fg: def.fg, 59 | bg: def.bg, 60 | underline_color: def.underline_color, 61 | add_modifier: def.add_modifier, 62 | sub_modifier: def.sub_modifier, 63 | } 64 | } 65 | } 66 | 67 | impl TryFrom for Theme { 68 | type Error = anyhow::Error; 69 | 70 | fn try_from(overlay: ThemeOverlay) -> Result { 71 | let mut theme: Self = match overlay.inherit.as_deref() { 72 | Some("default") => Theme::default(), 73 | Some("nocolor") => Theme::nocolor(), 74 | Some("plain") => Theme::plain(), 75 | Some(inherit) => { 76 | anyhow::bail!("'{}' is not a built-in theme", inherit) 77 | } 78 | None => Theme::default(), 79 | }; 80 | 81 | macro_rules! set { 82 | ($field:ident) => { 83 | if let Some($field) = overlay.$field { 84 | theme.$field = $field.into(); 85 | } 86 | }; 87 | } 88 | 89 | set!(default_device); 90 | set!(default_stream); 91 | set!(selector); 92 | set!(tab); 93 | set!(tab_selected); 94 | set!(tab_marker); 95 | set!(list_more); 96 | set!(node_title); 97 | set!(node_target); 98 | set!(volume); 99 | set!(volume_empty); 100 | set!(volume_filled); 101 | set!(meter_inactive); 102 | set!(meter_active); 103 | set!(meter_overload); 104 | set!(meter_center_inactive); 105 | set!(meter_center_active); 106 | set!(config_device); 107 | set!(config_profile); 108 | set!(dropdown_icon); 109 | set!(dropdown_border); 110 | set!(dropdown_item); 111 | set!(dropdown_selected); 112 | set!(dropdown_more); 113 | 114 | Ok(theme) 115 | } 116 | } 117 | 118 | impl Default for Theme { 119 | fn default() -> Self { 120 | Self { 121 | default_device: Style::default(), 122 | default_stream: Style::default(), 123 | selector: Style::default().fg(Color::LightCyan), 124 | tab: Style::default(), 125 | tab_selected: Style::default().fg(Color::LightCyan), 126 | tab_marker: Style::default().fg(Color::LightCyan), 127 | list_more: Style::default().fg(Color::DarkGray), 128 | node_title: Style::default(), 129 | node_target: Style::default(), 130 | volume: Style::default(), 131 | volume_empty: Style::default().fg(Color::DarkGray), 132 | volume_filled: Style::default().fg(Color::LightBlue), 133 | meter_inactive: Style::default().fg(Color::DarkGray), 134 | meter_active: Style::default().fg(Color::LightGreen), 135 | meter_overload: Style::default().fg(Color::Red), 136 | meter_center_inactive: Style::default().fg(Color::DarkGray), 137 | meter_center_active: Style::default().fg(Color::LightGreen), 138 | config_device: Style::default(), 139 | config_profile: Style::default(), 140 | dropdown_icon: Style::default(), 141 | dropdown_border: Style::default(), 142 | dropdown_item: Style::default(), 143 | dropdown_selected: Style::default() 144 | .fg(Color::LightCyan) 145 | .add_modifier(Modifier::REVERSED), 146 | dropdown_more: Style::default().fg(Color::DarkGray), 147 | } 148 | } 149 | } 150 | 151 | impl Theme { 152 | pub fn defaults() -> HashMap { 153 | HashMap::from([ 154 | (String::from("default"), Theme::default()), 155 | (String::from("nocolor"), Theme::nocolor()), 156 | (String::from("plain"), Theme::plain()), 157 | ]) 158 | } 159 | 160 | fn nocolor() -> Self { 161 | Self { 162 | default_device: Style::default(), 163 | default_stream: Style::default(), 164 | selector: Style::default().add_modifier(Modifier::BOLD), 165 | tab: Style::default(), 166 | tab_selected: Style::default().add_modifier(Modifier::BOLD), 167 | tab_marker: Style::default().add_modifier(Modifier::BOLD), 168 | list_more: Style::default(), 169 | node_title: Style::default(), 170 | node_target: Style::default(), 171 | volume: Style::default(), 172 | volume_empty: Style::default().add_modifier(Modifier::DIM), 173 | volume_filled: Style::default().add_modifier(Modifier::BOLD), 174 | meter_inactive: Style::default().add_modifier(Modifier::DIM), 175 | meter_active: Style::default().add_modifier(Modifier::BOLD), 176 | meter_overload: Style::default().add_modifier(Modifier::BOLD), 177 | meter_center_inactive: Style::default().add_modifier(Modifier::DIM), 178 | meter_center_active: Style::default().add_modifier(Modifier::BOLD), 179 | config_device: Style::default(), 180 | config_profile: Style::default(), 181 | dropdown_icon: Style::default(), 182 | dropdown_border: Style::default(), 183 | dropdown_item: Style::default(), 184 | dropdown_selected: Style::default() 185 | .add_modifier(Modifier::REVERSED | Modifier::BOLD), 186 | dropdown_more: Style::default(), 187 | } 188 | } 189 | 190 | fn plain() -> Self { 191 | Self { 192 | default_device: Style::default(), 193 | default_stream: Style::default(), 194 | selector: Style::default(), 195 | tab: Style::default(), 196 | tab_selected: Style::default(), 197 | tab_marker: Style::default(), 198 | list_more: Style::default(), 199 | node_title: Style::default(), 200 | node_target: Style::default(), 201 | volume: Style::default(), 202 | volume_empty: Style::default(), 203 | volume_filled: Style::default(), 204 | meter_inactive: Style::default(), 205 | meter_active: Style::default(), 206 | meter_overload: Style::default(), 207 | meter_center_inactive: Style::default(), 208 | meter_center_active: Style::default(), 209 | config_device: Style::default(), 210 | config_profile: Style::default(), 211 | dropdown_icon: Style::default(), 212 | dropdown_border: Style::default(), 213 | dropdown_item: Style::default(), 214 | dropdown_selected: Style::default(), 215 | dropdown_more: Style::default(), 216 | } 217 | } 218 | 219 | /// Merge deserialized themes with defaults 220 | pub fn merge<'de, D>( 221 | deserializer: D, 222 | ) -> Result, D::Error> 223 | where 224 | D: serde::Deserializer<'de>, 225 | { 226 | let configured = 227 | HashMap::::deserialize(deserializer)?; 228 | let mut merged = configured 229 | .into_iter() 230 | .map(|(key, value)| { 231 | Theme::try_from(value) 232 | .map_err(D::Error::custom) 233 | .map(move |theme| (key, theme)) 234 | }) 235 | .collect::, D::Error>>()?; 236 | if !merged.contains_key("default") { 237 | merged.insert(String::from("default"), Theme::default()); 238 | } 239 | if !merged.contains_key("nocolor") { 240 | merged.insert(String::from("nocolor"), Theme::nocolor()); 241 | } 242 | if !merged.contains_key("plain") { 243 | merged.insert(String::from("plain"), Theme::plain()); 244 | } 245 | Ok(merged) 246 | } 247 | } 248 | 249 | #[cfg(test)] 250 | mod tests { 251 | use super::*; 252 | 253 | #[test] 254 | fn unknown_field_theme() { 255 | let config = r#" 256 | unknown = "unknown" 257 | "#; 258 | assert!(toml::from_str::(&config).is_err()); 259 | } 260 | 261 | #[test] 262 | fn unknown_field_style() { 263 | let config = r#" 264 | unknown = "unknown" 265 | "#; 266 | assert!(toml::from_str::(&config).is_err()); 267 | } 268 | 269 | #[test] 270 | fn inherit_nonexistent() { 271 | let config = r#" 272 | inherit = "doesntexist" 273 | tab_selected = { } 274 | "#; 275 | 276 | let overlay = toml::from_str::(&config).unwrap(); 277 | let theme = Theme::try_from(overlay); 278 | assert!(theme.is_err()); 279 | } 280 | 281 | #[test] 282 | fn inherit() { 283 | for (builtin_key, builtin) in Theme::defaults().iter() { 284 | let config = format!( 285 | r#" 286 | inherit = "{}" 287 | tab_selected = {{ }} 288 | "#, 289 | builtin_key 290 | ); 291 | 292 | let overlay = toml::from_str::(&config).unwrap(); 293 | let theme = Theme::try_from(overlay).unwrap(); 294 | assert_eq!(theme.tab_selected, Style::default()); 295 | assert_eq!(theme.selector, builtin.selector); 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/device_kind.rs: -------------------------------------------------------------------------------- 1 | //! Type representing whether a device is sink or source. 2 | 3 | #[derive(Debug, Clone, Copy)] 4 | pub enum DeviceKind { 5 | Sink, 6 | Source, 7 | } 8 | -------------------------------------------------------------------------------- /src/device_widget.rs: -------------------------------------------------------------------------------- 1 | //! A Ratatui widget representing a single PipeWire node in an object list. 2 | 3 | use ratatui::{ 4 | layout::Flex, 5 | prelude::{Buffer, Constraint, Direction, Layout, Rect}, 6 | text::{Line, Span}, 7 | widgets::{StatefulWidget, Widget}, 8 | }; 9 | 10 | use crossterm::event::{MouseButton, MouseEventKind}; 11 | use smallvec::smallvec; 12 | 13 | use crate::app::{Action, MouseArea}; 14 | use crate::config::Config; 15 | use crate::object_list::ObjectList; 16 | use crate::view; 17 | 18 | pub struct DeviceWidget<'a> { 19 | device: &'a view::Device, 20 | selected: bool, 21 | config: &'a Config, 22 | } 23 | 24 | impl<'a> DeviceWidget<'a> { 25 | pub fn new( 26 | device: &'a view::Device, 27 | selected: bool, 28 | config: &'a Config, 29 | ) -> Self { 30 | Self { 31 | device, 32 | selected, 33 | config, 34 | } 35 | } 36 | 37 | /// Height of a full device display. 38 | pub fn height() -> u16 { 39 | 3 40 | } 41 | 42 | /// Spacing between objects 43 | pub fn spacing() -> u16 { 44 | 2 45 | } 46 | 47 | /// Area for the target dropdown 48 | pub fn dropdown_area( 49 | object_list: &ObjectList, 50 | list_area: &Rect, 51 | object_area: &Rect, 52 | ) -> Rect { 53 | // Number of items to show at once 54 | let max_visible_items = 5; 55 | 56 | let max_target_length = object_list 57 | .targets 58 | .iter() 59 | .map(|(_, title)| title.len()) 60 | .max() 61 | .unwrap_or(0); 62 | 63 | // Position the dropdown so that the first item is over the displayed item 64 | let x = list_area.left().saturating_add(4); 65 | let y = object_area.top().saturating_add(1); 66 | // Add 2 for vertical borders and 2 for highlight symbol 67 | let width = max_target_length.saturating_add(4) as u16; 68 | let height = std::cmp::min(max_visible_items, object_list.targets.len()) 69 | .saturating_add(2) as u16; // Add 2 for horizontal borders 70 | 71 | Rect::new(x, y, width, height) 72 | } 73 | } 74 | 75 | impl StatefulWidget for DeviceWidget<'_> { 76 | type State = Vec; 77 | 78 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 79 | let mouse_areas = state; 80 | 81 | mouse_areas.push(( 82 | area, 83 | smallvec![MouseEventKind::Down(MouseButton::Left)], 84 | smallvec![Action::SelectObject(self.device.id)], 85 | )); 86 | 87 | let layout = Layout::default() 88 | .direction(Direction::Horizontal) 89 | .constraints([ 90 | Constraint::Length(1), // selected_area 91 | Constraint::Min(0), // node_area 92 | ]) 93 | .split(area); 94 | let selected_area = layout[0]; 95 | let node_area = layout[1]; 96 | 97 | if self.selected { 98 | let rows = Layout::default() 99 | .direction(Direction::Vertical) 100 | .constraints([ 101 | Constraint::Length(1), 102 | Constraint::Length(1), 103 | Constraint::Length(1), 104 | ]) 105 | .split(selected_area); 106 | 107 | let style = self.config.theme.selector; 108 | 109 | Line::from(Span::styled(&self.config.char_set.selector_top, style)) 110 | .render(rows[0], buf); 111 | Line::from(Span::styled( 112 | &self.config.char_set.selector_middle, 113 | style, 114 | )) 115 | .render(rows[1], buf); 116 | Line::from(Span::styled( 117 | &self.config.char_set.selector_bottom, 118 | style, 119 | )) 120 | .render(rows[2], buf); 121 | } 122 | 123 | let layout = Layout::default() 124 | .direction(Direction::Vertical) 125 | .constraints([ 126 | Constraint::Length(1), // title_area 127 | Constraint::Length(1), // target_area 128 | ]) 129 | .spacing(1) 130 | .flex(Flex::Legacy) 131 | .split(node_area); 132 | let title_area = layout[0]; 133 | let target_area = layout[1]; 134 | 135 | Line::from(vec![ 136 | Span::from(" "), 137 | Span::styled(&self.device.title, self.config.theme.config_device), 138 | ]) 139 | .render(title_area, buf); 140 | 141 | Line::from(vec![ 142 | Span::from(" "), 143 | Span::styled( 144 | &self.config.char_set.dropdown_icon, 145 | self.config.theme.dropdown_icon, 146 | ), 147 | Span::from(" "), 148 | Span::styled( 149 | &self.device.target_title, 150 | self.config.theme.config_profile, 151 | ), 152 | ]) 153 | .render(target_area, buf); 154 | 155 | mouse_areas.push(( 156 | target_area, 157 | smallvec![MouseEventKind::Down(MouseButton::Left)], 158 | smallvec![ 159 | Action::SelectObject(self.device.id), 160 | Action::ActivateDropdown 161 | ], 162 | )); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/dropdown_widget.rs: -------------------------------------------------------------------------------- 1 | //! A Ratatui widget for a dropdown menu of options pertaining to a node or device 2 | //! widget. 3 | 4 | use ratatui::{ 5 | prelude::{Alignment, Buffer, Rect, Widget}, 6 | text::{Line, Span}, 7 | widgets::{Block, Borders, Clear, List, StatefulWidget}, 8 | }; 9 | 10 | use crossterm::event::{MouseButton, MouseEventKind}; 11 | use smallvec::smallvec; 12 | 13 | use crate::app::{Action, MouseArea}; 14 | use crate::config::Config; 15 | use crate::object_list::ObjectList; 16 | 17 | pub struct DropdownWidget<'a> { 18 | object_list: &'a mut ObjectList, 19 | dropdown_area: &'a Rect, 20 | config: &'a Config, 21 | } 22 | 23 | impl<'a> DropdownWidget<'a> { 24 | pub fn new( 25 | object_list: &'a mut ObjectList, 26 | dropdown_area: &'a Rect, 27 | config: &'a Config, 28 | ) -> Self { 29 | Self { 30 | object_list, 31 | dropdown_area, 32 | config, 33 | } 34 | } 35 | } 36 | 37 | impl StatefulWidget for DropdownWidget<'_> { 38 | type State = Vec; 39 | 40 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 41 | let mouse_areas = state; 42 | 43 | let targets: Vec<_> = self 44 | .object_list 45 | .targets 46 | .iter() 47 | .map(|(_, title)| title.clone()) 48 | .collect(); 49 | 50 | let dropdown_area = self.dropdown_area.clamp(area); 51 | 52 | // Click anywhere else in the object list to close the dropdown. 53 | mouse_areas.push(( 54 | area, 55 | smallvec![MouseEventKind::Down(MouseButton::Left)], 56 | smallvec![Action::CloseDropdown], 57 | )); 58 | 59 | // But clicking on the border does nothing. 60 | mouse_areas.push(( 61 | dropdown_area, 62 | smallvec![MouseEventKind::Down(MouseButton::Left)], 63 | smallvec![], 64 | )); 65 | 66 | Clear.render(dropdown_area, buf); 67 | 68 | let highlight_symbol = 69 | format!("{} ", self.config.char_set.dropdown_selector); 70 | let list = List::new(targets) 71 | .block( 72 | Block::default() 73 | .borders(Borders::ALL) 74 | .border_style(self.config.theme.dropdown_border) 75 | .border_type(self.config.char_set.dropdown_border), 76 | ) 77 | .style(self.config.theme.dropdown_item) 78 | .highlight_symbol(&highlight_symbol) 79 | .highlight_style(self.config.theme.dropdown_selected); 80 | 81 | StatefulWidget::render( 82 | &list, 83 | dropdown_area, 84 | buf, 85 | &mut self.object_list.list_state, 86 | ); 87 | 88 | let first_index = self.object_list.list_state.offset(); 89 | 90 | // Add a clickable indicator to the top border if there or more items 91 | // if scrolled up 92 | if first_index > 0 { 93 | let top_area = Rect::new( 94 | dropdown_area.x, 95 | dropdown_area.y, 96 | dropdown_area.width, 97 | 1, 98 | ); 99 | 100 | Line::from(Span::styled( 101 | &self.config.char_set.dropdown_more, 102 | self.config.theme.dropdown_more, 103 | )) 104 | .alignment(Alignment::Center) 105 | .render(top_area, buf); 106 | 107 | mouse_areas.push(( 108 | top_area, 109 | smallvec![MouseEventKind::Down(MouseButton::Left)], 110 | smallvec![Action::MoveUp], 111 | )); 112 | } 113 | 114 | // Subtract 2 for vertical borders 115 | let dropdown_area_inner_height = 116 | (dropdown_area.height as usize).saturating_sub(2); 117 | let last_index = first_index.saturating_add(dropdown_area_inner_height); 118 | // Add a clickable indicator to the bottom border if there or more 119 | // items if scrolled down 120 | if last_index < self.object_list.targets.len() { 121 | let y = dropdown_area 122 | .y 123 | .saturating_add(dropdown_area.height.saturating_sub(1)); 124 | let bottom_area = 125 | Rect::new(dropdown_area.x, y, dropdown_area.width, 1); 126 | 127 | Line::from(Span::styled( 128 | &self.config.char_set.dropdown_more, 129 | self.config.theme.dropdown_more, 130 | )) 131 | .alignment(Alignment::Center) 132 | .render(bottom_area, buf); 133 | 134 | mouse_areas.push(( 135 | bottom_area, 136 | smallvec![MouseEventKind::Down(MouseButton::Left)], 137 | smallvec![Action::MoveDown], 138 | )); 139 | } 140 | 141 | for i in 0..(dropdown_area.height - 2) { 142 | let target_area = Rect::new( 143 | dropdown_area.x, 144 | dropdown_area.y.saturating_add(1).saturating_add(i), 145 | dropdown_area.width, 146 | 1, 147 | ); 148 | 149 | let target = self 150 | .object_list 151 | .targets 152 | .iter() 153 | .skip(first_index) 154 | .nth(i as usize) 155 | .map(|(target, _)| target); 156 | if let Some(target) = target { 157 | mouse_areas.push(( 158 | target_area, 159 | smallvec![MouseEventKind::Down(MouseButton::Left)], 160 | smallvec![Action::SetTarget(*target)], 161 | )); 162 | } 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | //! Input events for the application. 2 | //! 3 | //! These come from [`monitor`](`crate::monitor`) (PipeWire events) and from 4 | //! [`input`](`crate::input`) (terminal input events). 5 | 6 | use pipewire::link::LinkInfoRef; 7 | 8 | use crate::media_class::MediaClass; 9 | use crate::object::ObjectId; 10 | 11 | #[derive(Debug)] 12 | pub enum MonitorEvent { 13 | DeviceDescription(ObjectId, String), 14 | DeviceEnumRoute(ObjectId, i32, String, bool, Vec, Vec), 15 | DeviceMediaClass(ObjectId, MediaClass), 16 | DeviceName(ObjectId, String), 17 | DeviceNick(ObjectId, String), 18 | DeviceEnumProfile(ObjectId, i32, String, bool, Vec<(MediaClass, Vec)>), 19 | DeviceProfile(ObjectId, i32), 20 | DeviceRoute(ObjectId, i32, i32, Vec, String, bool, Vec, bool), 21 | DeviceObjectSerial(ObjectId, i32), 22 | 23 | MetadataMetadataName(ObjectId, String), 24 | MetadataProperty(ObjectId, u32, Option, Option), 25 | 26 | NodeCardProfileDevice(ObjectId, i32), 27 | NodeDescription(ObjectId, String), 28 | NodeDeviceId(ObjectId, ObjectId), 29 | NodeMediaClass(ObjectId, MediaClass), 30 | NodeMediaName(ObjectId, String), 31 | NodeName(ObjectId, String), 32 | NodeNick(ObjectId, String), 33 | NodeObjectSerial(ObjectId, i32), 34 | NodePeaks(ObjectId, Vec, u32), 35 | NodePositions(ObjectId, Vec), 36 | NodeRate(ObjectId, u32), 37 | NodeVolumes(ObjectId, Vec), 38 | NodeMute(ObjectId, bool), 39 | 40 | Link(ObjectId, ObjectId, ObjectId), 41 | 42 | StreamStopped(ObjectId), 43 | 44 | Removed(ObjectId), 45 | } 46 | 47 | impl From<&LinkInfoRef> for MonitorEvent { 48 | fn from(link_info: &LinkInfoRef) -> Self { 49 | MonitorEvent::Link( 50 | ObjectId::from_raw_id(link_info.id()), 51 | ObjectId::from_raw_id(link_info.output_node_id()), 52 | ObjectId::from_raw_id(link_info.input_node_id()), 53 | ) 54 | } 55 | } 56 | 57 | #[derive(Debug)] 58 | pub enum Event { 59 | Input(crossterm::event::Event), 60 | Monitor(MonitorEvent), 61 | Error(String), 62 | Ready, 63 | } 64 | 65 | impl From for Event { 66 | fn from(event: crossterm::event::Event) -> Self { 67 | Event::Input(event) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/input.rs: -------------------------------------------------------------------------------- 1 | //! Setup and teardown of terminal input. 2 | //! 3 | //! [`spawn()`] starts the input thead. 4 | 5 | use std::sync::{mpsc, Arc}; 6 | use std::thread; 7 | use std::time::Duration; 8 | 9 | use crossterm::event::EventStream; 10 | use futures::{channel::oneshot, FutureExt, StreamExt}; 11 | use futures_timer::Delay; 12 | 13 | use crate::event::Event; 14 | 15 | /// Spawns a thread to listen for terminal input events. 16 | /// 17 | /// [`Event`](`crate::event::Event`)s are sent to tx. 18 | /// 19 | /// Returns a [`InputHandle`] to automatically clean up the thread. 20 | pub fn spawn(tx: Arc>) -> InputHandle { 21 | let (shutdown_tx, shutdown_rx) = oneshot::channel(); 22 | 23 | let handle = thread::spawn(move || { 24 | futures::executor::block_on(async move { 25 | input_loop(shutdown_rx, tx).await; 26 | }); 27 | }); 28 | 29 | InputHandle { 30 | tx: Some(shutdown_tx), 31 | handle: Some(handle), 32 | } 33 | } 34 | 35 | /// Handle for the input thread. 36 | /// 37 | /// On cleanup, the thread will be notified to quit and will be joined. 38 | pub struct InputHandle { 39 | tx: Option>, 40 | handle: Option>, 41 | } 42 | 43 | impl Drop for InputHandle { 44 | fn drop(&mut self) { 45 | if let Some(tx) = self.tx.take() { 46 | let _ = tx.send(()); 47 | } 48 | if let Some(handle) = self.handle.take() { 49 | let _ = handle.join(); 50 | } 51 | } 52 | } 53 | 54 | async fn input_loop( 55 | shutdown_rx: oneshot::Receiver<()>, 56 | tx: Arc>, 57 | ) { 58 | let mut reader = EventStream::new(); 59 | let mut shutdown = shutdown_rx.fuse(); 60 | 61 | loop { 62 | let mut delay = Delay::new(Duration::from_millis(1_000)).fuse(); 63 | let mut event = reader.next().fuse(); 64 | 65 | futures::select! { 66 | _ = shutdown => break, 67 | _ = delay => { }, 68 | maybe_event = event => { 69 | match maybe_event { 70 | Some(Ok(event)) => { 71 | let _ = tx.send(Event::from(event)); 72 | } 73 | None => break, 74 | _ => {}, 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod capture_manager; 3 | pub mod command; 4 | pub mod config; 5 | pub mod device_kind; 6 | pub mod device_widget; 7 | pub mod dropdown_widget; 8 | pub mod event; 9 | pub mod input; 10 | pub mod media_class; 11 | pub mod meter; 12 | pub mod monitor; 13 | pub mod node_widget; 14 | pub mod object; 15 | pub mod object_list; 16 | pub mod opt; 17 | pub mod state; 18 | pub mod truncate; 19 | pub mod view; 20 | 21 | #[cfg(feature = "trace")] 22 | pub mod trace; 23 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::io::stdout; 2 | use std::sync::{mpsc, Arc}; 3 | 4 | use anyhow::Result; 5 | 6 | use crossterm::{ 7 | event::{DisableMouseCapture, EnableMouseCapture}, 8 | ExecutableCommand, 9 | }; 10 | 11 | use wiremix::app; 12 | use wiremix::command::Command; 13 | use wiremix::config::Config; 14 | use wiremix::input; 15 | use wiremix::monitor; 16 | use wiremix::opt::Opt; 17 | 18 | fn main() -> Result<()> { 19 | // Event channel for sending PipeWire and input events to the UI 20 | let (event_tx, event_rx) = mpsc::channel(); 21 | let event_tx = Arc::new(event_tx); 22 | 23 | // Command channel for the UI to send commands to control PipeWire 24 | let (command_tx, command_rx) = pipewire::channel::channel::(); 25 | 26 | // Parse command-line arguments 27 | let opt = Opt::parse(); 28 | 29 | let config_default_path = Config::default_path(); 30 | let config_path = opt.config.as_deref().or(config_default_path.as_deref()); 31 | 32 | let config = Config::try_new(config_path, &opt)?; 33 | 34 | // Spawn the PipeWire monitor 35 | let _monitor_handle = monitor::spawn( 36 | config.remote.clone(), 37 | Arc::clone(&event_tx), 38 | command_rx, 39 | )?; 40 | let _input_handle = input::spawn(Arc::clone(&event_tx)); 41 | 42 | #[cfg(debug_assertions)] 43 | if opt.dump_events { 44 | // Event dumping mode for debugging the monitor code 45 | for received in event_rx { 46 | use wiremix::event::Event; 47 | match received { 48 | Event::Monitor(event) => print!("{:?}\r\n", event), 49 | event => { 50 | print!("{:?}\r\n", event); 51 | } 52 | } 53 | } 54 | 55 | return Ok(()); 56 | } 57 | 58 | // Normal UI mode 59 | let support_mouse = config.mouse; 60 | if support_mouse { 61 | stdout().execute(EnableMouseCapture)?; 62 | } 63 | let mut terminal = ratatui::init(); 64 | let app_result = 65 | app::App::new(command_tx, event_rx, config).run(&mut terminal); 66 | ratatui::restore(); 67 | if support_mouse { 68 | stdout().execute(DisableMouseCapture)?; 69 | } 70 | 71 | app_result 72 | } 73 | -------------------------------------------------------------------------------- /src/media_class.rs: -------------------------------------------------------------------------------- 1 | //! Type representing PipeWire media classes. 2 | 3 | #[derive(Debug, Clone, PartialEq)] 4 | pub struct MediaClass(String); 5 | 6 | impl From<&str> for MediaClass { 7 | fn from(s: &str) -> Self { 8 | MediaClass(String::from(s)) 9 | } 10 | } 11 | 12 | impl MediaClass { 13 | pub fn is_sink(&self) -> bool { 14 | matches!(self.0.as_str(), "Audio/Sink" | "Audio/Duplex") 15 | } 16 | 17 | pub fn is_source(&self) -> bool { 18 | matches!( 19 | self.0.as_str(), 20 | "Audio/Source" | "Audio/Duplex" | "Audio/Source/Virtual" 21 | ) 22 | } 23 | 24 | pub fn is_sink_input(&self) -> bool { 25 | self.0 == "Stream/Output/Audio" 26 | } 27 | 28 | pub fn is_source_output(&self) -> bool { 29 | self.0 == "Stream/Input/Audio" 30 | } 31 | 32 | pub fn is_monitor(&self) -> bool { 33 | self.0 == "Audio/Sink" 34 | } 35 | 36 | pub fn is_recordable(&self) -> bool { 37 | self.is_source() || self.is_sink() || self.is_sink_input() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/meter.rs: -------------------------------------------------------------------------------- 1 | //! Peak level meter rendering. 2 | 3 | use ratatui::{ 4 | prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect, Widget}, 5 | text::{Line, Span}, 6 | }; 7 | 8 | use crate::config::Config; 9 | 10 | fn render_peak(peak: f32, area: Rect) -> (usize, usize, usize) { 11 | fn normalize(value: f32) -> f32 { 12 | let amplitude = 10.0_f32.powf(value / 60.0); 13 | let min = 10.0_f32.powf(-60.0 / 60.0); 14 | let max = 10.0_f32.powf(6.0 / 60.0); 15 | 16 | (amplitude - min) / (max - min) 17 | } 18 | 19 | // Convert to dB between -20 and +3 20 | let db = 20.0 * (peak + 1e-10).log10(); 21 | let vu_value = db.clamp(-60.0, 6.0); 22 | 23 | let meter = normalize(vu_value); 24 | 25 | let total_chars = area.width as usize; 26 | let lit = ((meter * total_chars as f32).round() as usize).min(total_chars); 27 | 28 | // Values above 0.0 will be colored differently 29 | let zero_char = (normalize(0.0) * total_chars as f32).round() as usize; 30 | 31 | // Assign colors 32 | let active_size = lit.min(zero_char); 33 | let overload_size = lit.saturating_sub(zero_char); 34 | let inactive_size = total_chars 35 | .saturating_sub(active_size) 36 | .saturating_sub(overload_size); 37 | 38 | (active_size, overload_size, inactive_size) 39 | } 40 | 41 | pub fn render_stereo( 42 | meter_area: Rect, 43 | buf: &mut Buffer, 44 | peaks: Option<(f32, f32)>, 45 | config: &Config, 46 | ) { 47 | let layout = Layout::default() 48 | .direction(Direction::Horizontal) 49 | .constraints([ 50 | Constraint::Fill(2), // meter_left 51 | Constraint::Length(2), // meter_live 52 | Constraint::Fill(2), // meter_right 53 | ]) 54 | .spacing(1) 55 | .split(meter_area); 56 | let meter_left = layout[0]; 57 | let meter_live = layout[1]; 58 | let meter_right = layout[2]; 59 | 60 | let (left_peak, right_peak) = peaks.unwrap_or_default(); 61 | 62 | let area = meter_left; 63 | let (active_peak, overload_peak, inactive_peak) = 64 | render_peak(left_peak, area); 65 | Line::from(vec![ 66 | Span::styled( 67 | config.char_set.meter_left_inactive.repeat(inactive_peak), 68 | config.theme.meter_inactive, 69 | ), 70 | Span::styled( 71 | config.char_set.meter_left_overload.repeat(overload_peak), 72 | config.theme.meter_overload, 73 | ), 74 | Span::styled( 75 | config.char_set.meter_left_active.repeat(active_peak), 76 | config.theme.meter_active, 77 | ), 78 | ]) 79 | .alignment(Alignment::Right) 80 | .render(area, buf); 81 | 82 | let area = meter_right; 83 | let (active_peak, overload_peak, inactive_peak) = 84 | render_peak(right_peak, area); 85 | Line::from(vec![ 86 | Span::styled( 87 | config.char_set.meter_right_active.repeat(active_peak), 88 | config.theme.meter_active, 89 | ), 90 | Span::styled( 91 | config.char_set.meter_right_overload.repeat(overload_peak), 92 | config.theme.meter_overload, 93 | ), 94 | Span::styled( 95 | config.char_set.meter_right_inactive.repeat(inactive_peak), 96 | config.theme.meter_inactive, 97 | ), 98 | ]) 99 | .render(area, buf); 100 | 101 | let live_line = if peaks.is_some() { 102 | Line::from(Span::styled( 103 | format!( 104 | "{}{}", 105 | &config.char_set.meter_center_left_active, 106 | &config.char_set.meter_center_right_active, 107 | ), 108 | config.theme.meter_center_active, 109 | )) 110 | } else { 111 | Line::from(Span::styled( 112 | format!( 113 | "{}{}", 114 | &config.char_set.meter_center_left_inactive, 115 | &config.char_set.meter_center_right_inactive 116 | ), 117 | config.theme.meter_center_inactive, 118 | )) 119 | }; 120 | live_line.render(meter_live, buf); 121 | } 122 | 123 | pub fn render_mono( 124 | meter_area: Rect, 125 | buf: &mut Buffer, 126 | peak: Option, 127 | config: &Config, 128 | ) { 129 | let mono_peak = peak.unwrap_or_default(); 130 | 131 | let layout = Layout::default() 132 | .direction(Direction::Horizontal) 133 | .constraints([ 134 | Constraint::Length(1), // meter_live 135 | Constraint::Fill(2), // meter_mono 136 | ]) 137 | .spacing(1) 138 | .split(meter_area); 139 | let meter_live = layout[0]; 140 | let meter_mono = layout[1]; 141 | 142 | let area = meter_mono; 143 | let (active_peak, overload_peak, inactive_peak) = 144 | render_peak(mono_peak, area); 145 | Line::from(vec![ 146 | Span::styled( 147 | config.char_set.meter_right_active.repeat(active_peak), 148 | config.theme.meter_active, 149 | ), 150 | Span::styled( 151 | config.char_set.meter_right_overload.repeat(overload_peak), 152 | config.theme.meter_overload, 153 | ), 154 | Span::styled( 155 | config.char_set.meter_right_inactive.repeat(inactive_peak), 156 | config.theme.meter_inactive, 157 | ), 158 | ]) 159 | .render(area, buf); 160 | 161 | let live_line = if peak.is_some() { 162 | Line::from(Span::styled( 163 | &config.char_set.meter_center_right_active, 164 | config.theme.meter_center_active, 165 | )) 166 | } else { 167 | Line::from(Span::styled( 168 | &config.char_set.meter_center_right_inactive, 169 | config.theme.meter_center_inactive, 170 | )) 171 | }; 172 | live_line.render(meter_live, buf); 173 | } 174 | -------------------------------------------------------------------------------- /src/monitor.rs: -------------------------------------------------------------------------------- 1 | //! Setup and teardown of PipeWire monitoring. 2 | //! 3 | //! [`spawn()`] starts a PipeWire monitoring thread. 4 | 5 | mod deserialize; 6 | mod device; 7 | mod event_sender; 8 | mod execute; 9 | mod link; 10 | mod metadata; 11 | mod node; 12 | mod proxy_registry; 13 | mod stream; 14 | mod stream_registry; 15 | mod sync_registry; 16 | 17 | use anyhow::Result; 18 | use std::cell::RefCell; 19 | use std::rc::Rc; 20 | use std::sync::{mpsc, Arc}; 21 | use std::thread; 22 | 23 | use nix::sys::eventfd::{EfdFlags, EventFd}; 24 | use std::os::fd::AsRawFd; 25 | 26 | use pipewire::{ 27 | main_loop::MainLoop, properties::properties, proxy::ProxyT, 28 | types::ObjectType, 29 | }; 30 | 31 | use crate::command::Command; 32 | use crate::event::{Event, MonitorEvent}; 33 | use crate::monitor::{ 34 | event_sender::EventSender, proxy_registry::ProxyRegistry, 35 | stream_registry::StreamRegistry, sync_registry::SyncRegistry, 36 | }; 37 | use crate::object::ObjectId; 38 | 39 | /// Spawns a thread to monitor the PipeWire instance. 40 | /// 41 | /// [`Event`](`crate::event::Event`)s from PipeWire are sent to `tx`. 42 | /// [`Command`](`crate::command::Command`)s sent to `rx` will be executed. 43 | /// 44 | /// Returns a [`MonitorHandle`] to automatically clean up the thread. 45 | pub fn spawn( 46 | remote: Option, 47 | tx: Arc>, 48 | rx: pipewire::channel::Receiver, 49 | ) -> Result { 50 | let shutdown_fd = 51 | Arc::new(EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?); 52 | 53 | let handle = thread::spawn({ 54 | let shutdown_fd = Arc::clone(&shutdown_fd); 55 | move || { 56 | let _ = run(remote, tx, rx, shutdown_fd); 57 | } 58 | }); 59 | 60 | Ok(MonitorHandle { 61 | fd: Some(shutdown_fd), 62 | handle: Some(handle), 63 | }) 64 | } 65 | 66 | /// Wrapper for handling PipeWire initialization/deinitialization. 67 | fn run( 68 | remote: Option, 69 | tx: Arc>, 70 | rx: pipewire::channel::Receiver, 71 | shutdown_fd: Arc, 72 | ) -> Result<()> { 73 | pipewire::init(); 74 | 75 | let _guard = scopeguard::guard((), |_| unsafe { 76 | pipewire::deinit(); 77 | }); 78 | 79 | let main_loop = MainLoop::new(None)?; 80 | let sender = Rc::new(EventSender::new(tx, main_loop.downgrade())); 81 | 82 | let err_sender = Rc::clone(&sender); 83 | monitor_pipewire(remote, main_loop, sender, rx, shutdown_fd) 84 | .unwrap_or_else(move |e| { 85 | err_sender.send_error(e.to_string()); 86 | }); 87 | 88 | Ok(()) 89 | } 90 | 91 | /// Handle for a PipeWire monitoring thread. 92 | /// 93 | /// On cleanup, the PipeWire [`MainLoop`](`pipewire::main_loop::MainLoop`) will 94 | /// be notified to [`quit()`](`pipewire::main_loop::MainLoop::quit()`), and the 95 | /// thread will be joined. 96 | pub struct MonitorHandle { 97 | fd: Option>, 98 | handle: Option>, 99 | } 100 | 101 | impl Drop for MonitorHandle { 102 | fn drop(&mut self) { 103 | if let Some(fd) = self.fd.take() { 104 | let _ = fd.arm(); 105 | } 106 | if let Some(handle) = self.handle.take() { 107 | let _ = handle.join(); 108 | } 109 | } 110 | } 111 | 112 | /// Monitors PipeWire. 113 | /// 114 | /// Sets up core listeners and runs the PipeWire main loop. 115 | fn monitor_pipewire( 116 | remote: Option, 117 | main_loop: MainLoop, 118 | sender: Rc, 119 | rx: pipewire::channel::Receiver, 120 | shutdown_fd: Arc, 121 | ) -> Result<()> { 122 | let context = pipewire::context::Context::new(&main_loop)?; 123 | let props = remote.map(|remote| { 124 | properties! { 125 | *pipewire::keys::REMOTE_NAME => remote 126 | } 127 | }); 128 | let core = Rc::new(context.connect(props)?); 129 | 130 | let fd = shutdown_fd.as_raw_fd(); 131 | let _shutdown_watch = 132 | main_loop 133 | .loop_() 134 | .add_io(fd, libspa::support::system::IoFlags::IN, { 135 | let main_loop_weak = main_loop.downgrade(); 136 | move |_status| { 137 | if let Some(main_loop) = main_loop_weak.upgrade() { 138 | main_loop.quit(); 139 | } 140 | } 141 | }); 142 | 143 | let syncs = Rc::new(RefCell::new(SyncRegistry::default())); 144 | 145 | let _core_listener = core 146 | .add_listener_local() 147 | .done({ 148 | let sender_weak = Rc::downgrade(&sender); 149 | let syncs_weak = Rc::downgrade(&syncs); 150 | move |_id, seq| { 151 | let Some(sender) = sender_weak.upgrade() else { 152 | return; 153 | }; 154 | let Some(syncs) = syncs_weak.upgrade() else { 155 | return; 156 | }; 157 | if syncs.borrow_mut().done(seq) { 158 | sender.send_ready(); 159 | } 160 | } 161 | }) 162 | .error({ 163 | let sender_weak = Rc::downgrade(&sender); 164 | move |_id, _seq, _res, message| { 165 | if let Some(sender) = sender_weak.upgrade() { 166 | sender.send_error(message.to_string()); 167 | }; 168 | } 169 | }) 170 | .register(); 171 | 172 | let registry = Rc::new(core.get_registry()?); 173 | let registry_weak = Rc::downgrade(®istry); 174 | 175 | // Proxies and their listeners need to stay alive so store them here 176 | let proxies = Rc::new(RefCell::new(ProxyRegistry::try_new()?)); 177 | // It's not safe to delete proxies and listeners during PipeWire callbacks, 178 | // so registries defer cleanup and use an EventFd to signal that objects 179 | // are pending deletion. 180 | let _proxy_gc_watch = main_loop.loop_().add_io( 181 | proxies.borrow().gc_fd.as_raw_fd(), 182 | libspa::support::system::IoFlags::IN, 183 | { 184 | let proxies = Rc::clone(&proxies); 185 | move |_status| { 186 | proxies.borrow_mut().collect_garbage(); 187 | } 188 | }, 189 | ); 190 | 191 | // Proxies and their listeners need to stay alive so store them here 192 | let streams = Rc::new(RefCell::new(StreamRegistry::try_new()?)); 193 | // It's not safe to delete proxies and listeners during PipeWire callbacks, 194 | // so registries defer cleanup and use an EventFd to signal that objects 195 | // are pending deletion. 196 | let _streams_gc_watch = main_loop.loop_().add_io( 197 | streams.borrow().gc_fd.as_raw_fd(), 198 | libspa::support::system::IoFlags::IN, 199 | { 200 | let streams = Rc::clone(&streams); 201 | let sender_weak = Rc::downgrade(&sender); 202 | move |_status| { 203 | let collected = streams.borrow_mut().collect_garbage(); 204 | if let Some(sender) = sender_weak.upgrade() { 205 | for id in collected { 206 | sender.send(MonitorEvent::StreamStopped(id)); 207 | } 208 | } 209 | } 210 | }, 211 | ); 212 | 213 | let _registry_listener = registry 214 | .add_listener_local() 215 | .global({ 216 | let core_weak = Rc::downgrade(&core); 217 | let proxies = Rc::clone(&proxies); 218 | let sender_weak = Rc::downgrade(&sender); 219 | let streams_weak = Rc::downgrade(&streams); 220 | let syncs_weak = Rc::downgrade(&syncs); 221 | move |obj| { 222 | let obj_id = ObjectId::from(obj); 223 | let Some(registry) = registry_weak.upgrade() else { 224 | return; 225 | }; 226 | 227 | let Some(sender) = sender_weak.upgrade() else { 228 | return; 229 | }; 230 | 231 | let Some(streams) = streams_weak.upgrade() else { 232 | return; 233 | }; 234 | 235 | let Some(core) = core_weak.upgrade() else { 236 | return; 237 | }; 238 | 239 | let Some(syncs) = syncs_weak.upgrade() else { 240 | return; 241 | }; 242 | 243 | let proxy_spe = match obj.type_ { 244 | ObjectType::Node => { 245 | let result = 246 | node::monitor_node(®istry, obj, &sender); 247 | if let Some((node, listener)) = result { 248 | proxies.borrow_mut().add_node( 249 | obj_id, 250 | Rc::clone(&node), 251 | listener, 252 | ); 253 | Some(node as Rc) 254 | } else { 255 | None 256 | } 257 | } 258 | ObjectType::Device => { 259 | let result = 260 | device::monitor_device(®istry, obj, &sender); 261 | match result { 262 | Some((device, listener)) => { 263 | proxies.borrow_mut().add_device( 264 | obj_id, 265 | Rc::clone(&device), 266 | listener, 267 | ); 268 | Some(device as Rc) 269 | } 270 | None => None, 271 | } 272 | } 273 | ObjectType::Link => { 274 | let result = 275 | link::monitor_link(®istry, obj, &sender); 276 | match result { 277 | Some((link, listener)) => { 278 | proxies.borrow_mut().add_link( 279 | obj_id, 280 | Rc::clone(&link), 281 | listener, 282 | ); 283 | Some(link as Rc) 284 | } 285 | None => None, 286 | } 287 | } 288 | ObjectType::Metadata => { 289 | let result = 290 | metadata::monitor_metadata(®istry, obj, &sender); 291 | match result { 292 | Some((metadata, listener)) => { 293 | proxies.borrow_mut().add_metadata( 294 | obj_id, 295 | Rc::clone(&metadata), 296 | listener, 297 | ); 298 | Some(metadata as Rc) 299 | } 300 | None => None, 301 | } 302 | } 303 | _ => None, 304 | }; 305 | let Some(proxy_spe) = proxy_spe else { 306 | return; 307 | }; 308 | 309 | let proxy = proxy_spe.upcast_ref(); 310 | 311 | // Use a weak ref to prevent references cycle between Proxy and proxies: 312 | // - ref on proxies in the closure, bound to the Proxy lifetime 313 | // - proxies owning a ref on Proxy as well 314 | let proxies_weak = Rc::downgrade(&proxies); 315 | let streams_weak = Rc::downgrade(&streams); 316 | let sender_weak = Rc::downgrade(&sender); 317 | let listener = proxy 318 | .add_listener_local() 319 | .removed(move || { 320 | if let Some(sender) = sender_weak.upgrade() { 321 | sender.send(MonitorEvent::Removed(obj_id)); 322 | }; 323 | if let Some(proxies) = proxies_weak.upgrade() { 324 | proxies.borrow_mut().remove(obj_id); 325 | }; 326 | if let Some(streams) = streams_weak.upgrade() { 327 | streams.borrow_mut().remove(obj_id); 328 | }; 329 | }) 330 | .register(); 331 | 332 | proxies.borrow_mut().add_proxy_listener(obj_id, listener); 333 | 334 | syncs.borrow_mut().global(&core); 335 | } 336 | }) 337 | .register(); 338 | 339 | let proxies = Rc::clone(&proxies); 340 | let _receiver = rx.attach(main_loop.loop_(), { 341 | let core_weak = Rc::downgrade(&core); 342 | let sender_weak = Rc::downgrade(&sender); 343 | let streams_weak = Rc::downgrade(&streams); 344 | move |command| { 345 | let Some(core) = core_weak.upgrade() else { 346 | return; 347 | }; 348 | let Some(sender) = sender_weak.upgrade() else { 349 | return; 350 | }; 351 | let Some(streams) = streams_weak.upgrade() else { 352 | return; 353 | }; 354 | execute::execute_command( 355 | &core, 356 | sender, 357 | &mut streams.borrow_mut(), 358 | &Rc::clone(&proxies).borrow(), 359 | command, 360 | ); 361 | } 362 | }); 363 | 364 | main_loop.run(); 365 | 366 | Ok(()) 367 | } 368 | -------------------------------------------------------------------------------- /src/monitor/deserialize.rs: -------------------------------------------------------------------------------- 1 | use libspa::pod::{deserialize::PodDeserializer, Object, Pod, Value}; 2 | 3 | pub fn deserialize(param: Option<&Pod>) -> Option { 4 | param 5 | .and_then(|pod| { 6 | PodDeserializer::deserialize_any_from(pod.as_bytes()).ok() 7 | }) 8 | .and_then(|(_, value)| match value { 9 | Value::Object(obj) => Some(obj), 10 | _ => None, 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/monitor/device.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use pipewire::{ 4 | device::{Device, DeviceChangeMask, DeviceInfoRef}, 5 | proxy::Listener, 6 | registry::{GlobalObject, Registry}, 7 | }; 8 | 9 | use libspa::{ 10 | param::ParamType, 11 | pod::{Object, Value, ValueArray}, 12 | utils::dict::DictRef, 13 | }; 14 | 15 | use crate::event::MonitorEvent; 16 | use crate::media_class::MediaClass; 17 | use crate::monitor::{deserialize::deserialize, EventSender}; 18 | use crate::object::ObjectId; 19 | 20 | pub fn monitor_device( 21 | registry: &Registry, 22 | obj: &GlobalObject<&DictRef>, 23 | sender: &Rc, 24 | ) -> Option<(Rc, Box)> { 25 | let obj_id = ObjectId::from(obj); 26 | 27 | let props = obj.props?; 28 | let media_class = props.get("media.class")?; 29 | match media_class { 30 | "Audio/Device" => (), 31 | _ => return None, 32 | } 33 | 34 | sender.send(MonitorEvent::DeviceMediaClass( 35 | obj_id, 36 | MediaClass::from(media_class), 37 | )); 38 | 39 | let device: Device = registry.bind(obj).ok()?; 40 | let device = Rc::new(device); 41 | 42 | let params = [ 43 | ParamType::EnumRoute, 44 | ParamType::Route, 45 | ParamType::Profile, 46 | ParamType::EnumProfile, 47 | ]; 48 | 49 | let listener = device 50 | .add_listener_local() 51 | .param({ 52 | let sender_weak = Rc::downgrade(sender); 53 | move |_seq, id, _index, _next, param| { 54 | let Some(sender) = sender_weak.upgrade() else { 55 | return; 56 | }; 57 | if let Some(param) = deserialize(param) { 58 | if let Some(event) = match id { 59 | ParamType::EnumRoute => { 60 | device_enum_route(obj_id, param) 61 | } 62 | ParamType::Route => device_route(obj_id, param), 63 | ParamType::Profile => device_profile(obj_id, param), 64 | ParamType::EnumProfile => { 65 | device_enum_profile(obj_id, param) 66 | } 67 | _ => None, 68 | } { 69 | sender.send(event); 70 | } 71 | } 72 | } 73 | }) 74 | .info({ 75 | let sender_weak = Rc::downgrade(sender); 76 | let device_weak = Rc::downgrade(&device); 77 | move |info| { 78 | let Some(sender) = sender_weak.upgrade() else { 79 | return; 80 | }; 81 | for change in info.change_mask().iter() { 82 | if change == DeviceChangeMask::PROPS { 83 | device_info_props(&sender, obj_id, info); 84 | } 85 | } 86 | 87 | let Some(device) = device_weak.upgrade() else { 88 | return; 89 | }; 90 | for param in params.into_iter() { 91 | device.enum_params(0, Some(param), 0, u32::MAX); 92 | } 93 | } 94 | }) 95 | .register(); 96 | 97 | device.subscribe_params(¶ms); 98 | 99 | Some((device, Box::new(listener))) 100 | } 101 | 102 | fn device_enum_route(id: ObjectId, param: Object) -> Option { 103 | let mut index = None; 104 | let mut description = None; 105 | let mut available = None; 106 | let mut profiles = None; 107 | let mut devices = None; 108 | 109 | for prop in param.properties { 110 | match prop.key { 111 | libspa_sys::SPA_PARAM_ROUTE_index => { 112 | if let Value::Int(value) = prop.value { 113 | index = Some(value); 114 | } 115 | } 116 | libspa_sys::SPA_PARAM_ROUTE_description => { 117 | if let Value::String(value) = prop.value { 118 | description = Some(value); 119 | } 120 | } 121 | libspa_sys::SPA_PARAM_ROUTE_available => { 122 | if let Value::Id(libspa::utils::Id(value)) = prop.value { 123 | available = 124 | Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); 125 | } 126 | } 127 | libspa_sys::SPA_PARAM_ROUTE_profiles => { 128 | if let Value::ValueArray(ValueArray::Int(value)) = prop.value { 129 | profiles = Some(value); 130 | } 131 | } 132 | libspa_sys::SPA_PARAM_ROUTE_devices => { 133 | if let Value::ValueArray(ValueArray::Int(value)) = prop.value { 134 | devices = Some(value); 135 | } 136 | } 137 | _ => {} 138 | } 139 | } 140 | 141 | Some(MonitorEvent::DeviceEnumRoute( 142 | id, 143 | index?, 144 | description?, 145 | available?, 146 | profiles?, 147 | devices?, 148 | )) 149 | } 150 | 151 | fn device_route(id: ObjectId, param: Object) -> Option { 152 | let mut index = None; 153 | let mut device = None; 154 | let mut profiles = None; 155 | let mut description = None; 156 | let mut available = None; 157 | let mut channel_volumes = None; 158 | let mut mute = None; 159 | 160 | for prop in param.properties { 161 | match prop.key { 162 | libspa_sys::SPA_PARAM_ROUTE_index => { 163 | if let Value::Int(value) = prop.value { 164 | index = Some(value); 165 | } 166 | } 167 | libspa_sys::SPA_PARAM_ROUTE_device => { 168 | if let Value::Int(value) = prop.value { 169 | device = Some(value); 170 | } 171 | } 172 | libspa_sys::SPA_PARAM_ROUTE_profiles => { 173 | if let Value::ValueArray(ValueArray::Int(value)) = prop.value { 174 | profiles = Some(value); 175 | } 176 | } 177 | libspa_sys::SPA_PARAM_ROUTE_description => { 178 | if let Value::String(value) = prop.value { 179 | description = Some(value); 180 | } 181 | } 182 | libspa_sys::SPA_PARAM_ROUTE_available => { 183 | if let Value::Id(libspa::utils::Id(value)) = prop.value { 184 | available = 185 | Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); 186 | } 187 | } 188 | libspa_sys::SPA_PARAM_ROUTE_props => { 189 | if let Value::Object(value) = prop.value { 190 | for prop in value.properties { 191 | match prop.key { 192 | libspa_sys::SPA_PROP_channelVolumes => { 193 | if let Value::ValueArray(ValueArray::Float( 194 | value, 195 | )) = prop.value 196 | { 197 | channel_volumes = Some(value); 198 | } 199 | } 200 | libspa_sys::SPA_PROP_mute => { 201 | if let Value::Bool(value) = prop.value { 202 | mute = Some(value); 203 | } 204 | } 205 | _ => {} 206 | } 207 | } 208 | } 209 | } 210 | _ => {} 211 | } 212 | } 213 | 214 | Some(MonitorEvent::DeviceRoute( 215 | id, 216 | index?, 217 | device?, 218 | profiles?, 219 | description?, 220 | available?, 221 | channel_volumes?, 222 | mute?, 223 | )) 224 | } 225 | 226 | fn device_profile(id: ObjectId, param: Object) -> Option { 227 | for prop in param.properties { 228 | if prop.key == libspa_sys::SPA_PARAM_ROUTE_index { 229 | if let Value::Int(value) = prop.value { 230 | return Some(MonitorEvent::DeviceProfile(id, value)); 231 | } 232 | } 233 | } 234 | 235 | None 236 | } 237 | 238 | fn parse_class(value: &Value) -> Option<(MediaClass, Vec)> { 239 | if let Value::Struct(class) = value { 240 | if let [Value::String(name), _, _, Value::ValueArray(ValueArray::Int(devices))] = 241 | class.as_slice() 242 | { 243 | return Some((MediaClass::from(name.as_str()), devices.clone())); 244 | } 245 | } 246 | 247 | None 248 | } 249 | 250 | fn device_enum_profile(id: ObjectId, param: Object) -> Option { 251 | let mut index = None; 252 | let mut description = None; 253 | let mut available = None; 254 | let mut classes = None; 255 | 256 | for prop in param.properties { 257 | match prop.key { 258 | libspa_sys::SPA_PARAM_PROFILE_index => { 259 | if let Value::Int(value) = prop.value { 260 | index = Some(value); 261 | } 262 | } 263 | libspa_sys::SPA_PARAM_PROFILE_description => { 264 | if let Value::String(value) = prop.value { 265 | description = Some(value); 266 | } 267 | } 268 | libspa_sys::SPA_PARAM_PROFILE_available => { 269 | if let Value::Id(libspa::utils::Id(value)) = prop.value { 270 | available = 271 | Some(value != libspa_sys::SPA_PARAM_AVAILABILITY_no); 272 | } 273 | } 274 | libspa_sys::SPA_PARAM_PROFILE_classes => { 275 | if let Value::Struct(classes_struct) = prop.value { 276 | // Usually the first element is the size, which we skip. 277 | let skip = match classes_struct.first() { 278 | Some(Value::Int(_)) => 1, 279 | _ => 0, 280 | }; 281 | classes = Some(Vec::new()); 282 | for class in classes_struct.iter().skip(skip) { 283 | if let Some(classes) = &mut classes { 284 | classes.extend(parse_class(class)); 285 | } 286 | } 287 | } 288 | } 289 | _ => (), 290 | } 291 | } 292 | 293 | Some(MonitorEvent::DeviceEnumProfile( 294 | id, 295 | index?, 296 | description?, 297 | available?, 298 | classes?, 299 | )) 300 | } 301 | 302 | fn device_info_props( 303 | sender: &EventSender, 304 | id: ObjectId, 305 | device_info: &DeviceInfoRef, 306 | ) { 307 | let Some(props) = device_info.props() else { 308 | return; 309 | }; 310 | 311 | if let Some(device_name) = props.get("device.name") { 312 | sender.send(MonitorEvent::DeviceName(id, String::from(device_name))); 313 | } 314 | 315 | if let Some(device_nick) = props.get("device.nick") { 316 | sender.send(MonitorEvent::DeviceNick(id, String::from(device_nick))); 317 | } 318 | 319 | if let Some(device_description) = props.get("device.description") { 320 | sender.send(MonitorEvent::DeviceDescription( 321 | id, 322 | String::from(device_description), 323 | )); 324 | } 325 | 326 | if let Some(object_serial) = props.get("object.serial") { 327 | if let Ok(object_serial) = object_serial.parse() { 328 | sender.send(MonitorEvent::DeviceObjectSerial(id, object_serial)); 329 | } 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/monitor/event_sender.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{mpsc, Arc}; 2 | 3 | use pipewire::main_loop::WeakMainLoop; 4 | 5 | use crate::monitor::{Event, MonitorEvent}; 6 | 7 | pub struct EventSender { 8 | tx: Arc>, 9 | main_loop_weak: WeakMainLoop, 10 | } 11 | 12 | impl EventSender { 13 | pub fn new( 14 | tx: Arc>, 15 | main_loop_weak: WeakMainLoop, 16 | ) -> Self { 17 | Self { tx, main_loop_weak } 18 | } 19 | 20 | pub fn send(&self, event: MonitorEvent) { 21 | if self.tx.send(Event::Monitor(event)).is_err() { 22 | if let Some(main_loop) = self.main_loop_weak.upgrade() { 23 | main_loop.quit(); 24 | } 25 | } 26 | } 27 | 28 | pub fn send_ready(&self) { 29 | if self.tx.send(Event::Ready).is_err() { 30 | if let Some(main_loop) = self.main_loop_weak.upgrade() { 31 | main_loop.quit(); 32 | } 33 | } 34 | } 35 | 36 | pub fn send_error(&self, error: String) { 37 | if self.tx.send(Event::Error(error)).is_err() { 38 | if let Some(main_loop) = self.main_loop_weak.upgrade() { 39 | main_loop.quit(); 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/monitor/execute.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use crate::command::Command; 4 | use crate::monitor::{stream, EventSender, ProxyRegistry, StreamRegistry}; 5 | 6 | use pipewire::{core::Core, device::Device, node::Node}; 7 | 8 | use libspa::param::ParamType; 9 | use libspa::pod::{ 10 | serialize::PodSerializer, Object, Pod, Property, PropertyFlags, Value, 11 | ValueArray, 12 | }; 13 | 14 | pub fn execute_command( 15 | core: &Core, 16 | sender: Rc, 17 | streams: &mut StreamRegistry, 18 | proxies: &ProxyRegistry, 19 | command: Command, 20 | ) { 21 | match command { 22 | Command::NodeMute(obj_id, mute) => { 23 | if let Some(node) = proxies.nodes.get(&obj_id) { 24 | node_set_mute(node, mute); 25 | } 26 | } 27 | Command::DeviceMute(obj_id, route_index, route_device, mute) => { 28 | if let Some(device) = proxies.devices.get(&obj_id) { 29 | device_set_mute(device, route_index, route_device, mute); 30 | } 31 | } 32 | Command::NodeVolumes(obj_id, volumes) => { 33 | if let Some(node) = proxies.nodes.get(&obj_id) { 34 | node_set_volumes(node, volumes); 35 | } 36 | } 37 | Command::DeviceVolumes(obj_id, route_index, route_device, volumes) => { 38 | if let Some(device) = proxies.devices.get(&obj_id) { 39 | device_set_volumes(device, route_index, route_device, volumes); 40 | } 41 | } 42 | Command::DeviceSetRoute(obj_id, route_index, route_device) => { 43 | if let Some(device) = proxies.devices.get(&obj_id) { 44 | device_set_route(device, route_index, route_device); 45 | } 46 | } 47 | Command::DeviceSetProfile(obj_id, profile_index) => { 48 | if let Some(device) = proxies.devices.get(&obj_id) { 49 | device_set_profile(device, profile_index); 50 | } 51 | } 52 | Command::NodeCaptureStart(obj_id, object_serial, capture_sink) => { 53 | let result = stream::capture_node( 54 | core, 55 | &sender, 56 | obj_id, 57 | &object_serial.to_string(), 58 | capture_sink, 59 | ); 60 | if let Some((stream, listener)) = result { 61 | streams.add_stream(obj_id, stream, listener); 62 | } 63 | } 64 | Command::NodeCaptureStop(obj_id) => { 65 | streams.remove(obj_id); 66 | } 67 | Command::MetadataSetProperty(obj_id, subject, key, type_, value) => { 68 | if let Some(metadata) = proxies.metadatas.get(&obj_id) { 69 | metadata.set_property( 70 | subject, 71 | &key, 72 | type_.as_deref(), 73 | value.as_deref(), 74 | ); 75 | } 76 | } 77 | } 78 | } 79 | 80 | fn node_set_mute(node: &Node, mute: bool) { 81 | node_set_properties( 82 | node, 83 | vec![ 84 | Property { 85 | key: libspa_sys::SPA_PROP_mute, 86 | flags: PropertyFlags::empty(), 87 | value: Value::Bool(mute), 88 | }, 89 | Property { 90 | key: libspa_sys::SPA_PROP_mute, 91 | flags: PropertyFlags::empty(), 92 | value: Value::Bool(mute), 93 | }, 94 | ], 95 | ); 96 | } 97 | 98 | fn node_set_volumes(node: &Node, volumes: Vec) { 99 | node_set_properties( 100 | node, 101 | vec![Property { 102 | key: libspa_sys::SPA_PROP_channelVolumes, 103 | flags: PropertyFlags::empty(), 104 | value: Value::ValueArray(ValueArray::Float(volumes.clone())), 105 | }], 106 | ); 107 | } 108 | 109 | fn node_set_properties(node: &Node, properties: Vec) { 110 | let values = PodSerializer::serialize( 111 | std::io::Cursor::new(Vec::new()), 112 | &Value::Object(Object { 113 | type_: libspa_sys::SPA_TYPE_OBJECT_Props, 114 | id: libspa_sys::SPA_PARAM_Props, 115 | properties, 116 | }), 117 | ); 118 | 119 | if let Ok((values, _)) = values { 120 | if let Some(pod) = Pod::from_bytes(&values.into_inner()) { 121 | node.set_param(ParamType::Props, 0, pod); 122 | } 123 | } 124 | } 125 | 126 | fn device_set_mute( 127 | device: &Device, 128 | route_index: i32, 129 | route_device: i32, 130 | mute: bool, 131 | ) { 132 | device_set_route_properties( 133 | device, 134 | route_index, 135 | route_device, 136 | vec![ 137 | Property { 138 | key: libspa_sys::SPA_PROP_mute, 139 | flags: PropertyFlags::empty(), 140 | value: Value::Bool(mute), 141 | }, 142 | Property { 143 | key: libspa_sys::SPA_PROP_mute, 144 | flags: PropertyFlags::empty(), 145 | value: Value::Bool(mute), 146 | }, 147 | ], 148 | ); 149 | } 150 | 151 | fn device_set_volumes( 152 | device: &Device, 153 | route_index: i32, 154 | route_device: i32, 155 | volumes: Vec, 156 | ) { 157 | device_set_route_properties( 158 | device, 159 | route_index, 160 | route_device, 161 | vec![Property { 162 | key: libspa_sys::SPA_PROP_channelVolumes, 163 | flags: PropertyFlags::empty(), 164 | value: Value::ValueArray(ValueArray::Float(volumes.clone())), 165 | }], 166 | ); 167 | } 168 | 169 | fn device_set_route(device: &Device, route_index: i32, route_device: i32) { 170 | device_set_route_properties(device, route_index, route_device, Vec::new()); 171 | } 172 | 173 | fn device_set_route_properties( 174 | device: &Device, 175 | route_index: i32, 176 | route_device: i32, 177 | properties: Vec, 178 | ) { 179 | let mut route_properties = Vec::new(); 180 | route_properties.push(Property { 181 | key: libspa_sys::SPA_PARAM_ROUTE_index, 182 | flags: PropertyFlags::empty(), 183 | value: Value::Int(route_index), 184 | }); 185 | route_properties.push(Property { 186 | key: libspa_sys::SPA_PARAM_ROUTE_device, 187 | flags: PropertyFlags::empty(), 188 | value: Value::Int(route_device), 189 | }); 190 | if !properties.is_empty() { 191 | route_properties.push(Property { 192 | key: libspa_sys::SPA_PARAM_ROUTE_props, 193 | flags: PropertyFlags::empty(), 194 | value: Value::Object(Object { 195 | type_: libspa_sys::SPA_TYPE_OBJECT_Props, 196 | id: libspa_sys::SPA_PARAM_Route, 197 | properties, 198 | }), 199 | }); 200 | } 201 | route_properties.push(Property { 202 | key: libspa_sys::SPA_PARAM_ROUTE_save, 203 | flags: PropertyFlags::empty(), 204 | value: Value::Bool(true), 205 | }); 206 | let route_properties = route_properties; 207 | 208 | let values = PodSerializer::serialize( 209 | std::io::Cursor::new(Vec::new()), 210 | &Value::Object(Object { 211 | type_: libspa_sys::SPA_TYPE_OBJECT_ParamRoute, 212 | id: libspa_sys::SPA_PARAM_Route, 213 | properties: route_properties, 214 | }), 215 | ); 216 | 217 | if let Ok((values, _)) = values { 218 | if let Some(pod) = Pod::from_bytes(&values.into_inner()) { 219 | device.set_param(ParamType::Route, 0, pod); 220 | } 221 | } 222 | } 223 | 224 | fn device_set_profile(device: &Device, profile_index: i32) { 225 | let properties = vec![ 226 | Property { 227 | key: libspa_sys::SPA_PARAM_PROFILE_index, 228 | flags: PropertyFlags::empty(), 229 | value: Value::Int(profile_index), 230 | }, 231 | Property { 232 | key: libspa_sys::SPA_PARAM_PROFILE_save, 233 | flags: PropertyFlags::empty(), 234 | value: Value::Bool(true), 235 | }, 236 | ]; 237 | 238 | let values = PodSerializer::serialize( 239 | std::io::Cursor::new(Vec::new()), 240 | &Value::Object(Object { 241 | type_: libspa_sys::SPA_TYPE_OBJECT_ParamProfile, 242 | id: libspa_sys::SPA_PARAM_Profile, 243 | properties, 244 | }), 245 | ); 246 | 247 | if let Ok((values, _)) = values { 248 | if let Some(pod) = Pod::from_bytes(&values.into_inner()) { 249 | device.set_param(ParamType::Profile, 0, pod); 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/monitor/link.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use pipewire::{ 4 | link::{Link, LinkChangeMask, LinkInfoRef}, 5 | proxy::Listener, 6 | registry::{GlobalObject, Registry}, 7 | }; 8 | 9 | use libspa::utils::dict::DictRef; 10 | 11 | use crate::event::MonitorEvent; 12 | use crate::monitor::EventSender; 13 | 14 | pub fn monitor_link( 15 | registry: &Registry, 16 | obj: &GlobalObject<&DictRef>, 17 | sender: &Rc, 18 | ) -> Option<(Rc, Box)> { 19 | let link: Link = registry.bind(obj).ok()?; 20 | let link = Rc::new(link); 21 | 22 | let listener = link 23 | .add_listener_local() 24 | .info({ 25 | let sender_weak = Rc::downgrade(sender); 26 | move |info| { 27 | let Some(sender) = sender_weak.upgrade() else { 28 | return; 29 | }; 30 | for change in info.change_mask().iter() { 31 | if change == LinkChangeMask::PROPS { 32 | link_info_props(&sender, info); 33 | } 34 | } 35 | } 36 | }) 37 | .register(); 38 | 39 | Some((link, Box::new(listener))) 40 | } 41 | 42 | fn link_info_props(sender: &EventSender, link_info: &LinkInfoRef) { 43 | // Ignore props and get the nodes directly from the link info. 44 | sender.send(MonitorEvent::from(link_info)); 45 | } 46 | -------------------------------------------------------------------------------- /src/monitor/metadata.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use pipewire::{ 4 | metadata::Metadata, 5 | proxy::Listener, 6 | registry::{GlobalObject, Registry}, 7 | }; 8 | 9 | use libspa::utils::dict::DictRef; 10 | 11 | use crate::event::MonitorEvent; 12 | use crate::monitor::{EventSender, ObjectId}; 13 | 14 | pub fn monitor_metadata( 15 | registry: &Registry, 16 | obj: &GlobalObject<&DictRef>, 17 | sender: &Rc, 18 | ) -> Option<(Rc, Box)> { 19 | let obj_id = ObjectId::from(obj); 20 | 21 | let props = obj.props?; 22 | let metadata_name = props.get("metadata.name")?; 23 | if metadata_name != "default" { 24 | return None; 25 | } 26 | 27 | sender.send(MonitorEvent::MetadataMetadataName( 28 | obj_id, 29 | String::from(metadata_name), 30 | )); 31 | 32 | let metadata: Metadata = registry.bind(obj).ok()?; 33 | let metadata = Rc::new(metadata); 34 | 35 | let listener = metadata 36 | .add_listener_local() 37 | .property({ 38 | let sender_weak = Rc::downgrade(sender); 39 | move |subject, key, _type, value| { 40 | let Some(sender) = sender_weak.upgrade() else { 41 | return 0; 42 | }; 43 | 44 | sender.send(MonitorEvent::MetadataProperty( 45 | obj_id, 46 | subject, 47 | key.map(String::from), 48 | value.map(String::from), 49 | )); 50 | 51 | 0 52 | } 53 | }) 54 | .register(); 55 | 56 | Some((metadata, Box::new(listener))) 57 | } 58 | -------------------------------------------------------------------------------- /src/monitor/node.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use pipewire::{ 4 | node::{Node, NodeChangeMask, NodeInfoRef}, 5 | proxy::Listener, 6 | registry::{GlobalObject, Registry}, 7 | }; 8 | 9 | use libspa::{ 10 | param::ParamType, 11 | pod::{Object, Value, ValueArray}, 12 | utils::dict::DictRef, 13 | }; 14 | 15 | use crate::event::MonitorEvent; 16 | use crate::media_class::MediaClass; 17 | use crate::monitor::{deserialize::deserialize, EventSender}; 18 | use crate::object::ObjectId; 19 | 20 | pub fn monitor_node( 21 | registry: &Registry, 22 | obj: &GlobalObject<&DictRef>, 23 | sender: &Rc, 24 | ) -> Option<(Rc, Box)> { 25 | let obj_id = ObjectId::from(obj); 26 | 27 | let props = obj.props?; 28 | let media_class = props.get("media.class")?; 29 | match media_class { 30 | "Audio/Sink" => (), 31 | "Audio/Source" => (), 32 | "Stream/Output/Audio" => (), 33 | "Stream/Input/Audio" => (), 34 | _ => return None, 35 | } 36 | 37 | // Don't monitor capture streams to avoid clutter. 38 | match props.get("node.name") { 39 | // We especially don't want to capture our own capture streams. 40 | Some("wiremix-capture") => return None, 41 | Some("PulseAudio Volume Control") => return None, 42 | Some("ncpamixer") => return None, 43 | _ => (), 44 | } 45 | 46 | sender.send(MonitorEvent::NodeMediaClass( 47 | obj_id, 48 | MediaClass::from(media_class), 49 | )); 50 | 51 | let node: Node = registry.bind(obj).ok()?; 52 | let node = Rc::new(node); 53 | 54 | let listener = node 55 | .add_listener_local() 56 | .info({ 57 | let sender_weak = Rc::downgrade(sender); 58 | move |info| { 59 | let Some(sender) = sender_weak.upgrade() else { 60 | return; 61 | }; 62 | for change in info.change_mask().iter() { 63 | if change == NodeChangeMask::PROPS { 64 | node_info_props(&sender, obj_id, info); 65 | } 66 | } 67 | } 68 | }) 69 | .param({ 70 | let sender_weak = Rc::downgrade(sender); 71 | move |_seq, id, _index, _next, param| { 72 | let Some(sender) = sender_weak.upgrade() else { 73 | return; 74 | }; 75 | if let Some(param) = deserialize(param) { 76 | match id { 77 | ParamType::Props => { 78 | node_param_props(&sender, obj_id, param); 79 | } 80 | ParamType::PortConfig => { 81 | node_param_port_config(&sender, obj_id, param); 82 | } 83 | _ => {} 84 | } 85 | } 86 | } 87 | }) 88 | .register(); 89 | node.subscribe_params(&[ParamType::Props, ParamType::PortConfig]); 90 | 91 | Some((node, Box::new(listener))) 92 | } 93 | 94 | fn node_info_props( 95 | sender: &EventSender, 96 | id: ObjectId, 97 | node_info: &NodeInfoRef, 98 | ) { 99 | let Some(props) = node_info.props() else { 100 | return; 101 | }; 102 | 103 | if let Some(node_name) = props.get("node.name") { 104 | sender.send(MonitorEvent::NodeName(id, String::from(node_name))); 105 | } 106 | 107 | if let Some(node_nick) = props.get("node.nick") { 108 | sender.send(MonitorEvent::NodeNick(id, String::from(node_nick))); 109 | } 110 | 111 | if let Some(node_description) = props.get("node.description") { 112 | sender.send(MonitorEvent::NodeDescription( 113 | id, 114 | String::from(node_description), 115 | )); 116 | } 117 | 118 | if let Some(media_name) = props.get("media.name") { 119 | sender.send(MonitorEvent::NodeMediaName(id, String::from(media_name))); 120 | } 121 | 122 | if let Some(device_id) = props.get("device.id") { 123 | if let Ok(device_id) = device_id.parse() { 124 | sender.send(MonitorEvent::NodeDeviceId( 125 | id, 126 | ObjectId::from_raw_id(device_id), 127 | )); 128 | } 129 | } 130 | 131 | if let Some(object_serial) = props.get("object.serial") { 132 | if let Ok(object_serial) = object_serial.parse() { 133 | sender.send(MonitorEvent::NodeObjectSerial(id, object_serial)); 134 | } 135 | } 136 | 137 | if let Some(card_profile_device) = props.get("card.profile.device") { 138 | if let Ok(card_profile_device) = card_profile_device.parse() { 139 | sender.send(MonitorEvent::NodeCardProfileDevice( 140 | id, 141 | card_profile_device, 142 | )); 143 | } 144 | } 145 | } 146 | 147 | fn node_param_props(sender: &EventSender, id: ObjectId, param: Object) { 148 | for prop in param.properties { 149 | match prop.key { 150 | libspa_sys::SPA_PROP_channelVolumes => { 151 | if let Value::ValueArray(ValueArray::Float(value)) = prop.value 152 | { 153 | sender.send(MonitorEvent::NodeVolumes(id, value)); 154 | } 155 | } 156 | libspa_sys::SPA_PROP_mute => { 157 | if let Value::Bool(value) = prop.value { 158 | sender.send(MonitorEvent::NodeMute(id, value)); 159 | } 160 | } 161 | _ => {} 162 | } 163 | } 164 | } 165 | 166 | fn node_param_port_config(sender: &EventSender, id: ObjectId, param: Object) { 167 | let Some(format_prop) = param 168 | .properties 169 | .into_iter() 170 | .find(|prop| prop.key == libspa_sys::SPA_PARAM_PORT_CONFIG_format) 171 | else { 172 | return; 173 | }; 174 | 175 | let Value::Object(Object { properties, .. }) = format_prop.value else { 176 | return; 177 | }; 178 | 179 | let Some(position_prop) = properties 180 | .into_iter() 181 | .find(|prop| prop.key == libspa_sys::SPA_FORMAT_AUDIO_position) 182 | else { 183 | return; 184 | }; 185 | 186 | let Value::ValueArray(ValueArray::Id(value)) = position_prop.value else { 187 | return; 188 | }; 189 | 190 | let positions = value.into_iter().map(|x| x.0).collect(); 191 | sender.send(MonitorEvent::NodePositions(id, positions)); 192 | } 193 | -------------------------------------------------------------------------------- /src/monitor/proxy_registry.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::rc::Rc; 3 | 4 | use anyhow::Result; 5 | 6 | use nix::sys::eventfd::{EfdFlags, EventFd}; 7 | 8 | use pipewire::{ 9 | device::Device, 10 | link::Link, 11 | metadata::Metadata, 12 | node::Node, 13 | proxy::{Listener, ProxyListener, ProxyT}, 14 | }; 15 | 16 | use crate::object::ObjectId; 17 | 18 | /// Storage for keeping proxies and their listeners alive 19 | pub struct ProxyRegistry { 20 | /// Storage for keeping devices alive 21 | pub devices: HashMap>, 22 | /// Storage for keeping nodes alive 23 | pub nodes: HashMap>, 24 | /// Storage for keeping metadata alive 25 | pub metadatas: HashMap>, 26 | /// Storage for keeping links alive 27 | links: HashMap>, 28 | /// Storage for keeping listeners alive 29 | listeners: HashMap>>, 30 | /// Devices, nodes, links, and metadata pending deletion 31 | garbage_proxies_t: Vec>, 32 | /// Listeners pending deletion 33 | garbage_listeners: Vec>, 34 | /// EventFd for signalling to [`crate::monitor`] that objects are pending 35 | /// deletion and that [`Self::collect_garbage()`] needs to be called 36 | pub gc_fd: EventFd, 37 | } 38 | 39 | impl Drop for ProxyRegistry { 40 | fn drop(&mut self) { 41 | // Drop listeners while their proxies are still alive. 42 | self.garbage_listeners.clear(); 43 | self.listeners.clear(); 44 | } 45 | } 46 | 47 | impl ProxyRegistry { 48 | pub fn try_new() -> Result { 49 | let gc_fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?; 50 | Ok(Self { 51 | devices: HashMap::new(), 52 | nodes: HashMap::new(), 53 | links: HashMap::new(), 54 | metadatas: HashMap::new(), 55 | listeners: HashMap::new(), 56 | garbage_proxies_t: Vec::new(), 57 | garbage_listeners: Vec::new(), 58 | gc_fd, 59 | }) 60 | } 61 | 62 | /// Clean up proxies and listeners pending deletion. It is unsafe to call 63 | /// this from within the PipeWire main loop! 64 | pub fn collect_garbage(&mut self) { 65 | self.garbage_listeners.clear(); 66 | self.garbage_proxies_t.clear(); 67 | let _ = self.gc_fd.read(); 68 | } 69 | 70 | /// Register a device and its listener, evicting any with the same ID. 71 | pub fn add_device( 72 | &mut self, 73 | obj_id: ObjectId, 74 | device: Rc, 75 | listener: Box, 76 | ) { 77 | if let Some(old) = self.devices.insert(obj_id, device) { 78 | self.garbage_proxies_t.push(old); 79 | if let Some(listeners) = self.listeners.get_mut(&obj_id) { 80 | self.garbage_listeners.append(listeners); 81 | } 82 | let _ = self.gc_fd.arm(); 83 | } 84 | 85 | let v = self.listeners.entry(obj_id).or_default(); 86 | v.push(listener); 87 | } 88 | 89 | /// Register a node and its listener, evicting any with the same ID. 90 | pub fn add_node( 91 | &mut self, 92 | obj_id: ObjectId, 93 | node: Rc, 94 | listener: Box, 95 | ) { 96 | if let Some(old) = self.nodes.insert(obj_id, node) { 97 | self.garbage_proxies_t.push(old); 98 | if let Some(listeners) = self.listeners.get_mut(&obj_id) { 99 | self.garbage_listeners.append(listeners); 100 | } 101 | let _ = self.gc_fd.arm(); 102 | } 103 | 104 | let v = self.listeners.entry(obj_id).or_default(); 105 | v.push(listener); 106 | } 107 | 108 | /// Register a link and its listener, evicting any with the same ID. 109 | pub fn add_link( 110 | &mut self, 111 | obj_id: ObjectId, 112 | link: Rc, 113 | listener: Box, 114 | ) { 115 | if let Some(old) = self.links.insert(obj_id, link) { 116 | self.garbage_proxies_t.push(old); 117 | if let Some(listeners) = self.listeners.get_mut(&obj_id) { 118 | self.garbage_listeners.append(listeners); 119 | } 120 | let _ = self.gc_fd.arm(); 121 | } 122 | 123 | let v = self.listeners.entry(obj_id).or_default(); 124 | v.push(listener); 125 | } 126 | 127 | /// Register metadata and its listener, evicting any with the same ID. 128 | pub fn add_metadata( 129 | &mut self, 130 | obj_id: ObjectId, 131 | metadata: Rc, 132 | listener: Box, 133 | ) { 134 | if let Some(old) = self.metadatas.insert(obj_id, metadata) { 135 | self.garbage_proxies_t.push(old); 136 | if let Some(listeners) = self.listeners.get_mut(&obj_id) { 137 | self.garbage_listeners.append(listeners); 138 | } 139 | let _ = self.gc_fd.arm(); 140 | } 141 | 142 | let v = self.listeners.entry(obj_id).or_default(); 143 | v.push(listener); 144 | } 145 | 146 | /// Register a listener, evicting any with the same ID. 147 | pub fn add_proxy_listener( 148 | &mut self, 149 | obj_id: ObjectId, 150 | listener: ProxyListener, 151 | ) { 152 | let v = self.listeners.entry(obj_id).or_default(); 153 | v.push(Box::new(listener)); 154 | } 155 | 156 | /// Remove an object, defering deletion until [`Self::collect_garbage()`] 157 | /// is called. 158 | pub fn remove(&mut self, obj_id: ObjectId) { 159 | if let Some(listeners) = self.listeners.get_mut(&obj_id) { 160 | if !listeners.is_empty() { 161 | let _ = self.gc_fd.arm(); 162 | } 163 | self.garbage_listeners.append(listeners); 164 | } 165 | if let Some(old) = self.devices.remove(&obj_id) { 166 | self.garbage_proxies_t.push(old); 167 | let _ = self.gc_fd.arm(); 168 | } 169 | if let Some(old) = self.nodes.remove(&obj_id) { 170 | self.garbage_proxies_t.push(old); 171 | let _ = self.gc_fd.arm(); 172 | } 173 | if let Some(old) = self.links.remove(&obj_id) { 174 | self.garbage_proxies_t.push(old); 175 | let _ = self.gc_fd.arm(); 176 | } 177 | if let Some(old) = self.metadatas.remove(&obj_id) { 178 | self.garbage_proxies_t.push(old); 179 | let _ = self.gc_fd.arm(); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/monitor/stream.rs: -------------------------------------------------------------------------------- 1 | use std::mem; 2 | use std::rc::Rc; 3 | 4 | use pipewire::{ 5 | core::Core, 6 | properties::properties, 7 | stream::{Stream, StreamListener}, 8 | }; 9 | 10 | use libspa::{ 11 | param::audio::{AudioFormat, AudioInfoRaw}, 12 | param::format::{MediaSubtype, MediaType}, 13 | param::{format_utils, ParamType}, 14 | pod::{Object, Pod}, 15 | }; 16 | 17 | use crate::event::MonitorEvent; 18 | use crate::monitor::EventSender; 19 | use crate::object::ObjectId; 20 | 21 | #[derive(Default)] 22 | pub struct StreamData { 23 | format: AudioInfoRaw, 24 | cursor_move: bool, 25 | } 26 | 27 | pub fn capture_node( 28 | core: &Core, 29 | sender: &Rc, 30 | obj_id: ObjectId, 31 | serial: &str, 32 | capture_sink: bool, 33 | ) -> Option<(Rc, StreamListener)> { 34 | let mut props = properties! { 35 | *pipewire::keys::TARGET_OBJECT => String::from(serial), 36 | *pipewire::keys::STREAM_MONITOR => "true", 37 | *pipewire::keys::NODE_NAME => "wiremix-capture", 38 | }; 39 | if capture_sink { 40 | props.insert(*pipewire::keys::STREAM_CAPTURE_SINK, "true"); 41 | } 42 | 43 | let data = StreamData { 44 | format: Default::default(), 45 | cursor_move: false, 46 | }; 47 | 48 | let stream = Stream::new(core, "wiremix-capture", props).ok()?; 49 | let stream = Rc::new(stream); 50 | let listener = stream 51 | .add_local_listener_with_user_data(data) 52 | .param_changed({ 53 | let sender_weak = Rc::downgrade(sender); 54 | 55 | move |_stream, user_data, id, param| { 56 | // NULL means to clear the format 57 | let Some(param) = param else { 58 | return; 59 | }; 60 | if id != ParamType::Format.as_raw() { 61 | return; 62 | } 63 | 64 | let (media_type, media_subtype) = 65 | match format_utils::parse_format(param) { 66 | Ok(v) => v, 67 | Err(_) => return, 68 | }; 69 | 70 | // only accept raw audio 71 | if media_type != MediaType::Audio 72 | || media_subtype != MediaSubtype::Raw 73 | { 74 | return; 75 | } 76 | 77 | // call a helper function to parse the format for us. 78 | let _ = user_data.format.parse(param); 79 | 80 | let Some(sender) = sender_weak.upgrade() else { 81 | return; 82 | }; 83 | sender.send(MonitorEvent::NodeRate( 84 | obj_id, 85 | user_data.format.rate(), 86 | )); 87 | } 88 | }) 89 | .process({ 90 | let sender_weak = Rc::downgrade(sender); 91 | 92 | move |stream, user_data| { 93 | let Some(mut buffer) = stream.dequeue_buffer() else { 94 | return; 95 | }; 96 | let Some(sender) = sender_weak.upgrade() else { 97 | return; 98 | }; 99 | let datas = buffer.datas_mut(); 100 | if datas.is_empty() { 101 | return; 102 | } 103 | 104 | let data = &mut datas[0]; 105 | let n_channels = user_data.format.channels(); 106 | let n_samples = 107 | data.chunk().size() / (mem::size_of::() as u32); 108 | 109 | if let Some(samples) = data.data() { 110 | let mut peaks = Vec::new(); 111 | for c in 0..n_channels { 112 | let mut max: f32 = 0.0; 113 | for n in (c..n_samples).step_by(n_channels as usize) { 114 | let start = n as usize * mem::size_of::(); 115 | let end = start + mem::size_of::(); 116 | let chan = &samples[start..end]; 117 | let f = f32::from_le_bytes( 118 | chan.try_into().unwrap_or([0; 4]), 119 | ); 120 | max = max.max(f.abs()); 121 | } 122 | 123 | peaks.push(max); 124 | } 125 | sender.send(MonitorEvent::NodePeaks( 126 | obj_id, peaks, n_samples, 127 | )); 128 | user_data.cursor_move = true; 129 | } 130 | } 131 | }) 132 | .register() 133 | .ok()?; 134 | 135 | let mut audio_info = AudioInfoRaw::new(); 136 | audio_info.set_format(AudioFormat::F32LE); 137 | let pod_obj = Object { 138 | type_: pipewire::spa::utils::SpaTypes::ObjectParamFormat.as_raw(), 139 | id: ParamType::EnumFormat.as_raw(), 140 | properties: audio_info.into(), 141 | }; 142 | let values: Vec = 143 | pipewire::spa::pod::serialize::PodSerializer::serialize( 144 | std::io::Cursor::new(Vec::new()), 145 | &pipewire::spa::pod::Value::Object(pod_obj), 146 | ) 147 | .ok()? 148 | .0 149 | .into_inner(); 150 | 151 | let mut params = [Pod::from_bytes(&values)?]; 152 | 153 | stream 154 | .connect( 155 | libspa::utils::Direction::Input, 156 | None, 157 | pipewire::stream::StreamFlags::AUTOCONNECT 158 | | pipewire::stream::StreamFlags::MAP_BUFFERS, 159 | &mut params, 160 | ) 161 | .ok()?; 162 | 163 | Some((stream, listener)) 164 | } 165 | -------------------------------------------------------------------------------- /src/monitor/stream_registry.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::rc::Rc; 3 | 4 | use anyhow::Result; 5 | 6 | use nix::sys::eventfd::{EfdFlags, EventFd}; 7 | 8 | use pipewire::stream::{Stream, StreamListener}; 9 | 10 | use crate::object::ObjectId; 11 | 12 | /// Storage for keeping streams and their listeners alive 13 | pub struct StreamRegistry { 14 | /// Storage for keeping streams 15 | streams: HashMap>, 16 | /// Storage for keeping listeners alive 17 | listeners: HashMap>>, 18 | /// Streams pending deletion 19 | garbage_streams: Vec>, 20 | /// Listeners pending deletion 21 | garbage_listeners: Vec>, 22 | /// Track garbage node IDs so [`Self::collect_garbage()`] can report on who 23 | /// was collected. 24 | garbage_ids: HashSet, 25 | /// EventFd for signalling to [`crate::monitor`] that objects are pending 26 | /// deletion and that [`Self::collect_garbage()`] needs to be called 27 | pub gc_fd: EventFd, 28 | } 29 | 30 | impl Drop for StreamRegistry { 31 | fn drop(&mut self) { 32 | // Drop listeners while the stream is still alive. 33 | self.garbage_listeners.clear(); 34 | self.listeners.clear(); 35 | } 36 | } 37 | 38 | impl StreamRegistry { 39 | pub fn try_new() -> Result { 40 | let gc_fd = EventFd::from_value_and_flags(0, EfdFlags::EFD_NONBLOCK)?; 41 | Ok(Self { 42 | streams: HashMap::new(), 43 | listeners: HashMap::new(), 44 | garbage_streams: Vec::new(), 45 | garbage_listeners: Vec::new(), 46 | garbage_ids: HashSet::new(), 47 | gc_fd, 48 | }) 49 | } 50 | 51 | /// Clean up streams and listeners pending deletion. It is unsafe to call 52 | /// this from within the PipeWire main loop! 53 | /// 54 | /// Returns the IDs of the streams deleted. 55 | pub fn collect_garbage(&mut self) -> Vec { 56 | self.garbage_listeners.clear(); 57 | self.garbage_streams.clear(); 58 | let _ = self.gc_fd.read(); 59 | self.garbage_ids.drain().collect() 60 | } 61 | 62 | /// Register a stream and its listener, evicting any with the same ID. 63 | pub fn add_stream( 64 | &mut self, 65 | stream_id: ObjectId, 66 | stream: Rc, 67 | listener: StreamListener, 68 | ) { 69 | if let Some(old) = self.streams.insert(stream_id, stream) { 70 | self.garbage_streams.push(old); 71 | if let Some(listeners) = self.listeners.get_mut(&stream_id) { 72 | self.garbage_listeners.append(listeners); 73 | } 74 | let _ = self.gc_fd.arm(); 75 | } 76 | 77 | let v = self.listeners.entry(stream_id).or_default(); 78 | v.push(listener); 79 | } 80 | 81 | /// Remove a stream, deferring deletion until [`Self::collect_garbage()`] 82 | /// is called. 83 | pub fn remove(&mut self, stream_id: ObjectId) { 84 | if let Some(stream) = self.streams.remove(&stream_id) { 85 | let _ = stream.disconnect(); 86 | self.garbage_streams.push(stream); 87 | self.garbage_ids.insert(stream_id); 88 | let _ = self.gc_fd.arm(); 89 | } 90 | if let Some(listeners) = self.listeners.get_mut(&stream_id) { 91 | if !listeners.is_empty() { 92 | let _ = self.gc_fd.arm(); 93 | self.garbage_ids.insert(stream_id); 94 | } 95 | self.garbage_listeners.append(listeners); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/monitor/sync_registry.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use libspa::utils::result::AsyncSeq; 4 | use pipewire::core::Core; 5 | 6 | /// Track pending syncs in order to determine when the monitor has all initial 7 | /// information and is waiting for new events. 8 | #[derive(Default)] 9 | pub struct SyncRegistry { 10 | pending: HashSet, 11 | done: bool, 12 | } 13 | 14 | impl SyncRegistry { 15 | /// Register a pending sync. 16 | pub fn global(&mut self, core: &Core) { 17 | if !self.done { 18 | if let Ok(seq) = core.sync(0) { 19 | self.pending.insert(seq.seq()); 20 | } 21 | } 22 | } 23 | 24 | /// Mark a sync as done, return true when all are done for the first time. 25 | pub fn done(&mut self, seq: AsyncSeq) -> bool { 26 | if self.done { 27 | return false; 28 | } 29 | 30 | self.pending.remove(&seq.seq()); 31 | self.done |= self.pending.is_empty(); 32 | self.pending.is_empty() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/node_widget.rs: -------------------------------------------------------------------------------- 1 | //! A Ratatui widget representing a single PipeWire node in an object list. 2 | 3 | use ratatui::{ 4 | layout::Flex, 5 | prelude::{Alignment, Buffer, Constraint, Direction, Layout, Rect}, 6 | text::{Line, Span}, 7 | widgets::{StatefulWidget, Widget}, 8 | }; 9 | 10 | use crossterm::event::{MouseButton, MouseEventKind}; 11 | use smallvec::smallvec; 12 | 13 | use crate::app::{Action, MouseArea}; 14 | use crate::config::{Config, Peaks}; 15 | use crate::device_kind::DeviceKind; 16 | use crate::meter; 17 | use crate::object_list::ObjectList; 18 | use crate::truncate; 19 | use crate::view; 20 | 21 | fn is_default(node: &view::Node, device_kind: Option) -> bool { 22 | match device_kind { 23 | Some(DeviceKind::Sink) => node.is_default_sink, 24 | Some(DeviceKind::Source) => node.is_default_source, 25 | None => false, 26 | } 27 | } 28 | 29 | fn node_title(node: &view::Node, device_kind: Option) -> &str { 30 | match (device_kind, &node.title_source_sink) { 31 | ( 32 | Some(DeviceKind::Source | DeviceKind::Sink), 33 | Some(title_source_sink), 34 | ) => title_source_sink, 35 | _ => &node.title, 36 | } 37 | } 38 | 39 | pub struct NodeWidget<'a> { 40 | node: &'a view::Node, 41 | selected: bool, 42 | device_kind: Option, 43 | config: &'a Config, 44 | } 45 | 46 | impl<'a> NodeWidget<'a> { 47 | pub fn new( 48 | node: &'a view::Node, 49 | selected: bool, 50 | device_kind: Option, 51 | config: &'a Config, 52 | ) -> Self { 53 | Self { 54 | node, 55 | selected, 56 | device_kind, 57 | config, 58 | } 59 | } 60 | 61 | /// Height of a full node display. 62 | pub fn height() -> u16 { 63 | 3 64 | } 65 | 66 | /// Spacing between nodes 67 | pub fn spacing() -> u16 { 68 | 2 69 | } 70 | 71 | /// Area for the target dropdown 72 | pub fn dropdown_area( 73 | object_list: &ObjectList, 74 | list_area: &Rect, 75 | object_area: &Rect, 76 | ) -> Rect { 77 | // Number of items to show at once 78 | let max_visible_items = 5; 79 | 80 | let max_target_length = object_list 81 | .targets 82 | .iter() 83 | .map(|(_, title)| title.len()) 84 | .max() 85 | .unwrap_or(0); 86 | 87 | // Add 2 for vertical borders and 2 for highlight symbol 88 | let width = max_target_length.saturating_add(4) as u16; 89 | let height = std::cmp::min(max_visible_items, object_list.targets.len()) 90 | .saturating_add(2) as u16; // Plus 2 for horizontal borders 91 | 92 | // Align to the right of the list area 93 | let x = list_area.right().saturating_sub(width); 94 | // Subtract 1 for the top border 95 | let y = object_area.top().saturating_sub(1); 96 | 97 | Rect::new(x, y, width, height) 98 | } 99 | } 100 | 101 | impl StatefulWidget for NodeWidget<'_> { 102 | type State = Vec; 103 | 104 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { 105 | let mouse_areas = state; 106 | 107 | mouse_areas.push(( 108 | area, 109 | smallvec![MouseEventKind::Down(MouseButton::Left)], 110 | smallvec![Action::SelectObject(self.node.id)], 111 | )); 112 | 113 | mouse_areas.push(( 114 | area, 115 | smallvec![MouseEventKind::Down(MouseButton::Right)], 116 | smallvec![Action::SelectObject(self.node.id), Action::SetDefault], 117 | )); 118 | 119 | mouse_areas.push(( 120 | area, 121 | smallvec![MouseEventKind::ScrollLeft], 122 | smallvec![ 123 | Action::SelectObject(self.node.id), 124 | Action::SetRelativeVolume(-0.01), 125 | ], 126 | )); 127 | 128 | mouse_areas.push(( 129 | area, 130 | smallvec![MouseEventKind::ScrollRight], 131 | smallvec![ 132 | Action::SelectObject(self.node.id), 133 | Action::SetRelativeVolume(0.01), 134 | ], 135 | )); 136 | 137 | let layout = Layout::default() 138 | .direction(Direction::Horizontal) 139 | .constraints([ 140 | Constraint::Length(1), // selected_area 141 | Constraint::Min(0), // node_area 142 | ]) 143 | .split(area); 144 | let selected_area = layout[0]; 145 | let node_area = layout[1]; 146 | 147 | if self.selected { 148 | // Render and indication that this is the selected node. 149 | let rows = Layout::default() 150 | .direction(Direction::Vertical) 151 | .constraints([ 152 | Constraint::Length(1), 153 | Constraint::Length(1), 154 | Constraint::Length(1), 155 | ]) 156 | .split(selected_area); 157 | 158 | let style = self.config.theme.selector; 159 | 160 | // Render the selected node indicator 161 | Span::styled(&self.config.char_set.selector_top, style) 162 | .render(rows[0], buf); 163 | Span::styled(&self.config.char_set.selector_middle, style) 164 | .render(rows[1], buf); 165 | Span::styled(&self.config.char_set.selector_bottom, style) 166 | .render(rows[2], buf); 167 | } 168 | 169 | let layout = Layout::default() 170 | .direction(Direction::Vertical) 171 | .constraints([ 172 | Constraint::Length(1), // header_area 173 | Constraint::Length(1), // bar_area 174 | ]) 175 | .spacing(1) 176 | .flex(Flex::Legacy) 177 | .split(node_area); 178 | let header_area = layout[0]; 179 | let bar_area = layout[1]; 180 | 181 | let node_title = node_title(self.node, self.device_kind); 182 | let target_line = match self.node.target { 183 | Some(view::Target::Default) => { 184 | // Add the default target indicator 185 | Line::from(vec![ 186 | Span::styled( 187 | &self.config.char_set.default_stream, 188 | self.config.theme.default_stream, 189 | ), 190 | Span::from(" "), 191 | Span::styled( 192 | &self.node.target_title, 193 | self.config.theme.node_target, 194 | ), 195 | ]) 196 | } 197 | _ => Line::from(Span::styled( 198 | &self.node.target_title, 199 | self.config.theme.node_target, 200 | )), 201 | }; 202 | 203 | let layout = Layout::default() 204 | .direction(Direction::Horizontal) 205 | .constraints([ 206 | Constraint::Min(0), // header_left 207 | Constraint::Length(target_line.width() as u16), // header_right 208 | ]) 209 | .horizontal_margin(1) 210 | .spacing(1) 211 | .split(header_area); 212 | let header_left = layout[0]; 213 | let header_right = layout[1]; 214 | 215 | target_line 216 | .alignment(Alignment::Right) 217 | .render(header_right, buf); 218 | mouse_areas.push(( 219 | header_right, 220 | smallvec![MouseEventKind::Down(MouseButton::Left)], 221 | smallvec![ 222 | Action::SelectObject(self.node.id), 223 | Action::ActivateDropdown 224 | ], 225 | )); 226 | 227 | let default_span = if is_default(self.node, self.device_kind) { 228 | Span::styled( 229 | &self.config.char_set.default_device, 230 | self.config.theme.default_device, 231 | ) 232 | } else { 233 | Span::from(" ") 234 | }; 235 | let node_title = truncate::with_ellipses( 236 | node_title, 237 | (header_left.width.saturating_sub(2)) as usize, 238 | ); 239 | Line::from(vec![ 240 | default_span, 241 | Span::from(" "), 242 | Span::styled(node_title, self.config.theme.node_title), 243 | ]) 244 | .render(header_left, buf); 245 | 246 | let constraints = if self.config.peaks != Peaks::Off { 247 | vec![ 248 | Constraint::Length(2), // _padding 249 | Constraint::Fill(4), // volume_area 250 | Constraint::Fill(1), // _padding 251 | Constraint::Fill(4), // meter_area 252 | Constraint::Fill(1), // _padding 253 | ] 254 | } else { 255 | vec![ 256 | Constraint::Length(2), // _padding 257 | Constraint::Fill(9), // volume_area 258 | Constraint::Fill(1), // _padding 259 | ] 260 | }; 261 | let layout = Layout::default() 262 | .direction(Direction::Horizontal) 263 | .constraints(constraints) 264 | .split(bar_area); 265 | // index 0 is _padding 266 | let volume_area = layout[1]; 267 | // index 2 is _padding 268 | let meter_area = (self.config.peaks != Peaks::Off).then(|| layout[3]); 269 | 270 | let layout = Layout::default() 271 | .direction(Direction::Horizontal) 272 | .constraints([ 273 | Constraint::Length(5), // volume_label 274 | Constraint::Min(0), // volume_bar 275 | ]) 276 | .spacing(1) 277 | .split(volume_area); 278 | let volume_label = layout[0]; 279 | let volume_bar = layout[1]; 280 | 281 | let volumes = &self.node.volumes; 282 | if !volumes.is_empty() { 283 | let mean = volumes.iter().sum::() / volumes.len() as f32; 284 | let volume = mean.cbrt(); 285 | let percent = (volume * 100.0).round() as u32; 286 | 287 | Line::from(Span::styled( 288 | format!("{}%", percent), 289 | self.config.theme.volume, 290 | )) 291 | .alignment(Alignment::Right) 292 | .render(volume_label, buf); 293 | 294 | let count = ((volume.clamp(0.0, 1.5) / 1.5) 295 | * volume_bar.width as f32) as usize; 296 | 297 | let filled = self.config.char_set.volume_filled.repeat(count); 298 | let blank = self 299 | .config 300 | .char_set 301 | .volume_empty 302 | .repeat((volume_bar.width as usize).saturating_sub(count)); 303 | Line::from(vec![ 304 | Span::styled(filled, self.config.theme.volume_filled), 305 | Span::styled(blank, self.config.theme.volume_empty), 306 | ]) 307 | .render(volume_bar, buf); 308 | } 309 | if self.node.mute { 310 | Line::from("muted").render(volume_label, buf); 311 | } 312 | 313 | mouse_areas.push(( 314 | volume_label, 315 | smallvec![MouseEventKind::Down(MouseButton::Left)], 316 | smallvec![Action::SelectObject(self.node.id), Action::ToggleMute], 317 | )); 318 | 319 | // Add mouse areas for setting volume 320 | for i in 0..=volume_bar.width { 321 | let volume_area = Rect::new( 322 | volume_bar.x.saturating_add(i), 323 | volume_bar.y, 324 | 1, 325 | volume_bar.height, 326 | ); 327 | 328 | let volume_step = 1.5 / volume_bar.width as f32; 329 | let volume = volume_step * i as f32; 330 | // Make the volume sticky around 100%. Otherwise it's often not 331 | // possible to select by mouse. 332 | let sticky_volume = if (1.0 - volume).abs() <= volume_step { 333 | 1.0 334 | } else { 335 | volume 336 | }; 337 | 338 | mouse_areas.push(( 339 | volume_area, 340 | smallvec![ 341 | MouseEventKind::Down(MouseButton::Left), 342 | MouseEventKind::Drag(MouseButton::Left), 343 | ], 344 | smallvec![ 345 | Action::SelectObject(self.node.id), 346 | Action::SetAbsoluteVolume(sticky_volume), 347 | ], 348 | )); 349 | } 350 | 351 | // Render peaks 352 | if let Some(meter_area) = meter_area { 353 | match self.node.peaks.as_deref() { 354 | Some([left, right]) if self.config.peaks != Peaks::Mono => { 355 | meter::render_stereo( 356 | meter_area, 357 | buf, 358 | Some((*left, *right)), 359 | self.config, 360 | ) 361 | } 362 | Some(peaks @ [..]) => meter::render_mono( 363 | meter_area, 364 | buf, 365 | (!peaks.is_empty()).then_some( 366 | peaks.iter().sum::() / peaks.len() as f32, 367 | ), 368 | self.config, 369 | ), 370 | _ => match self 371 | .node 372 | .positions 373 | .as_ref() 374 | .map(|positions| positions.len()) 375 | { 376 | Some(2) if self.config.peaks != Peaks::Mono => { 377 | meter::render_stereo(meter_area, buf, None, self.config) 378 | } 379 | _ => meter::render_mono(meter_area, buf, None, self.config), 380 | }, 381 | } 382 | } 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/object.rs: -------------------------------------------------------------------------------- 1 | //! Type for representing PipeWire object IDs. 2 | 3 | use libspa::utils::dict::DictRef; 4 | use pipewire::registry::GlobalObject; 5 | 6 | #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] 7 | pub struct ObjectId(u32); 8 | 9 | impl From<&GlobalObject<&DictRef>> for ObjectId { 10 | fn from(obj: &GlobalObject<&DictRef>) -> Self { 11 | ObjectId(obj.id) 12 | } 13 | } 14 | 15 | impl From for u32 { 16 | fn from(id: ObjectId) -> u32 { 17 | id.0 18 | } 19 | } 20 | 21 | #[allow(clippy::to_string_trait_impl)] // This isn't for end-users 22 | impl ToString for ObjectId { 23 | fn to_string(&self) -> String { 24 | self.0.to_string() 25 | } 26 | } 27 | 28 | impl ObjectId { 29 | pub fn from_raw_id(id: u32) -> Self { 30 | ObjectId(id) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/opt.rs: -------------------------------------------------------------------------------- 1 | //! Parse command-line arguments. 2 | 3 | use std::path::PathBuf; 4 | 5 | use clap::Parser; 6 | 7 | use crate::app::TabKind; 8 | use crate::config; 9 | 10 | #[derive(Parser)] 11 | #[clap(name = "wiremix", about = "PipeWire mixer")] 12 | #[command(version)] 13 | pub struct Opt { 14 | #[clap( 15 | short = 'c', 16 | long, 17 | value_name = "FILE", 18 | help = "Override default config file path" 19 | )] 20 | pub config: Option, 21 | 22 | #[clap( 23 | short, 24 | long, 25 | value_name = "NAME", 26 | help = "The name of the remote to connect to" 27 | )] 28 | pub remote: Option, 29 | 30 | #[clap( 31 | short, 32 | long, 33 | help = "Target frames per second (or 0 for unlimited)" 34 | )] 35 | pub fps: Option, 36 | 37 | #[clap( 38 | short = 's', 39 | long, 40 | value_name = "NAME", 41 | help = "Character set to use [built-in sets: default, compat, extracompat]" 42 | )] 43 | pub char_set: Option, 44 | 45 | #[clap( 46 | short, 47 | long, 48 | value_name = "NAME", 49 | help = "Theme to use [built-in themes: default, nocolor, plain]" 50 | )] 51 | pub theme: Option, 52 | 53 | #[clap( 54 | short, 55 | long, 56 | value_parser = clap::value_parser!(config::Peaks), 57 | help = "Audio peak meters" 58 | )] 59 | pub peaks: Option, 60 | 61 | #[clap(long, conflicts_with = "mouse", help = "Disable mouse support")] 62 | pub no_mouse: bool, 63 | 64 | #[clap(long, conflicts_with = "no_mouse", help = "Enable mouse support")] 65 | pub mouse: bool, 66 | 67 | #[clap( 68 | short = 'v', 69 | long, 70 | value_enum, 71 | value_parser = clap::value_parser!(TabKind), 72 | help = "Initial tab view" 73 | )] 74 | pub tab: Option, 75 | 76 | #[cfg(debug_assertions)] 77 | #[clap(short, long, help = "Dump events without showing interface")] 78 | pub dump_events: bool, 79 | } 80 | 81 | impl Opt { 82 | pub fn parse() -> Self { 83 | ::parse() 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/trace.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "trace")] 2 | use std::path::PathBuf; 3 | 4 | use anyhow::Result; 5 | use tracing_error::ErrorLayer; 6 | use tracing_subscriber::{ 7 | self, layer::SubscriberExt, util::SubscriberInitExt, Layer, 8 | }; 9 | 10 | pub fn initialize_logging() -> Result<()> { 11 | let log_file: String = format!("{}.log", env!("CARGO_PKG_NAME")); 12 | 13 | let directory = PathBuf::from("."); 14 | std::fs::create_dir_all(directory.clone())?; 15 | let log_path = directory.join(log_file.clone()); 16 | let log_file = std::fs::File::create(log_path)?; 17 | std::env::set_var("RUST_LOG", std::env::var("RUST_LOG").unwrap()); 18 | let file_subscriber = tracing_subscriber::fmt::layer() 19 | .with_file(true) 20 | .with_line_number(true) 21 | .with_writer(log_file) 22 | .with_target(false) 23 | .with_ansi(false) 24 | .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env()); 25 | tracing_subscriber::registry() 26 | .with(file_subscriber) 27 | .with(ErrorLayer::default()) 28 | .init(); 29 | Ok(()) 30 | } 31 | 32 | /// Similar to the `std::dbg!` macro, but generates `tracing` events rather 33 | /// than printing to stdout. 34 | /// 35 | /// By default, the verbosity level for the generated events is `DEBUG`, but 36 | /// this can be customized. 37 | #[macro_export] 38 | macro_rules! trace_dbg { 39 | (target: $target:expr, level: $level:expr, $ex:expr) => {{ 40 | match $ex { 41 | value => { 42 | tracing::event!(target: $target, $level, ?value, stringify!($ex)); 43 | value 44 | } 45 | } 46 | }}; 47 | (level: $level:expr, $ex:expr) => { 48 | trace_dbg!(target: module_path!(), level: $level, $ex) 49 | }; 50 | (target: $target:expr, $ex:expr) => { 51 | trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex) 52 | }; 53 | ($ex:expr) => { 54 | trace_dbg!(level: tracing::Level::DEBUG, $ex) 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/truncate.rs: -------------------------------------------------------------------------------- 1 | //! String truncation tools. 2 | 3 | use unicode_width::UnicodeWidthStr; 4 | 5 | pub fn with_ellipses(text: &str, len: usize) -> String { 6 | if UnicodeWidthStr::width(text) <= len { 7 | return String::from(text); 8 | } 9 | 10 | let ellipses = "..."; 11 | 12 | let mut result = String::new(); 13 | let mut current_width = 0; 14 | 15 | for c in text.chars() { 16 | let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); 17 | if current_width + char_width + ellipses.len() > len { 18 | break; 19 | } 20 | 21 | result.push(c); 22 | current_width += char_width; 23 | } 24 | 25 | // Truncate ellipses if necessary 26 | result + &ellipses[0..len.min(ellipses.len())] 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | 33 | #[test] 34 | fn equal() { 35 | assert_eq!(with_ellipses("hello", 5), "hello"); 36 | } 37 | 38 | #[test] 39 | fn larger() { 40 | assert_eq!(with_ellipses("hello", 6), "hello"); 41 | } 42 | 43 | #[test] 44 | fn shorter() { 45 | assert_eq!(with_ellipses("hello", 4), "h..."); 46 | } 47 | 48 | #[test] 49 | fn too_short() { 50 | assert_eq!(with_ellipses("hello", 3), "..."); 51 | } 52 | 53 | #[test] 54 | fn much_too_short() { 55 | assert_eq!(with_ellipses("hello", 2), ".."); 56 | } 57 | 58 | #[test] 59 | fn empty() { 60 | assert_eq!(with_ellipses("hello", 0), ""); 61 | } 62 | } 63 | --------------------------------------------------------------------------------